diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json deleted file mode 100644 index c2d27f9..0000000 --- a/.agents/plugins/marketplace.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "android-skills", - "interface": { - "displayName": "Android Skills" - }, - "plugins": [ - { - "name": "android-skills", - "source": { - "source": "url", - "url": "https://github.com/android/skills", - "ref": "main" - }, - "policy": { - "installation": "AVAILABLE", - "authentication": "ON_INSTALL" - }, - "category": "Developer Tools" - } - ] -} \ No newline at end of file diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json deleted file mode 100644 index 5db479d..0000000 --- a/.claude-plugin/marketplace.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "android-skills", - "description": "Google's official Android skills for agentic Android development.", - "owner": { - "name": "Google / Android" - }, - "plugins": [ - { - "name": "android-skills", - "description": "Android skills are Google's official dedicated repository of AI-optimized, modular instructions and resources, to help LLMs better understand and execute specific patterns that follow the best practices and guidance on Android development. Android skills follow the open-standard agent skills - markdown files (SKILL.md) that provide a technical specification of a task, and ground LLMs with information on specialized domains and workflows.", - "author": { - "name": "Google / Android" - }, - "category": "development", - "source": "./", - "homepage": "https://github.com/android/skills", - "license": "Apache-2.0" - } - ] -} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json deleted file mode 100644 index 15e0b2d..0000000 --- a/.claude-plugin/plugin.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "name": "android-skills", - "version": "1.0.14", - "displayName": "Android Skills", - "description": "Android skills are Google's official dedicated repository of AI-optimized, modular instructions and resources, to help LLMs better understand and execute specific patterns that follow the best practices and guidance on Android development. Android skills follow the open-standard agent skills - markdown files (SKILL.md) that provide a technical specification of a task, and ground LLMs with information on specialized domains and workflows.", - "author": { - "name": "Google / Android", - "url": "https://github.com/android" - }, - "homepage": "https://github.com/android/skills", - "repository": "https://github.com/android/skills", - "license": "Apache-2.0", - "keywords": [ - "android", - "kotlin", - "jetpack-compose", - "skills" - ], - "skills": [ - "./build-system/agp/agp-9-upgrade", - "./camera/camerax", - "./device-ai/appfunctions", - "./device-ai/ml-kit-genai-prompt-api", - "./devtools/android-cli", - "./identity/restore-credentials", - "./identity/verified-email", - "./jetpack-compose/adaptive", - "./jetpack-compose/migration/migrate-xml-views-to-jetpack-compose", - "./jetpack-compose/theming/styles", - "./media/media3-cast-integration", - "./navigation/navigation-3", - "./navigation/navigation-event", - "./performance/r8-analyzer", - "./play/engage-sdk-integration", - "./play/play-billing-library-version-upgrade", - "./play/play-policy-insights", - "./profilers/android-profiler", - "./security/android-intent-security", - "./security/android-permissions-security", - "./system/edge-to-edge", - "./testing/testing-setup", - "./tv/leanback-to-compose-tv-migration", - "./wear/wear-compose-m3", - "./xr/display-glasses-with-jetpack-compose-glimmer" - ] -} diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json deleted file mode 100644 index 5b67b40..0000000 --- a/.codex-plugin/plugin.json +++ /dev/null @@ -1,159 +0,0 @@ -{ - "name": "android-skills", - "version": "1.0.14", - "description": "Android skills are Google's official dedicated repository of AI-optimized, modular instructions and resources, to help LLMs better understand and execute specific patterns that follow the best practices and guidance on Android development. Android skills follow the open-standard agent skills - markdown files (SKILL.md) that provide a technical specification of a task, and ground LLMs with information on specialized domains and workflows.", - "author": { - "name": "Google / Android", - "url": "https://github.com/android" - }, - "repository": "https://github.com/android/skills", - "license": "Apache-2.0", - "keywords": [ - "android", - "kotlin", - "jetpack-compose", - "skills" - ], - "skills": [ - { - "source": { - "path": "./build-system/agp/agp-9-upgrade" - } - }, - { - "source": { - "path": "./camera/camerax" - } - }, - { - "source": { - "path": "./device-ai/appfunctions" - } - }, - { - "source": { - "path": "./device-ai/ml-kit-genai-prompt-api" - } - }, - { - "source": { - "path": "./devtools/android-cli" - } - }, - { - "source": { - "path": "./identity/restore-credentials" - } - }, - { - "source": { - "path": "./identity/verified-email" - } - }, - { - "source": { - "path": "./jetpack-compose/adaptive" - } - }, - { - "source": { - "path": "./jetpack-compose/migration/migrate-xml-views-to-jetpack-compose" - } - }, - { - "source": { - "path": "./jetpack-compose/theming/styles" - } - }, - { - "source": { - "path": "./media/media3-cast-integration" - } - }, - { - "source": { - "path": "./navigation/navigation-3" - } - }, - { - "source": { - "path": "./navigation/navigation-event" - } - }, - { - "source": { - "path": "./performance/r8-analyzer" - } - }, - { - "source": { - "path": "./play/engage-sdk-integration" - } - }, - { - "source": { - "path": "./play/play-billing-library-version-upgrade" - } - }, - { - "source": { - "path": "./play/play-policy-insights" - } - }, - { - "source": { - "path": "./profilers/android-profiler" - } - }, - { - "source": { - "path": "./security/android-intent-security" - } - }, - { - "source": { - "path": "./security/android-permissions-security" - } - }, - { - "source": { - "path": "./system/edge-to-edge" - } - }, - { - "source": { - "path": "./testing/testing-setup" - } - }, - { - "source": { - "path": "./tv/leanback-to-compose-tv-migration" - } - }, - { - "source": { - "path": "./wear/wear-compose-m3" - } - }, - { - "source": { - "path": "./xr/display-glasses-with-jetpack-compose-glimmer" - } - } - ], - "interface": { - "displayName": "Android Skills", - "shortDescription": "AI-optimized Android development skills from Google.", - "longDescription": "Android skills are Google's official dedicated repository of AI-optimized, modular instructions and resources, to help LLMs better understand and execute specific patterns that follow the best practices and guidance on Android development. Android skills follow the open-standard agent skills - markdown files (SKILL.md) that provide a technical specification of a task, and ground LLMs with information on specialized domains and workflows.", - "developerName": "Google / Android", - "category": "Developer Tools", - "capabilities": [ - "Skills" - ], - "websiteURL": "https://github.com/android/skills", - "defaultPrompt": [ - "Help me upgrade my app to AGP 9.", - "Migrate this Camera1 code to CameraX." - ] - } -} diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index af0172a..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -name: Bug Report -about: Report an issue with an existing skill. -title: '[Bug] ' -labels: bug -assignees: '' ---- - -### Affected Skill - - -### Description - - -### Expected Behavior - - -### Actual Behavior - - -### Steps to Reproduce - - -### Suggested Fix - diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index e30f9e7..0000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -name: Feature Request -about: Suggest a new skill to fill a gap in the LLM's knowledge. -title: '[Feature] ' -labels: enhancement -assignees: '' ---- - -> [!NOTE] -> Feature requests are accepted only if the proposed skill fills a verified gap in state-of-the-art LLMs. To assess this, please test your use case against both a fast model (e.g., Gemini 3 Flash) and a reasoning/thinking model (e.g., Gemini 3.1 Pro). - -### What is the gap in the LLM's knowledge? - - -### Proposed Skill - - -### Additional Context - diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 1bcf118..92c9029 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -16,50 +16,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout main - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@v4 with: ref: main - token: ${{ secrets.ADR_GITHUB_BOT_PAT || secrets.GITHUB_TOKEN }} - - - name: Update Plugin Versions - env: - TAG_NAME: ${{ inputs.tag_name }} - run: | - VERSION="${TAG_NAME#v}" - echo "Updating plugin manifest versions to $VERSION" - - VERSION="$VERSION" python3 -c " - import json - import os - import pathlib - - version = os.environ['VERSION'] - - # Both manifests carry the version. Claude Code pins installs to the - # version in .claude-plugin/plugin.json, so leaving it stale means - # users never receive the new release. - for path in ('.claude-plugin/plugin.json', '.codex-plugin/plugin.json'): - p = pathlib.Path(path) - if not p.exists(): - raise FileNotFoundError(f'Required file {p} does not exist.') - with open(p, 'r') as f: - data = json.load(f) - data['version'] = version - with open(p, 'w') as f: - json.dump(data, f, indent=2) - f.write('\n') - print(f'Set {p} version to {version}') - " - - - name: Commit and push updated version - env: - TAG_NAME: ${{ inputs.tag_name }} - run: | - git config user.name "android-devrel-github-bot" - git config user.email "android-devrel-github-bot@users.noreply.github.com" - git add .claude-plugin/plugin.json .codex-plugin/plugin.json - git commit -m "Bump plugin version to $TAG_NAME" || echo "No version change to commit" - git push origin main - name: Create Zip Archive run: | @@ -67,10 +26,6 @@ jobs: - name: Create Release env: - GITHUB_TOKEN: ${{ secrets.ADR_GITHUB_BOT_PAT || secrets.GITHUB_TOKEN }} - TAG_NAME: ${{ inputs.tag_name }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - gh release create "$TAG_NAME" android-skills.zip \ - --target main \ - --title "Release $TAG_NAME" \ - --notes "Release $TAG_NAME of Android Skills." + gh release create ${{ inputs.tag_name }} android-skills.zip --title "Release ${{ inputs.tag_name }}" --notes "Manual release of main branch contents." diff --git a/.github/workflows/update-skills.yml b/.github/workflows/update-skills.yml index 6eb079c..08bf523 100644 --- a/.github/workflows/update-skills.yml +++ b/.github/workflows/update-skills.yml @@ -8,14 +8,13 @@ on: permissions: contents: write - pull-requests: write jobs: update-skills: runs-on: ubuntu-latest steps: - name: Checkout main - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@v4 with: ref: main token: ${{ secrets.ADR_GITHUB_BOT_PAT }} @@ -60,87 +59,11 @@ jobs: rm -rf temp_skills echo "Successfully copied directories from github-skills branch." - - name: Update Skill Lists in Plugin Files + - name: Commit and push changes run: | - python3 -c " - import json - import pathlib - - # Discover all skills with SKILL.md - skill_paths = [] - for path in pathlib.Path('.').glob('**/SKILL.md'): - if any(p.startswith('.') for p in path.parts[:-1]): - continue - skill_paths.append(f'./{path.parent.as_posix()}') - - skill_paths = sorted(list(set(skill_paths))) - - # 1. Update .claude-plugin/plugin.json in-place. - # The skill list belongs in the plugin MANIFEST, never in the - # marketplace entry. Component fields such as 'skills' on the entry - # are rejected by Anthropic's plugin directory with - # MARKETPLACE_ENTRY_INLINE_MANIFEST. - claude_manifest = pathlib.Path('.claude-plugin/plugin.json') - if not claude_manifest.exists(): - raise FileNotFoundError(f'Required file {claude_manifest} does not exist. Ensure you are running on the correct branch.') - - with open(claude_manifest, 'r') as f: - data = json.load(f) - data['skills'] = skill_paths - with open(claude_manifest, 'w') as f: - json.dump(data, f, indent=2) - f.write('\n') - - # 2. Keep .claude-plugin/marketplace.json a pure catalogue. - # With a plugin.json present, strict:false plus any component field - # makes the plugin fail to load with 'conflicting manifests'. - claude_marketplace = pathlib.Path('.claude-plugin/marketplace.json') - if not claude_marketplace.exists(): - raise FileNotFoundError(f'Required file {claude_marketplace} does not exist. Ensure you are running on the correct branch.') - - with open(claude_marketplace, 'r') as f: - data = json.load(f) - if 'plugins' in data and len(data['plugins']) > 0: - for stale in ('skills', 'strict', 'commands', 'agents', 'hooks', 'outputStyles', 'themes'): - data['plugins'][0].pop(stale, None) - with open(claude_marketplace, 'w') as f: - json.dump(data, f, indent=2) - f.write('\n') - - # 3. Update .codex-plugin/plugin.json in-place - codex_file = pathlib.Path('.codex-plugin/plugin.json') - if not codex_file.exists(): - raise FileNotFoundError(f'Required file {codex_file} does not exist. Ensure you are running on the correct branch.') - - with open(codex_file, 'r') as f: - data = json.load(f) - data['skills'] = [{'source': {'path': p}} for p in skill_paths] - with open(codex_file, 'w') as f: - json.dump(data, f, indent=2) - f.write('\n') - " - - - name: Commit and push to PR branch - run: | - BRANCH="update-skills/$(date +'%Y-%m-%d-%H%M')" - git config user.name "android-devrel-github-bot" git config user.email "android-devrel-github-bot@users.noreply.github.com" - git checkout -b "$BRANCH" git add -A - - # The workflow doesn't fail if there's nothing new to commit - if git diff --cached --quiet; then - echo "No changes to commit — skipping PR creation." - exit 0 - fi - - git commit -m "Updates skills ($(date +'%Y-%m-%d %H:%M'))" - git push origin "$BRANCH" - gh pr create \ - --title "Update skills ($(date +'%Y-%m-%d %H:%M'))" \ - --body "Automated skill update from the \`Update Skills\` workflow. Please review the changes before merging." \ - --base main \ - --head "$BRANCH" - env: - GH_TOKEN: ${{ secrets.ADR_GITHUB_BOT_PAT }} \ No newline at end of file + # The '|| true' ensures the workflow doesn't fail if there's nothing new + git commit -m "Updates skills ($(date +'%Y-%m-%d %H:%M'))" || echo "No changes to commit" + git push origin main diff --git a/README.md b/README.md index dae981c..effd96e 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,15 @@ ## Android skills -**Android skills** are a dedicated repository of **AI-optimized, modular instructions** and -resources, to help agents better understand and execute specific patterns that follow the best -practices and guidance on Android development -from [developer.android.com](https://developer.android.com). +Android skills are a dedicated repository of AI-optimised, modular instructions and resources, to +help LLMs better understand and execute specific patterns that follow the best practices and +guidance on Android development from [developer.android.com](developer.android.com). Android skills follow the [open-standard agent skills](https://agentskills.io/home) - markdown -files (SKILL.md) that provide a technical specification of a task, and **ground LLMs** with -information on specialized domains and workflows. +files (SKILL.md) that provide a technical specification of a task, and ground LLMs with information +on specialized domains and workflows. -Our Android skill development focuses on **use cases and workflows where evaluations show LLMs -underperform**. -We aren't prioritizing well-established areas where LLMs are already proficient, such -as basic Jetpack Compose best practices. - -To learn more, read the official documentation: - -- [Android skills](https://developer.android.com/tools/agents/android-skills) -- [Android CLI](https://developer.android.com/tools/agents/android-cli) -- [Android Studio](https://developer.android.com/studio/gemini/skills) - -### Install Android skills - -Use Android CLI to install a specific skill into the current directory: - -``` -android skills add r8-analyzer --project=. -``` - -Use Android CLI to install all Android skills to directories for all detected agents: - -``` -android skills add --all -``` - -If you don't have any existing agent directories and don't specify particular agents, the skills -will be installed for Gemini and Antigravity at `~/.gemini/antigravity/skills`. - -**Options:** - -- `--all` - Add all Android skills. If omitted, only the `android-cli` skill will be installed. -- `--agent` - A comma-separated list of agents to install the skill for. If omitted, the skill will be installed for all detected agents. -- `--project` - Path to a project root in which to install the skills. - - -To update all skills or specific ones, run: - -``` -android skills update --all -android skills update wear-compose-m3 -``` - -### Install as an agent plugin - -**Claude Code**: - -``` -/plugin install android-skills --marketplace android/skills -``` - -**Codex:** - -``` -codex plugin marketplace add android/skills -codex plugin add android-skills@android-skills -``` - -### Activate skills - -Your agent automatically activates skills that are relevant to your task. To use a skill, prompt your agent to complete a task that is related to the skill, for example "Make my app UI edge-to-edge." The agent should automatically find and use the skill if it has been installed. +To use Android skills, check +your agent's documentation. ## Disclaimer @@ -89,4 +30,4 @@ details. ## Review our community guidelines This project follows -[Google's Open Source Community Guidelines](https://opensource.google/conduct/). +[Google's Open Source Community Guidelines](https://opensource.google/conduct/). \ No newline at end of file diff --git a/build-system/agp/agp-9-upgrade/SKILL.md b/build/agp/agp-9-upgrade/SKILL.md similarity index 91% rename from build-system/agp/agp-9-upgrade/SKILL.md rename to build/agp/agp-9-upgrade/SKILL.md index efb2f1a..6436b33 100644 --- a/build-system/agp/agp-9-upgrade/SKILL.md +++ b/build/agp/agp-9-upgrade/SKILL.md @@ -1,12 +1,10 @@ --- name: agp-9-upgrade description: Upgrades, or migrates, an Android project to use Android Gradle Plugin - (AGP) version 9. Do not use this skill for migrating Kotlin Multiplatform (KMP) - projects. + (AGP) version 9. Do not use this skill for migrating Kotlin Multiplatform (KMP) projects. license: Complete terms in LICENSE.txt metadata: author: Google LLC - last-updated: '2026-09-18' keywords: - Android Gradle Plugin 9 - AGP 9 @@ -29,7 +27,8 @@ Upgrade Assistant in Android Studio to update to the latest stable version of AGP, and confirm when done. The user may also request that this requirement be skipped; if this is the case, you should update the version of AGP to the latest stable version as part of the AGP 9 migration. See the -[AGP 9 migration guide](references/android/build/releases/agp-9-0-0-release-notes.md) for how to do this. +[AGP 9 migration guide](references/android/build/releases/agp-9-0-0-release-notes.md) +for how to do this. Each version of AGP has its own set of compatibilities with other tools, such as Gradle, JDK, and Kotlin. The release notes for each of these versions will @@ -49,11 +48,11 @@ version 2.3.6 or higher. If Hilt is used in the project, ensure it is on version 2.59.2 or higher. -### Step 2: Migrate to built-in Kotlin +### Step 2: Migrate to built-in Kotlin. See [the guide](references/android/build/migrate-to-built-in-kotlin.md) for detailed information. -### Step 3. Migrate to the new AGP DSL +### Step 3. Migrate to the new AGP DSL. See [the guide](references/android/build/releases/agp-9-0-0-release-notes.md) for detailed information. @@ -63,8 +62,8 @@ that is compatible with AGP 9 and the new DSL. ### Step 4. Migrate kapt to KSP or legacy-kapt If KSP (`com.google.devtools.ksp`) or kapt (`org.jetbrains.kotlin.kapt`) are -used in the project, see [KSP, kapt, and legacy-kapt](references/ksp-kapt.md) for detailed migration -steps. +used in the project, [KSP, kapt, and legacy-kapt](references/ksp-kapt.md) +for detailed migration steps. ### Step 5. BuildConfig @@ -100,4 +99,4 @@ After migration, verify the following: ## Troubleshooting Paparazzi v2.0.0-alpha04 and lower versions have issues with AGP 9. See -[references/paparazzi-gradle-9.md](references/paparazzi-gradle-9.md) for details. +[references/paparazzi-gradle-9.md](references/paparazzi-gradle-9.md) for details. \ No newline at end of file diff --git a/build-system/agp/agp-9-upgrade/references/android/build/migrate-to-built-in-kotlin.md b/build/agp/agp-9-upgrade/references/android/build/migrate-to-built-in-kotlin.md similarity index 100% rename from build-system/agp/agp-9-upgrade/references/android/build/migrate-to-built-in-kotlin.md rename to build/agp/agp-9-upgrade/references/android/build/migrate-to-built-in-kotlin.md diff --git a/build-system/agp/agp-9-upgrade/references/android/build/releases/agp-9-0-0-release-notes.md b/build/agp/agp-9-upgrade/references/android/build/releases/agp-9-0-0-release-notes.md similarity index 78% rename from build-system/agp/agp-9-upgrade/references/android/build/releases/agp-9-0-0-release-notes.md rename to build/agp/agp-9-upgrade/references/android/build/releases/agp-9-0-0-release-notes.md index 4e0aa6b..b6a6e56 100644 --- a/build-system/agp/agp-9-upgrade/references/android/build/releases/agp-9-0-0-release-notes.md +++ b/build/agp/agp-9-upgrade/references/android/build/releases/agp-9-0-0-release-notes.md @@ -3,17 +3,12 @@ Android Gradle plugin 9.0 is a major release that brings API and behavior changes. -To update to Android Gradle plugin 9.0.1, use the -[Android Gradle plugin Upgrade Assistant](https://developer.android.com/build/agp-upgrade-assistant). The AGP upgrade assistant helps -preserve existing behaviors when upgrading your project whenever appropriate, so -you can upgrade your project to use AGP 9.0 even if you're not ready to -adopt all the new defaults in AGP 9.0. +To update to Android Gradle plugin 9.0.1, +use the [Android Gradle plugin Upgrade Assistant](https://developer.android.com/build/agp-upgrade-assistant). -There are also two agent skills available to make the upgrade process easier. -For a non-KMP app, try the [AGP 9 upgrade skill](https://github.com/android/skills/tree/main/build-system/agp/agp-9-upgrade) from the -Android skills repository. For a KMP app, try the [AGP 9 upgrade -skill](https://github.com/Kotlin/kotlin-agent-skills/tree/main/skills/kotlin-tooling-agp9-migration) from JetBrains. For more information about using skills -in Android Studio, see [Extend Agent Mode with skills](https://developer.android.com/studio/gemini/skills). +The AGP upgrade assistant helps preserve existing behaviors when upgrading your +project whenever appropriate, allowing you to upgrade your project to use AGP +9.0 even if you're not ready to adopt all the new defaults in AGP 9.0. ## Compatibility @@ -27,7 +22,6 @@ Here is other compatibility info: | SDK Build Tools | 36.0.0 | 36.0.0 | [Install](https://developer.android.com/studio/intro/update#sdk-manager) or [configure](https://developer.android.com/tools/releases/build-tools) SDK Build Tools. | | NDK | N/A | 28.2.13676358 | [Install](https://developer.android.com/studio/projects/install-ndk#specific-version) or [configure](https://developer.android.com/studio/projects/install-ndk#apply-specific-version) a different version of the NDK. | | JDK | 17 | 17 | To learn more, see [setting the JDK version](https://developer.android.com/studio/intro/studio-config#jdk). | -| Kotlin Gradle plugin (KGP) | 2.2.10 | 2.2.10 | To learn more, see [Runtime dependency on Kotlin Gradle plugin](https://developer.android.com/build/releases/agp-9-0-0-release-notes#runtime-dependency-on-kotlin-gradle-plugin). |
@@ -105,9 +99,6 @@ your `gradle.properties` file: android.newDsl=false -Alternatively, for a more gradual migration, AGP 9.4 lets you opt out individual -modules. To learn how, see [Variant API module opt-out](https://developer.android.com/build/releases/agp-9-4-0-release-notes#new-dsl-opt-out). - The previous classes are marked as deprecated in AGP 9.0. This means projects that opt out of the `newDsl` flag will see deprecation warnings, including on the `android` block itself. @@ -550,6 +541,11 @@ options of D8 and R8. ### Android Gradle plugin 9.0.0 -Fixed Issues **Android Gradle Plugin** [Issue #171293712](https://issuetracker.google.com/issues/171293712) Feature Request: Inject ideal AGP version as a property [Issue #443976533](https://issuetracker.google.com/issues/443976533) Stabilize SingleArtifact.VERSION_CONTROL_INFO_FILE [Issue #223643506](https://issuetracker.google.com/issues/223643506) androidTest connectedCheck logcat output is broken [Issue #386221070](https://issuetracker.google.com/issues/386221070) Built-in Kotlin support in AGP should not synchronize with the Kotlin sourcesets [Issue #460094802](https://issuetracker.google.com/issues/460094802) missingDimensionStrategy prefers a flavor maching its own name even from an unrelated dimension [Issue #386221070](https://issuetracker.google.com/issues/386221070) Built-in Kotlin support in AGP should not synchronize with the Kotlin sourcesets [Issue #471410336](https://issuetracker.google.com/issues/471410336) AGP 9.0.0-rc01 doesn't resolve Kotlin libraries via kotlin() function [Issue #450851465](https://issuetracker.google.com/issues/450851465) Built-in-kotlin does not publish kotlin-stdlib dependency constraint in maven pom [Issue #420592288](https://issuetracker.google.com/issues/420592288) Add a test case for divergence between compileSdk and targetSdk [Issue #449153004](https://issuetracker.google.com/issues/449153004) empty resConfigs value leads to obscure aapt error [Issue #447375921](https://issuetracker.google.com/issues/447375921) Pure Java project have dependency on kotlin stdlib. [Issue #368600704](https://issuetracker.google.com/issues/368600704) Remove deprecated KotlinMultiplatformAndroidCompilationBuilder properties in AGP 9.0 [Issue #445209309](https://issuetracker.google.com/issues/445209309) \`com.android.tools.build:gradle:9.0.0-alpha05\` should have an api dependency on KGP and gradle-api [Issue #452645779](https://issuetracker.google.com/issues/452645779) Rename com.android.experimental.built-in-kotlin Gradle plugin [Issue #446220448](https://issuetracker.google.com/issues/446220448) AGP9: \`variant.sources.kotlin!!.addGeneratedSourceDirectory()\` is not working [Issue #448450771](https://issuetracker.google.com/issues/448450771) Aar metadata checks on compile Sdk uses the old DSL [Issue #441523448](https://issuetracker.google.com/issues/441523448) Remove deprecated \`com.android.build.api.dsl.ManagedDevices.devices\` property [Issue #386221070](https://issuetracker.google.com/issues/386221070) Built-in Kotlin support in AGP should not synchronize with the Kotlin sourcesets [Issue #433758231](https://issuetracker.google.com/issues/433758231) Fail android library publishing if consumer keep file contains -dontobfuscate [Issue #241955408](https://issuetracker.google.com/issues/241955408) No Options to Print Mapping for Optimized Resources [Issue #436595826](https://issuetracker.google.com/issues/436595826) Make it an error to call finalizeDsl after this phase has been passed [Issue #457089670](https://issuetracker.google.com/issues/457089670) AGP initializes jetifier config even when jetifier is disabled [Issue #452246814](https://issuetracker.google.com/issues/452246814) built in kotlin does not add kotlinStdlib as a compile time dependency when \`kotlin.stdlib.default.dependency\` is true to module and pom files [Issue #442250902](https://issuetracker.google.com/issues/442250902) New optimizaation DSL does not create configuration.txt by default [Issue #443587266](https://issuetracker.google.com/issues/443587266) AGP 8.13.0 fails to verify nav graph in a module [Issue #444260628](https://issuetracker.google.com/issues/444260628) AGP uses deprecated Gradle API: multi-string notation [Issue #347732357](https://issuetracker.google.com/issues/347732357) Warn users trying to use legacy multidex library with minSdkVersion \>=21 [Issue #333831734](https://issuetracker.google.com/issues/333831734) A build fails if there are code generation tasks [Issue #446123111](https://issuetracker.google.com/issues/446123111) With \`android.builtInKotlin=false\` and \`android.newDsl=false\` and \`android.enableLegacyVariantApi=false\`, using \`kotlin-android\` plugin will fail with "API 'applicationVariants' is obsolete" [Issue #443037365](https://issuetracker.google.com/issues/443037365) Built-in Kotlin fails to resolve unversioned kotlin-stdlib when kotlin.stdlib.default.dependency=false [Issue #445967244](https://issuetracker.google.com/issues/445967244) DexData opens a file without closing, preventing cleanup [Issue #368609737](https://issuetracker.google.com/issues/368609737) AndroidSourceDirectorySet should stop extending PatternFilterable in AGP 9.0 [Issue #389707041](https://issuetracker.google.com/issues/389707041) Test Fixture Error in test only modules [Issue #353249347](https://issuetracker.google.com/issues/353249347) Incorrect error when using context receivers in test fixtures [Issue #351046197](https://issuetracker.google.com/issues/351046197) Incorrect IDE errors for Kotlin code in testFixtures [Issue #446889652](https://issuetracker.google.com/issues/446889652) \`legacy-kapt\` plugin skips annotation processing unlike \`kotlin-kapt\` [Issue #446492061](https://issuetracker.google.com/issues/446492061) compileSdkSpec.minorApiLevel is not working with SettingsExtension [Issue #429253579](https://issuetracker.google.com/issues/429253579) \[fused lib - public\] Generated fused library does not include sources [Issue #149770867](https://issuetracker.google.com/issues/149770867) extractNativeLibs and useEmbeddedDex should not be coming from the manifest [Issue #449114518](https://issuetracker.google.com/issues/449114518) Warnings from R8 in AGP 9.0.0-alpha09 [Issue #368426598](https://issuetracker.google.com/issues/368426598) Remove deprecated AndroidSourceSet.jni in AGP 9.0 [Issue #368484483](https://issuetracker.google.com/issues/368484483) Remove Installation.installOptions() in AGP 9.0 [Issue #368482484](https://issuetracker.google.com/issues/368482484) Remove BuildType.isRenderscriptDebuggable in AGP 9.0. [Issue #428646179](https://issuetracker.google.com/issues/428646179) Remove android.defaults.buildfeatures.renderscript [Issue #436887358](https://issuetracker.google.com/issues/436887358) \`com.android.kotlin.multiplatform.library\` crashes with Gradle Managed Devices [Issue #428645763](https://issuetracker.google.com/issues/428645763) Remove \`android.defaults.buildfeatures.aidl\` defaults gradle.properties flags [Issue #294183018](https://issuetracker.google.com/issues/294183018) Fail build when proguard file does not exist [Issue #254305041](https://issuetracker.google.com/issues/254305041) remove buildconfig defaults gradle.properties flags [Issue #280674230](https://issuetracker.google.com/issues/280674230) Change the app's targetSdk default value to be based on compileSdk instead of minSdk [Issue #436878535](https://issuetracker.google.com/issues/436878535) When \`isIncludeAndroidResources\` is enabled, \`process{Variant}UnitTestManifest\` fails to merge tools:overrideLibrary usages in AGP 8.12.0 [Issue #411739086](https://issuetracker.google.com/issues/411739086) AGP causing deprecation warnings in Gradle for JVM test tasks [Issue #235457021](https://issuetracker.google.com/issues/235457021) DependencyReportTask is incompatible with the configuration cache [Issue #369246556](https://issuetracker.google.com/issues/369246556) Switch default source/target Java version from Java 8 to Java 11 in AGP 9.0 [Issue #258855275](https://issuetracker.google.com/issues/258855275) Flip android.useAndroidX default to true [Issue #442763200](https://issuetracker.google.com/issues/442763200) Better exception when applying kapt plugin with built-in Kotlin. [Issue #441679226](https://issuetracker.google.com/issues/441679226) android.proguard.failOnMissingFiles is not working for consumerProguardFiles [Issue #443051391](https://issuetracker.google.com/issues/443051391) Update Kotlin Gradle plugin dependency to 2.2.10 [Issue #429981132](https://issuetracker.google.com/issues/429981132) Create KotlinJvmAndroidCompilation using KGP API [Issue #442869731](https://issuetracker.google.com/issues/442869731) Kotlin explicit API mode applied to test sources **Lint** [Issue #430991549](https://issuetracker.google.com/issues/430991549) AGP 8.11.0: lintAnalyzeRelease task crashes when applying .gradle.kts files with apply(from = "...") [Issue #441536820](https://issuetracker.google.com/issues/441536820) Lint ChecksSdkIntAtLeast Check does not check if the annotated value is correct [Issue #446696613](https://issuetracker.google.com/issues/446696613) Built-in Kotlin does not add .kotlin_module to META-INF [Issue #449031505](https://issuetracker.google.com/issues/449031505) Lint classpath contains duplicate classes at different versions [Issue #448148350](https://issuetracker.google.com/issues/448148350) Overriding private resources workaround not working (tools:override = "true") [Issue #405676712](https://issuetracker.google.com/issues/405676712) Bug: removal of unused resources doesn't also remove the translations of them, and doesn't ask about it either [Issue #440415636](https://issuetracker.google.com/issues/440415636) Lint throwing warning "Could not clean up K2 caches" [Issue #440415636](https://issuetracker.google.com/issues/440415636) Lint throwing warning "Could not clean up K2 caches" **Lint Integration** [Issue #460068798](https://issuetracker.google.com/issues/460068798) AndroidLintAnalysisTask cache misses across different JDK vendors or minor versions due to systemPropertyInputs.javaVersion differences [Issue #444447002](https://issuetracker.google.com/issues/444447002) Lint automatically uses latest installed SDK despite compileSdk, doesn't register as task input and breaks caching **Shrinker (R8)** [Issue #454927488](https://issuetracker.google.com/issues/454927488) R8 optimized resource shrinking silently fails if using final resource IDs +| Fixed Issues || +|---|---| +| **Android Gradle Plugin** | |---| | [Issue #171293712](https://issuetracker.google.com/issues/171293712) Feature Request: Inject ideal AGP version as a property | | [Issue #443976533](https://issuetracker.google.com/issues/443976533) Stabilize SingleArtifact.VERSION_CONTROL_INFO_FILE | | [Issue #223643506](https://issuetracker.google.com/issues/223643506) androidTest connectedCheck logcat output is broken | | [Issue #386221070](https://issuetracker.google.com/issues/386221070) Built-in Kotlin support in AGP should not synchronize with the Kotlin sourcesets | | [Issue #460094802](https://issuetracker.google.com/issues/460094802) missingDimensionStrategy prefers a flavor maching its own name even from an unrelated dimension | | [Issue #386221070](https://issuetracker.google.com/issues/386221070) Built-in Kotlin support in AGP should not synchronize with the Kotlin sourcesets | | [Issue #471410336](https://issuetracker.google.com/issues/471410336) AGP 9.0.0-rc01 doesn't resolve Kotlin libraries via kotlin() function | | [Issue #450851465](https://issuetracker.google.com/issues/450851465) Built-in-kotlin does not publish kotlin-stdlib dependency constraint in maven pom | | [Issue #420592288](https://issuetracker.google.com/issues/420592288) Add a test case for divergence between compileSdk and targetSdk | | [Issue #449153004](https://issuetracker.google.com/issues/449153004) empty resConfigs value leads to obscure aapt error | | [Issue #447375921](https://issuetracker.google.com/issues/447375921) Pure Java project have dependency on kotlin stdlib. | | [Issue #368600704](https://issuetracker.google.com/issues/368600704) Remove deprecated KotlinMultiplatformAndroidCompilationBuilder properties in AGP 9.0 | | [Issue #445209309](https://issuetracker.google.com/issues/445209309) \`com.android.tools.build:gradle:9.0.0-alpha05\` should have an api dependency on KGP and gradle-api | | [Issue #452645779](https://issuetracker.google.com/issues/452645779) Rename com.android.experimental.built-in-kotlin Gradle plugin | | [Issue #446220448](https://issuetracker.google.com/issues/446220448) AGP9: \`variant.sources.kotlin!!.addGeneratedSourceDirectory()\` is not working | | [Issue #448450771](https://issuetracker.google.com/issues/448450771) Aar metadata checks on compile Sdk uses the old DSL | | [Issue #441523448](https://issuetracker.google.com/issues/441523448) Remove deprecated \`com.android.build.api.dsl.ManagedDevices.devices\` property | | [Issue #386221070](https://issuetracker.google.com/issues/386221070) Built-in Kotlin support in AGP should not synchronize with the Kotlin sourcesets | | [Issue #433758231](https://issuetracker.google.com/issues/433758231) Fail android library publishing if consumer keep file contains -dontobfuscate | | [Issue #241955408](https://issuetracker.google.com/issues/241955408) No Options to Print Mapping for Optimized Resources | | [Issue #436595826](https://issuetracker.google.com/issues/436595826) Make it an error to call finalizeDsl after this phase has been passed | | [Issue #457089670](https://issuetracker.google.com/issues/457089670) AGP initializes jetifier config even when jetifier is disabled | | [Issue #452246814](https://issuetracker.google.com/issues/452246814) built in kotlin does not add kotlinStdlib as a compile time dependency when \`kotlin.stdlib.default.dependency\` is true to module and pom files | | [Issue #442250902](https://issuetracker.google.com/issues/442250902) New optimizaation DSL does not create configuration.txt by default | | [Issue #443587266](https://issuetracker.google.com/issues/443587266) AGP 8.13.0 fails to verify nav graph in a module | | [Issue #444260628](https://issuetracker.google.com/issues/444260628) AGP uses deprecated Gradle API: multi-string notation | | [Issue #347732357](https://issuetracker.google.com/issues/347732357) Warn users trying to use legacy multidex library with minSdkVersion \>=21 | | [Issue #333831734](https://issuetracker.google.com/issues/333831734) A build fails if there are code generation tasks | | [Issue #446123111](https://issuetracker.google.com/issues/446123111) With \`android.builtInKotlin=false\` and \`android.newDsl=false\` and \`android.enableLegacyVariantApi=false\`, using \`kotlin-android\` plugin will fail with "API 'applicationVariants' is obsolete" | | [Issue #443037365](https://issuetracker.google.com/issues/443037365) Built-in Kotlin fails to resolve unversioned kotlin-stdlib when kotlin.stdlib.default.dependency=false | | [Issue #445967244](https://issuetracker.google.com/issues/445967244) DexData opens a file without closing, preventing cleanup | | [Issue #368609737](https://issuetracker.google.com/issues/368609737) AndroidSourceDirectorySet should stop extending PatternFilterable in AGP 9.0 | | [Issue #389707041](https://issuetracker.google.com/issues/389707041) Test Fixture Error in test only modules | | [Issue #353249347](https://issuetracker.google.com/issues/353249347) Incorrect error when using context receivers in test fixtures | | [Issue #351046197](https://issuetracker.google.com/issues/351046197) Incorrect IDE errors for Kotlin code in testFixtures | | [Issue #446889652](https://issuetracker.google.com/issues/446889652) \`legacy-kapt\` plugin skips annotation processing unlike \`kotlin-kapt\` | | [Issue #446492061](https://issuetracker.google.com/issues/446492061) compileSdkSpec.minorApiLevel is not working with SettingsExtension | | [Issue #429253579](https://issuetracker.google.com/issues/429253579) \[fused lib - public\] Generated fused library does not include sources | | [Issue #149770867](https://issuetracker.google.com/issues/149770867) extractNativeLibs and useEmbeddedDex should not be coming from the manifest | | [Issue #449114518](https://issuetracker.google.com/issues/449114518) Warnings from R8 in AGP 9.0.0-alpha09 | | [Issue #368426598](https://issuetracker.google.com/issues/368426598) Remove deprecated AndroidSourceSet.jni in AGP 9.0 | | [Issue #368484483](https://issuetracker.google.com/issues/368484483) Remove Installation.installOptions() in AGP 9.0 | | [Issue #368482484](https://issuetracker.google.com/issues/368482484) Remove BuildType.isRenderscriptDebuggable in AGP 9.0. | | [Issue #428646179](https://issuetracker.google.com/issues/428646179) Remove android.defaults.buildfeatures.renderscript | | [Issue #436887358](https://issuetracker.google.com/issues/436887358) \`com.android.kotlin.multiplatform.library\` crashes with Gradle Managed Devices | | [Issue #428645763](https://issuetracker.google.com/issues/428645763) Remove \`android.defaults.buildfeatures.aidl\` defaults gradle.properties flags | | [Issue #294183018](https://issuetracker.google.com/issues/294183018) Fail build when proguard file does not exist | | [Issue #254305041](https://issuetracker.google.com/issues/254305041) remove buildconfig defaults gradle.properties flags | | [Issue #280674230](https://issuetracker.google.com/issues/280674230) Change the app's targetSdk default value to be based on compileSdk instead of minSdk | | [Issue #436878535](https://issuetracker.google.com/issues/436878535) When \`isIncludeAndroidResources\` is enabled, \`process{Variant}UnitTestManifest\` fails to merge tools:overrideLibrary usages in AGP 8.12.0 | | [Issue #411739086](https://issuetracker.google.com/issues/411739086) AGP causing deprecation warnings in Gradle for JVM test tasks | | [Issue #235457021](https://issuetracker.google.com/issues/235457021) DependencyReportTask is incompatible with the configuration cache | | [Issue #369246556](https://issuetracker.google.com/issues/369246556) Switch default source/target Java version from Java 8 to Java 11 in AGP 9.0 | | [Issue #258855275](https://issuetracker.google.com/issues/258855275) Flip android.useAndroidX default to true | | [Issue #442763200](https://issuetracker.google.com/issues/442763200) Better exception when applying kapt plugin with built-in Kotlin. | | [Issue #441679226](https://issuetracker.google.com/issues/441679226) android.proguard.failOnMissingFiles is not working for consumerProguardFiles | | [Issue #443051391](https://issuetracker.google.com/issues/443051391) Update Kotlin Gradle plugin dependency to 2.2.10 | | [Issue #429981132](https://issuetracker.google.com/issues/429981132) Create KotlinJvmAndroidCompilation using KGP API | | [Issue #442869731](https://issuetracker.google.com/issues/442869731) Kotlin explicit API mode applied to test sources | | +| **Lint** | |---| | [Issue #430991549](https://issuetracker.google.com/issues/430991549) AGP 8.11.0: lintAnalyzeRelease task crashes when applying .gradle.kts files with apply(from = "...") | | [Issue #441536820](https://issuetracker.google.com/issues/441536820) Lint ChecksSdkIntAtLeast Check does not check if the annotated value is correct | | [Issue #446696613](https://issuetracker.google.com/issues/446696613) Built-in Kotlin does not add .kotlin_module to META-INF | | [Issue #449031505](https://issuetracker.google.com/issues/449031505) Lint classpath contains duplicate classes at different versions | | [Issue #448148350](https://issuetracker.google.com/issues/448148350) Overriding private resources workaround not working (tools:override = "true") | | [Issue #405676712](https://issuetracker.google.com/issues/405676712) Bug: removal of unused resources doesn't also remove the translations of them, and doesn't ask about it either | | [Issue #440415636](https://issuetracker.google.com/issues/440415636) Lint throwing warning "Could not clean up K2 caches" | | [Issue #440415636](https://issuetracker.google.com/issues/440415636) Lint throwing warning "Could not clean up K2 caches" | | +| **Lint Integration** | |---| | [Issue #460068798](https://issuetracker.google.com/issues/460068798) AndroidLintAnalysisTask cache misses across different JDK vendors or minor versions due to systemPropertyInputs.javaVersion differences | | [Issue #444447002](https://issuetracker.google.com/issues/444447002) Lint automatically uses latest installed SDK despite compileSdk, doesn't register as task input and breaks caching | | +| **Shrinker (R8)** | |---| | [Issue #454927488](https://issuetracker.google.com/issues/454927488) R8 optimized resource shrinking silently fails if using final resource IDs | |
\ No newline at end of file diff --git a/build-system/agp/agp-9-upgrade/references/buildconfig.md b/build/agp/agp-9-upgrade/references/buildconfig.md similarity index 90% rename from build-system/agp/agp-9-upgrade/references/buildconfig.md rename to build/agp/agp-9-upgrade/references/buildconfig.md index e4e19b6..273f876 100644 --- a/build-system/agp/agp-9-upgrade/references/buildconfig.md +++ b/build/agp/agp-9-upgrade/references/buildconfig.md @@ -37,7 +37,7 @@ In custom build-logic using `CommonExtension`: ### Step 2: Migrate to the new API -Use the **addCustomBuildConfigFields** recipe from the [gradle-recipes](https://developer.android.com/agents/skills/build-system/agp/agp-9-upgrade/references/recipes) +Use the **addCustomBuildConfigFields** recipe from the [gradle-recipes](https://developer.android.com/agents/skills/build/agp/agp-9-upgrade/references/recipes) repository. **IMPORTANT:** For `BuildConfigField`s with a type of `String`, the `value` field diff --git a/build-system/agp/agp-9-upgrade/references/ksp-kapt.md b/build/agp/agp-9-upgrade/references/ksp-kapt.md similarity index 100% rename from build-system/agp/agp-9-upgrade/references/ksp-kapt.md rename to build/agp/agp-9-upgrade/references/ksp-kapt.md diff --git a/build-system/agp/agp-9-upgrade/references/paparazzi-gradle-9.md b/build/agp/agp-9-upgrade/references/paparazzi-gradle-9.md similarity index 100% rename from build-system/agp/agp-9-upgrade/references/paparazzi-gradle-9.md rename to build/agp/agp-9-upgrade/references/paparazzi-gradle-9.md diff --git a/build-system/agp/agp-9-upgrade/references/recipes.md b/build/agp/agp-9-upgrade/references/recipes.md similarity index 94% rename from build-system/agp/agp-9-upgrade/references/recipes.md rename to build/agp/agp-9-upgrade/references/recipes.md index d3ecc74..c26d85f 100644 --- a/build-system/agp/agp-9-upgrade/references/recipes.md +++ b/build/agp/agp-9-upgrade/references/recipes.md @@ -4,7 +4,8 @@ migrated. ## Guidelines -- **DO NOT** search the web for examples of how to do this. Use the **gradle-recipes** repository examples **only**. +- **DO NOT** search the web for examples of how to do this. Use the + **gradle-recipes** repository examples **only**. - **DO NOT** use AGP internals in migrated code. - **DO** use only public APIs in migrated code. @@ -38,7 +39,7 @@ Additional details for each use-case follow. ### Add custom BuildConfig fields -See the detailed guide at [BuildConfig](https://developer.android.com/agents/skills/build-system/agp/agp-9-upgrade/references/buildconfig). +See the detailed guide at [BuildConfig](https://developer.android.com/agents/skills/build/agp/agp-9-upgrade/references/buildconfig). ### Renaming an APK diff --git a/camera/camerax/SKILL.md b/camera/camerax/SKILL.md deleted file mode 100644 index 3963a12..0000000 --- a/camera/camerax/SKILL.md +++ /dev/null @@ -1,132 +0,0 @@ ---- -name: camerax -description: Provide technical guidance for Android camera development with CameraX. - Use when implementing camera features, handling asynchronous recording lifecycles, - wiring low-level hardware interop using CameraX, or integrating ML Kit or Media3 - effects. -license: Complete terms in LICENSE.txt -metadata: - author: Google LLC - last-updated: '2026-09-24' - keywords: - - recipe - - Android - - Camera - - Camera1 - - Camera2 - - CameraX - - migration - - Compose - - guide - - dependencies - - PreviewView - - CameraXViewfinder - - ImageCapture - - VideoCapture - - ImageAnalysis. ---- - -This skill provides procedural guidance and standard patterns for building -camera applications on Android, with a focus on CameraX, including its -`Camera2Interop` utilities, and Media3 integrations. - -## Core workflows - -### Handling immutable API patterns - -Various Android camera and media APIs, especially CameraX `VideoCapture`, use a -**fluent, immutable builder-like pattern** where methods return a new instance. -Failing to reassign these results in settings, such as audio, being ignored. - -**Pattern: Reassignment is required** - - -```kotlin -// WRONG -run { - val pending = recorder.prepareRecording(context, opts) - pending.withAudioEnabled() // This returns a new instance which is ignored - val active = pending.start(exec, listener) -} - -// CORRECT -run { - val pending = recorder.prepareRecording(context, opts) - .withAudioEnabled() // Chaining works - val active = pending.start(exec, listener) -} - -// ALSO CORRECT -run { - var pending = recorder.prepareRecording(context, opts) - pending = pending.withAudioEnabled() // Reassignment - val active = pending.start(exec, listener) -} -``` - -
- -See [immutability](references/immutability.md) for a list of affected classes. - -### Migrating to CameraX - -When migrating legacy camera codebases to the CameraX Jetpack library: - -- **Camera1 to CameraX** : For migrating legacy `android.hardware.Camera` implementations, surface handling, and manual lifecycles, see the [Camera1 migration guide](references/camera1-to-camerax.md). -- **Camera2 to CameraX** : For migrating more recent but verbose `android.hardware.camera2` implementations, session state callbacks, and interop patterns, see the [Camera2 migration guide](references/camera2-to-camerax.md). - -### Comprehensive feature blueprinting - -For multi-step features that involve multiple files and hardware-level wiring, -follow the [Structural Blueprinting](references/expert-blueprints.md) approach to avoid -system timeouts. Such complex features include: - -- **Manual controls** : Break down into the `ViewModel` state, the controller layer, and the `Camera2Interop` wiring in the session. -- **RAW capture**: Separate JPEG and RAW output configurations into discrete build steps. -- **Custom effects** : Prefer `Media3Effect` or `SurfaceProcessor` over manual OpenGL pipelines unless absolute performance is required. -- **Low-light** : See [low-light](references/low-light.md) for Night Mode and LLB guidance. -- **Foldables** : See [foldables](references/foldables.md) for handling dynamic postures and hinge states. -- **XR, AR, and VR** : See [xr](references/xr.md) for spatial tracking, passthrough synchronization, and latency guardrails. -- **Thermals and power** : See [thermals](references/thermals.md) for managing `StreamUseCase` optimizations and `PowerManager` thermal states. -- **Testing and mocking** : See [testing](references/testing.md) for using `FakeCameraConfig`, handling asynchronous lifecycles, and validating analysis pipelines. -- **ML Kit spatial analysis** : See [mlkit-spatial](references/mlkit-spatial.md) for coordinate mapping, rotation logic, and mirrored lens handling. -- **Wear OS camera remote** : See [wear-os](references/wear-os.md) for circular UI constraints, Data Layer API syncing, and remote trigger logic. - -See [expert-blueprints](references/expert-blueprints.md) for step-by-step guides. - -### API discovery - -Always use higher-level abstractions instead of low-level manual wiring: - -- **Analysis** : Use `MlKitAnalyzer` instead of manual `ImageAnalysis.Analyzer`. -- **Filters and effects** : Use `Media3Effect` for standard post-processing. -- **Multi-camera** : Use `ConcurrentCamera` APIs for dual-stream setups. - -See [modern-apis](references/modern-apis.md) for current recommendations. - -### Code quality and architectural rules - -Adhere to the following Android ecosystem standard patterns when building your -camera implementations: - -- **Testing, fakes over mocks** : Avoid mocking libraries like `Mockito`, especially for multi-step CameraX interfaces like `ImageProxy`. Build "Fakes" to verify state rather than unreliable implementation details. -- **Google Truth assertions** : Use `assertThat` over standard `JUnit` assertions like `assertEquals` for improved readability. -- **Explicit test runners** : Always define an explicit `@RunWith` for test classes to ensure the CI environment executes them correctly. -- **Semantic UI merging** : When building custom camera controls in Compose, such as a button with an `Icon` and `Text`, use `semantics { - mergeDescendants = true }` to ensure screen readers announce them as a single, coherent unit. - -## Hardware and device diversity - -Camera apps run on a wide variety of hardware, from mobile phones and -foldables to tablets, laptops, and even smart appliances. Have consideration -for the specific hardware the app is running on. - -- **Form factors**: Account for screen size and orientation changes on foldables and tablets. -- **Multi-camera arrays**: Some devices have a rear-facing camera and a front-facing camera. Other devices have multiple rear-facing cameras, such as wide-angle and telephoto lenses. -- **Feature parity**: Features like flash or auto-focus behave differently across hardware. For example, CameraX handles both physical flash, back, and screen-based flash, front, and both must be considered when implementing flash functionality. - -## Common pitfalls - -- **Asynchronous lifecycles** : Check `isRecording` state before attempting to stop or pause. Handle `VideoRecordEvent.Start` for UI state updates, not just the initial call. -- **Thread safety**: Camera callbacks often run on background executors. Dispatch UI updates on the main thread. -- **Permission handling** : Check `CAMERA` permission; check for `RECORD_AUDIO` specifically when enabling audio in `VideoCapture`. diff --git a/camera/camerax/references/camera1-to-camerax.md b/camera/camerax/references/camera1-to-camerax.md deleted file mode 100644 index 9c6e80e..0000000 --- a/camera/camerax/references/camera1-to-camerax.md +++ /dev/null @@ -1,198 +0,0 @@ -## Remove `Camera1` implementation - -1. Delete all `android.hardware.Camera` instances. -2. Delete `SurfaceView` and `SurfaceHolder.Callback` implementations `surfaceCreated`, `surfaceChanged`, and `surfaceDestroyed`. -3. Remove custom lifecycle handling that opens or releases the camera in `onResume` or `onPause`. -4. Remove manual matrix calculations for orientation. - -## Initialize `ProcessCameraProvider` - -Request the `ProcessCameraProvider` and bind use cases to the Activity or -Fragment lifecycle. - - -```kotlin -val context = LocalContext.current -val lifecycleOwner = LocalLifecycleOwner.current -LaunchedEffect(context, lifecycleOwner) { - val cameraProvider = ProcessCameraProvider.getInstance(context).await() - - val cameraSelector = CameraSelector.Builder() - .requireLensFacing(CameraSelector.LENS_FACING_BACK) - .build() - - val preview = Preview.Builder().build() - val imageCapture = ImageCapture.Builder() - .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) - .build() - - cameraProvider.unbindAll() // Unbind before rebinding - - val camera = cameraProvider.bindToLifecycle( - lifecycleOwner, - cameraSelector, - preview, - imageCapture - ) - val cameraControl = camera.cameraControl -} -``` - -
- -## Implement the preview and tap-to-focus - -Choose exactly one of the following patterns based on the app's UI toolkit: - -### Option A: For Android Views - -Use `androidx.camera.view.PreviewView`. - -1. **Set up preview**: - - - ```kotlin - preview.setSurfaceProvider(previewView.surfaceProvider) - ``` - -
- -2. **Handle tap-to-focus**: - - - ```kotlin - val factory = previewView.meteringPointFactory - val point = factory.createPoint(x, y) // x, y from touch event - val action = FocusMeteringAction.Builder(point, FocusMeteringAction.FLAG_AF).build() - cameraControl?.startFocusAndMetering(action) - ``` - -
- -### Option B: For Jetpack Compose - -Use `androidx.camera.compose.CameraXViewfinder`. - -1. **Set up preview and SurfaceRequest**: - - - ```kotlin - var surfaceRequest by remember { mutableStateOf(null) } - val preview = remember { - Preview.Builder().build().apply { - setSurfaceProvider { request -> surfaceRequest = request } - } - } - ``` - -
- -2. **Render viewfinder**: - - - ```kotlin - surfaceRequest?.let { request -> - CameraXViewfinder( - surfaceRequest = request, - coordinateTransformer = coordinateTransformer, - modifier = Modifier - ) - } - ``` - -
- -3. **Handle tap-to-focus in Compose**: - - - ```kotlin - // Inside your tap gesture handler... - val surfaceCoords = with(coordinateTransformer) { offset.transform() } - val factory = SurfaceOrientedMeteringPointFactory( - request.resolution.width.toFloat(), - request.resolution.height.toFloat() - ) - val point = factory.createPoint(surfaceCoords.x, surfaceCoords.y) - val action = FocusMeteringAction.Builder(point, FocusMeteringAction.FLAG_AF).build() - cameraControl?.startFocusAndMetering(action) - ``` - -
- -4. **Update target rotation for Compose**: - - - ```kotlin - LaunchedEffect(configuration) { - if (!view.isInEditMode) { - val rotation = view.display?.rotation ?: Surface.ROTATION_0 - imageCapture.targetRotation = rotation - preview.targetRotation = rotation - } - } - ``` - -
- -## Capture a photo - -Use the `ImageCapture` use case to take the picture. The `ImageProxy` handles -rotation directly. - - -```kotlin -imageCapture.takePicture( - cameraExecutor, - object : ImageCapture.OnImageCapturedCallback() { - override fun onCaptureSuccess(image: ImageProxy) { - val buffer = image.planes[0].buffer - val bytes = ByteArray(buffer.remaining()) - buffer.get(bytes) - val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) - - // Adjust rotation natively via ImageProxy - val matrix = Matrix() - matrix.postRotate(image.imageInfo.rotationDegrees.toFloat()) - if (lensFacing == CameraSelector.LENS_FACING_FRONT) { - matrix.postScale(-1f, 1f) // Mirror for front camera - } - - val rotatedBitmap = Bitmap.createBitmap( - bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true - ) - - // MUST close proxy - image.close() - } - - override fun onError(exception: ImageCaptureException) { - Log.e("CameraX", "Capture failed: ${exception.message}", exception) - } - } -) -``` - -
- -## Switch cameras - -To flip between front and rear cameras, change the `CameraSelector` and -retrigger the `ProcessCameraProvider` logic. - - -```kotlin -lensFacing = if (lensFacing == CameraSelector.LENS_FACING_BACK) { - CameraSelector.LENS_FACING_FRONT -} else { - CameraSelector.LENS_FACING_BACK -} -``` - -
- -## Follow constraints - -- **Don't manage the camera lifecycle manually** : Bind the camera to a `LifecycleOwner` through the `ProcessCameraProvider`. Avoid manual camera open or close logic in `onResume` or `onPause`. -- **Don't calculate focus matrices manually** : `MeteringPointFactory` handles coordinate transformations, including device rotation offsets. Avoid custom matrix implementations. -- **Don't forget to close the `ImageProxy`** : Remember to invoke `image.close()` in the capture callback. Skipping this call locks the capture pipeline and interrupts subsequent photos. -- **Don't wrap `PreviewView` in `AndroidView` for Compose code** : For Compose UI layouts, use `CameraXViewfinder`. Compiling `PreviewView` in an `AndroidView` is an earlier fallback option that introduces resizing issues. \ No newline at end of file diff --git a/camera/camerax/references/camera2-to-camerax.md b/camera/camerax/references/camera2-to-camerax.md deleted file mode 100644 index 5b21f87..0000000 --- a/camera/camerax/references/camera2-to-camerax.md +++ /dev/null @@ -1,193 +0,0 @@ -Camera2 offers granular control but introduces boilerplate: managing -`CameraDevice` states, `CameraCaptureSession` lifecycles, background threads, -`HandlerThread`, and manual orientation calculations. - -CameraX simplifies this by binding high-level `UseCase`s such as `Preview`, -`ImageCapture`, and `ImageAnalysis` directly to Android lifecycles, handling -thread management and device-specific workarounds automatically. - -*** ** * ** *** - -## Remove Camera2 boilerplate - -Migrating to CameraX removes manual setup code: - -1. **Delete manual thread handling** : Remove `HandlerThread`, `Handler`, and executors dedicated to camera background tasks. CameraX manages its own threads. -2. **Remove session and device callbacks** : Delete `CameraDevice.StateCallback` and `CameraCaptureSession.StateCallback` implementations. -3. **Remove surface management** : Remove manual routing of `Surface` objects from `TextureView` or `SurfaceView` to the camera device. -4. **Remove manual orientation logic** : Delete calculations involving `CameraCharacteristics.SENSOR_ORIENTATION` and display rotation for adjusting preview and capture orientation. - -*** ** * ** *** - -## Initialize `ProcessCameraProvider` - -Request the `ProcessCameraProvider` and bind your use cases to the -`LifecycleOwner` activity or fragment. This replaces the -`CameraManager.openCamera` flow. - - -```kotlin -val context = LocalContext.current -val lifecycleOwner = LocalLifecycleOwner.current -LaunchedEffect(context, lifecycleOwner) { - val cameraProvider = ProcessCameraProvider.getInstance(context).await() - - val cameraSelector = CameraSelector.Builder() - .requireLensFacing(CameraSelector.LENS_FACING_BACK) - .build() - - val preview = Preview.Builder().build() - val imageCapture = ImageCapture.Builder() - .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) - .build() - - // ImageAnalysis is common when migrating from Camera2 ImageReader - val imageAnalysis = ImageAnalysis.Builder() - .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) - .build() - - cameraProvider.unbindAll() - - val camera = cameraProvider.bindToLifecycle( - lifecycleOwner, - cameraSelector, - preview, - imageCapture, - imageAnalysis - ) -} -``` - -
- -*** ** * ** *** - -## Implement the preview and tap-to-focus - -CameraX handles surface configuration automatically. Choose based on your UI -toolkit: - -### Option A: For Android Views - -Use `androidx.camera.view.PreviewView` in your layout, and bind it to the -`Preview` use case. - - -```kotlin -preview.setSurfaceProvider(previewView.surfaceProvider) -``` - -
- -### Option B: For Jetpack Compose - -Use `androidx.camera.compose.CameraXViewfinder`. - - -```kotlin -var surfaceRequest by remember { mutableStateOf(null) } -val preview = remember { - Preview.Builder().build().apply { - setSurfaceProvider { request -> surfaceRequest = request } - } -} -``` - -
- -*** ** * ** *** - -## Capture a photo - -Replace `ImageReader` capture flows and -`CaptureRequest.Builder.TEMPLATE_STILL_CAPTURE` with the `ImageCapture` use -case. CameraX handles the rotation natively using the returned `ImageProxy`. - - -```kotlin -imageCapture.takePicture( - cameraExecutor, - object : ImageCapture.OnImageCapturedCallback() { - override fun onCaptureSuccess(image: ImageProxy) { - val buffer = image.planes[0].buffer - val bytes = ByteArray(buffer.remaining()) - buffer.get(bytes) - val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) - if (bitmap != null) { - val matrix = Matrix() - matrix.postRotate(image.imageInfo.rotationDegrees.toFloat()) - if (lensFacing == CameraSelector.LENS_FACING_FRONT) { - matrix.postScale(-1f, 1f) - } - - val rotatedBitmap = Bitmap.createBitmap( - bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true - ) - } - - // MUST close proxy - image.close() - } - - override fun onError(exception: ImageCaptureException) { - Log.e("CameraX", "Capture failed: ${exception.message}", exception) - } - } -) -``` - -
- -*** ** * ** *** - -## Implement image analysis - -If you were using `ImageReader` in Camera2 to access raw frames, e.g., for QR -scanning or ML,, replace it with the CameraX `ImageAnalysis` use case. - - -```kotlin -imageAnalysis.setAnalyzer(cameraExecutor) { imageProxy -> - try { - val rotationDegrees = imageProxy.imageInfo.rotationDegrees - // Process image here (e.g., run object detection) - // ... - } finally { - // MUST close the imageProxy to avoid blocking the pipeline - imageProxy.close() - } -} -``` - -
- -*** ** * ** *** - -## Use Camera2 interop - -If your app requires specific Camera2 configuration options, such as custom -exposure modes or flash settings, that aren't exposed directly in CameraX, -use `Camera2Interop` to apply them to your CameraX use cases. - - -```kotlin -// Use Camera2Interop to set Camera2-specific capture options -val extender = Camera2Interop.Extender(imageCaptureBuilder) -extender.setCaptureRequestOption( - CaptureRequest.CONTROL_AE_MODE, - CaptureRequest.CONTROL_AE_MODE_OFF -).setCaptureRequestOption( - CaptureRequest.FLASH_MODE, - CaptureRequest.FLASH_MODE_TORCH -) -``` - -
- -*** ** * ** *** - -## Follow constraints - -- **Don't open the CameraDevice manually** : Let CameraX manage device opening and closing using `bindToLifecycle`. -- **Don't forget to close `ImageProxy`** : In both `ImageCapture` and `ImageAnalysis` callbacks, you **must** call `image.close()`. Failure to do so will block the camera pipeline. -- **Don't block the ImageAnalysis thread** : The analyzer runs frame-by-frame. If you need to perform heavy computations, offload them to a background worker and close the `ImageProxy` as soon as possible. \ No newline at end of file diff --git a/camera/camerax/references/expert-blueprints.md b/camera/camerax/references/expert-blueprints.md deleted file mode 100644 index e1f2f25..0000000 --- a/camera/camerax/references/expert-blueprints.md +++ /dev/null @@ -1,58 +0,0 @@ -Complex camera features often fail due to "Agent Stall" or timeouts when -attempted in a single turn. Use these blueprints to break tasks into manageable -phases. - -## Manual controls - -### Phase one: ViewModel and state - -1. Define a `ManualSettings` data class. -2. Add a `MutableStateFlow` to your `CameraViewModel`. -3. Implement the Jetpack Compose UI with sliders and switches to update this flow. - -### Phase two: Controller wiring - -1. In `CameraController.kt`, create a new function `updateManualSettings(settings: ManualSettings)`. -2. Map these settings into the `CameraSystem` layer. - -### Phase three: CameraX `Camera2Interop` wiring - -1. In `CameraSession.kt`, use the `Camera2Interop.Extender` utility to access Camera2 capture request keys. -2. Apply the hardware keys: - - `CaptureRequest.SENSOR_SENSITIVITY` to set ISO sensitivity. - - `CaptureRequest.SENSOR_EXPOSURE_TIME` to set exposure time. - - `CaptureRequest.LENS_FOCUS_DISTANCE` to set focus distance. -3. **Critical** : If manual exposure is active, set `CaptureRequest.CONTROL_AE_MODE` to `CameraMetadata.CONTROL_AE_MODE_OFF`. --- - -## RAW and JPEG capture - -### Phase one: Output configuration - -1. Verify device support for RAW capture using `CameraInfo`. -2. Configure `ImageCapture.Builder` with `OUTPUT_FORMAT_RAW_JPEG` or `OUTPUT_FORMAT_RAW`. - -### Phase two: Implementation - -1. Provide `ImageCapture.OutputFileOptions` for the target storage locations. -2. Invoke `takePicture`. CameraX internally manages `DngCreator` to wrap RAW data with the required `CameraCharacteristics` and `CaptureResult` metadata. - -*** ** * ** *** - -## Apply image effects - -### Phase one: Effects selection - -1. Use the `androidx.media3:media3-effect` dependency. -2. Use `RgbFilter` or `HslAdjustment` for standard color grading. - -### Phase two: Application - -1. Configure `Composition.Builder` or `MediaItem.Builder` with the list of effects. -2. Inject the list of effects into the CameraX `Recorder` or `Preview` using the `setEffects` method. - -*** ** * ** *** - -## Low-light capture - -For guidance on Night Mode Extensions and Low Light Boost, -[low-light.md](https://developer.android.com/agents/skills/camera/camerax/references/low-light). \ No newline at end of file diff --git a/camera/camerax/references/foldables.md b/camera/camerax/references/foldables.md deleted file mode 100644 index 22d378a..0000000 --- a/camera/camerax/references/foldables.md +++ /dev/null @@ -1,83 +0,0 @@ -Foldable devices introduce unique challenges for camera applications, including -dynamic layout changes, multiple display orientations, and physical device -postures, such as tabletop and book modes. - -## Manage fold states and postures - -| State | Posture | User interaction | Implementation goal | -|---|---|---|---| -| `FLAT` | Standard | Full screen preview | Conventional mobile phone layout. | -| `HALF_OPENED` | Tabletop | Lower half for controls | Split-screen layout, viewfinder on top, controls on bottom. | -| `HALF_OPENED` | Book | Side-by-side | Viewfinder on one panel, gallery and controls on the other. | - -*** ** * ** *** - -## Follow the implementation guide - -### Detect posture changes - -Use the Jetpack WindowManager library to observe the device's hinge state and -fold layout. - - -```kotlin -lifecycleScope.launch { - lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { - windowInfoTracker.windowLayoutInfo(activity) - .collect { layoutInfo -> - val displayFeature = layoutInfo.displayFeatures - .filterIsInstance() - .firstOrNull() - - updateCameraLayout(displayFeature) - } - } -} -``` - -
- -### Handle tabletop mode - -In Tabletop mode, horizontal fold, you should move the viewfinder to the top -half of the screen and the controls to the bottom half to prevent the user from -seeing a "bent" image. - -- **Identify orientation:** Check `FoldingFeature.orientation`. -- **Calculate geometry:** Use `FoldingFeature.bounds` to identify the hinge's physical location on the screen. -- **Update UI:** Apply padding or constraints to move the `PreviewView` above the hinge. - -### Coordinate mapping and `Viewport` - -When the UI layout changes due to a fold, you **must** update the `Viewport` to -ensure that tap-to-focus and image capture coordinates remain accurate. - - -```kotlin -val viewport = ViewPort.Builder(Rational(viewfinder.width, viewfinder.height), display.rotation) - .setScaleType(ViewPort.FILL_CENTER) - .build() - -val useCaseGroup = UseCaseGroup.Builder() - .addUseCase(preview) - .setViewPort(viewport) - .build() -``` - -
- -### Rear display mode - -Some foldables allow using the rear camera with the cover display while the -device is unfolded. - -- **Verification:** If available through OEM SDKs or Android 14 (API level 34) or higher, check `DeviceState.REAR_DISPLAY_STATE`. -- **Logic:** Handle preview detachment and reattachment on different display surfaces with varying aspect ratios. - -*** ** * ** *** - -## Foldable pitfalls - -- **Hinge distortion:** Don't span the camera preview across a hinge in `HALF_OPENED` state. -- **Physical orientation:** The camera sensor's physical orientation relative to the screen often changes when you fold or unfold the device. Always rely on `CameraInfo.getSensorRotationDegrees`. -- **Latency:** Rebinding `UseCase` objects during a fold event is expensive. Use the internal scaling of `PreviewView` before performing a full `bindToLifecycle` reconfiguration. \ No newline at end of file diff --git a/camera/camerax/references/immutability.md b/camera/camerax/references/immutability.md deleted file mode 100644 index 1dade21..0000000 --- a/camera/camerax/references/immutability.md +++ /dev/null @@ -1,60 +0,0 @@ -Many Android APIs are designed with immutability in mind to prevent race -conditions in async environments. However, this often trips up developers used -to mutable builder patterns. - -## Common immutable classes - -The following classes use fluent APIs that **return a new instance**. You must -reassign the variable. - -| Class | Methods that return a new instance | Result if not reassigned | -|---|---|---| -| `PendingRecording` | `withAudioEnabled`, `asPersistentRecording` | Audio isn't recorded. | -| `ImageCapture.Builder` | `setTargetRotation`, `setTargetResolution` | The output has the wrong orientation. | -| `Recorder.Builder` | `setQualitySelector`, `setExecutor` | The recording uses the default quality. | -| `Viewport.Builder` | `setScaleType`, `setLayoutDirection` | The viewfinder is stretched. | - -## Use standard patterns - -### CameraX video recording - -To set up video recording, use the following code: - - -```kotlin -// WRONG -run { - val pending = recorder.prepareRecording(context, opts) - pending.withAudioEnabled() // This returns a new instance which is ignored - val active = pending.start(exec, listener) -} - -// CORRECT -run { - val pending = recorder.prepareRecording(context, opts) - .withAudioEnabled() // Chaining works - val active = pending.start(exec, listener) -} - -// ALSO CORRECT -run { - var pending = recorder.prepareRecording(context, opts) - pending = pending.withAudioEnabled() // Reassignment - val active = pending.start(exec, listener) -} -``` - -
- -### Viewport construction - -To set up the viewport, use the following code: - - -```kotlin -val viewport = ViewPort.Builder(Rational(width, height), displayRotation) - .setScaleType(ViewPort.FILL_CENTER) - .build() -``` - -
\ No newline at end of file diff --git a/camera/camerax/references/low-light.md b/camera/camerax/references/low-light.md deleted file mode 100644 index e03e00c..0000000 --- a/camera/camerax/references/low-light.md +++ /dev/null @@ -1,124 +0,0 @@ -This guide covers implementing low-light features using **Night mode -extensions** and **Low Light Boost (LLB)**. - -## Choosing the right tool - -| Feature | Used for | Implementation | UX impact | -|---|---|---|---| -| **Night mode** | High-quality stills | `ExtensionsManager` | Possibly requires user to hold still for several seconds. | -| **LLB, AE mode** | Real-time preview or video | `Camera2Interop`, CameraX utility | Hardware drops the frame rate to increase brightness. | -| **LLB, Play services** | Real-time preview and video | `SurfaceProcessor` | Software-based brightening; maintains a higher frame rate. | - -*** ** * ** *** - -## Night mode extension - -CameraX Extensions provide access to the device's built-in computational -photography pipeline. - -### Basic setup - -To set up the extension, initialize the extension manager: - - -```kotlin -// Use ListenableFuture.await() extension function for coroutine support -val extensionsManager = ExtensionsManager.getInstanceAsync(context, cameraProvider).await() -if (extensionsManager.isExtensionAvailable(cameraSelector, ExtensionMode.NIGHT)) { - val nightSelector = extensionsManager.getExtensionEnabledCameraSelector( - cameraSelector, ExtensionMode.NIGHT - ) - cameraProvider.bindToLifecycle(lifecycleOwner, nightSelector, imageCapture, preview) -} -``` - -
- -### Comprehensive features - -- **Image postview** : Display a low-resolution image immediately while the multi-frame processing occurs. - - ```kotlin - val imageCapture = ImageCapture.Builder() - .setPostviewEnabled(true) - .build() - ``` -- **Extension strength** : Let users control the intensity of the night effect. - - ```kotlin - // Set the strength of the active extension (e.g. NIGHT mode intensity) - val extensionsManager = ExtensionsManager.getInstanceAsync(context, cameraProvider).await() - val extensionsControl = extensionsManager.getCameraExtensionsControl(camera.cameraControl) - extensionsControl?.setExtensionStrength(strength) - ``` -- **Capture progress** : Show a UI progress bar for long exposures. - - ```kotlin - // Use the suspend extension function for takePicture to avoid callback boilerplate - try { - val result = imageCapture.takePicture(outputOptions) - // Use result.savedUri or other fields - } catch (e: ImageCaptureException) { - // Handle capture failure - } - ``` - -*** ** * ** *** - -## Low-light boost - -LLB is designed for preview and video streams where you prefer high frame rates. - -### AE mode - -The built-in CameraX way to prioritize brightness. It modifies the hardware's -auto-exposure algorithm. - -- **Activation** : Use `CameraControl.enableLowLightBoostAsync`. -- **Implementation** : - - ```kotlin - // Enable Low Light Boost (LLB) natively in CameraX 1.4+ - camera.cameraControl.enableLowLightBoostAsync(true) - ``` -- **Monitoring** : Observe `CameraInfo.lowLightBoostState` to track when the hardware actively applies the enhancement. - -### Google Play services LLB - -It's a multi-step implementation that uses a session-based `SurfaceProcessor`. - -**Dependency** : `com.google.android.gms:play-services-camera-low-light-boost` - -To implement Google Play services LLB, follow these core steps: - -1. **Initialize client** : `val client = LowLightBoost.getClient`. -2. **Implement `SurfaceProcessor`** : - - **Manage session** : Call `client.createSession`. - - **Forward required metadata** : Observe the camera's `TotalCaptureResult` stream and forward every result to the session: `session.processCaptureResult`. - - **Provide surface** : Get the input surface from the session, `session.getCameraSurface`, and provide it to the camera's `SurfaceRequest`. - - **Lifecycle** : Release the session, `session.release`, when the processor is closed or the `SurfaceRequest` completes. -3. **Wire using `CameraEffect`** : - - ```kotlin - val effect = SimpleCameraEffect( - CameraEffect.PREVIEW or CameraEffect.VIDEO_CAPTURE, - executor, - llbSurfaceProcessor - ) { throw it } - - // Add to UseCaseGroup - val useCaseGroup = UseCaseGroup.Builder() - .addUseCase(preview) - .addUseCase(videoCapture) - .addEffect(effect) - .build() - ``` -4. **Scene detection** : Use `session.setSceneDetectorCallback` to receive `boostStrength` updates for real-time UI indicators. - -*** ** * ** *** - -## Implementation notes - -- **Thread safety** : Always handle `ExtensionsManager` and `LowLightBoostClient` initialization asynchronously. -- **FPS trade-offs**: AE mode LLB often drops the frame rate significantly to increase brightness. -- **Compatibility** : Extensions, Night Mode, possibly conflict with `ConcurrentCamera`. Always verify support before binding. \ No newline at end of file diff --git a/camera/camerax/references/mlkit-spatial.md b/camera/camerax/references/mlkit-spatial.md deleted file mode 100644 index 833daf1..0000000 --- a/camera/camerax/references/mlkit-spatial.md +++ /dev/null @@ -1,75 +0,0 @@ -When you use ML Kit for features such as face mesh, object detection, or pose -detection, the most common failure point is the coordinate disparity between the -analysis image and the viewfinder UI. - -## The mapping mindset - -| Dimension | Analysis frame | Viewfinder UI on the screen | -|---|---|---| -| Resolution | Fixed, such as 640x480 | Dynamic, such as 1080x2400 | -| Rotation | 0° for the raw buffer | 90° or 270° in portrait or landscape mode | -| Origin | Top-left of buffer at (0,0) | Top-left of screen at (0,0) | - -*** ** * ** *** - -## Follow the implementation guide - -### Coordinate transformation matrix - -Android provides the `Viewport` and `UseCaseGroup` APIs to calculate the -transformation matrix automatically. **Don't** calculate aspect ratio scaling -manually. - - -```kotlin -val transform = previewView.viewPort?.let { viewPort -> - // Use CameraX's built-in coordinate mapper - viewPort.getTransformationMatrix(imageProxy.imageInfo.rotationDegrees) -} -``` - -
- -### Handling the "double rotation" bug - -ML Kit results, bounding boxes, are relative to the **rotated buffer**. If the -device is in portrait, the buffer is often 480x640, landscape, but the screen -is 1080x1920. - -To map the coordinates, use the following workflow: - -1. Query `imageProxy.imageInfo.rotationDegrees`. -2. Pass this rotation to the ML Kit `InputImage`. -3. Use the `MappingUtils.transformRect` method to map the result `Rect` to the screen. - -### Face mesh and pose normalization - -For high-precision spatial analysis, for example, "Is the user's hand at a -specific screen button?", use **normalized coordinates from 0.0 to 1.0**. - - -```kotlin -// Example: Converting a Pose landmark to a Screen Coordinate -val screenX = landmark.position.x / analysisWidth * screenWidth -val screenY = landmark.position.y / analysisHeight * screenHeight -``` - -
- -**Warning** : Always account for **mirrored lenses** . If the `LENS_FACING_FRONT` -is used, you must flip the X-coordinate: `actualX = screenWidth - screenX`. - -### Overlays and canvas clipping - -Use a custom `GraphicOverlay` view on top of the `PreviewView`. - -- **Buffer lock** : Ensure your `GraphicOverlay` clears its canvas every time a new `ImageAnalysis` frame is processed to prevent "ghosting" of bounding boxes. - -*** ** * ** *** - -## Spatial pitfalls - -- **The "stretched box" bug** : Caused by assuming the Analysis Frame aspect ratio, 4:3, matches the screen aspect ratio, 21:9. Use `PreviewView.SCALE_TYPE_FILL_CENTER` and map coordinates accordingly. -- **Latency** : If ML processing exceeds 50 ms, the bounding box trails behind the user's face. - - **Fix** : Use `ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST` to avoid queuing stale frames. -- **Sensor vs. display rotation** : On some tablets, the sensor is mounted horizontally. Always query `display.rotation` and `cameraInfo.sensorRotationDegrees`. \ No newline at end of file diff --git a/camera/camerax/references/modern-apis.md b/camera/camerax/references/modern-apis.md deleted file mode 100644 index 616d549..0000000 --- a/camera/camerax/references/modern-apis.md +++ /dev/null @@ -1,42 +0,0 @@ -Always prefer these various abstractions over legacy Camera2 or early CameraX -implementations. - -## Compare APIs - -| Use case | Legacy or verbose way | Recommendation | -|---|---|---| -| QR or face scanning | `ImageAnalysis.Analyzer` and manual ByteBuffer math | **`MlKitAnalyzer`**, which automates coordinate mapping and multi-format support | -| Post-processing | Custom OpenGL shaders or `SurfaceTexture` | **`Media3Effect`** for composable, declarative effects | -| Dual camera | Manual binding of two UseCases | **`ConcurrentCamera`**, which provides built-in support in CameraX 1.3 and higher | -| High dynamic range | Manual bit-depth and profile config | **`DynamicRange`** , which uses `DYNAMIC_RANGE_HLG10` or `SDR` | -| Zoom and focus | `Camera2Interop` for CameraX-to-Camera2 mapping | **`CameraControl.setZoomRatio`** or **`setLinearZoom`** | - -## Hardware awareness - -Modern APIs abstract away the complexity of hardware diversity. - -- **`CameraSelector`** : Use `DEFAULT_BACK_CAMERA` or `DEFAULT_FRONT_CAMERA` instead of hardcoding camera IDs. Use `filter` if you need specific lens capabilities. -- **Extensions** : Before enabling advanced modes, such as night, bokeh, and face retouch, use `ExtensionsManager` to query whether the device supports them. -- **Foldables** : Observe `Lifecycle` and `Viewport` updates to handle posture changes, like a half-opened posture, on foldable devices. - -## Required dependencies - -Add the following dependencies to your `libs.versions.toml` file: - - # CameraX ML Kit - - androidx-camera-mlkit-vision = { group = "androidx.camera", name = - "camera-mlkit-vision", version.ref = "camerax" } - - # Media3 effects - - androidx-media3-effect = { group = "androidx.media3", name = "media3-effect", - version.ref = "media3" } - - # Camera extensions - - androidx-camera-extensions = { group = "androidx.camera", name = - "camera-extensions", version.ref = "camerax" } - -Refer to the official [CameraX Release Notes](https://developer.android.com/jetpack/androidx/releases/camera) for the -stable versions. \ No newline at end of file diff --git a/camera/camerax/references/testing.md b/camera/camerax/references/testing.md deleted file mode 100644 index 972f285..0000000 --- a/camera/camerax/references/testing.md +++ /dev/null @@ -1,69 +0,0 @@ -Automated testing for camera features is notoriously difficult because you -can't easily mock physical hardware, lighting, or motion. This guide provides -patterns for reliable, hermetic camera tests. - -## Develop a testing mindset - -| Challenge | Conventional approach | Camera technical approach | -|---|---|---| -| **Frameworks** | `Mockito` or `MockK` | **Fakes over mocks** | -| **Assertions** | `assertEquals`, `assertTrue` | **Google Truth, `assertThat`** | -| **Environment** | Implied environments | **Explicit `@RunWith` annotations** | -| **Async operations** | `Thread.sleep` | **Explicit `timeoutMillis` or `IdlingResource`** | - -*** ** * ** *** - -## Follow the implementation guide - -### Fakes over mocks - -**Don't use Mockito.** Relying on mocks for complex, rapidly changing interfaces -like `ImageProxy` or `CameraInfo` makes tests brittle. Instead, build "Fake" -implementations that verify state rather than behavior. - - -```kotlin -// Create a Fake ImageProxy for ML Testing (Fakes over Mocks) -val fakeImage = FakeImageProxy(w = 640, h = 480) - -// Feed the fake buffer into your analyzer -``` - -
- -### Mock camera capabilities - -Use `FakeAppConfig` from `androidx.camera:camera-testing` to simulate specific -hardware constraints in tests, such as a device without a flash. - - -```kotlin -// Use awaitInstance() extension function for coroutine-based provider retrieval -val cameraProvider = ProcessCameraProvider.awaitInstance(context) -``` - -
- -### Use Truth assertions - -Use Google Truth, `assertThat`, instead of standard JUnit assertions. It -provides more readable assertion chains and useful failure messages. - -### Test asynchronous lifecycles - -Camera initialization is asynchronous. Use `IdlingResource` to ensure -your test waits for the `UseCase` to be bound before asserting. - -To test asynchronous lifecycles, use the following pattern: - -1. Wrap the `ProcessCameraProvider` initialization in a `CountDownLatch` or `IdlingResource`. -2. Assert only after the `cameraControl` instance is non-null. - -*** ** * ** *** - -## Testing pitfalls - -- **Resource leaks** : To prevent "Camera in Use" errors, in your `@After` block, call `cameraProvider.unbindAll`. -- **The "flaky initializer"**: Camera tests often fail on CI because the "Virtual Camera" takes too long to warm up. Use a sufficient explicit timeout for the first initialization. -- **Permission blockers** : To bypass the system permission dialogs, in your Espresso tests, use `GrantPermissionRule`. -- **Resolution mismatch** : Tests on emulators often default to 640x480. Ensure your `ResolutionSelector` handles this low-res fallback correctly. \ No newline at end of file diff --git a/camera/camerax/references/thermals.md b/camera/camerax/references/thermals.md deleted file mode 100644 index 41cfe19..0000000 --- a/camera/camerax/references/thermals.md +++ /dev/null @@ -1,85 +0,0 @@ -Camera operations are among the most power-intensive tasks on mobile devices. -Without proactive management, the system throttle hardware, drop frames, or -force-close the camera app. - -## The thermal management strategy - -| Priority | Strategy | Implementation | -|---|---|---| -| **1. Inform** | Use case hints | Provide `StreamUseCase` to allow the OS to optimize hardware. | -| **2. Monitor** | Thermal state listener | Observe `PowerManager.addThermalStatusListener`. | -| **3. Act** | Graceful degradation | Dynamically reduce FPS, resolution, or disable demanding effects such as HDR. | - -*** ** * ** *** - -## Follow the implementation guide - -### Stream use case optimization - -Android 13 (API level 33) introduced `StreamUseCase`. This is the **single most -effective** way to tell the hardware how to balance quality versus power. - - -```kotlin -// In CameraX: Set the hint on your Use Case -val preview = Preview.Builder() - .setTargetName("Preview") - .apply { - Camera2Interop.Extender(this).setStreamUseCase( - CameraMetadata.SCALER_AVAILABLE_STREAM_USE_CASES_VIDEO_CALL.toLong() - ) - } - .build() -``` - -
- -Review the following key use cases for stream optimization: - -- `PREVIEW`: This option is the default and provides a balanced configuration. -- `STILL_CAPTURE`: This option provides high-quality capture for short bursts. -- `VIDEO_RECORD`: This option maintains sustained power and is optimized for encoding. -- `VIDEO_CALL`: This option minimizes power consumption for long-duration sessions. - -### Monitor thermal status - -Don't wait for a crash. Monitor the `PowerManager` status and react before -`THERMAL_STATUS_CRITICAL`. - - -```kotlin -val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager -powerManager.addThermalStatusListener { status -> - when (status) { - PowerManager.THERMAL_STATUS_MODERATE -> { - // Signal to UI: "Device is warming up" - } - PowerManager.THERMAL_STATUS_SEVERE -> { - // ACTION: Reduce Frame Rate from 60fps to 30fps - // ACTION: Disable HDR or High-Quality Post-processing - } - PowerManager.THERMAL_STATUS_CRITICAL -> { - // ACTION: Close the camera session to prevent hardware damage - } - } -} -``` - -
- -### Graceful degradation tiers - -| Tier | Action | User impact | -|---|---|---| -| **Mild** | Stop background analysis using ML Kit | Minimal | -| **Moderate** | Cap frame rate to 30 FPS | Noticeable but smooth | -| **Severe** | Drop resolution from 1080p to 720p | Significant visual change | -| **Critical** | Shut down the session | App unusable in safe mode | - -*** ** * ** *** - -## Thermal pitfalls - -- **The "double work" bug**: Don't run two high-resolution streams---such as a preview and a video capture stream---at different aspect ratios unless necessary. This forces the image signal processor (ISP) to perform double the scaling work, which generates excessive heat. -- **Surface overload** : Don't use multi-step `SurfaceProcessor` or `Media3Effect` chains during `THERMAL_STATUS_SEVERE`. -- **Flash heat** : Flash or torch usage generates high thermal load. Proactively disable the flash if thermal status is `SEVERE`. \ No newline at end of file diff --git a/camera/camerax/references/wear-os.md b/camera/camerax/references/wear-os.md deleted file mode 100644 index 4a942db..0000000 --- a/camera/camerax/references/wear-os.md +++ /dev/null @@ -1,76 +0,0 @@ -Developing camera features for Wear OS is rarely about the watch's own lens, if -it even has one. It's almost always about creating a **Remote Viewfinder** to -control the phone's camera. - -## The Wear OS remote mindset - -| Feature | Phone camera app | Wear OS remote app | -|---|---|---| -| **Screen** | Rectangular (Large) | **Circular and less than 2 inches in size** | -| **Connectivity** | Local Hardware | **Bluetooth or Wi-Fi data layer API** | -| **Latency** | Direct and less than 20 ms | **Networked, 100 ms to 500 ms** | -| **Interaction** | Multi-touch gestures | **Rotary input or single taps** | - -*** ** * ** *** - -## Follow the implementation guide - -### The circular UI challenge - -Wear OS devices are often round. Standard rectangular layouts clip corner -buttons. - -Follow these blueprint recommendations: - -- Use `Horologist` or `Wear Compose` libraries. -- Use `ScalingLazyColumn` for lists so that items stay within the "safe zone" of the circular display. -- **Preview scaling**: Crop the center of the rectangular phone viewfinder to fit the circular watch screen. - -### Stream the viewfinder - -You can't send a raw 60 fps stream over Bluetooth. compress and throttle. - - -```kotlin -// Example: Sending a viewfinder frame to the watch -val bitmap = previewView.bitmap // Capture current frame -if (bitmap != null) { - val compressed = compressToJpeg(bitmap, quality = 50) - val request = PutDataMapRequest.create("/camera/preview").apply { - dataMap.putAsset("image", Asset.createFromBytes(compressed)) - } - Wearable.getDataClient(context).putDataItem(request.asPutDataRequest()) -} -``` - -
- -**Optimization** : Cap the watch preview at **10-15 fps** to preserve battery and -bandwidth. - -### Remote triggers and syncing - -Use the `MessageClient` for low-latency commands like "Take Photo" or "Switch -Camera." - - -```kotlin -// Watch sends a trigger to the phone -Wearable.getMessageClient(context).sendMessage(nodeId, "/camera/capture", null) -``` - -
- -### Rotary input support - -On devices that support it, use the physical crown, Rotary Input, to control -**Zoom** or **Exposure**. - -*** ** * ** *** - -## Wear OS pitfalls - -- **Corner clipping** : Placing a **Close** button in the top-right corner of a square layout makes it impossible to tap the button on a round watch display. -- **Battery drain**: Sustained Bluetooth data transfer, viewfinder sync, drains watch battery. Proactively close the remote app if the phone screen is turned off. -- **Node discovery** : The phone is possibly connected to multiple "Nodes" (watches, earbuds, or tablets). Ensure your `CapabilityClient` filters for the specific `camera_remote_host` capability. -- **Disconnect handling**: If the watch disconnects, the phone camera must stop its high-power preview to save energy. \ No newline at end of file diff --git a/camera/camerax/references/xr.md b/camera/camerax/references/xr.md deleted file mode 100644 index b699979..0000000 --- a/camera/camerax/references/xr.md +++ /dev/null @@ -1,55 +0,0 @@ -Developing camera features for XR devices, headsets, and AR glasses requires a -shift from 2D pixel-pushing to 3D spatial awareness. - -## Understand the XR development mindset - -| Concept | Mobile focus | **XR focus** | -|---|---|---| -| **Input** | Raw camera stream | **Spatial tracking (visual-inertial odometry (VIO) or simultaneous localization and mapping (SLAM))** | -| **Output** | Screen viewfinder | **Stereo passthrough and occlusion** | -| **Constraint** | Battery life | **Motion-to-photon latency (less than 20 ms)** | - -*** ** * ** *** - -## Follow the implementation guide - -### API selection - -On XR devices, standard `CameraX` implementations are often restricted or -insufficient. Always use spatial software development kits (SDKs): - -- **ARCore**: Use ARCore for plane detection, depth sensing, and motion tracking. -- **OpenXR**: Use OpenXR as the cross-platform standard for VR and AR rendering and input. -- **OEM SDKs**: Use manufacturer-specific libraries for hardware-accelerated passthrough. - -### Handle spatial passthrough - -Unlike a 2D viewport, XR passthrough is often system-managed. - -**\[Key requirement\] Frame synchronization**: Synchronize your application's -frame clock with the headset's head-mounted display (HMD) pose. - - -```kotlin -// Example: Querying the spatial pose for the current camera frame -val headPose = xrSession.getHeadPose(frameTime) -val projectionMatrix = headPose.getProjectionMatrix(eyeIndex) -``` - -
- -### Manage depth and occlusion - -Digital content must respect real-world depth to ensure accurate occlusion. - -- **Depth map** : Access raw depth data using `ARCore` or `SurfaceProcessor` to create an occlusion mask. -- **Hardware buffers** : Use `HardwareBuffer` to share camera frames directly with the GPU without CPU-side copies to minimize latency. - -*** ** * ** *** - -## XR pitfalls - -- **The nausea limit**: Any processing that delays the viewfinder by more than 20 ms causes user sickness. Don't perform image processing on the main thread. -- **Privacy restrictions** : Some XR devices return a black frame if you attempt to record the "Passthrough" layer. Check `Session.isRecordingSupported`. -- **Field of view (FOV)**: The camera FOV possibly doesn't match the display FOV. Use the SDK's projection matrixes instead of calculating aspect ratios manually. -- **Front buffer rendering** : If the device supports it, use `FrontBufferRenderer` for real-time overlays to bypass standard double-buffering latency. \ No newline at end of file diff --git a/device-ai/appfunctions/SKILL.md b/device-ai/appfunctions/SKILL.md deleted file mode 100644 index 17f3f91..0000000 --- a/device-ai/appfunctions/SKILL.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -name: appfunctions -description: Analyzes Android apps to identify key user workflows for AppFunctions - such as creating a note, playing media, or sending an automated or AI agent triggered - message, voice commands, or system shortcuts, without needing to open the app UI. - Generates Kotlin code to expose these workflows to the Android system, allowing - agents to discover and execute them on-device. Also refines KDoc documentation to - ensure AI agents correctly understand and use the provided functionality. -license: Complete terms in LICENSE.txt -metadata: - author: Google LLC - last-updated: '2026-08-14' - keywords: - - AppFunctions - - Kotlin - - KSP - - ADB - - AI - - LLM - - MCP ---- - -Analyzes Android apps to identify key user workflows for AppFunctions such as -creating a note, playing media, or sending an automated or AI agent triggered -message, voice commands, or system shortcuts, without needing to open the app -UI. - -Generates Kotlin code to expose these workflows to the Android system, -allowing agents to discover and execute them on-device. - -Also refines KDoc documentation to ensure AI agents correctly -understand and use the provided functionality. - -## Prerequisites - -The app must **`targetSdk 36`** or newer and use **`compileSdk 37`** or newer as -AppFunctions, part of the Android platform API, are available from Android 16 -onwards. -Always use the Jetpack library because it handles backward compatibility. - -## Workflows - -This skill enables the caller to discover features that will be provided to -system agents, implement these with AppFunctions, improve function description -for agents, and use ADB commands for local evaluation and testing. - -The full AppFunction development flow consists of these four steps: - -- *[Step 1: Discovery](references/feature-discovery-analysis.md)*: Analyze Android codebases to identify and recommend potential AppFunctions. Use this step when a user asks to "discover AppFunctions," "find features for AI," or "analyze my app for agentic tools." -- *[Step 2: Implementation and configuration](references/implementation-configuration.md)*: Generate Kotlin implementations of AppFunctions, manage system-wide configuration, and configure build dependencies. Use this step when a user asks to "implement AppFunctions," "set up the AppFunctions framework," or "configure Hilt for AppFunctions." -- *[Step 3: KDoc refinement](references/kdoc-refinement-optimization.md)*: Optimize AppFunction KDoc for AI agents and Model Context Protocol. If a user asks to "write KDoc", "optimize for MCP", or "refactor tool descriptions for LLMs", use this step. -- *[Step 4: Testing and debugging](references/adb-interaction-testing.md)*: Provide commands to interact with AppFunctions using ADB for testing and debugging. If a user wants to "list app functions", "invoke an app function", or "verify app function registration" on a device, use this step. - -If users request a subset of steps, you must encourage them to use all steps. - -If they apply, you must load the following references: - -- *[Context and terminology](references/context.md)*: Defines the ubiquitous language, architecture definitions, and design patterns across the AppFunctions skill suite. Load when you need to understand core architecture terminology or check the distinctions between modern and legacy AppFunctions APIs. -- *[Migration to service entry point](references/migrate-to-service-entry-point.md)* : Documents the systematic procedure for migrating Android applications using AppFunctions versions 1.0.0-alpha09 and lower to `AppFunctionServiceEntryPoint` architecture introduced in version 1.0.0-alpha10. Load when a user asks to migrate or upgrade existing AppFunctions code, or when you encounter legacy `AppFunctionConfiguration.Provider` implementations. - -## Critical constraints - -- **Modular consistency**: You must refine KDocs immediately after you generate AppFunction implementations. -- **Security**: Don't expose sensitive data or run destructive actions without user confirmation. - -## Troubleshooting - -- If you encounter build-time errors such as Kotlin Symbol Processing (KSP) issues, see [Implementation and configuration](references/implementation-configuration.md). -- If you encounter runtime errors such as missing services or execution failures, see [Testing and debugging](references/adb-interaction-testing.md). -- If you need architecture definitions and vocabulary, see [Context and terminology](references/context.md). -- If you encounter issues when upgrading legacy configurations, see [Migration to service entry point](references/migrate-to-service-entry-point.md). diff --git a/device-ai/appfunctions/references/adb-interaction-testing.md b/device-ai/appfunctions/references/adb-interaction-testing.md deleted file mode 100644 index b487923..0000000 --- a/device-ai/appfunctions/references/adb-interaction-testing.md +++ /dev/null @@ -1,95 +0,0 @@ -Provides commands to interact with AppFunctions on a connected device or -emulator using ADB for AppFunction testing and debugging. - -## Instructions - -### Scenario 1: List app functions - -Use this scenario when you want to see which app functions are registered on -the device. - -1. **List all functions** : To view all registered app functions in JSON format, run `adb shell cmd app_function list-app-functions`. -2. **Filter by package** : To view functions for a specific package, pipe the output to `grep` or a JSON tool: `adb shell cmd app_function - list-app-functions | grep `. - -### Scenario 2: Invoke app functions - -If you want to test the execution of an app function, use this scenario. - -1. **Analyze description** : Before invoking, you must read the `description` field for the function in the `list-app-functions` output. This often contains critical usage constraints, required workflows, or disambiguation rules. -2. **Follow constraints**: Follow all instructions in the description, such as asking the user to disambiguate or calling another tool first. -3. **Format parameters** : Format the `--parameters` argument as a valid JSON string that represents the function's input arguments. -4. **Execute function** : Use `adb shell cmd app_function execute-app-function - --package --function - --parameters ''`. -5. **Handle response** : The command returns the result as a JSON string. To get brief YAML output, use `--brief-yaml`. - -### Scenario 3: Manage function state - -If you need to enable or disable an app function for testing, use this scenario. - -1. **Set enabled state** : Use `adb shell cmd app_function set-enabled - --package - --function - --state `. - -## Critical constraints - -### Follow metadata descriptions - -**Mandatory** : The `description` field in the app function metadata is a set of -instructions for the LLM. If a description says to "disambiguate with the user" -or "call another function first," you must perform those steps before execution. - -### JSON escaping - -**Critical** : When passing JSON using `adb shell`, always wrap the JSON string -in single quotes to prevent the shell from interpreting special characters or -spaces. Example: `--parameters '{"key": "value"}'`. - -### Device availability - -The `app_function` service must be available on the device. If `cmd: Can't find -service: app_function` is returned, the device doesn't support this feature. - -## Examples - -### Example 1: Verify app function service availability on connected device - - adb shell cmd app_function help - -If executing the preceding command returns a help page, use the commands and -parameters provided to guide the ADB interaction testing tool interactions. - -### Example 2: List all registered app functions - - adb shell cmd app_function list-app-functions - -### Example 3: Execute a "send message" function - - adb shell cmd app_function execute-app-function \ - --package com.example.messaging \ - --function - 'com.example.messaging.appfunctions.MessagingAppFunctionService#sendMessage' \ - --parameters '{"recipient": "Alice", "message": "Hello!"}' - -### Example 4: Disable a specific function - - adb shell cmd app_function set-enabled --package com.example.app \ - --function 'com.example.app.appfunctions.SomeAppFunctionService#someFunction' - --state disable - -## Troubleshooting - -### Error: "Unknown command" - -**Cause**: You are likely using an older version of the instructions. - -**Solution**: Look up supported commands using "Example 1". - -### Error: "Function not found" - -**Cause**: The function ID or package name is incorrect. - -**Solution** : Run `list-app-functions` and search for the relevant identifiers -in the JSON output. \ No newline at end of file diff --git a/device-ai/appfunctions/references/context.md b/device-ai/appfunctions/references/context.md deleted file mode 100644 index dc49d25..0000000 --- a/device-ai/appfunctions/references/context.md +++ /dev/null @@ -1,26 +0,0 @@ -Defines the ubiquitous language for the Android AppFunctions skill suite. - -## Architecture and versioning - -`AppFunctionServiceEntryPoint` API: -This compile-time AppFunctions architecture was introduced in version -1.0.0-alpha10. You annotate a wrapper service extending -`AppFunctionService` with `@AppFunctionServiceEntryPoint`. KSP then generates -XML metadata and routes services at compile time. - -Legacy manual provider API: -This deprecated AppFunctions architecture applies to version -1.0.0-alpha09 and earlier. Applications implement -`AppFunctionConfiguration.Provider` on the `Application` class. Methods -require `AppFunctionContext` as the first parameter. Projects depend on -a standalone `appfunctions-service` library. - -## Patterns - -Service entry point pattern: -Version 1.0.0-alpha10 introduced this architectural pattern. You -declare `@AppFunction` methods directly inside an abstract class extending -`AppFunctionService`. This class uses `@AppFunctionServiceEntryPoint` and -`@AndroidEntryPoint` annotations. These annotations let you inject data -sources or repositories directly, without an intermediate business logic -delegation layer. \ No newline at end of file diff --git a/device-ai/appfunctions/references/feature-discovery-analysis.md b/device-ai/appfunctions/references/feature-discovery-analysis.md deleted file mode 100644 index 583f67c..0000000 --- a/device-ai/appfunctions/references/feature-discovery-analysis.md +++ /dev/null @@ -1,71 +0,0 @@ -Analyzes Android codebases to identify and recommend high-value AppFunctions. - -## Instructions - -### Discover features - -1. **Analyze manifest and entry points** : Scan `AndroidManifest.xml` and Activity, Fragment, and Service classes to identify core user journeys, for example, search, create, or share. -2. **Identify atomic tasks**: Look for methods or logic that represent distinct, self-contained user outcomes. -3. **Evaluate AI value**: Prioritize tasks that are frequently used or difficult to navigate using touch UI, but instead be expressed using voice or text, for example "Remind me to call Alice when I get home." -4. **Recommend and justify**: List recommendations with a rationale. Focus on how an AI assistant adds value, such as efficiency, hands-free use, or multi-step automation. - -## Critical constraints - -### Tool-first thinking - -Avoid recommending functions that are purely informational or redundant with -existing system actions. Focus on "mutations" (writing data) or "rich queries" -(finding specific entities). - -### Security and privacy - -Don't recommend exposing functions that handle raw credentials, financial -secrets, or irreversible destructive actions without explicit user confirmation -steps. - -## Examples - -### Example 1: Media app discovery - -**Recommended AppFunction** : `playArtistRadio` - -**Rationale**: Lets you start a personalized music stream using a voice -command, bypassing several layers of navigation in the "Search" and "Artist" -menus. - -**Input required** : `artistName` as a `String`. - -### Example 2: Chat app contact discovery - -**Recommended AppFunction** : `searchContacts` - -**Rationale** : Serves as a "rich query" to resolve a human-readable contact -name, email, or chat group to a unique identifier (`endpointValue`), which is a -prerequisite before executing actions like sending messages or -initiating calls. Also allows retrieving recently contacted entities when given -a blank query. - -**Input required** : Query as a `String`, and Filter Type as a `String` -constrained to `"INDIVIDUAL"` or `"GROUP"`. - -### Example 3: Chat app message sending - -**Recommended AppFunction** : `send` - -**Rationale**: This mutation function lets you send text messages and -optional image attachments to a contact or group using natural language -commands, for example, "Tell Alice I'm running 5 minutes late." This -eliminates multi-step UI navigation across contact lists and conversation -threads. - -**Input required** : Endpoint Value as a `String`, Message Body as a `String`, -and Image URIs as an optional `List` of URIs. - -### Example 4: Chat app voice calling - -**Recommended AppFunction** : `makeCall` - -**Rationale**: Lets you initiate voice calls hands-free to a contact or -group using an AI agent without navigating the app's UI. - -**Input required** : `endpointValue` as a `String`. \ No newline at end of file diff --git a/device-ai/appfunctions/references/implementation-configuration.md b/device-ai/appfunctions/references/implementation-configuration.md deleted file mode 100644 index fb9292c..0000000 --- a/device-ai/appfunctions/references/implementation-configuration.md +++ /dev/null @@ -1,282 +0,0 @@ -Specialized instructions for generating Kotlin implementations of AppFunctions, -handling system-wide configuration, and managing build dependencies. - -## Instructions - -### Step 1: Configure Gradle dependencies and KSP - -Add the following to `build.gradle.kts`. App Functions requires the KSP (Kotlin -Symbol Processing) plugin. - -1. **Version check** : Use library version `1.0.0-alpha10` or later from maven.google.com. - - -```kotlin -implementation(libs.androidx.appfunctions) -ksp(libs.androidx.appfunctions.compiler) -``` - -
- -
- -
- -### Step 2: Set up app metadata XML - -Describe the app's capabilities to the LLM by defining -`res/xml/app_metadata.xml`. - - -```xml - -``` - -
- -Register the service and reference the app metadata in `AndroidManifest.xml` -within the `` tag: - - -```xml - - - - - - - - -``` - -
- -### Step 3: Implement functions - -When generating Kotlin code for AppFunctions, you MUST adhere to these rules: - -1. **Annotations** : - - Annotate the function with `@AppFunction(isDescribedByKDoc = true)`. - - Annotate associated data classes with `@AppFunctionSerializable(isDescribedByKDoc = true)`. -2. **Parameter strategy** : - - **Specificity**: Keep parameters specific. State objects must be unambiguous. - - **Optionality**: If a parameter isn't essential, make it optional with a default value. -3. **Execution and threading** : - - Use `suspend` functions. - - To avoid blocking the Android UI thread, always run AppFunction implementations on a background dispatcher, such as `withContext(Dispatchers.IO)`. -4. **Supported types** : - - **Primitives** : `Int`, `Long`, `Float`, `Double`, `Boolean` - - **Arrays** : `IntArray`, `LongArray`, `FloatArray`, `DoubleArray`, `BooleanArray` - - **Native types** : `String`, `PendingIntent`, `Uri`, `LocalTime`, `LocalDate`, `LocalDateTime`, `Instant`. Prefer using `LocalDateTime` or `Instant` for date and time fields. - - **Custom objects** : Classes annotated with `@AppFunctionSerializable`. - - **Collections** : `List` of any supported non-primitive type -5. **Default values** : - - Use defaults that align with the type's empty state, such as `0` for `Int`, `null` for nullable objects, and `emptyList()` for `List`. -6. **Error handling** : - - Throw subclasses of `androidx.appfunctions.AppFunctionException` to report errors to callers. -7. **Security** : - - Don't expose highly sensitive user data, such as passwords or financial details. - - Don't expose irreversible destructive actions without confirmation steps. - -### Step 4: Set up dependency injection and service entry points - -In version 1.0.0-alpha10 and later, App Functions use the compile-time -`@AppFunctionServiceEntryPoint` architecture. Create an abstract class extending -`AppFunctionService` annotated with `@AppFunctionServiceEntryPoint`. KSP -generates the concrete service class and XML schema. - -#### Recommended approach with Hilt - -Annotate your service with `@AndroidEntryPoint` and inject your data -repositories or use cases using standard `@Inject internal lateinit var`: - - -```kotlin -@RequiresApi(36) -@AndroidEntryPoint -@AppFunctionServiceEntryPoint( - serviceName = "ConfigAppFunctionServiceHeader", - appFunctionXmlFileName = "config_app_function_service_header", -) -abstract class BaseAppFunctionServiceHeader : AppFunctionService() { - @Inject internal lateinit var messageRepository: MessageRepository - - @AppFunction(isDescribedByKDoc = true) - internal suspend fun send( - name: String, - endpointValue: String, - messageBody: String, - ): MessageResult { - return messageRepository.send(name, endpointValue, messageBody) - } -} -``` - -
- -#### Framework-agnostic approach with alternative dependency injection or service locators - -While Hilt is recommended, many Android applications implement AppFunctions with -alternative dependency injection frameworks (like Koin, Anvil, or manual Service -Locators). Because `AppFunctionService` inherits from Android -`android.app.Service` (and therefore `Context`), you are able access your -application's DI container directly through `applicationContext` in property -getters or during service lifecycle execution: - - -```kotlin -@RequiresApi(36) -@AppFunctionServiceEntryPoint( - serviceName = "ServiceLocatorConfigAppFunctionService", - appFunctionXmlFileName = "service_locator_config_app_function_service", -) -abstract class BaseAppFunctionServiceLocator : AppFunctionService() { - // Example using a manual Service Locator / Application container - private val messageRepository by lazy { - (applicationContext as AppFunctionApplication).appContainer.messageRepository - } - // Or with Koin / alternative DI locators: - // private val messageRepository: MessageRepository by inject() - - @AppFunction(isDescribedByKDoc = true) - internal suspend fun send( - name: String, - endpointValue: String, - messageBody: String, - ): MessageResult { - return messageRepository.send(name, endpointValue, messageBody) - } -} -``` - -
- -### Step 5: Architectural cleanliness - -Don't attempt to make an `AppFunction` class or method OS-agnostic---App Functions are inherently part of the Android platform integration in `androidx.appfunctions`. For architectural cleanliness, use existing -application functionality (such as existing repositories, use cases, or domain -orchestrators) to execute the behavior within your `@AppFunction` methods rather -than creating redundant abstraction layers around the OS service. - -
- -## Critical constraints - -### KSP compliance for serializables - -**Critical constraints** : For `@AppFunctionSerializable` data classes, KSP -only extracts documentation if it's written as inline KDoc directly for each -property definition. Don't use class-level `@param` or `@property` tags. - -### Package integrity - -Configuration APIs and the `@AppFunction` annotation are located in -`androidx.appfunctions`. - -## Examples - -### Example: Serializable with inline KDoc - - -```kotlin -/** The parameter to create the task. */ -@AppFunctionSerializable(isDescribedByKDoc = true) -data class CreateTaskParams( - /** The title of the task. */ - val title: String?, - /** The content of the task. */ - val content: String?, -) - -/** The user-created task. */ -@AppFunctionSerializable(isDescribedByKDoc = true) -data class Task( - /** The ID of the task. */ - val id: String, - /** The title of the task. */ - val title: String, - /** The content of the task. */ - val content: String, -) -``` - -
- -
- -### Example: Implementation detail - - -```kotlin -@RequiresApi(36) -@AndroidEntryPoint -@AppFunctionServiceEntryPoint( - serviceName = "TaskAppFunctionService", - appFunctionXmlFileName = "task_app_function_service", -) -abstract class BaseTaskAppFunctionService : AppFunctionService() { - @Inject internal lateinit var taskRepository: TaskRepository - - /** - * Creates a task based on [createTaskParams]. - * - * @param createTaskParams The parameter to describe how to create the task. - */ - @AppFunction(isDescribedByKDoc = true) - suspend fun createTask( - createTaskParams: CreateTaskParams, - ): Task = withContext(Dispatchers.IO) { - // Developers can use predefined exceptions to let the agent know - // why it failed. - if (createTaskParams.title == null && createTaskParams.content == null) { - throw AppFunctionInvalidArgumentException("Title or content should be non-null") - } - - val id = taskRepository.createTask( - createTaskParams.title, - createTaskParams.content - ) - - return@withContext taskRepository - .getTask(id) - ?.toTask() - ?: throw AppFunctionElementNotFoundException("Task not found for ID = $id") - } - - // Maps internal TaskEntity - private fun TaskEntity.toTask() = Task(id = id, title = title, content = description) -} -``` - -
- -
- -## Troubleshooting - -### Error: "AppFunction unavailable" or "Metadata missing" - -**Cause**: The AppSearch indexing failed to extract the schema correctly. - -**Solution**: - -1. Verify `@AppFunctionSerializable` classes use inline KDoc comments, not class-level `@param` tags. -2. Check that the `assets/.xml` file exists in the APK. -3. Confirm the `ksp("androidx.appfunctions:appfunctions-compiler")` dependency is correctly applied. -4. Ensure the `ksp` argument `appfunctions:aggregateAppFunctions` is set to `"true"`. \ No newline at end of file diff --git a/device-ai/appfunctions/references/kdoc-refinement-optimization.md b/device-ai/appfunctions/references/kdoc-refinement-optimization.md deleted file mode 100644 index c0a3209..0000000 --- a/device-ai/appfunctions/references/kdoc-refinement-optimization.md +++ /dev/null @@ -1,75 +0,0 @@ -Optimizes AppFunction KDoc for AI agents and Model Context Protocol. - -## Instructions - -### Workflow: Agent-centric documentation - -1. **Identify the core outcome** : Start the description with a strong imperative verb, for example, "Search", "Create", or "Update". Focus on the *user benefit*, not the code implementation. -2. **Workflow dependencies** : Explicitly state if another function must be called first using the standard phrase: **Required workflow: Call "Function - A" first to "Objective"**. -3. **Parameter documentation** : - - For **functions** : Use specific `@param` tags. Isolate validation rules and default values here. - - For **serializables** : Use inline KDoc directly for each property declaration. KSP **won't** extract documentation from class-level tags. -4. **Error surface mapping** : Rewrite `@throws` descriptions to provide useful recovery steps for the AI agent, for example "If "Error", suggest the user check their internet connection." - -### Workflow: Global app description for server instructions - -When writing the `appfn:description` for `app_metadata.xml`, follow these -instructions: - -1. **Capture cross-function relationships**: Explain dependencies or sequences between tools, for example, "Always call 'authenticate' before fetching data.". -2. **Document operational patterns**: Guide the LLM on token-conserving usage, for example, "Use 'batch_update' over multiple 'update' calls." -3. **Specify constraints**: Define clear boundaries, for example, "File operations limited to workspace" or "Rate limit: 10 requests per minute." -4. **Anti-patterns** : - - Don't repeat individual function descriptions. - - Don't include marketing claims or subjective praise. - - Don't attempt to prompt model personality or conversation style. - -## Critical constraints - -### Descriptive, not imperative - -Describe what the function *does* , not what the LLM *must* do. Avoid phrases -like "You must call this..." in favor of "This function provides...". - -### No "fluff" - -Remove conversational padding like "This method is used to..." or "Helpful -for...". Be concise and technical. - -### Inline KDoc for serializables - -**Mandatory** : For `@AppFunctionSerializable` classes, documentation must be -inline for each property. KSP ignores class-level `@param` or `@property` tags -for these classes. - -## Examples - -### Example: MCP refactoring - -**Original**: - -`/** This function helps you find people. */` - -**Refined**: - - /** - * Search for message recipients by name or email. - * Required workflow: Call this before "sendMessage" to obtain valid recipient IDs. - * @param query Search string for name/email. If null, returns 3 most recent contacts. - * @return List of "Recipient" objects matching the query. - */ - -### Example: Global app description - -**Refined**: - - This app provides functions for task management and team collaboration. - - Operational Patterns: - - Always use 'searchUsers' to resolve user handles to internal IDs before calling 'assignTask'. - - Prefer 'batchUpdateStatus' when modifying more than three tasks simultaneously to reduce latency. - - Constraints: - - Task titles are limited to 100 characters. - - Attachment uploads are limited to 5 MB. \ No newline at end of file diff --git a/device-ai/appfunctions/references/migrate-to-service-entry-point.md b/device-ai/appfunctions/references/migrate-to-service-entry-point.md deleted file mode 100644 index f350f61..0000000 --- a/device-ai/appfunctions/references/migrate-to-service-entry-point.md +++ /dev/null @@ -1,226 +0,0 @@ -Follow this systematic procedure to migrate Android applications that use the -AppFunctions API in version 1.0.0-alpha09 and lower to the compile-time -`@AppFunctionServiceEntryPoint` architecture introduced in version -`1.0.0-alpha10`. - -*** ** * ** *** - -## Architectural shift overview - -In lower versions of the AppFunctions API, for example version `1.0.0-alpha09`: - -- Applications require separate dependencies for core functionality, specifically `androidx.appfunctions:appfunctions`, and service components, specifically `androidx.appfunctions:appfunctions-service`. -- The application implements `AppFunctionConfiguration.Provider` on its `Application` class. -- You manually register enclosing class instantiation using `AppFunctionConfiguration.Builder().addEnclosingClassFactory(...)`. -- You place metadata property tags directly under ``. - -In version `1.0.0-alpha10` featuring `@AppFunctionServiceEntryPoint`: - -- The core and service dependencies are consolidated into a single runtime artifact, `androidx.appfunctions:appfunctions`, which eliminates the need for the standalone `appfunctions-service` library. -- A dedicated wrapper class extending `AppFunctionService` is annotated with `@AppFunctionServiceEntryPoint` and Hilt's `@AndroidEntryPoint` or an alternative dependency injection framework. -- The KSP compiler generates a concrete service subclass and an XML metadata schema file in `assets/`. -- The OS discovers and routes executions using a consolidated `` and `app_metadata` declaration in `AndroidManifest.xml`. - -### Strict migration requirements from 1.0.0-alpha09 to 1.0.0-alpha10 - -When focusing solely on the mandatory API changes required by the new -`@AppFunctionServiceEntryPoint` architecture, the migration consists of four -strict requirements that you must complete: - -1. **Build dependency consolidation** : Remove the merged `appfunctions-service` dependency while retaining core `appfunctions` and the KSP compiler. -2. **Service wrapper creation** : Replace the legacy `AppFunctionConfiguration.Provider` on the `Application` class with an abstract class extending `AppFunctionService`, annotated with `@AppFunctionServiceEntryPoint`. -3. **Annotation and context decoupling** : Move `@AppFunction` annotations to the new wrapper methods and drop `AppFunctionContext` parameters because `AppFunctionService` inherits directly from `Context`. -4. **Manifest registration** : Register the KSP-generated concrete service in `AndroidManifest.xml` with `BIND_APP_FUNCTION_SERVICE`, the `AppFunctionService` intent filter, and metadata property tags. - -*** ** * ** *** - -## Systematic migration steps - -### Consolidate AppFunctions build dependencies - -Remove the standalone `appfunctions-service` library from your module build -files like `build.gradle.kts` and version catalog like `libs.versions.toml`. In -version `1.0.0-alpha10`, all core service capabilities are consolidated directly -within the main `appfunctions` artifact. - - // build.gradle.kts - dependencies { - implementation(libs.androidx.appfunctions) - - implementation(libs.androidx.appfunctions.service) - ksp(libs.androidx.appfunctions.compiler) - } - - # libs.versions.toml - [versions] - appfunctions = "1.0.0-alpha10" - - [libraries] - androidx-appfunctions = { module = "androidx.appfunctions:appfunctions", version.ref = "appfunctions" } - - androidx-appfunctions-service = { module = "androidx.appfunctions:appfunctions-service", version.ref = "appfunctions" } - androidx-appfunctions-compiler = { module = "androidx.appfunctions:appfunctions-compiler", version.ref = "appfunctions" } - -> [!NOTE] -> **Note:** If another dependency in your project uses snapshot builds like `1.0.0-SNAPSHOT` or custom snapshot repositories from `https://androidx.dev/snapshots/...`, preserve your custom snapshot repository configuration in `settings.gradle.kts`. Otherwise, standard Google Maven repositories resolve `1.0.0-alpha10` directly. - -*** ** * ** *** - -### Create a dedicated wrapper service extending `AppFunctionService` - -Instead of annotating standalone business logic classes or implementing manual -configuration providers, create an abstract service wrapper across your project, -for example `BaseAppFunctionService`, extending `AppFunctionService` and -annotated with `@AppFunctionServiceEntryPoint`. - -#### Recommended approach using Hilt - -Annotate your service with `@AndroidEntryPoint` and inject your data -repositories or use cases using standard `@Inject internal lateinit var`: - - -```kotlin -@RequiresApi(36) -@AndroidEntryPoint -@AppFunctionServiceEntryPoint( - serviceName = "MyAppFunctionService", - appFunctionXmlFileName = "my_app_function_service", // Do NOT include .xml extension -) -abstract class BaseAppFunctionService : AppFunctionService() { - @Inject internal lateinit var messageRepository: MessageRepository - - @AppFunction(isDescribedByKDoc = true) - internal suspend fun send( - name: String, - endpointValue: String, - messageBody: String, - ): MessageResult { - return messageRepository.send(name, endpointValue, messageBody) - } -} -``` - -
- -#### Framework-agnostic approach using alternative dependency injection or a service locator - -While Hilt is recommended, many Android applications implement AppFunctions with -alternative dependency injection frameworks like Koin, Anvil, or manual Service -Locators. Because `AppFunctionService` inherits from Android -`android.app.Service` and therefore `Context`, you are able access your -application's DI container directly through `applicationContext` in property -getters or during service lifecycle execution: - - -```kotlin -@RequiresApi(36) -@AppFunctionServiceEntryPoint( - serviceName = "ServiceLocatorMyAppFunctionService", - appFunctionXmlFileName = "service_locator_my_app_function_service", -) -abstract class ServiceLocatorBaseAppFunctionService : AppFunctionService() { - // Example using a manual Service Locator or Application container - private val messageRepository by lazy { - (applicationContext as AppFunctionApplication).appContainer.messageRepository - } - // Or with Koin / alternative DI locators: - // private val messageRepository: MessageRepository by inject() - - @AppFunction(isDescribedByKDoc = true) - internal suspend fun send( - name: String, - endpointValue: String, - messageBody: String, - ): MessageResult { - return messageRepository.send(name, endpointValue, messageBody) - } -} -``` - -
- -> [!IMPORTANT] -> **Important:** The `appFunctionXmlFileName` parameter, for example `"my_app_function_service"`, mustn't include the `.xml` extension, as the KSP compiler automatically appends `.xml`. Passing `"my_app_function_service.xml"` results in the asset being named `"my_app_function_service.xml.xml"`. - -*** ** * ** *** - -### Simplify method signatures and decouple context - -Remove legacy `AppFunctionContext` parameters from your core methods. When a -method requires an Android `Context`, for example when constructing a -`PendingIntent`, access `this` directly from your `AppFunctionService` wrapper -because the wrapper inherently extends `android.content.Context`. - - - suspend fun makeCall(appFunctionContext: AppFunctionContext, contactName: String?): PendingIntent - + suspend fun makeCall(contactName: String?): PendingIntent - -*** ** * ** *** - -### Remove legacy configuration provider - -Update your `Application` class by removing -`AppFunctionConfiguration.Provider` and its associated builder entry points: - - - abstract class BaseChatApplication : Application(), AppFunctionConfiguration.Provider { ... } - + abstract class BaseChatApplication : Application() - -*** ** * ** *** - -### Avoid redundant abstraction layers - -Don't attempt to make an `AppFunction` class or method OS-agnostic---AppFunctions -are inherently part of the Android platform integration through the -`androidx.appfunctions` package. For architectural cleanliness, use existing -application functionality, such as existing repositories, use cases, or domain -orchestrators, to execute the behavior within your `@AppFunction` methods rather -than creating redundant abstraction layers around the OS service. - -*** ** * ** *** - -### Consolidate service and metadata manifest declarations - -Register the KSP-generated service declaration and `app_metadata` property -inside your module manifest, for example in `src/main/AndroidManifest.xml` -within the `` tag: - - -```xml - - - - - - - - -``` - -
- -*** ** * ** *** - -## Verification and troubleshooting - -1. **Clean rebuild and deploy** : `bash - ./gradlew clean installDebug` -2. **Verify AppSearch discovery / indexing** : Run the following ADB command to - confirm the OS successfully discovered and indexed your functions: - `bash - adb shell cmd app_function list-app-functions` - *If your package doesn't appear, confirm that `android.app.appfunctions.v2` - matches the exact asset name generated in `assets/`.* - -3. **Verify execution using ADB** : - `bash - adb shell "cmd app_function execute-app-function \ - --package com.example.chatapp \ - --function 'com.example.chatapp.appfunctions.BaseAppFunctionService#send' \ - --parameters '{\"name\": \"Alice\", \"endpointValue\": \"1\", \"messageBody\": \"Hello Alice!\"}'"` \ No newline at end of file diff --git a/device-ai/ml-kit-genai-prompt-api/SKILL.md b/device-ai/ml-kit-genai-prompt-api/SKILL.md deleted file mode 100644 index 9ac0ca4..0000000 --- a/device-ai/ml-kit-genai-prompt-api/SKILL.md +++ /dev/null @@ -1,121 +0,0 @@ ---- -name: ml-kit-genai-prompt-api -description: Analyzes Android codebases to implement ML Kit GenAI Prompt API. Use - this skill to send natural language requests on-device to Gemini Nano, use structured - output with Prompt API, implement prefix caching, optimize the current prompt, or - apply best practices." -license: Complete terms in LICENSE.txt -metadata: - author: Google LLC - last-updated: '2026-09-03' - keywords: - - ML Kit - - Prompt API - - Structured Output - - Prefix Caching - - Gemini Nano ---- - -This skill provides step-by-step guidance for integrating and optimizing the ML -Kit GenAI Prompt API in Android apps. - -## Prerequisites - -- Android API level must be 26 or higher. If `minSdk` is below 26, update it to 26. -- Add the ML Kit GenAI Prompt API dependency (`com.google.mlkit:genai-prompt`) to the app-level `build.gradle` file, with version at least `1.0.0-beta4`. -- If `com.google.mlkit:genai-schema-compiler` dependency is used and KSP plugin version is below 2.3.6, update it to 2.3.6. - -## Detailed steps - -### 1. Prompt optimization - -To optimize prompts for use with the ML Kit Prompt API, follow the -[prompt optimization guide](https://developer.android.com/agents/skills/device-ai/prompt-api/references/prompt-optimization). - -### 2. Prefix caching optimization - -If the prompt is more than 200 words, implement the [prefix caching API](https://developer.android.com/agents/skills/device-ai/prompt-api/references/prefix-caching). - -### 3. Lifecycle and best practices - -- The model must be fully downloaded and available before calling the first inference. Follow the guide on [implementing a generative model](https://developer.android.com/agents/skills/device-ai/prompt-api/references/get-started) to check that the `FeatureStatus` of a model is `AVAILABLE` before making an inference. -- Release ML Kit instances by calling `close()` when an `Activity`, - `Fragment`, or `ViewModel` is destroyed. Example: - - - ```kotlin - // Instantiating model in activity, fragment, or ViewModel - val generativeModel = Generation.getClient() - - // When activity, fragment, or ViewModel is destroyed - generativeModel.close() - ``` - -
- -### 4. Structured output - -When implementing or refactoring a prompt to use structured output, follow -these rules: - -1. **Check for API availability:** Verify Structured Output feature is available on the device with `isStructuredOutputFeatureAvailable()` before using it. Refer to the [Structured Output API guide](https://developer.android.com/agents/skills/device-ai/prompt-api/references/structured-output) for full instructions. -2. **Return type:** Return the `@Generable` typed object from the function - signature instead of a `String` or JSON string. - - For example: - - fun parseEmail(email: String): String { - ... - } - - should be refactored to: - - fun parseEmail(email: String): ParsedEmail? { - ... - } - -3. **Example:** - - This is the example code before refactoring: - - - ```kotlin - suspend fun parseEmail(email: String): String { - val parseEmailPrompt = "Parse this email and return the sender, title, and short summary of the email less than 10 words: " - - val parsedEmail = generativeModel.generateContent(parseEmailPrompt + email) - - return parsedEmail.candidates[0].text - } - ``` - -
- - This is the example code after using Structured Output API: - - - ```kotlin - @Generable - data class ParsedEmail( - @Guide(description = "Sender of the email") - var sender: String = "", - - @Guide(description = "Title of the email") - var title: String = "", - - @Guide(description = "Summary of the email less than 10 words") - var summary: String = "" - ) - - suspend fun parseEmail(email: String): ParsedEmail? { - val parseEmailPrompt = - "Parse this email: $email" - - val baseRequest = GenerateContentRequest.Builder(TextPart(parseEmailPrompt)).build() - val typedRequest = generateTypedContentRequest(baseRequest, ParsedEmail::class) - val typedResponse = generativeModel.generateContent(typedRequest) - return typedResponse.candidates[0].response - } - ``` - -
diff --git a/device-ai/ml-kit-genai-prompt-api/references/get-started.md b/device-ai/ml-kit-genai-prompt-api/references/get-started.md deleted file mode 100644 index ba6598a..0000000 --- a/device-ai/ml-kit-genai-prompt-api/references/get-started.md +++ /dev/null @@ -1,272 +0,0 @@ -This page describes how to do the following: - -- Configure your project to use Prompt API -- Provide text-only input and receive a response -- Provide an image input with related text input and receive a response - -For more details about the Prompt API, see the -reference documentation for Kotlin ([com.google.mlkit.genai.prompt](https://developer.android.com/android/reference/kotlin/com/google/mlkit/genai/prompt/package-summary)) and -Java ([com.google.mlkit.genai.prompt.java](https://developer.android.com/android/reference/com/google/mlkit/genai/prompt/java/package-summary), -[com.google.mlkit.genai.prompt](https://developer.android.com/android/reference/com/google/mlkit/genai/prompt/package-summary)). - -## Configure project - -> [!NOTE] -> **Note:** This API requires Android API level 26 or higher. - -Add the ML Kit Prompt API as a dependency in your `build.gradle` configuration: - - implementation("com.google.mlkit:genai-prompt:1.0.0-beta2") - -If you need your responses in a certain format using the Structured Output API, -you need to configure KSP and add additional dependencies. For details, see -[Generate structured output](https://developer.android.com/agents/skills/device-ai/prompt-api/references/structured-output). - -## Implement generative model - -To implement the code in your project, follow these steps: - -- Create a `generativeModel` object: - - ### Kotlin - - // Get a GenerativeModel instance - val generativeModel = Generation.getClient() - - ### Java - - // Get a GenerativeModel instance - GenerativeModelFutures generativeModelFutures = GenerativeModelFutures - .from(Generation.INSTANCE.getClient()); - -- Check if Gemini Nano is `AVAILABLE,` `DOWNLOADABLE`, or `UNAVAILABLE`. Then, - download the feature if it is downloadable: - - ### Kotlin - - val status = generativeModel.checkStatus() - when (status) { - FeatureStatus.UNAVAILABLE -> { - // Gemini Nano not supported on this device or device hasn't fetched the latest configuration to support it - } - - FeatureStatus.DOWNLOADABLE -> { - // Gemini Nano can be downloaded on this device, but is not currently downloaded - generativeModel.download().collect { status -> - when (status) { - is DownloadStatus.DownloadStarted -> - Log.d(TAG, "starting download for Gemini Nano") - - is DownloadStatus.DownloadProgress -> - Log.d(TAG, "Nano ${status.totalBytesDownloaded} bytes downloaded") - - DownloadStatus.DownloadCompleted -> { - Log.d(TAG, "Gemini Nano download complete") - modelDownloaded = true - } - - is DownloadStatus.DownloadFailed -> { - Log.e(TAG, "Nano download failed ${status.e.message}") - } - } - } - } - - FeatureStatus.DOWNLOADING -> { - // Gemini Nano currently being downloaded - } - - FeatureStatus.AVAILABLE -> { - // Gemini Nano currently downloaded and available to use on this device - } - } - - ### Java - - ListenableFuture status = generativeModelFutures.checkStatus(); - Futures.addCallback(generativeModelFutures.checkStatus(), new FutureCallback<>() { - @Override - public void onSuccess(Integer featureStatus) { - switch (featureStatus) { - case FeatureStatus.AVAILABLE -> { - // Gemini Nano currently downloaded and available to use on this device - } - case FeatureStatus.UNAVAILABLE -> { - // Gemini Nano not supported on this device or device hasn't fetched the latest configuration to support it - } - case FeatureStatus.DOWNLOADING -> { - // Gemini Nano currently being downloaded - } - case FeatureStatus.DOWNLOADABLE -> { - generativeModelFutures.download(new DownloadCallback() { - @Override - public void onDownloadStarted(long l) { - Log.d(TAG, "starting download for Gemini Nano"); - } - @Override - public void onDownloadProgress(long l) { - Log.d(TAG, "Nano " + l + " bytes downloaded"); - } - @Override - public void onDownloadCompleted() { - Log.d(TAG, "Gemini Nano download complete"); - } - @Override - public void onDownloadFailed(@NonNull GenAiException e) { - Log.e(TAG, "Nano download failed: " + e.getMessage()); - } - }); - } - } - } - @Override - public void onFailure(@NonNull Throwable t) { - // Failed to check status - } - }, ContextCompat.getMainExecutor(context)); - -## Provide text-only input - -### Kotlin - - val response = generativeModel.generateContent("Write a 3 sentence story about a magical dog.") - -### Java - - GenerateContentResponse response = generativeModelFutures.generateContent( - new GenerateContentRequest.Builder( - new TextPart("Write a 3 sentence story about a magical dog.")) - .build()) - .get(); - -Alternatively, add optional parameters: - -### Kotlin - - val response = generativeModel.generateContent( - generateContentRequest( - TextPart("Write a 3 sentence story about a magical dog."), - ) { - // Optional parameters - temperature = 0.2f - topK = 10 - candidateCount = 3 - }, - ) - -### Java - - GenerateContentRequest.Builder requestBuilder = - new GenerateContentRequest.Builder( - new TextPart("Write a 3 sentence story about a magical dog.")); - requestBuilder.setTemperature(.2f); - requestBuilder.setTopK(10); - requestBuilder.setCandidateCount(3); - - GenerateContentResponse response = - generativeModelFutures.generateContent(requestBuilder.build()).get(); - -For more information about the optional parameters, see [Optional -configurations](https://developer.android.com/agents/skills/device-ai/ml-kit-genai-prompt-api/references/get-started#optional-configurations). - -## Provide multimodal (image and text) input - -Bundle an image and a text input together in the `generateContentRequest()` -function, with the text prompt being a question or command related to the -image. You can bundle multiple images and text together in the same request. - -### Kotlin - - val response = generativeModel.generateContent( - generateContentRequest(ImagePart(bitmap), TextPart(textPrompt)) { - // optional parameters - ... - }, - ) - -### Java - - GenerateContentResponse response = generativeModelFutures.generateContent( - new GenerateContentRequest.Builder( - new ImagePart(bitmap), - new TextPart("textPrompt")) - // optional parameters - .build()) - .get(); - -## Process inference result - -- Run the inference and retrieve the result. You can choose to either wait for - the full result or stream the response as it's generated for both text-only - and multimodal prompts. - - - This uses non-streaming inference, which retrieves the entire result from - the AI model before returning the result: - - ### Kotlin - - // Call the AI model to generate content and store the complete - // in a new variable named 'response' once it's finished - val response = generativeModel.generateContent("Write a 3 sentence story about a magical dog") - - ### Java - - GenerateContentResponse response = generativeModelFutures.generateContent( - new GenerateContentRequest.Builder( - new TextPart("Write a 3 sentence story about a magical dog.")) - .build()) - .get(); - - - The following snippets are examples of using streaming inference, which - retrieves the result in chunks as it's being generated: - - ### Kotlin - - // Streaming inference - var fullResponse = "" - generativeModel.generateContentStream("Write a 3 sentence story about a magical dog").collect { chunk -> - val newChunkReceived = chunk.candidates[0].text - print(newChunkReceived) - fullResponse += newChunkReceived - } - - ### Java - - // Streaming inference - StringBuilder fullResponse = new StringBuilder(); - generativeModelFutures.generateContent(new GenerateContentRequest.Builder( - (new TextPart("Write a 3 sentence story about a magical dog"))).build(), - chunk -> { - Log.d(TAG, chunk); - fullResponse.append(chunk); - }); - -For more information about streaming and non-streaming inference, see [Streaming -versus non-streaming](https://developer.android.com/ml-kit/genai#streaming-vs-non). - -## Latency optimization - -To optimize for the first inference call, your application may optionally call -`warmup()`. This loads Gemini Nano into memory and initializes runtime -components. - -## Optional configurations - -As part of each `GenerateContentRequest`, you can set the following optional -parameters: - -- `temperature` : Controls the degree of randomness in token selection. -- `seed` : Enables generating stable and deterministic results. -- `topK` : Controls randomness and diversity in results. -- `candidateCount` : Requests the number of unique responses returned. Note that the exact number of responses may not be the same as `candidateCount` because duplicate responses are automatically removed. -- `maxOutputTokens` : Defines the maximum number of tokens that can be generated in the response. - -For more guidance on setting optional configurations, see -[`GenerateContentRequest`](https://developer.android.com/android/reference/kotlin/com/google/mlkit/genai/prompt/GenerateContentRequest). - -## Supported features and limitations - -- Input must be under 4000 tokens (or approximately 3000 English words). For more information, see the [`countTokens`](https://developer.android.com/android/reference/com/google/mlkit/genai/prompt/GenerativeModel#countTokens(com.google.mlkit.genai.prompt.GenerateContentRequest)) reference. -- Use cases that require long output (more than 4K tokens) should be avoided. -- AICore enforces an inference quota per app. For more information, see [Quota - per application](https://developer.android.com/ml-kit/genai#quota-per). \ No newline at end of file diff --git a/device-ai/ml-kit-genai-prompt-api/references/prefix-caching.md b/device-ai/ml-kit-genai-prompt-api/references/prefix-caching.md deleted file mode 100644 index 13eb571..0000000 --- a/device-ai/ml-kit-genai-prompt-api/references/prefix-caching.md +++ /dev/null @@ -1,146 +0,0 @@ -> [!NOTE] -> **Note:** Prefix caching is experimental and may change in the future. This feature is only available on a subset of the supported devices for Prompt API, with support for more devices coming soon. We encourage you to experiment with this API on a Pixel device to understand how it can improve inference latency speeds for your specific use case. - -*Prefix caching* is a feature that reduces inference time by storing and reusing -the intermediate LLM state of processing a shared and recurring prompt prefix -part. To enable prefix caching, you only have to separate the static prefix from -the dynamic suffix in your API request. - -Prefix caching currently only supports text-only input, so you shouldn't use -this feature if you're providing an image in your prompt. - -There are two approaches to implement prefix caching: implicit or explicit: - -- [Implicit (automatic) prefix caching](https://developer.android.com/agents/skills/device-ai/ml-kit-genai-prompt-api/references/prefix-caching#implicit) is a lightweight approach where the application only needs to define a shared portion of the prompt. -- [Explicit (manual) prefix caching](https://developer.android.com/agents/skills/device-ai/ml-kit-genai-prompt-api/references/prefix-caching#explicit) allows applications to have more control over caches, including cache creation, querying, and deletion. - -## Use prefix caching implicitly - -To enable prefix caching, add the shared portion of the prompt to the -`promptPrefix` field, as shown in the following code snippets: - -### Kotlin - - val promptPrefix = "Reverse the given sentence: " - val dynamicSuffix = "Hello World" - - val result = generativeModel.generateContent( - generateContentRequest(TextPart(dynamicSuffix)) { - promptPrefix = PromptPrefix(promptPrefix) - } - ) - -### Java - - String promptPrefix = "Reverse the given sentence: "; - String dynamicSuffix = "Hello World"; - - GenerateContentResponse response = generativeModelFutures.generateContent( - new GenerateContentRequest.Builder(new TextPart(dynamicSuffix)) - .setPromptPrefix(new PromptPrefix(promptPrefix)) - .build()) - .get(); - -In the preceding snippet, the `dynamicSuffix` is passed as the main content, and -the `promptPrefix` is provided separately. - -### Estimated performance gains - -|---|---|---| -| | **Without prefix caching** | **With prefix cache-hit** (Prefix cache-miss may occur when prefix is used for the first time) | -| Pixel 9 with 300-token fixed prefix and a 50-token dynamic suffix prompt | 0.82 seconds | 0.45 seconds | -| Pixel 9 with a 1,000-token fixed prefix and a 100-token dynamic suffix prompt | 2.11 seconds | 0.5 seconds | - -### Storage considerations - -With implicit prefix caching, cache files are saved on the client application's -private storage, which increases your app's storage usage. Encrypted cache files -and their associated metadata, including original prefix text, are stored. Keep -the following storage considerations in mind: - -- The number of caches is managed by an LRU (Least Recently Used) mechanism. Least used caches are deleted automatically when exceeding the max total cache amount. -- Prompt cache sizes are dependent on the length of the prefix. -- To clear all caches created from prefix caching, use the - [`generativeMode.clearImplicitCaches()`](https://developer.android.com/android/reference/kotlin/com/google/mlkit/genai/prompt/GenerativeModel#clearCaches%28%29) method. - - > [!NOTE] - > **Note:** The `clearImplicitCaches()` method is experimental and may change in the future. - -## Use explicit cache management - -The Prompt API includes explicit cache management methods to give developers -more precise control over how caches are created, searched, used, and removed. -These manual operations run independently of the system's automated cache -handling. - -This example illustrates how to initialize explicit cache management and -perform inference: - -### Kotlin - - val cacheName = "my_cache" - val promptPrefix = "Reverse the given sentence: " - val dynamicSuffix = "Hello World" - - // Create a cache - val cacheRequest = createCachedContextRequest(cacheName, PromptPrefix(promptPrefix)) - val cache = generativeModel.caches.create(cacheRequest) - - // Run inference with the cache - val response = generativeModel.generateContent( - generateContentRequest(TextPart(dynamicSuffix)) { - cachedContextName = cache.name - } - ) - -### Java - - String cacheName = "my_cache"; - String promptPrefix = "Reverse the given sentence: "; - String dynamicSuffix = "Hello World"; - - // Create a cache - CachedContext cache = cachesFutures.create( - new CreateCachedContextRequest.Builder(cacheName, new PromptPrefix(promptPrefix)) - .build()) - .get(); - - // Run inference with the cache - GenerateContentResponse response = generativeModelFutures.generateContent( - new GenerateContentRequest.Builder(new TextPart(dynamicSuffix)) - .setCachedContextName(cache.getName()) - .build()) - .get(); - -This example demonstrates how to query, retrieve, and delete explicitly managed -caches using `generativeModel.caches`: - -### Kotlin - - val cacheName = "my_cache" - - // Query pre-created caches - for (cache in generativeModel.caches.list()) { - // Do something with cache - } - - // Get specific cache - val cache = generativeModel.caches.get(cacheName) - - // Delete a pre-created cache - generativeModel.caches.delete(cacheName) - -### Java - - String cacheName = "my_cache"; - - // Query pre-created caches - for (PrefixCache cache : cachesFutures.list().get()) { - // Do something with cache - } - - // Get specific cache - PrefixCache cache = cachesFutures.get(cacheName).get(); - - // Delete a pre-created cache - cachesFutures.delete(cacheName); \ No newline at end of file diff --git a/device-ai/ml-kit-genai-prompt-api/references/prompt-design.md b/device-ai/ml-kit-genai-prompt-api/references/prompt-design.md deleted file mode 100644 index bba16ab..0000000 --- a/device-ai/ml-kit-genai-prompt-api/references/prompt-design.md +++ /dev/null @@ -1,55 +0,0 @@ -When using Prompt API, there are specific strategies you can use to tailor your -prompts and receive optimal results. This page describes best practices for -formatting prompts for Gemini Nano. - -For more general prompt engineering guidance, see [Prompt Engineering -whitepaper](https://www.kaggle.com/whitepaper-prompt-engineering), [Prompt Engineering for Generative -AI](https://developer.android.com/machine-learning/resources/prompt-eng), and [Prompt design strategies](https://ai.google.dev/gemini-api/docs/prompting-strategies). - -Alternatively, to automatically refine and improve prompts, you can use the -[zero-shot optimizer](https://docs.cloud.google.com/vertex-ai/generative-ai/docs/learn/prompts/zero-shot-optimizer#optimizing_for_smaller_models), which can target on-device models such as -`gemma-3n-e4b-it`. - -## Prompt design best practices - -When designing prompts for Prompt API, use the following techniques: - -- **Provide examples for in-context learning**. Add well-distributed examples to - your prompt to show Gemini Nano the kind of result you expect. - - Consider using the [prefix caching](https://developer.android.com/agents/skills/device-ai/prompt-api/references/prefix-caching) feature when you use in-context - learning, as providing examples makes the prompt longer and increases - inference time. -- **Be concise** . Verbose preambles with repeated instructions can produce - suboptimal results. Keep your prompt focused and to-the-point. If you need to - repeat a short directive that guides the model's behavior, consider using - [system instructions](https://developer.android.com/agents/skills/device-ai/prompt-api/references/system-instructions). - -- **Structure prompts** to generate more effective responses, such as this - [sample prompt template](https://cloud.google.com/vertex-ai/generative-ai/docs/learn/prompts/prompt-design-strategies#sample-prompt-template) that clearly defines instructions, - constraints, and examples. - -- **Keep output short** . LLM inference speeds are heavily dependent on the - output length. Carefully consider how you can generate the shortest possible - output for your use case and do manual post-processing to structure the output - in the chosen format. To help ensure that the response output is in your - preferred format, use the [Structured Output API](https://developer.android.com/agents/skills/device-ai/prompt-api/references/structured-output). - -- **Add delimiters** . Use delimiters like ``, - ``, and `##` to create separation between different parts of your - prompt. Using `##` between components is particularly critical for Gemini - Nano, as it significantly reduces the chances of the model failing to - correctly interpret each component. - -- **Prefer simple logic and a more focused task** . If you find it challenging - to achieve good results with a prompt requiring multi-step reasoning (for - example, *do X first, if the result of X is A, do M; otherwise do N; - then do Y...* ), consider breaking the task up and let each Gemini Nano call - handle a more focused task, while using code to chain multiple calls together. - If you do need to tackle a complex, reasoning-intensive task all at once, - consider using [thinking mode](https://developer.android.com/ml-kit/genai/prompt/android/thinking-mode). - -- **Use lower temperature values for deterministic tasks** . For tasks such as - entity extraction or translation that don't rely on creativity, consider - starting with a `temperature` value of `0.2`, and tune this value based on - your testing. \ No newline at end of file diff --git a/device-ai/ml-kit-genai-prompt-api/references/prompt-optimization.md b/device-ai/ml-kit-genai-prompt-api/references/prompt-optimization.md deleted file mode 100644 index 05afba0..0000000 --- a/device-ai/ml-kit-genai-prompt-api/references/prompt-optimization.md +++ /dev/null @@ -1,74 +0,0 @@ -This skill provides guidance on how to optimize prompts for use with the ML Kit -Prompt API. - -## Guidelines \& Rules - -The best practices for prompt design for Prompt API are at -[Prompt Design](https://developer.android.com/agents/skills/device-ai/prompt-api/references/prompt-design). Apply these core rules: - -- Include examples for in-context learning with examples of the output. This is an example prompt that includes in-context learning because it has examples of the desired model output: Analyze the message and return whether the customer sentiment is positive, negative, or neutral. Example 1: Input - "This product doesn't work"; Output - "Negative" Example 2: Input - "I liked this product"; Output - "Positive" -- Make prompts concise. Remove duplicate or repeated instructions. Do not include conversational filler such as: hello, please, do this, help me with, etc. For example, a verbose prompt such as: "Hello! I want you to act as an expert translator for me today. I am going to give you a short paragraph of text in English, and I would really like it if you could translate it into formal, high-level Business French. Please make sure that you do not include any intro like 'Here is your translation' or any closing remarks at all. Just output the translation itself." can be improved with this concise prompt: "Translate to formal Business French. Return ONLY the translation" -- Use paired XML/HTML delimiters to denote dynamic inputs (for example `[content]`) to clearly isolate user data from prompt instructions. Mention that the delimiters represent placeholder text that should be replaced by code in the actual implementation. -- Use the [Structured Output API](https://developer.android.com/agents/skills/device-ai/prompt-api/references/structured-output) if the output requires parsing responses into certain formats. Don't include the Structured Output API implementation in the prompt itself, but mention it after presenting the optimized prompt to the user so the user knows how to parse the model output. Prompts that use Structured Output API must return the `@Generable` typed object from the function signature instead of a string or JSON string. -- Keep output short by adding output constraints such as word count, character count, or number of bullets or sentences. For example, add constraints such as "The summarization must be 10 words or fewer." -- Use [system instructions](https://developer.android.com/agents/skills/device-ai/prompt-api/references/system-instructions) for short instructions that define how a model should behave, and use [prefix caching](https://developer.android.com/agents/skills/device-ai/prompt-api/references/prefix-caching) for prompts that are over 200 words. - -## Examples - -Here are examples of prompts that the user might ask you to optimize, and the -improved, optimized versions. - -### Example 1 - -Unoptimized prompt: - - Process this customer email and return the order ID, what was bought, the phone - number, and what they want. Format it as JSON so my app can read it. - -Optimized prompt: - - ## Task - - Extract key details from the customer email below. - - ## Customer Email - - - [email_content] - - -Explanation: Because the information that the user wants to extract using the -prompt is well suited for the Structured Output API, the optimized prompt -implicitly uses the Structured Output API and doesn't need to explicitly define -the schema for the extracted data. In your response to the user that explains -the optimizations made to the prompt, mention that the optimized prompt is -designed for use with the Structured Output API. Also, mention that the -"" delimiters signify placeholder text that should be replaced with code in the user's actual implementation. - -### Example 2 - -Unoptimized prompt: - - Given this itinerary for a trip: [Flight to Paris (CDG), Check-in: Hotel Le - Meurice, Visit to the Louvre, Dinner at Le Jules Verne, Eiffel Tower Visit, - Versailles Palace Tour, Montmartre Walk, Seine River Cruise, Pastry and Macaron - Tasting, Flight Out], generate the following: overall vibe, tips on how to - prepare for this trip, and common short phrases to learn for the trip. - -Optimized prompt: - - ## Task - - Analyze the trip itinerary below and extract details to build a concise travel - guide. - - ## Rules - - Overall vibe: Limit to 1 short sentence (under 15 words). - - Preparation tips: Provide exactly 3 short tips (maximum 10 words per tip). - - Useful phrases: Provide exactly 3 phrases (under 15 words each). - - ## Itinerary - - - [itinerary_content] - \ No newline at end of file diff --git a/device-ai/ml-kit-genai-prompt-api/references/structured-output.md b/device-ai/ml-kit-genai-prompt-api/references/structured-output.md deleted file mode 100644 index 2740974..0000000 --- a/device-ai/ml-kit-genai-prompt-api/references/structured-output.md +++ /dev/null @@ -1,219 +0,0 @@ -If you need to parse the responses from the Prompt API into certain formats, -such as JSON, for further processing, use the Structured Output API. - -With the Structured Output API, you define the target output structure using -Kotlin classes and annotations. The Prompt API then returns a response in the -form of your Kotlin object. - -Generating structured output is particularly useful for tasks like the -following: - -- **Entity extraction**: Extracting structured fields (for example, event name, date, location) from unstructured text. -- **Classification**: Categorizing input text into predefined categories. -- **Data serialization**: Converting unstructured user input into a format suitable for database storage or API calls. - -## Prerequisites - -To verify that the Structured Output API is available on the device, use the -`isStructuredOutputFeatureAvailable()` API. The API returns `true` if the -Structured Output API is available on the device, and `false` otherwise. - - suspend fun isStructuredOutputFeatureAvailable(): Boolean - -The Structured Output API also has the following requirements: - -- Android API level 26 or higher (`minSdk` 26) -- KSP plugin version 2.3.6 or higher - -## Limitations - -The Structured Output API has the following limitations: - -- Works in Kotlin only. -- ProGuard might interfere with the parsing of your annotated class. Add your annotated class to your [keep rules](https://developer.android.com/topic/performance/app-optimization/keep-rules-overview) to exclude them from ProGuard if you get errors parsing, for example: - - # Keep classes used by structured output for deserialization for release builds. - -keep class com.google.mlkit.genai.demo.kotlin.Plant { *; } - -## Configure project - -To get started with the Structured Output API, follow these steps: - -1. [Add the ML Kit Prompt API as a dependency](https://developer.android.com/agents/skills/device-ai/prompt-api/references/get-started#configure-project) in your - app-level `build.gradle.kts` (or `build.gradle`) file, if you haven't - already. - -2. Add the KSP plugin to your project-level `build.gradle.kts` file. Use a - KSP plugin version that is compatible with your Kotlin version; we - recommend KSP version 2.3.6 or higher. - - dependencies { - ... - classpath "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin:2.3.6" - } - -3. Add the structured compiler dependencies to your app-level - `build.gradle.kts` file: - - dependencies { - ... - ksp("com.google.mlkit:genai-schema-compiler:1.0.0-alpha1") - } - -## Define the output structure - -Define the structure of the data you want the model to return using Kotlin -data classes. There are two main annotations for defining the output -structure: - -- Use the `@Generable` annotation to define the class as a target for structured output. -- Use the `@Guide` annotations on the class properties to provide descriptions and constraints that guide the model's output. - -The following example defines a structure for extracting plant information: - - import com.google.mlkit.genai.schema.annotations.Generable - import com.google.mlkit.genai.schema.annotations.Guide - - @Generable - data class PlantList( - @Guide(description = "The list of plants found", minItems = 1, maxItems = 5) - val plants: List - ) - - @Generable("Information about a plant species") - data class Plant( - @Guide(description = "The common name of the plant") - val commonName: String, - - @Guide(description = "The full latin scientific name of the plant") - val scientificName: String, - - @Guide( - description = "The maximum height of the plant in centimeters.", - minimum = 1.0, - maximum = 10000.0 - ) - val maxHeightCm: Int, - - @Guide(description = "Whether the plant is poisonous or not") - val isPoisonous: Boolean?, - - @Guide( - description = "The primary continent where this plant is native to", - enumValues = ["Africa", "Antarctica", "Asia", "Australia", "Europe", "North America", "South America"] - ) - val nativeContinent: String - ) - -### Supported types and constraints - -The following types are supported within a `@Generable` annotated class, -along with their respective `@Guide` constraints: - -| Type | Description | Supported `@Guide` constraints | -|---|---|---| -| `String` | For text. | `description`, `enumValues` | -| `Double` / `Float` | For floating-point numbers. | `description`, `minimum`, `maximum` | -| `Int` / `Long` | For whole numbers. | `description`, `minimum`, `maximum` | -| `Boolean` | For true/false values. | `description` | -| `List` | For lists of supported types or nested `@Generable` classes. | `description`, `minItems`, `maxItems` | -| `List` | For lists of `String` values. | `description`, `enumValues`, `minItems`, `maxItems` > [!NOTE] > **Note:** Setting the `enumValues` parameter defines the values allowed for the individual list items. | -| `@Generable` class | For nested structured objects. | `description` | - -> [!NOTE] -> **Note:** Circular dependencies between nested `@Generable` classes are not supported (for example, a class referencing itself, or Class A referencing Class B which in turn references Class A). - -## Generate structured content - -To request structured output, use the `generateTypedContentRequest` helper -function to wrap your standard prompt and specify the target output class. - - // 1. Initialize your GenerativeModel as usual - val generativeModel = Generation.getClient() - - // 2. Prepare the prompt text - val promptText = "List some common plants found in California." - val baseRequest = GenerateContentRequest.Builder(TextPart(promptText)).build() - - // 3. Create the typed request, specifying the target class (e.g., PlantList) - val typedRequest = generateTypedContentRequest( - generateContentRequest = baseRequest, - outputClass = PlantList::class - ) - - // 4. Run the inference - try { - val typedResponse = generativeModel.generateContent(typedRequest) - - // 5. Access the parsed object - // The response candidates contain the parsed object of type T (PlantList in this case) - val plantList: PlantList? = typedResponse.candidates.firstOrNull()?.response - - if (plantList != null) { - // Process the structured data - for (plant in plantList.plants) { - Log.d("StructuredOutput", "Found plant: ${plant.commonName} (${plant.scientificName})") - } - } else { - Log.e("StructuredOutput", "Failed to parse response into the desired structure.") - - // Inspect finish reason for details - val finishReason = typedResponse.candidates.firstOrNull()?.finishReason - Log.d("StructuredOutput", "Finish reason: $finishReason") - } - } catch (e: GenAiException) { - // Handle API errors - when (e.errorCode) { - GenAiException.STRUCTURED_OUTPUT_INVALID_CLASS -> { - Log.e("StructuredOutput", "The class structure is not supported.") - } - GenAiException.STRUCTURED_OUTPUT_INVALID_VALUE -> { - Log.e("StructuredOutput", "The model generated values that violate the schema constraints.") - } - else -> { - Log.e("StructuredOutput", "API error: ${e.message}") - } - } - } - -## Handle finish reasons and errors - -When using the Structured Output API, you should handle potential exceptions -thrown by the API and inspect the `finishReason` property in the response -candidates if the parsed response is null. - -### finishReason values - -The `finishReason` property can take one of the following values: - -- `TypedFinishReason.STOP`: The model finished generating successfully and the output matches the schema. -- `TypedFinishReason.MAX_TOKENS`: The model stopped because it reached the token limit. The output might be incomplete. -- `TypedFinishReason.PARSE_CLASS_ERROR`: The model completed generation, but the resulting JSON couldn't be parsed into the target Kotlin class. -- `TypedFinishReason.STRUCTURE_NOT_ANNOTATED`: The target class or its nested classes are missing the required `@Generable` annotation. -- `TypedFinishReason.STRUCTURE_VALUES_INVALID`: The generated values violated the constraints defined in the `@Guide` annotations (for example value out of range, list size out of bounds). -- `TypedFinishReason.OTHER`: Generation stopped due to other reasons. - -### Exceptions - -The Structured Output API might throw `GenAiException` with the following -error codes: - -- `GenAiException.STRUCTURED_OUTPUT_INVALID_CLASS` (-104): The structure of the annotated class is invalid or contains unsupported types. This is typically a development-time configuration error. Review your `@Generable` data class definition to check that all property types are supported and that there aren't any circular dependencies. -- `GenAiException.STRUCTURED_OUTPUT_INVALID_VALUE` (-105): The values generated by the model are invalid or fail constraints verification. This is a runtime error. If you encounter this error frequently, consider the following solutions: - - Refining your prompt instructions to guide the model more strictly. - - Relaxing the constraints (like minimum, maximum, or list size limits) in your `@Guide` annotations if they are too restrictive for the model's capabilities. - - Implementing a fallback strategy in your app, such as retrying the request or displaying a default state. - -## Count tokens - -To check if your structured prompt is within the input token limit, calculate -the token count using the [`countTokens()`](https://developer.android.com/android/reference/com/google/mlkit/genai/prompt/GenerativeModel#countTokens(com.google.mlkit.genai.prompt.GenerateContentRequest)) method. - -Because structured output requests need to instruct the model on the schema -structure, counting tokens on just the raw prompt text (using a -`GenerateContentRequest` instance) isn't accurate. To get an accurate token -count, you must pass the complete `GenerateTypedContentRequest` instance, which -includes your target class and schema configurations, to the `countTokens()` -method: - - suspend fun countTokens(request: GenerateTypedContentRequest): CountTokensResponse \ No newline at end of file diff --git a/device-ai/ml-kit-genai-prompt-api/references/system-instructions.md b/device-ai/ml-kit-genai-prompt-api/references/system-instructions.md deleted file mode 100644 index 844131c..0000000 --- a/device-ai/ml-kit-genai-prompt-api/references/system-instructions.md +++ /dev/null @@ -1,71 +0,0 @@ -System instructions let you give the model a persona, set the tone of its -responses, or provide specific rules it must follow. These instructions are -separate from the user's prompt and are treated with higher priority by the -model to ensure it behaves as expected. - -Common use cases include: - -- **Setting a persona:** For example, "You are a helpful math tutor." -- **Enforcing output format:** For example, "Always respond in bullet points." -- **Setting constraints:** For example, "Do not answer questions about politics." - -## Prerequisites - -System instructions work on devices running Gemini Nano V3 and higher. For a -list of supported devices, see [Prompt API device support](https://developer.android.com/ml-kit/genai#prompt-device). - -## Limitations - -We don't recommend using system instructions with -[prefix caching](https://developer.android.com/agents/skills/device-ai/prompt-api/references/prefix-caching). In general, use system instructions for -short instructions that define how the model should behave; use prefix -caching if you need to repeat a large part of your prompt across queries and -need to optimize performance. - -## How to use system instructions - -To provide system instructions, create a `SystemInstruction` object and pass it -to the `GenerateContentRequest` builder: - - import com.google.mlkit.genai.prompt.SystemInstruction - import com.google.mlkit.genai.prompt.TextPart - import com.google.mlkit.genai.prompt.generateContentRequest - - // 1. Define the system instruction - val systemInstruction = - SystemInstruction("You are a concise assistant. Answer in 2 sentences or less.") - - // 2. Create the request - val request = generateContentRequest(TextPart("How does photosynthesis work?")) { - this.systemInstruction = systemInstruction - } - - // 3. Run inference - try { - val response = generativeModel.generateContent(request) - println(response.candidates.firstOrNull()?.text) - } catch (e: GenAiException) { - // Handle SDK-specific exceptions - } - -You can further simplify the code by passing the system instructions directly -into the `generateContentRequest` request builder: - - val request = generateContentRequest( - SystemInstruction("You are a pirate. Speak like one."), - TextPart("What is the weather like today?") - ) { - // Optional configurations like temperature - temperature = 0.7f - } - -## Best practices - -Here are some best practices when using system instructions: - -- **Be clear and direct:** The model follows clear, direct instructions better than ambiguous ones. Here are some examples: - - Vague (avoid): "Don't write too much. Try to be helpful and friendly, and format the output nicely." - - Clear (preferred): "You are a friendly customer support assistant. Limit your responses to a maximum of 3 sentences. Format any lists using bullet points." -- **Be concise:** While system instructions are powerful, very long instructions can consume the model's limited context window. -- **Factor in token counts:** Make sure that your token counting logic includes the system instructions to avoid underestimating request size. We recommend keeping your system instructions to under 150 words (100-200 tokens). -- **Test and iterate:** Model behavior can vary based on phrasing. Test with various user inputs to ensure the model maintains its persona consistently. \ No newline at end of file diff --git a/devtools/android-cli/SKILL.md b/devtools/android-cli/SKILL.md deleted file mode 100644 index c9ebe6d..0000000 --- a/devtools/android-cli/SKILL.md +++ /dev/null @@ -1,471 +0,0 @@ ---- -name: android-cli -description: Provides instructions for installing and using the `android` CLI. The - `android` command-line tool is a critical tool for Android development and helps - you create new Android projects, run Android apps on devices, manage and interact - with Android virtual devices (including screenshots and UI inspection), manage Android - SDK components, look up official Android documentation, and discover and install - official Android skills. -license: Complete terms in LICENSE.txt -metadata: - author: Google LLC - last-updated: '2026-09-12' - keywords: - - android-cli - - sdk - - emulator - - skills - - docs - - knowledge base - - project creation - - screenshots ---- - -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. - -## Installation - -If the android tool is not in the path, install it. To install run the following -command: - -- Linux: curl -fsSL https://dl.google.com/android/cli/latest/linux_x86_64/install.sh \| bash -- Mac Arm: curl -fsSL https://dl.google.com/android/cli/latest/darwin_arm64/install.sh \| bash -- Mac Intel: curl -fsSL https://dl.google.com/android/cli/latest/darwin_x86_64/install.sh \| bash -- Windows: curl -fsSL https://dl.google.com/android/cli/latest/windows_x86_64/install.cmd -o "%TEMP%\\i.cmd" \&\& "%TEMP%\\i.cmd" - -## 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 Android devices - -Use the `android layout` command to inspect the UI layout of an Android -application in JSON format. Use the `android screen` command to visually inspect -the UI and obtain bounding coordinates for visual regions. - -**IMPORTANT:** Before using `android layout` or `android screen`, you MUST read -this reference: [interact.md](references/interact.md) The reference file -[interact.md](references/interact.md) contains instructions for properly -interacting with Android devices. Follow its instructions exactly. - -### Running journey tests - -Journey tests involve device interaction; use the `android layout` and `android -screen` commands to evaluate Journeys. The instructions for device interaction -apply here as well. - -**IMPORTANT:** Before evaluating a journey, you MUST read this reference: -[journeys.md](references/journeys.md) The reference file -[journeys.md](references/journeys.md) contains instructions for correctly -evaluating an Android journey. Follow its instructions exactly. - -## 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 screen` command. - -## Managing skills - -Manage agent skills for Android using the `android skills` command. - -## Updating the CLI - -Update the Android CLI using the `android update` command. - -## `android help` output - - Usage: android [-Vhv] [--sdk=PARAM] [COMMAND] - -h, --help Show the help message for this command - --sdk=PARAM Path to the Android SDK - -v, --verbose Enable verbose output for troubleshooting - -V, --version Print version information and exit - Commands: - completion Installs shell autocomplete configuration for the Android CLI in - the current user profile - create Create a new Android project from available templates. Allows - specifying project name, output directory, minSdk, and dry-run - execution - describe 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 - docs Android documentation commands for searching and fetching - developer documentation from the official knowledge base - emulator Manage Android Virtual Devices (AVDs). Includes commands to start, - stop, list, and view details about emulators - help Shows the help of all commands - info Print environment information including SDK location, connected - devices, and configuration variables. Use specific fields to - narrow the output - init Initializes the environment for the Android CLI. Sets up required - configurations, directories, and default skills - install Install an Android Application (one or more APKs) to a connected - device or emulator without activating any components using - incremental optimizations for faster deployment than adb - layout Returns the layout tree of an application - run Build, deploy, and launch an Android application on a connected - device or emulator - screen Commands to view the device - sdk Manage the Android SDK installation. Includes commands to install, - update, remove, and list available and installed SDK packages - skills Manage Android CLI skills. Includes commands to install, remove, - list, and search for skills by keyword - studio Android Studio commands - update Update the Android CLI to the latest version - - completion - Usage: android completion [-h] [] - Installs shell autocomplete configuration for the Android CLI in the - current user profile - - Options: - -h, --help Show the help message for this command - - android options: - --sdk=PARAM Path to the Android SDK - -v, --verbose Enable verbose output for troubleshooting - -V, --version Print version information and exit - - Positional Parameters: - If provided, print the given shell completion without - installing. Supported: bash, zsh - - create - Usage: android create [-h] [--applicationId=PARAM] [--list] [--minSdk=PARAM] - [--name=PARAM] [--namespace=PARAM] [--output=PARAM] - [] - Create a new Android project from available templates. Allows - specifying project name, output directory, minSdk, and dry-run - execution - - Options: - --applicationId=PARAM The application ID for the application - (e.g. 'com.example.myapp') - -h, --help Show the help message for this command - --list List all available templates - --minSdk=PARAM The 'minSdk' supported by the application - (default is defined in the template) - --name=PARAM The name of the application (e.g. 'My - Application') - --namespace=PARAM The namespace for resources and package - name for Kotlin source files - -o, --output=PARAM The destination project directory path - (default is '.') - - android options: - --sdk=PARAM Path to the Android SDK - -v, --verbose Enable verbose output for troubleshooting - -V, --version Print version information and exit - - Positional Parameters: - The template name - - describe - Usage: android describe [-h] [--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 - - Options: - -h, --help Show the help message for this command - --project_dir=PARAM The project directory to describe - - android options: - --sdk=PARAM Path to the Android SDK - -v, --verbose Enable verbose output for troubleshooting - -V, --version Print version information and exit - - docs - Usage: android docs [-h] [COMMAND] - Android documentation commands for searching and fetching developer - documentation from the official knowledge base - - Options: - -h, --help Show the help message for this command - - android options: - --sdk=PARAM Path to the Android SDK - -v, --verbose Enable verbose output for troubleshooting - -V, --version Print version information and exit - Commands: - search Search Android documentation. Enclose keywords in quotes - fetch Fetch an Android documentation article from a URL (kb://...) - - emulator - Usage: android emulator [-h] [COMMAND] - Manage Android Virtual Devices (AVDs). Includes commands to start, - stop, list, and view details about emulators - - Options: - -h, --help Show the help message for this command - - android options: - --sdk=PARAM Path to the Android SDK - -v, --verbose Enable verbose output for troubleshooting - -V, --version Print version information 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 [-h] [COMMAND] - Shows the help of all commands - - Options: - -h, --help Show the help message for this command - - android options: - --sdk=PARAM Path to the Android SDK - -v, --verbose Enable verbose output for troubleshooting - -V, --version Print version information and exit - - Positional Parameters: - COMMAND The command to show help for - - info - Usage: android info [-h] [] - Print environment information including SDK location, connected - devices, and configuration variables. Use specific fields to narrow - the output - - Options: - -h, --help Show the help message for this command - - android options: - --sdk=PARAM Path to the Android SDK - -v, --verbose Enable verbose output for troubleshooting - -V, --version Print version information and exit - - Positional Parameters: - The specific field to print the value of. If omitted print - all - - init - Usage: android init [-h] - Initializes the environment for the Android CLI. Sets up required - configurations, directories, and default skills - - Options: - -h, --help Show the help message for this command - - android options: - --sdk=PARAM Path to the Android SDK - -v, --verbose Enable verbose output for troubleshooting - -V, --version Print version information and exit - - install - Usage: android install [-h] [--apks=PARAM] [--device=PARAM] [--install-options=PARAM] - [--use-delta-install] - Install an Android Application (one or more APKs) to a connected - device or emulator without activating any components using incremental - optimizations for faster deployment than adb - - Options: - --apks=PARAM The paths to the APKs, comma separated - --device=PARAM The device serial number - -h, --help Show the help message for this command - --install-options=PARAM Additional options/flags to pass to - package manager install (e.g. -g,-d) - --use-delta-install Use fast delta install (speeds up - incremental updates by transferring - only modified code and resources; - default: true) - - android options: - --sdk=PARAM Path to the Android SDK - -v, --verbose Enable verbose output for - troubleshooting - -V, --version Print version information and exit - - layout - Usage: android layout [-dhp] [--device=PARAM] [--flat] [--full] [--no-idle] - [--output=PARAM] - Returns the layout tree of an application - - Options: - --device=PARAM The device serial number - -d, --diff Deprecated; no-op flag. Will be removed in a - future release - --flat Returns a flat list instead of a tree - --full Return the full tree, including non-interactive - and hidden elements - -h, --help Show the help message for this command - --no-idle Don't wait for layout idle state when fetching - layout - -o, --output=PARAM Writes the layout to the specified file or - directory. If omitted, prints to standard output - -p, --pretty Pretty-prints the returned JSON - - android options: - --sdk=PARAM Path to the Android SDK - -v, --verbose Enable verbose output for troubleshooting - -V, --version Print version information and exit - - run - Usage: android run [-h] [--activity=PARAM] [--apks=PARAM] [--debug] [--device=PARAM] - [--install-options=PARAM] [--type=PARAM] [--use-delta-install] - Build, deploy, and launch an Android application on a connected device - or emulator - - Options: - --activity=PARAM The activity name - --apks=PARAM The paths to the APKs, comma separated - --debug Run in debug mode - --device=PARAM The device serial number - -h, --help Show the help message for this command - --install-options=PARAM Additional options/flags to pass to - package manager install (e.g. -g,-d) - --type=PARAM The component type (ACTIVITY, - WATCH_FACE, TILE, COMPLICATION, - DECLARATIVE_WATCH_FACE, WEAR_WIDGET) - --use-delta-install Use fast delta install (speeds up - incremental updates by transferring - only modified code and resources; - default: true) - - android options: - --sdk=PARAM Path to the Android SDK - -v, --verbose Enable verbose output for - troubleshooting - -V, --version Print version information and exit - - screen - Usage: android screen [-h] [COMMAND] - Commands to view the device - - Options: - -h, --help Show the help message for this command - - android options: - --sdk=PARAM Path to the Android SDK - -v, --verbose Enable verbose output for troubleshooting - -V, --version Print version information and exit - Commands: - capture Outputs the device screen to a PNG - resolve Target UI elements visually. Substitutes bounding box - coordinates from a annotated screenshot into a string. - Replaces all instances of '#N' with the center coordinates - of the bounding box labeled 'N' - - sdk - Usage: android sdk [-h] [--platform=PARAM] [COMMAND] - Manage the Android SDK installation. Includes commands to install, - update, remove, and list available and installed SDK packages - - Options: - -h, --help Show the help message for this command - --platform=PARAM Target platform _ (e.g. - linux_x86_64, mac_arm64, windows_x86), - defaults to current host - - android options: - --sdk=PARAM Path to the Android SDK - -v, --verbose Enable verbose output for troubleshooting - -V, --version Print version information and exit - Commands: - install Install SDK packages - update Update one or all packages to the latest version - remove Remove packages from the SDK - list List installed and available SDK packages - - skills - Usage: android skills [-h] [COMMAND] - Manage Android CLI skills. Includes commands to install, remove, list, - and search for skills by keyword - - Options: - -h, --help Show the help message for this command - - android options: - --sdk=PARAM Path to the Android SDK - -v, --verbose Enable verbose output for troubleshooting - -V, --version Print version information and exit - Commands: - add Install a specific skill by its ID to your environment - remove Remove an installed skill by its ID - list List installed and available skills - find Search for available skills in the repository matching a - keyword - update Update installed skills - - studio - Usage: android studio [-h] [COMMAND] - Android Studio commands - - Options: - -h, --help Show the help message for this command - - android options: - --sdk=PARAM Path to the Android SDK - -v, --verbose Enable verbose output for troubleshooting - -V, --version Print version information and exit - Commands: - find-declaration Find declaration of a symbol - find-usages Find usages of a symbol - open-file Open a file in Android Studio - check Check the status of running Studio instances - analyze-file Analyze a file in Android Studio - render-compose-preview Render a Compose preview in Android Studio - version-lookup Looks up the latest available versions on - the internet of maven artifacts, Android - versions, and more - - update - Usage: android update [-h] [--url=PARAM] - Update the Android CLI to the latest version - - Options: - -h, --help Show the help message for this command - --url=PARAM The URL to download the update from - - android options: - --sdk=PARAM Path to the Android SDK - -v, --verbose Enable verbose output for troubleshooting - -V, --version Print version information and exit diff --git a/devtools/android-cli/references/interact.md b/devtools/android-cli/references/interact.md deleted file mode 100644 index 2f2a4c4..0000000 --- a/devtools/android-cli/references/interact.md +++ /dev/null @@ -1,113 +0,0 @@ -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 capture --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. - - { - "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 -250 200 500`. This swipes from the center to the top over 500ms. - -### Text Input - -To enter text, ensure the field is focused and execute `adb shell input text -""`. - -- **Spaces (`%s`):** Replace spaces with `%s` (Android ignores text after raw spaces): `adb shell input text "Hello%sworld"` (enters `"Hello world"`). -- **Special characters:** Escape shell metacharacters (`&`, `$`, `(`, `)`, `!`): `adb shell input text "AT\&T"` or `adb shell input text 'Price:\$10'`. -- **Submit / Enter:** `adb shell input keyevent 66` - -## 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. -3. Always scroll slowly when executing scroll inputs. The 5th argument to `adb - shell input swipe` controls scroll duration. -4. 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/devtools/android-cli/references/journeys.md b/devtools/android-cli/references/journeys.md deleted file mode 100644 index f3cc163..0000000 --- a/devtools/android-cli/references/journeys.md +++ /dev/null @@ -1,120 +0,0 @@ -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 - -After evaluating the whole journey, output a markdown (.md) file describing the -results. - -The file should have the following format: - - # Journey: - - ## Results - - - ### Action: Click the blue button ✅ - - **Commands**: - - - `adb shell input swipe 490 200 500 500 500` - - `adb shell input tap 45 920` - - - - **Comment**: The journey step doesn't specify that the button requires scrolling to see - - ### Action: Check if "Switch 2" is visible on the screen ✅ - - **Screenshot**: [switch_2_screenshot.png](path/to/screenshot) - - **Comment**: Confirmed both in layout and visually - - ### Action: The home screen is shown ❌ - - **Comment**: The settings page was shown - - - ### Action: Swipe down to view active notifications \ No newline at end of file diff --git a/identity/restore-credentials/SKILL.md b/identity/restore-credentials/SKILL.md deleted file mode 100644 index cc9078c..0000000 --- a/identity/restore-credentials/SKILL.md +++ /dev/null @@ -1,356 +0,0 @@ ---- -name: restore-credentials -description: Provides knowledge and workflows to implement Android's Restore Credentials - feature using the androidx.credentials library. Use this skill to create, sign in - with, and delete restore keys, enabling silent user sign-in on new devices after - a restore. It covers version compatibility, dependencies, server-side prerequisites, - and the complete client-side implementation for creating, retrieving, and clearing - restore keys. -license: Complete terms in LICENSE.txt -metadata: - author: Google LLC - last-updated: '2026-09-14' - keywords: - - Credential Manager - - Restore Credentials - - backup & restore - - backup - - restore - - implementation ---- - -## Fundamentals - -The objective is to implement the **Restore Credentials** feature through the -Android Credential Manager API (`androidx.credentials`). This allows apps that -use or are integrating Credential Manager to silently log users back in when -they restore their app on a new device. Restore Credentials operates -independently of the app's primary authentication method (passwords, passkeys, -federated sign-in) and requires no UI changes to existing sign-in flows. - -### Scope - -**Crucial:** This skill focuses exclusively on the Android client-side -integration. It does **not** implement the server-side cryptographic -validation logic. The developer must be reminded of this and the -[Points to inform the developer about](#backend-guidelines) after implementation is done. - -## Implementation Guidelines - -When instructed to implement Restore Credentials on a developer's application, -remember the following: - -1. Before the implementation, you **MUST** read and understand the [Two-Tier - Restoration Architecture](#two-tier-restoration-architecture) and review the [DOs and DON'Ts](#dos-and-donts). -2. After the implementation, you **MUST** present the developer with the [Backend Guidelines](#backend-guidelines) as a reminder for their backend setup. It is important that you remind the developer that they still have to implement the backend. - -## Two-Tier Restoration Architecture - -To enable a resilient sign-in experience, retrieve credentials through a -**two-tier architecture**: - -1. **Tier 1 (Primary - Background):** Executes automatically during device setup using the app's `BackupAgent.onRestoreFinished()` callback. This provides an invisible restoration before the user opens the app for the first time, allowing background sync and notification delivery. -2. **Tier 2 (Secondary - Foreground):** Runs in the Launcher `Activity.onCreate()` to catch failovers if background restoration didn't complete (example: dropped network, delayed restoration) or if `allowBackup` is disabled. - -If `allowBackup` in the manifest is set to true, implement both. Otherwise, only -implement tier 2 (Foreground Restoration). Do **NOT** change the value of -`allowBackup` in the manifest. - -If you added a `BackupAgent` to the app, you **MUST** also set -`android:fullBackupOnly="true"` in the manifest. Do **NOT** do this if there -already existed a `BackupAgent` in the app before your implementation. - -## DOs and DON'Ts - -**DO:** - -- Do check `AndroidManifest.xml` for the value of allowBackup to determine what you have to implement. -- Do implement a fallback for createCredential: always try calling it first with `isCloudBackupEnabled` set to true. If an `E2eeUnavailableException` is thrown, catch it and retry the call with `isCloudBackupEnabled` set to `false`. -- Do implement a `BackupAgent` (subclass of `android.app.backup.BackupAgent`) if `allowBackup` is true in the manifest. -- Do set `android:fullBackupOnly="true"` in the manifest if you added a `BackupAgent` to the app. -- Call `clearCredentialState()` when the user signs out. This is a mandatory security measure to log the user out fully. -- Do attempt to get the restore key on the first launch of the app on a new device and also within the `BackupAgent.onRestoreFinished()` callback if your app uses it. -- Do ensure that a restore credential is created even if the user is already logged in. -- Do ensure that the credential retrieval and login in `onRestoreFinished()` is performed synchronously (for example using `runBlocking`). -- Do restore notifications in the `BackupAgent` if your app uses them. (For example capture and send FCM token to backend) -- Do ensure that if you implement mock network requests or stubs, you replace all placeholders with valid, properly formatted JSON payloads for the credential requests. -- Do encapsulate credential creation and retrieval into their own dedicated functions. Because credential creation must be called in multiple places (sign-up, sign-in) and retrieval across multiple tiers (`BackupAgent` and Launcher `Activity`), this prevents code duplication. -- Do remind the developer of the [critical guidelines](#implementation-guide) for implementing the backend once you're done with the implementation. -- Do generate a separate restore key for each application if the organization has multiple apps with different package names, as a restore key is tied to a unique application package name. - -**DON'T:** - -- DON'T change the value of `allowBackup` in `AndroidManifest.xml`. Restore Credentials functionality is not affected by the allowBackup setting, meaning the user will still be automatically logged in when a Restore Credential exists, even if `allowBackup` is false. -- DON'T implement a `BackupAgent` if `allowBackup` is `false` in `AndroidManifest.xml`. -- DON'T assume the credential stored in the `GetCredentialResponse` to be of type `PublicKeyCredential`. It has type `RestoreCredential`. -- DON'T chain `GetRestoreCredentialOption` with any other `CredentialOption` in the construction of a `GetCredentialRequest`. -- DON'T assume `CredentialManager` or the Android system will automatically delete a restore key when a user signs out of the app. You must explicitly call `clearCredentialState` with a `ClearCredentialStateRequest` of `TYPE_CLEAR_RESTORE_CREDENTIAL`. -- DON'T remove any existing calls to `clearCredentialState()`. A `ClearCredentialStateRequest` without a type specified only clears all NON-restore credentials. - -## Implementation Guide - -Implement the Android client-side code by using the following guide. Follow it -**step-by-step** and don't implement any backend functionality, only remind -the user of the [Backend Guidelines](#backend-guidelines) once you're done. - -## Version compatibility - -Credential Manager's Restore Credentials works on devices running Android 9 (API -level 28) and higher, Google Play services (GMS) core version 24220000 or -higher, and version 1.5.0 or higher of the `androidx.credentials` library. - -## Prerequisites - -Set up a [relying party server](#backend-guidelines) similar to the server for [passkeys](#dos-and-donts). If -you already have a [server](#two-tier-restoration-architecture) set up to handle authentication with passkeys, -use the same server-side implementation for restore keys. - -> [!NOTE] -> **Note:** While the server-side implementation is the same for passkeys and restore keys, your client-side app can support restore keys without supporting passkeys. Because restore keys work independently of the authentication method in your app (for example, passwords or Sign in with Google), you don't need to make any additional changes to the existing authentication methods in your app's code. - -## Dependencies - -Add the following dependencies to your app module's `build.gradle` file: - -### Kotlin - -```kotlin -dependencies { - implementation("androidx.credentials:credentials:1.7.0-alpha03") - implementation("androidx.credentials:credentials-play-services-auth:1.7.0-alpha03") -} -``` - -### Groovy - -```groovy -dependencies { - implementation "androidx.credentials:credentials:1.7.0-alpha03" - implementation "androidx.credentials:credentials-play-services-auth:1.7.0-alpha03" -} -``` - -Restore Credentials is available from version 1.5.0 and higher of the -androidx.credentials library. However, it's recommended to use the latest stable -versions of the dependencies where possible. - -> [!NOTE] -> **Note:** The Restore Credentials feature works regardless of whether [`allowBackup`](references/android/guide/topics/manifest/application-element.md) is set in the manifest. - -## Overview - -1. [**Create a restore key**](#create-restore-key): To create a restore key, complete the following steps: - 1. [**Instantiate Credential Manager**](#implementation-guide): Create a `CredentialManager` object. - 2. [**Get credential creation options from the app server**](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API): Send the client app the details required to create the restore key from your app server. - 3. [**Create the restore key**](https://w3c.github.io/webauthn/#dictdef-publickeycredentialcreationoptionsjson): Create a restore key for the user's account if the user is signed in to your app. - 4. [**Handle the credential creation response**](https://w3c.github.io/webauthn/#dictdef-publickeycredentialrequestoptionsjson): Send the credentials from your client app to your app server for processing, and handle any exceptions. -2. [**Sign in with a restore key**](#sign-restore): To sign in with a restore key, complete the following steps: - 1. [**Get credential retrieval options from the app server**](#get-credential-retrieval): Send the client app the details required to retrieve the restore key from your app server. - 2. [**Get the restore key**](#get-restore): Request the restore key from Credential Manager when the user sets up a new device. This lets the user sign in without additional input. - 3. [**Handle the credential retrieval response**](#handle-sign-in): Send the restore key from the client app to the app server to sign in the user. -3. [**Delete a restore key**](#delete-restore). - -## Create a restore key - -Your app should cover all cases of a user signing in to ensure active users have -a restore key created. Create the restore key in the following scenarios: - -- If the user is signed in and a restore key isn't already created (such as in the `onCreate` method for the main `Activity`). -- When the user is signing in or completing a new account registration flow. - -To optimize performance and avoid the overhead of creating or checking for a -restore credential on every single login, set a `boolean` flag or a credential -creation timestamp in local storage, such as `has_synced_restore_credential`, to -track whether the key has already been created. - -> [!NOTE] -> **Note:** A restore key is tied to an application's unique package name. If your organization's main app and sub-apps have different package names, create a separate restore key for each app. - -### Instantiate Credential Manager - -Use your app's activity context to instantiate a `CredentialManager` object. - - // Use your app or activity context to instantiate a client instance of - // CredentialManager. - private val credentialManager = CredentialManager.create(context) - -### Get credential creation options from your app server - -Use a FIDO-compliant library in your app server to send your client app the -information required to create the restore credential, such as information about -the user, the app, and additional configuration properties. For more information -about the server-side implementation, see [Server-side -guidance](https://developers.google.com/identity/passkeys/developer-guides/server-registration). - -### Create the restore key - -After parsing the public key creation options sent by the server, create a -restore key by wrapping these options in a -[`CreateRestoreCredentialRequest`](https://developer.android.com/reference/androidx/credentials/CreateRestoreCredentialRequest) object and calling the -[`createCredential()`](https://developer.android.com/reference/androidx/credentials/CredentialManager#createCredential(android.content.Context,androidx.credentials.CreateCredentialRequest)) method with the `CredentialManager` object. - - // createRestoreRequest contains the details sent by the server - val response = credentialManager.createCredential(context, createRestoreRequest) - -#### Key points about the code - -- The `CreateRestoreCredentialRequest` object contains the following fields: - - - `requestJson`: The credential creation options sent by the app server in the [Web Authentication API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API) format for [`PublicKeyCredentialCreationOptionsJSON`](https://w3c.github.io/webauthn/#dictdef-publickeycredentialcreationoptionsjson). - - `isCloudBackupEnabled`: `Boolean` field to determine if the restore key - should be backed up to the cloud. By default, this flag is `true`. This - field has these values: - - - `true`: (**Recommended**) This value enables the backup of restore keys to the cloud if the user has Google Backup and end-to-end encryption, such as a screen lock, enabled. - - `false`: This value saves the key locally and not in the cloud. The key is not available on the new device if the user chooses to restore from the cloud. - - > [!CAUTION] - > **Caution:** It's recommended to set `isCloudBackupEnabled` to `true`. If cloud backup is disabled and the user restores from a cloud backup, the call to retrieve the restore key fails. Users who restore your app with a cloud backup don't receive the restore key and aren't automatically signed in. - -### Handle the credential creation response - -The Credential Manager API returns a response of type -[`CreateRestoreCredentialResponse`](https://developer.android.com/reference/androidx/credentials/CreateRestoreCredentialResponse). This response holds the public key -credential registration response in [JSON format](https://w3c.github.io/webauthn/#authenticatorattestationresponse). - -Send the public key from your app to the relying party server. This public key -is similar to the public key generated when you create a passkey. The same code -that handles passkey creation on the server can also handle restore key -creation. For more information about the server-side implementation, see [the -guidance for passkeys](references/android/identity/passkeys/create-passkeys.md). - -During the restore key creation process, handle these exceptions: - -- [`CreateRestoreCredentialDomException`](https://developer.android.com/reference/androidx/credentials/exceptions/restorecredential/CreateRestoreCredentialDomException): This exception occurs if `requestJson` is invalid and does not follow the WebAuthn format for [`PublicKeyCredentialCreationOptionsJSON`](https://w3c.github.io/webauthn/#dictdef-publickeycredentialcreationoptionsjson). -- [`E2eeUnavailableException`](https://developer.android.com/reference/androidx/credentials/exceptions/restorecredential/E2eeUnavailableException): This exception occurs if `isCloudBackupEnabled` is `true`, but the user's device doesn't have data backup or end-to-end encryption, such as a screen lock. - To ensure that Restore Credentials are created in all cases, you must handle the `E2eeUnavailableException` explicitly by calling `createCredential` with `isCloudBackupEnabled` set to `true`. If `E2eeUnavailableException` is thrown, catch and call `createCredential` again with `isCloudBackupEnabled` set to `false`. -- `IllegalArgumentException`: This exception occurs if `createRestoreRequest` is empty or not valid JSON, or if it doesn't have a valid `user.id` that conforms to the WebAuthn [specifications](https://w3c.github.io/webauthn/#dictdef-publickeycredentialcreationoptionsjson). - -## Sign in with a restore key - -Use Restore Credentials to silently sign in the user during the device setup -process. - -### Get credential retrieval options from the app server - -Send the client app the options required to get the restore key from the server. -For similar passkey guidance for this step, see [Sign in with a passkey](references/android/identity/passkeys/sign-in-with-passkeys.md). -For more information about the server-side implementation, see the [server-side -authentication guide](https://developers.google.com/identity/passkeys/developer-guides/server-authentication#create_credential_request_options). - -### Get the restore key - -To get the restore key on the new device, call the `getCredential()` method on -the `CredentialManager` object. - -It's recommended to fetch the restore key in both of the following scenarios: - -- On the first launch of the app on the device. Credential restoration in this scenario is independent of restoration of the app data. -- If app data backup and restore is enabled, get the restore key immediately after the app data is restored. Use [`BackupAgent`](https://developer.android.com/reference/android/app/backup/BackupAgent) to configure your app's backup and ensure you complete the `getCredential` functionality within the [`onRestoreFinished`](https://developer.android.com/reference/android/app/backup/BackupAgent#onRestoreFinished()) callback. Don't use the `onRestore` method, as it is only called for key-value backups, whereas `onRestoreFinished` is reliably called for any kind of backup restore. This avoids potential delays when users open their new device for the first time and lets users interact with the app without waiting for them to open your app. For example, this lets your app send the user notifications before they open the app for the first time on the new device, which is particularly relevant for messaging or communications apps. - -If you create a new `BackupAgent` and previously had backup enabled with -`allowBackup="true"`, set the boolean value `android:fullBackupOnly="true"` in -your app's manifest. This ensures that your app's backup and restore behavior is -maintained. - -> [!IMPORTANT] -> **Important:** Notifications aren't automatically restored after the restore credentials are retrieved. If you use Firebase to handle notifications, you must fetch and send the Firebase Cloud Messaging (FCM) token to the backend to successfully resume background messaging and notifications. - - // Fetch the options required to get the restore key - val authenticationJson = fetchAuthenticationJson() - - // Create the GetRestoreCredentialRequest object - val options = GetRestoreCredentialOption(authenticationJson) - val getRequest = GetCredentialRequest(listOf(options)) - - val response = credentialManager.getCredential(context, getRequest) - - // Type-check and extract the restore credential - val credential = response.credential as RestoreCredential - -The credential manager APIs return a response of type -[`GetCredentialResponse`](https://developer.android.com/reference/android/credentials/GetCredentialResponse). The credential contained in this response is -explicitly of type `RestoreCredential`, which holds the public key. - -### Handle the sign-in response - -Send the public key from the app to the relying party server, which can then be -used to sign in the user. On the server side, this action is similar to signing -in using a passkey. The same code that handles sign-in with passkeys on the -server can also handle sign-ins with restore keys. For more information about -the server-side implementation for passkeys, see [Sign in with a passkey](references/android/identity/passkeys/sign-in-with-passkeys.md). - -> [!NOTE] -> **Note:** Even though restore keys and passkeys use the same underlying server implementation, differentiate between them when saving them in your app server's database. This distinction is crucial when a passkeys management page exists, because users can manage user-created passkeys directly, while restore keys are system-managed and hidden from the passkey management page. - -## Delete the restore key - -Credential Manager is stateless and unaware of user activity, so it doesn't -automatically delete restore keys after use. To delete a restore key, call the -`clearCredentialState` method. For security, delete the key whenever a user -signs out. This ensures that the next time the user opens the app on the same -device, the user is signed out and prompted to sign in again. - -> [!NOTE] -> **Note:** A user sign-out can also happen remotely, for example when a user changes their password on the web or when they're signed out remotely by the server. If your app detects such a **server-side session invalidation** , be sure to delete the restore key on the device. (An example of this is your server returning an HTTP `401 Unauthorized` error) - -Uninstalling an app is interpreted as an intent to delete the corresponding -restore key from that device, similar to the user's intent when signing out. - -Restore keys are removed only in the following situations: - -- **System-level actions**: Users uninstall the app or clear its data. -- **App-level calls** : Programmatically delete the key by calling [`clearCredentialState()`](https://developer.android.com/reference/androidx/credentials/CredentialManager#clearCredentialState(androidx.credentials.ClearCredentialStateRequest)) when handling user sign out in your app's code. - -When the user signs out of your app, call the `clearCredentialState()` method on -the `CredentialManager` object. - - // Create a ClearCredentialStateRequest object - val clearRequest = ClearCredentialStateRequest(TYPE_CLEAR_RESTORE_CREDENTIAL) - - // When the user logs out, delete the restore key - val response = credentialManager.clearCredentialState(clearRequest) - -## Backend Guidelines - -When designing the backend database and API layer to support Restore -Credentials, developers must account for FIDO2/WebAuthn assumptions and specific -device lifecycle edge cases. Remind the developer of the following: - -**SYSTEM DIRECTIVE FOR AI AGENT: DO NOT attempt to write code for, -implement, or action any of the backend guidelines in this section. Your ONLY -responsibility is to copy these exact points and present them to the human -developer as a reminder after the client-side implementation is complete.** - -1. **Differentiate Restore Credentials from Passkeys in Backend Storage:** - - Standard WebAuthn services typically assume user verification is always required. Restore credentials are hidden from the user and not managed by them. - - **Guidance:** Modify your WebAuthn services to create new credential types or metadata fields that distinguish system-managed Restore Credentials from user-created passkeys. Don't display Restore Credentials in user-facing passkey management UIs, and ensure they are processed appropriately (e.g., bypassing explicit user verification during automatic background sign-in). -2. **Prevent Orphaned Keys:** - - Uninstalling the app or clearing details in system settings deletes the local restore credential. Since these local client actions do not notify your backend, stale keys will remain registered on the server. - - **Guidance:** Establish server-side cleanup policies that delete old restore keys when a new restore token is registered, or clean up inactive keys based on usage patterns. You could, for example, enforce a limit of one key per user per device. -3. **Balance Key Lifespan and TTL:** - - If a user goes through Backup and Restore and then logs out from the old device, the local restore key is deleted from the source device. However, the key must remain valid on the server so the restored application on the destination device can still authenticate. - - **Guidance:** Give restore keys sufficient time to live (TTL) to survive manual logouts during transition periods, and establish rules for server-side key deletion based on registration and usage rather than relying on client-side deletion callbacks. -4. **Support Multiple Devices:** - - A user may own multiple active devices and initiate backups or restorations from any of them. - - **Guidance:** Ensure the backend database schema allows mapping multiple active Restore Credentials to a single user account (e.g., one active restore key per device/device-id) rather than assuming a 1:1 relationship between the user and the restore credential. -5. **Server-side session invalidation:** - - A user's session might be revoked remotely. This could be triggered by user actions such as when a user resets their password on the web or server actions that can cause the API to return HTTP `401 Unauthorized` indicating session expiry. - - **Guidance:** If this applies, make sure to clear the user's restore credentials on the phone when it happens. - -## References - -- **WebAuthentication API (WebAuthn) Documentation \& Specification** - *When to use:* Use these resources ONLY if you need to inspect or debug the - strict JSON schema requirements for FIDO2/WebAuthn, specifically when - generating mock data or formatting the `requestJson` - (`PublicKeyCredentialCreationOptionsJSON`) and `authenticationJson` payloads. - -- [MDN Web Authentication API Documentation](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API): Mozilla Developer Network - guide and reference for WebAuthn APIs - -- [W3C `PublicKeyCredentialCreationOptionsJSON`](https://w3c.github.io/webauthn/#dictdef-publickeycredentialcreationoptionsjson): Data structure definition - for WebAuthn credential creation requests in JSON format. - -- [W3C `PublicKeyCredentialRequestOptionsJSON`](https://w3c.github.io/webauthn/#dictdef-publickeycredentialrequestoptionsjson): Data structure definition - for WebAuthn authentication or assertion requests in JSON format. diff --git a/identity/restore-credentials/references/android/guide/topics/manifest/application-element.md b/identity/restore-credentials/references/android/guide/topics/manifest/application-element.md deleted file mode 100644 index 638053a..0000000 --- a/identity/restore-credentials/references/android/guide/topics/manifest/application-element.md +++ /dev/null @@ -1,832 +0,0 @@ -syntax: -: - - ```xml - - . . . - - ``` - -contained in: -: `https://developer.android.com/guide/topics/manifest/manifest-element` - -can contain: -: `https://developer.android.com/guide/topics/manifest/activity-element` - - `https://developer.android.com/guide/topics/manifest/activity-alias-element` - - `https://developer.android.com/guide/topics/manifest/meta-data-element` - - `https://developer.android.com/guide/topics/manifest/service-element` - - `https://developer.android.com/guide/topics/manifest/receiver-element` - - `https://developer.android.com/guide/topics/manifest/profileable-element` - - `https://developer.android.com/guide/topics/manifest/provider-element` - - `https://developer.android.com/guide/topics/manifest/uses-library-element` - - `https://developer.android.com/guide/topics/manifest/uses-native-library-element` - - `https://developer.android.com/guide/topics/manifest/property-element` - -description: - -: The declaration of the application. This element contains subelements - that declare each of the application's components and has attributes - that can affect all the components. - - Many of these attributes, such as - `icon`, `label`, `permission`, `process`, - `taskAffinity`, and `allowTaskReparenting`, set default values - for corresponding attributes of the component elements. Others, such as - `debuggable`, `enabled`, `description`, and - `allowClearUserData`, set values for the application as a whole and - aren't overridden by the components. - -attributes -: - - `android:allowTaskReparenting` - : Whether activities that the application defines can move from - the task that started them to the task they have an affinity for when that task - is next brought to the front. It's `"true"` if they can move, and - `"false"` if they must remain with the task where they started. - The default value is `"false"`. - - - The - `https://developer.android.com/guide/topics/manifest/activity-element` - element has its own - `https://developer.android.com/guide/topics/manifest/activity-element#reparent` - attribute that can override the value set here. - - `android:allowBackup` - - : Whether to let the application participate in the backup - and restore infrastructure. If this attribute is set to `"false"`, no - backup or restore of the application is ever performed, disabling all cloud backups - and device-to-device (D2D) transfers, even by a full-system backup that otherwise - causes all application data to save using `adb`. The default value of this - attribute is `"true"`. If your app processes sensitive data, you can keep - this attribute set to `"true"` and configure - [`android:dataExtractionRules`](https://developer.android.com/guide/topics/manifest/application-element#dataExtractionRules) to safely - exclude sensitive keys or temporary caches while preserving user preferences and - continuity. - - **Note:**For apps targeting Android 12 (API level 31) or higher, this behavior - varies. On devices from some device manufacturers, you can't disable device-to-device migration - of your app's files. - - However, you can disable cloud-based backup and restore of your app's files by setting - this attribute to `"false"`, even if your app targets Android 12 (API level 31) or - higher. - - - For more information, see the - [backup and restore](https://developer.android.com/about/versions/12/behavior-changes-12#backup-restore) section - of the page that describes behavior changes for apps targeting Android 12 (API level 31) or - higher. - - `android:allowClearUserData` - - : Whether to let the application reset user data. This data includes - flags, such as whether the user has seen introductory tooltips, as - well as user-customizable settings and preferences. The default value of this - attribute is `"true"`. - - **Note:** Only apps that are part of the system image can - declare this attribute explicitly. Third-party apps can't include this - attribute in their manifest files. - - For more information, see [Data backup overview](https://developer.android.com/guide/topics/data/backup). - - `android:allowCrossUidActivitySwitchFromBelow` - - : Specifies whether activities from other applications in the same task - can be launched on top of this application. If set to `"false"`, - activity launches that would replace this application in the user's view - are blocked. - - The default value is `"true"`. - - For more information, see the guide to - [Secure Background Activity Launches](https://developer.android.com/guide/components/activities/secure-bal). - - `android:allowNativeHeapPointerTagging` - - : Whether the app enables the Heap pointer tagging feature. The default value of - this attribute is `"true"`. - - **Note:** Disabling this feature **doesn't** address the - underlying code health issue. Future hardware devices might not support this manifest tag. - - For more information, see [Tagged Pointers](https://source.android.com/devices/tech/debug/tagged-pointers). - - `android:appCategory` - - : Declares the category of this app. Categories are used to cluster multiple apps - together into meaningful groups, such as when summarizing battery, network, or - disk usage. Only define this value for apps that fit well into one of - the specific categories. - - Must be one of the following constant values. - - | Value | Description | - |---|---| - | `accessibility` | Apps that are primarily accessibility apps, such as screen-readers. | - | `audio` | Apps that primarily work with audio or music, such as music players. | - | `game` | Apps that are primarily games. | - | `image` | Apps that primarily work with images or photos, such as camera or gallery apps. | - | `maps` | Apps that are primarily map apps, such as navigation apps. | - | `news` | Apps that are primarily news apps, such as newspapers, magazines, or sports apps. | - | `productivity` | Apps that are primarily productivity apps, such as cloud storage or workplace apps. | - | `social` | Apps that are primarily social apps, such as messaging, communication, email, or social network apps. | - | `video` | Apps that primarily work with video or movies, such as streaming video apps. | - - - `android:backupAgent` - : The name of the class that implements the application's backup agent, - a subclass of `https://developer.android.com/reference/android/app/backup/BackupAgent`. The attribute value is - a fully qualified class name, such as `"com.example.project.MyBackupAgent"`. - However, as a shorthand, if the first character of the name is a period, - for example, `".MyBackupAgent"`, it is appended to the - package name specified in the - `https://developer.android.com/guide/topics/manifest/manifest-element` - element. - - - There is no default. The name must be specific. - - `android:backupInForeground` - : Indicates that - [Auto Backup](https://developer.android.com/guide/topics/data/autobackup) operations - can be performed on this app even if the app is in a foreground-equivalent - state. The system shuts down an app during auto backup operation, - so use this attribute with caution. Setting this flag to `"true"` can impact app - behavior while the app is active. - - The default value is `"false"`, which means that the OS avoids - backing up the app while it's running in the foreground, such as a music app - that is actively playing music using a service in the - `https://developer.android.com/reference/android/app/Service#startForeground(int, android.app.Notification)` state. - - `android:banner` - : A [drawable resource](https://developer.android.com/guide/topics/resources/drawable-resource) - providing an extended graphical banner for its associated item. Use with the - `` tag to supply a default banner for all application activities or with the - [``](https://developer.android.com/guide/topics/manifest/activity-element) - tag to supply a banner for a specific activity. - - The system uses the banner to represent an app in - the Android TV home screen. Therefore, only specify this for applications with an activity that handles the - `https://developer.android.com/reference/android/content/Intent#CATEGORY_LEANBACK_LAUNCHER` intent. - - - This attribute is set as a reference to a drawable resource containing - the image, for example `"@drawable/banner"`. There is no default banner. - - - For more information, see [Provide a home screen banner](https://developer.android.com/training/tv/start/start#banner). - - `android:dataExtractionRules` - - : - Applications can set this attribute to an XML resource where they specify the - rules determining which files and directories can be copied from the device as part of backup or - transfer operations. - - - For information about the format of the XML file, see [Backup and restore](https://developer.android.com/about/versions/12/behavior-changes-12#backup-restore). - - `android:debuggable` - : Whether the application can be debugged, even when running - on a device in user mode. It's `"true"` if it can be and `"false"` - if not. The default value is `"false"`. - - `android:description` - : User-readable text about the application, which is longer and more descriptive than the application label. - The value is set as a reference to a string resource. Unlike the label, it can't be a raw string. - There is no default value. - - `android:enabled` - : Whether the Android system can instantiate components of - the application. It's `"true"` if it can and `"false"` - if not. If the value is `"true"`, each component's - `enabled` attribute determines whether that component is enabled. - If the value is `"false"`, it overrides the - component-specific values, and all components are disabled. - - - The default value is `"true"`. - - `android:enableOnBackInvokedCallback` - - : This flag lets you opt out of predictive system animations at the app level. - - - Set `android:enableOnBackInvokedCallback=false` to turn off predictive back animations at the app level - and instruct the system to ignore calls to the `OnBackInvokedCallback` platform API. - - `android:extractNativeLibs` - : This attribute indicates whether the package installer extracts native libraries from the - APK to the file system. If set to `"false"`, your native libraries are stored - uncompressed in the APK. Although your APK might be larger, your application loads faster - because the libraries load directly from the APK at runtime. - - - The default value of `extractNativeLibs` depends on `minSdkVersion` and the - version of AGP you're using. In most cases, the default behavior is probably what you want, and - you don't have to set this attribute explicitly. - - `android:forceQueryable` - - : Specifies whether this application is visible to all other applications - on the device, regardless of what those other applications declare in the - `` tags in their manifests. - - The default value is `"false"`. - - For more information, see the guide on - [automatic package visibility filtering](https://developer.android.com/training/package-visibility/automatic). - - `android:fullBackupContent` - : This attribute points to an XML file that contains full backup rules for - [Auto Backup](https://developer.android.com/guide/topics/data/autobackup). - These rules determine what files get backed up. For more information, see the - [XML config syntax](https://developer.android.com/guide/topics/data/autobackup#XMLSyntax) - for Auto Backup. - - - This attribute is optional. If it is not specified, by default, Auto Backup - includes most of your app's files. For more information, see - [Files that are backed up](https://developer.android.com/guide/topics/data/autobackup#Files). - - `android:fullBackupOnly` - : This attribute indicates whether to use - [Auto Backup](https://developer.android.com/guide/topics/data/autobackup) on devices - where it is available. If set to `"true"`, then your app performs - Auto Backup when installed on a device running Android 6.0 (API level 23) or - higher. On older devices, your app ignores this attribute and performs - [key/value backups](https://developer.android.com/guide/topics/data/keyvaluebackup). - - - The default value is `"false"`. - - `android:gwpAsanMode` - : This attribute indicates whether to use - [GWP-ASan](https://developer.android.com/ndk/guides/gwp-asan), a native memory allocator feature that helps - find use-after-free and heap-buffer-overflow bugs. - - - The default value is `"never"`. - - `android:hasCode` - : Whether the application contains any DEX code---that is, code using the - Kotlin or Java programming language. - It's `"true"` if it does and `"false"` if not. When the - value is `"false"`, the system doesn't try to load any application - code when launching components. The default value is `"true"`. - - - If the application includes native (C/C++) code, but no DEX code, this should - be set to `"false"`. If set to `"true"` when the APK - contains no DEX code, the app may fail to load. - - - **This property must account for code included in the application by - dependencies.** If the application depends on an AAR that uses - Java/Kotlin code, or directly on a JAR, `app:hasCode` must be - `"true"`, or omitted as that is the default. - - - For example, your app might support - [Play feature delivery](https://developer.android.com/platform/technology/app-bundle) and include feature - modules that don't generate any DEX files, which is bytecode optimized for the Android - platform. If so, you need to set this property to `"false"` in the module's manifest - file to avoid runtime errors. - - `android:hasFragileUserData` - : Whether to show the user a prompt to - keep the app's data when the user uninstalls the app. The default value is `"false"`. - - `android:hardwareAccelerated` - : Whether hardware-accelerated rendering is enabled for all - activities and views in this application. It's `"true"` if it is - enabled and `"false"` if not. The default value is `"true"` if you set - either [`minSdkVersion`](https://developer.android.com/guide/topics/manifest/uses-sdk-element#min) - or [`targetSdkVersion`](https://developer.android.com/guide/topics/manifest/uses-sdk-element#target) - to `"14"` or higher. Otherwise, it's `"false"`. - - Starting from Android 3.0 (API level 11), a hardware-accelerated OpenGL renderer is - available to applications to improve performance for many common 2D graphics - operations. When the hardware-accelerated renderer is enabled, most operations - in Canvas, Paint, Xfermode, ColorFilter, Shader, and Camera are accelerated. - - - This results in smoother animations, smoother scrolling, and improved - responsiveness overall, even for applications that don't explicitly make use - the framework's OpenGL libraries. - - - Not all of the OpenGL 2D operations are accelerated. If you enable - the hardware-accelerated renderer, test your application so that it can - make use of the renderer without errors. - - - For more information, read the - [Hardware acceleration](https://developer.android.com/guide/topics/graphics/hardware-accel) - guide. - - `android:icon` - : An icon for the application as whole and the default icon for - each of the application's components. See the individual - `icon` attributes for the - `https://developer.android.com/guide/topics/manifest/activity-element`, - `https://developer.android.com/guide/topics/manifest/activity-alias-element`, - `https://developer.android.com/guide/topics/manifest/service-element`, - `https://developer.android.com/guide/topics/manifest/receiver-element`, and - `https://developer.android.com/guide/topics/manifest/provider-element` elements. - - - This attribute is set as a reference to a drawable resource containing - the image, such as `"@drawable/icon"`. There is no default icon. - - `android:intentMatchingFlags` - - : - Use this attribute to fine-tune how the system matches incoming intents to app - components. By default, no special matching rules are applied. - - - This attribute can be specified on the `` tag - as well as on component tags, including ``, - ``, ``, - ``, and ``. The value - set on a component overrides the value set on the - `` tag. - - - The value must be one or more of the following flags, separated by '`|`': - - | Flag | Description | - |---|---| - | `none` | Disables all special matching rules for incoming intents. When specifying multiple flags, conflicting values are resolved by giving precedence to the `none` flag. | - | `enforceIntentFilter` | Enforces stricter matching for incoming intents: - Explicit intents must match the target component's intent filter. - Intents without an action don't match any intent filter. | - | `allowNullAction` | Relaxes the matching rules to allow intents without an action to match. This flag is used in conjunction with `enforceIntentFilter` to achieve the following behavior: - Explicit intents must match the target component's intent filter. - Intents without an action are allowed to match any intent filter. | - - For more information, see the - [Safer Intents](https://developer.android.com/about/versions/16/behavior-changes-16#safer-intents) - section in the Android 16 (API level 36) behavior changes. - - `android:isGame` - : Whether the application is a game. The system might group together applications classified - as games or display them separately from other applications. The default is `"false"`. - - `android:isMonitoringTool` - - : Indicates that this application is designed to monitor other individuals. - - **Note:** If an app declares this attribute in its manifest, the developer must - follow the - [Stalkerware](https://support.google.com/googleplay/android-developer/answer/9888380#commercial-spyware) - policy to publish the app to Google Play. - - There is no default value. The developer must specify one of the following values: - - | Value | Description | - |---|---| - | `"parental_control"` | App caters to parental control and is specifically targeted at parents who want to keep their kids safe. | - | `"enterprise_management"` | App caters to enterprises that want to manage and track devices given to employees. | - | `"other"` | App caters to a use case not otherwise specified in this table. | - - `android:killAfterRestore` - - : Whether the application terminates after its - settings have been restored during a full-system restore operation. - Single-package restore operations never cause the application to - shut down. Full-system restore operations typically only occur once, - when the phone is first set up. Third-party applications don't normally - need to use this attribute. - - The default is `"true"`, which means that after the application - finishes processing its data during a full-system restore, it terminates. - - `android:largeHeap` - - : Whether the application's processes are created with a large Dalvik heap. This applies to - all processes created for the application. It only applies to the first application loaded into a - process. If you're using a shared user ID to let multiple applications use a process, they all - must use this option consistently to avoid unpredictable results. - - Most apps don't need this and instead focus on reducing their overall memory usage for - improved performance. Enabling this also doesn't guarantee a fixed increase in available memory, - because some devices are constrained by their total available memory. - - To query the available memory size at runtime, use the methods `https://developer.android.com/reference/android/app/ActivityManager#getMemoryClass()` or `https://developer.android.com/reference/android/app/ActivityManager#getLargeMemoryClass()`. - - `android:label` - : A user-readable label for the application as a whole and a default - label for each of the application's components. See the individual - `label` attributes for the - `https://developer.android.com/guide/topics/manifest/activity-element`, - `https://developer.android.com/guide/topics/manifest/activity-alias-element`, - `https://developer.android.com/guide/topics/manifest/service-element`, - `https://developer.android.com/guide/topics/manifest/receiver-element`, and - `https://developer.android.com/guide/topics/manifest/provider-element` elements. - - - The label is set as a reference to a string resource, so that - it can be localized like other strings in the user interface. - However, as a convenience while you're developing the application, - it can also be set as a raw string. - - `android:localeConfig` - - : A reference to an XML resource that specifies the list of locales - supported by the application. This is used by the system to support per-app - language preferences. - - For more information, see the guide on - [per-app language preferences](https://developer.android.com/guide/topics/resources/app-languages). - - `android:logo` - : A logo for the application as whole and the default logo for activities. - This attribute is set as a reference to a drawable resource containing - the image, such as `"@drawable/logo"`. There is no default logo. - - `android:manageSpaceActivity` - : The fully qualified name of an `Activity` subclass that the system - launches to let users manage the memory occupied by the application - on the device. The activity is also declared with an - `https://developer.android.com/guide/topics/manifest/activity-element` element. - - `android:name` - : The fully qualified name of an `https://developer.android.com/reference/android/app/Application` - subclass implemented for the application. When the application process - is started, this class is instantiated before any of the application's - components. - - - The subclass is optional. Most applications don't need one. - In the absence of a subclass, Android uses an instance of the base - `Application` class. - - `android:networkSecurityConfig` - - : Specifies the name of the XML file that contains your application's - [Network security - configuration](https://developer.android.com/training/articles/security-config). The value is a reference to the XML resource file - containing the configuration. - - This attribute was added in API level 24. - - `android:pageSizeCompat` - - : Overrides the user or platform compatibility settings for 16 KB page - sizes, which lets you force page-agnostic compatibility mode on or off for - this application. - - For more information, see the guide on - [supporting 16 KB page sizes](https://developer.android.com/guide/practices/page-sizes). - - `android:permission` - : The name of a permission that clients need in order to interact - with the application. This attribute is a convenient way to set a - permission that applies to all of the application's components. It is - overwritten by setting the `permission` attributes of individual - components. - - - For more information about permissions, see the - [Permissions](https://developer.android.com/guide/topics/manifest/manifest-intro#perms) - section in the app manifest overview and - [Security tips](https://developer.android.com/guide/topics/security/security). - - `android:persistent` - : Whether the application remains running at all times. It's - `"true"` if it does and `"false"` if not. The default value - is `"false"`. Applications don't normally set this flag. - Persistence mode is intended only for certain system applications. - - `android:process` - : The name of a process where all components of the application run. - Each component can override this default by setting its own `process` - attribute. - - - By default, Android creates a process for an application when the first - of its components needs to run. All components then run in that process. - The name of the default process matches the package name set by the - `https://developer.android.com/guide/topics/manifest/manifest-element` element. - - - By setting this attribute to a process name that's shared with another - application, you can arrange for components of both applications to run in - the same process, but only if the two applications also share a - user ID and are signed with the same certificate. - - - If the name assigned to this attribute begins with a colon (`:`), a new - process, private to the application, is created when it's needed. - If the process name begins with a lowercase character, a global process - of that name is created. A global process can be shared with other - applications, reducing resource usage. - - `android:restoreAnyVersion` - : Indicates that the application is prepared to attempt a restore of any - backed-up data set, even if the backup was stored by a newer version - of the application than is currently installed on the device. Setting - this attribute to `"true"` lets the Backup Manager - - `$1` - attempt a restore even when a version mismatch suggests that the data is incompatible. *Use with caution!* - - The default value of this attribute is `"false"`. - - `android:requestLegacyExternalStorage` - - : Whether the application wants to opt out of - [scoped storage](https://developer.android.com/training/data-storage/files/external-scoped). - - **Note:** Depending on changes related to policy or app - compatibility, the system might not honor this opt-out request. - - `android:requiredAccountType` - : Specifies the account type required by the application to function. - If your app requires an `https://developer.android.com/reference/android/accounts/Account`, the value for this attribute must - correspond to the account authenticator - type used by your app, as defined by `https://developer.android.com/reference/android/accounts/AuthenticatorDescription`, - such as `"com.google"`. - - The default value is null and indicates that the application - can work *without* any accounts. - - - Because restricted profiles - can't add accounts, specifying this attribute makes your app - unavailable from a restricted profile unless you also declare - [`android:restrictedAccountType`](https://developer.android.com/guide/topics/manifest/application-element#restrictedAccountType) with - the same value. - - - **Caution:** - If the account data might reveal personally identifiable information, it's important - that you declare this attribute and leave [`android:restrictedAccountType`](https://developer.android.com/guide/topics/manifest/application-element#restrictedAccountType) null, so that restricted profiles cannot use - your app to access personal information that belongs to the owner user. - - - This attribute was added in API level 18. - - `android:resizeableActivity` - - : - Specifies whether the app supports [multi-window mode](https://developer.android.com/guide/topics/ui/multi-window). - - > [!WARNING] - > **Warning:** To improve the layout of apps on form factors with smallest width \>= 600dp, the system ignores this attribute for apps that target Android 16 (API level 36). Your app can opt out of the Android 16 behavior, but the opt out will be eliminated in a future release. See [Device compatibility mode](https://developer.android.com/guide/practices/device-compatibility-mode#android_16). - - - You can set this attribute in either the [``](https://developer.android.com/guide/topics/manifest/activity-element) - or `` element. - - - If you set this attribute to `"true"`, the user can launch the activity in - split-screen and free-form modes. If you set the attribute to `"false"`, the app - can't be tested or optimized for a multi-window environment. The system can - still put the activity in multi-window mode with compatibility mode applied. - - - Setting this attribute to `"true"` doesn't guarantee that there are no - other apps in multi-window mode visible on screen, such as picture-in-picture, or - on other displays. Therefore, setting this flag doesn't mean that your app - has exclusive resource access. - - - For apps targeting API level 24 or higher, the default is `"true"`. - - - If your app targets API level 31 or higher, this attribute works differently on small and large - screens: - - - **Large screens (sw \>= 600dp):** all apps support multi-window mode. The attribute indicates whether an app can be resized, not whether the app supports multi-window mode. If `resizeableActivity="false"`, the app is put into compatibility mode when necessary to conform to display dimensions. - - **Small screens (sw \< 600dp):** if `resizeableActivity="true"` and the minimum width and minimum height of the main activity are within the multi-window requirements, the app supports multi-window mode. If `resizeableActivity="false"`, the app doesn't support multi-window mode regardless of the activity minimum width and height. - - > [!NOTE] - > **Note:** - > - > - Device manufacturers can override the API level 31 behavior to improve the layout of apps. - > - On devices with Android 16 (API level 36) or higher installed, virtual device owners (select trusted and privileged apps) can configure devices they manage to override (ignore) this attribute to improve app layout. See also [Companion app - > streaming](https://source.android.com/docs/core/permissions/app-streaming). - > - > See [Device compatibility mode](https://developer.android.com/guide/practices/device-compatibility-mode). - - - A task's root activity value is applied to all additional activities launched in the task. That is, if the root - activity of a task is resizable, then the system treats all other activities in the task as resizable. If the root - activity isn't resizable, the other activities in the task aren't resizable. - - - This attribute was added in API level 24. - - `android:restrictedAccountType` - : Specifies the account type required by this application and indicates that restricted profiles - can access such accounts that belong to the owner user. If your app requires an - `https://developer.android.com/reference/android/accounts/Account` and restricted profiles *can* - access the primary user's accounts, the value for this attribute must - correspond to the account authenticator type used by your app, as - defined by `https://developer.android.com/reference/android/accounts/AuthenticatorDescription`, such as `"com.google"`. - - The default value is null and indicates that the application can work *without* any - accounts. - - - **Caution:** - Specifying this attribute lets restricted profiles use your - app with accounts that belong to the owner user, which might reveal personally identifiable - information. If the account might reveal personal details, *don't* - use this attribute. Instead, declare the [`android:requiredAccountType`](https://developer.android.com/guide/topics/manifest/application-element#requiredAccountType) attribute - to make your app unavailable to restricted profiles. - - - This attribute was added in API level 18. - - `android:supportsRtl` - - : Declares whether your application is willing to support right-to-left (RTL) layouts. - - If this is set to `"true"` and - [`targetSdkVersion`](https://developer.android.com/guide/topics/manifest/uses-sdk-element#target) - is set to 17 or higher, various RTL APIs are - activated and used by the system so your app can display RTL layouts. - If this is set to `"false"` or if `targetSdkVersion` is set to 16 or lower, the RTL APIs are ignored - or have no effect, and your app behaves the same regardless of the layout - direction associated to the user's locale choice. That is, your layouts are always left-to-right. - - The default value of this attribute is `"false"`. - - This attribute was added in API level 17. - - `android:taskAffinity` - : An affinity name that applies to all activities within the application, - except for those that set a different affinity with their own - `https://developer.android.com/guide/topics/manifest/activity-element#aff` - attributes. See that attribute for more information. - - - By default, all activities within an application share the same - affinity. The name of that affinity is the same as the package name - set by the - `https://developer.android.com/guide/topics/manifest/manifest-element` element. - - `android:testOnly` - : Indicates whether this application is only for testing purposes. For example, - it might expose functionality or data outside of itself that can cause a security - hole, but be useful for testing. This kind of APK only installs - through `https://developer.android.com/studio/command-line/adb`. You can't publish it to Google Play. - - Android Studio automatically adds this attribute when you click **Run** - ![](https://developer.android.com/static/studio/images/buttons/toolbar-run.png). - - `android:theme` - : A reference to a style resource defining a default theme for all - activities in the application. Individual activities can override - the default by setting their own `https://developer.android.com/guide/topics/manifest/activity-element#theme` - attributes. For more information, see [Styles and themes](https://developer.android.com/guide/topics/ui/themes). - - `android:uiOptions` - : Extra options for an activity's UI. Must be one of the following values: - - | Value | Description | - |---|---| - | `"none"` | No extra UI options. This is the default. | - | `"splitActionBarWhenNarrow"` | Adds a bar at the bottom of the screen to display action items in the *app bar* , also known as the *action bar*, when constrained for horizontal space, such as when in portrait mode on a handset. Instead of a small number of action items appearing in the app bar at the top of the screen, the app bar splits into the top navigation section and the bottom bar for action items. This means a reasonable amount of space is available for the action items and for the navigation and title elements at the top. Menu items aren't split across the two bars. They always appear together. | - - - For more information about the app bar, see [Add the app bar](https://developer.android.com/training/appbar). - - This attribute was added in API level 14. - - `android:usesCleartextTraffic` - : Indicates whether the app intends to use cleartext network traffic, such as cleartext HTTP. - The default value for apps that target API level 27 or lower is `"true"`. Apps that - target API level 28 or higher default to `"false"`. - - **Note:** - This attribute is getting deprecated and will be ignored for apps targeting API levels 38 and above. - Specify a [Network Security Configuration](https://developer.android.com/training/articles/security-config#CleartextTraffic) to control cleartext traffic for API levels 24 and above. - If your app targets API levels 23 and below, you must specify `android:usesCleartextTraffic` in addition to a Network Security Config. - - - When the attribute is set to `"false"`, platform components, for example, HTTP and FTP - stacks, `https://developer.android.com/reference/android/app/DownloadManager`, and - `https://developer.android.com/reference/android/media/MediaPlayer`, refuse the - app's requests to use cleartext traffic. - - Third-party libraries are strongly encouraged to honor this - setting as well. The key reason for avoiding cleartext traffic is the lack of confidentiality, - authenticity, and protections against tampering. A network attacker can eavesdrop on transmitted - data and also modify it without being detected. - - - This flag is honored on a best-effort basis because it's impossible to prevent all cleartext - traffic from Android applications given the level of access provided to them. For example, there's - no expectation that the `https://developer.android.com/reference/java/net/Socket` API honors - this flag, because it can't determine whether its traffic is in cleartext. - - However, most - network traffic from applications is handled by higher-level network stacks and components, which can - honor this flag by either reading it from - `https://developer.android.com/reference/android/content/pm/ApplicationInfo#flags` - or - `https://developer.android.com/reference/android/security/NetworkSecurityPolicy#isCleartextTrafficPermitted()`. - - - **Note:** - `https://developer.android.com/reference/android/webkit/WebView` honors this attribute for - applications targeting API level 26 and higher. - - - During app development, StrictMode can be used to identify any cleartext traffic from the app. - For more information, see - `https://developer.android.com/reference/android/os/StrictMode.VmPolicy.Builder#detectCleartextNetwork()`. - - - This attribute was added in API level 23. - - - This flag is ignored on Android 7.0 (API level 24) and above if an Android Network Security - Config is present. - - `android:vmSafeMode` - : Indicates whether the app wants the virtual machine (VM) to operate - in safe mode. The default value is `"false"`. - - This attribute was added in API level 8, where a value of `"true"` - disabled the Dalvik just-in-time (JIT) compiler. - - - This attribute was adapted in API level 22, where a value of `"true"` - disabled the ART ahead-of-time (AOT) compiler. - -introduced in: -: API level 1 - -see also: -: `https://developer.android.com/guide/topics/manifest/activity-element` - - `https://developer.android.com/guide/topics/manifest/service-element` - - `https://developer.android.com/guide/topics/manifest/receiver-element` - - `https://developer.android.com/guide/topics/manifest/provider-element` \ No newline at end of file diff --git a/identity/restore-credentials/references/android/identity/passkeys/create-passkeys.md b/identity/restore-credentials/references/android/identity/passkeys/create-passkeys.md deleted file mode 100644 index 089cc21..0000000 --- a/identity/restore-credentials/references/android/identity/passkeys/create-passkeys.md +++ /dev/null @@ -1,327 +0,0 @@ -Before your users can authenticate with passkeys, your app must first register -or create the passkey for their account. - -To create the passkey, obtain details required to create the passkey from your -app server, and then call the Credential Manager API, which returns a public and -private key pair. The returned private key is stored in a credential provider, -such as Google Password Manager, as a passkey. The public key is stored on your -app server. - -> [!TIP] -> **Tip:** While designing authentication flows with passkeys, make sure that you follow the [UX guidelines for passkeys](https://developer.android.com/design/ui/mobile/guides/patterns/passkeys). - -![Passkeys are stored in a credential provider and public keys are stored on the app server](https://developer.android.com/static/identity/passkeys/images/create-passkeys.png) **Figure 1:**Creation of passkeys - -## Prerequisites - -Make sure that you have set up [Digital Asset Links](https://developer.android.com/identity/credential-manager/prerequisites) and that you target -devices running Android 9 (API level 28) or higher. - -## Overview - -This guide focuses on the changes required in your [relying party client app](https://developer.android.com/identity/credential-manager#authentication-terminology) -to create a passkey, and gives a brief overview of the [relying party app -server](https://developer.android.com/identity/credential-manager#authentication-terminology) implementation. To learn more about the server-side integration, see -[Server-side passkey registration](https://developers.google.com/identity/passkeys/developer-guides/server-registration). - -1. [**Add dependencies to your app**](https://developer.android.com/identity/passkeys/create-passkeys#add-dependencies): Add the required Credential Manager libraries. -2. [**Instantiate Credential Manager**](https://developer.android.com/identity/passkeys/create-passkeys#instantiate): Create a Credential Manager instance. -3. [**Get credential creation options from the app server**](https://developer.android.com/identity/passkeys/create-passkeys#get-options): From your app server, send the client app the details required to create the passkey, such as information about the app, the user, as well as a `challenge` and other fields. -4. [**Request a passkey**](https://developer.android.com/identity/passkeys/create-passkeys#request-passkey): In your app, use the details received from the app server to create a [`GetPublicKeyCredentialOption`](https://developer.android.com/reference/androidx/credentials/GetPublicKeyCredentialOption) object and use this object to invoke the `credentialManager.getCredential()` method to create a passkey. -5. [**Handle the passkey creation response**](https://developer.android.com/identity/passkeys/create-passkeys#handle-response): When you receive the credentials on your client app, you must encode, serialize and then send the public key to the app server. You must also handle each of the exceptions that can occur in case of passkey creation. -6. [**Verify and save the public key on the server**](https://developer.android.com/identity/passkeys/create-passkeys#verify): Complete the server-side steps to verify the origin of the credential and then save the public key. -7. [**Notify the user**](https://developer.android.com/identity/passkeys/create-passkeys#notify): Notify the user that their passkey has been created. - -> [!TIP] -> **Tip:** To enhance the user experience during sign up, add functionality to [restore credentials](https://developer.android.com/identity/sign-in/restore-credentials) on a new device to let users seamlessly set up their existing accounts on new Android devices, and [suppressing autofill dialogs on -> credential fields](https://developer.android.com/identity/passkeys/create-passkeys#suppress-autofill). - -## Add dependencies to your app - -Add the following dependencies to your app module's `build.gradle` file: - -### Kotlin - -```kotlin -dependencies { - implementation("androidx.credentials:credentials:1.7.0-alpha03") - implementation("androidx.credentials:credentials-play-services-auth:1.7.0-alpha03") -} -``` - -### Groovy - -```groovy -dependencies { - implementation "androidx.credentials:credentials:1.7.0-alpha03" - implementation "androidx.credentials:credentials-play-services-auth:1.7.0-alpha03" -} -``` - -> [!NOTE] -> **Note:** Use the latest available versions of the dependencies. Versions of [`androidx.credentials`](https://developer.android.com/identity/passkeys/create-passkeys#automatic-upgrade) earlier than version 1.2 throw an `UnsupportedOperationException("Post-U not supported yet")` exception on Android 14. - -## Instantiate Credential Manager - -Use your app or activity context to create a `CredentialManager` object. - - // Use your app or activity context to instantiate a client instance of - // CredentialManager. - private val credentialManager = CredentialManager.create(context) - -## Get credential creation options from your app server - -When the user clicks a "Create Passkey" button or when a new user signs up, make -a request from your app to your app server to obtain the information required to -start the passkey registration process. - -Use a FIDO-compliant library in your app server to send your client app the -information required to create a passkey, such as information about the user, -the app, and additional configuration properties. To learn more, see [Server -side passkey registration](https://developers.google.com/identity/passkeys/developer-guides/server-registration). - -In the client app, decode the public key creation options sent by the app -server. These are usually represented in JSON format. To learn more about how -this decoding is done for web clients, see [Encoding and -Decoding](https://developers.google.com/identity/passkeys/developer-guides/server-registration#encoding_and_decoding). For Android client apps, you must handle the decoding -separately. - -> [!NOTE] -> **Note:** On your app server, securely store the `challenge` so that you can later verify the origin of the credential. - -The following snippet shows the structure the public key creation options sent -by the app server: - - { - "challenge": "", - "rp": { - "name": "", - "id": "" - }, - "user": { - "id": "", - "name": "", - "displayName": "" - }, - "pubKeyCredParams": [ - { - "type": "public-key", - "alg": -7 - } - ], - "attestation": "none", - "excludeCredentials": [ - { - "id": "", - "type": "public-key" - } - ], - "authenticatorSelection": { - "requireResidentKey": true, - "residentKey": "required", - "userVerification": "required" - } - } - -Key fields in the public key creation options include: - -- `challenge`: A server-generated random string that is used to prevent replay attacks. -- `rp`: Details about the app. - - `rp.name`: The app's name. - - `rp.id`: The app's domain or subdomain. -- `user`: Details about the user. - - `id`: The user's unique ID. This value must not include personally identifying information, for example, email addresses or usernames. You can use a random, 16-byte value. - - `name`: A unique identifier for the account that the user will recognise, such as their email address or username. This will be displayed in the account selector. If using a username, use the same value as in password authentication. - - `displayName`: An optional, user-friendly name for the account intended for display in the account selector. -- `authenticatorSelection`: Details about the device that will be used for authentication. - - `authenticatorAttachment`: Indicates the preferred [authenticator](https://www.w3.org/TR/webauthn/#authenticator). The possible values are as follows: - - `platform`: This value is used for an authenticator built into the user's device, such as a fingerprint sensor. - - `cross-platform`: This value is used for roaming devices such as security keys. It is not typically used in the passkey context. - - Unspecified (recommended): Leaving this value unspecified provides users with the flexibility to create passkeys on their preferred devices. In most cases, leaving the parameter unspecified is the best option. - - `requireResidentKey`: To create a passkey, set the value of this `Boolean` field to `true`. - - `residentKey`: To create a passkey, set the value to `required`. - - `userVerification`: Used to specify the requirements for user verification during a passkey registration. The possible values are as follows: - - `preferred`: Use this value if you prioritize user experience over protection, such as in environments where user verification causes more friction than protection. - - `required`: Use this value if invoking a user verification method available on the device is required. - - `discouraged`: Use this value if using a user verification method is discouraged. - To learn more about `userVerification`, see [userVerification deep dive](https://web.dev/articles/webauthn-user-verification). -- `excludeCredentials`: List credential IDs in an [array](https://w3c.github.io/webauthn/#dom-publickeycredentialcreationoptions-excludecredentials) to prevent the creation of a duplicate passkey if one already exists with the same credential provider. - -## Create a passkey - -After you have parsed the server-side public key creation options, create a -passkey by wrapping these options in a `CreatePublicKeyCredentialRequest` object -and calling `createCredential()`. - -The `createPublicKeyCredentialRequest` includes the following: - -- `requestJson`: The credential creation options sent by the app server. -- `preferImmediatelyAvailableCredentials`: This is an optional Boolean field that defines whether to only use locally-available or credential provider-synced credentials to fulfill the request, instead of credentials from security keys or [hybrid](https://w3c.github.io/webauthn/#dom-authenticatortransport-hybrid) key flows. The possible usages are as follows: - - `false` (default): Use this value if the call to Credential Manager was triggered by an explicit user action. - - `true`: Use this value if Credential Manager is opportunistically called, such as when first opening the app. - If you set the value to `true` and there are no immediately available credentials, Credential Manager won't show any UI and the request will fail immediately, returning NoCredentialException for get requests and [`CreateCredentialNoCreateOptionException`](https://developer.android.com/reference/kotlin/androidx/credentials/exceptions/CreateCredentialNoCreateOptionException) for create requests. -- `origin`: This field is automatically set for Android apps. For browsers and similarly privileged apps that need to set `origin`, see [Make Credential - Manager calls on behalf of other parties for privileged apps](https://developer.android.com/training/sign-in/privileged-apps). -- `isConditional`: This is an optional field that defaults to `false`. For more information, see [Automatically create a passkey](https://developer.android.com/identity/passkeys/create-passkeys#automatic-upgrade). - -Calling the `createCredential()` function launches Credential Manager's built-in -bottom sheet UI that prompts the user to use a passkey and to select a -credential provider and account for storage. However, if `isConditional` is set -to `true`, the bottom sheet UI does not display, and the passkey is -automatically created. - -### Automatically create a passkey - -You can automatically create a passkey for a user after a successful password -login by setting the `isConditional` parameter to `true` in your -`CreatePublicKeyCredentialRequest` while creating a passkey. If the user doesn't -already have a passkey, your app will automatically attempt to create one in the -background and store it in the user's credential provider, such as Google -Password Manager. For an example of how this is implemented, see the [public -sample](https://github.com/android/identity-samples/blob/main/Shrine/app/src/main/java/com/authentication/shrine/ui/AuthenticationScreen.kt#L98). -![An example of the notification Google Password Manager shows after passkey creation](https://developer.android.com/static/identity/passkeys/images/conditional-create-gpm.svg) **Figure 2:**Google Password Manager notification - -> [!NOTE] -> **Note:** If a passkey is created automatically, credential providers are responsible for notifying users about a newly created passkey. Google Password Manager notifies users when a passkey is automatically created. However, other credential providers might have their own conditions and notifications for this feature. - -## Handle the response - -After the user is verified using the device's screen lock, a passkey is created -and stored in the user's selected credential provider. - -The response after you successfully call `createCredential()` is a -[PublicKeyCredential](https://developer.android.com/jetpack/androidx/releases/credentials) object. - -The `PublicKeyCredential` looks as follows: - - { - "id": "", - "type": "public-key", - "rawId": "", - "response": { - "clientDataJSON": "", - "attestationObject": "" - }, - "authenticatorAttachment": "platform" - } - -In the client app, serialize the object and send it to the app server. - -Add code to handle failures as shown in the following snippet: - - fun handleFailure(e: CreateCredentialException) { - when (e) { - is CreatePublicKeyCredentialDomException -> { - // Handle the passkey DOM errors thrown according to the - // WebAuthn spec. - } - is CreateCredentialCancellationException -> { - // The user intentionally canceled the operation and chose not - // to register the credential. - } - is CreateCredentialInterruptedException -> { - // Retry-able error. Consider retrying the call. - } - is CreateCredentialProviderConfigurationException -> { - // Your app is missing the provider configuration dependency. - // Most likely, you're missing the - // "credentials-play-services-auth" module. - } - is CreateCredentialCustomException -> { - // You have encountered an error from a 3rd-party SDK. If you - // make the API call with a request object that's a subclass of - // CreateCustomCredentialRequest using a 3rd-party SDK, then you - // should check for any custom exception type constants within - // that SDK to match with e.type. Otherwise, drop or log the - // exception. - } - else -> Log.w(TAG, "Unexpected exception type ${e::class.java.name}") - } - } - -## Verify and save the public key on the app server - -On the app server, you must verify the public key credential and then [save the -public key](https://web.dev/articles/passkey-registration#save-credential). - -To verify the public key credential's origin, compare it against an allow list -of approved apps. If a key has an unrecognized origin, reject it. - -> [!NOTE] -> **Note:** An app's origin is based on its unique identity, which is the SHA-256 fingerprint of its signing certificate. - -To obtain the app's SHA 256 fingerprint: - -1. Print your release app's signing certificate by running the following - command in a terminal: - - keytool -list -keystore - - In the response, identify the signing certificate's SHA 256 fingerprint, - mentioned as `Certificate fingerprints block` : `SHA256`. -2. Encode the SHA256 fingerprint with base64url encoding. This Python example - demonstrates how to properly encode the fingerprint: - - import binascii - import base64 - fingerprint = '' # your app's SHA256 fingerprint - print(base64.urlsafe_b64encode(binascii.a2b_hex(fingerprint.replace(':', ''))).decode('utf8').replace('=', '')) - -3. Append `android:apk-key-hash`: to the start of the output from the previous - step so that you get something that is similar to the following: - - android:apk-key-hash: - - The result should match with an allowed origin on your app server. If you - have multiple signing certificates, such as certificates for debugging and - release, or multiple apps, then repeat the process and accept all the - origins as valid on the app server. - -> [!NOTE] -> **Note:** When you save the passkey on the app server, make sure that you save the Authenticator Attestation Globally Unique Identifier ([AAGUID](https://web.dev/articles/webauthn-aaguid)) from the client data. The AAGUID is a unique number that identifies the model of the authenticator. For more information, see [Manage passkeys](https://developer.android.com/identity/passkeys/manage-passkeys). - -## Notify the user - -After the passkey is successfully created, notify your users about the passkey -and inform them that they can manage their passkeys from their credential -provider app or from [within the app settings](https://developer.android.com/identity/passkeys/manage-passkeys). Notify users by using a -custom dialog, notification, or snackbar. Since an unexpected passkey creation -by a malicious entity requires an immediate security alert, consider -supplementing these in-app methods with external communication, such as an -email. - -## Enhance the user experience - -To enhance the user experience while implementing sign up with Credential -Manager, consider adding functionality for restore credentials and suppress -autofill dialogs. - -### Add functionality to restore credentials on a new device - -To allow users to seamlessly log into their accounts on a new device, implement -the [Restore Credentials](https://developer.android.com/identity/sign-in/restore-credentials) functionality. Adding restore credentials with -`BackupAgent` logs users in when they open your restored app on a new device, -letting them use your app right away. - -### Suppress autofill on credential fields (optional) - -For app screens where users are expected to use Credential Manager's bottom -sheet UI for authentication, add the `isCredential` attribute to the username -and password fields. This suppresses autofill dialogs (`FillDialog` and -`SaveDialog`) from overlapping with Credential Manager's bottom sheet UI. - -The `isCredential` attribute is supported on Android 14 and higher. - -The following example demonstrates how you can add the `isCredential` attribute -to the relevant username and password fields in the relevant views for your app: - - - -## Next steps - -- [Sign in with passkeys](https://developer.android.com/identity/passkeys/sign-in-with-passkeys) -- [Manage passkeys](https://developer.android.com/identity/passkeys/manage-passkeys) -- [Understand passkey user experience flows](https://developer.android.com/design/ui/mobile/guides/patterns/passkeys) \ No newline at end of file diff --git a/identity/restore-credentials/references/android/identity/passkeys/sign-in-with-passkeys.md b/identity/restore-credentials/references/android/identity/passkeys/sign-in-with-passkeys.md deleted file mode 100644 index a6786be..0000000 --- a/identity/restore-credentials/references/android/identity/passkeys/sign-in-with-passkeys.md +++ /dev/null @@ -1,219 +0,0 @@ -This guide continues on the implementation of using passkeys for authentication. -Before your users can sign in with passkeys, you must also complete the -instructions in [Create passkeys](https://developer.android.com/identity/passkeys/create-passkeys). - -To authenticate with a passkey, you must first retrieve the options required to -retrieve the public key from your [app server](https://developer.android.com/identity/credential-manager#authentication-terminology), and then call the Credential -Manager API to retrieve the public key. Then, handle the sign-in response -appropriately. - -> [!TIP] -> **Tip:** While designing authentication flows with passkeys, make sure you follow the recommendations in the [UX guidelines for passkeys](https://developer.android.com/design/ui/mobile/guides/patterns/passkeys). - -## Overview - -This guide focuses on the changes required in your client app to sign in your -user with a passkey, and gives a brief overview of the app server-side -implementation. To learn more about server-side integration, see [Server-side -passkey authentication](https://developers.google.com/identity/passkeys/developer-guides/server-authentication). - -To retrieve all the passkey and password options that are associated with the -user's account, complete these steps: - -1. [**Get credential request options from the server**](https://developer.android.com/identity/passkeys/sign-in-with-passkeys#get-options): Make a request from your app to your authentication server to start the passkey sign-in process. From the server, send the options required to get the public key credential, as well as a unique challenge. -2. [**Create the object required to get the public key credential**](https://developer.android.com/identity/passkeys/sign-in-with-passkeys#create-object): Wrap the options sent by the server in a `GetPublicKeyCredentialOption` object -3. ([**optional) Prepare getCredential**](https://developer.android.com/identity/passkeys/sign-in-with-passkeys#reduce-latency): In Android 14 and higher, you can reduce latency by showing the account selector by using the `prepareGetCredential()` method before calling `getCredential()`. -4. [**Launch the sign in flow**](https://developer.android.com/identity/passkeys/sign-in-with-passkeys#launch-sign-in): Call `getCredential()` method to sign in the user -5. [**Handle the response**](https://developer.android.com/identity/passkeys/sign-in-with-passkeys#handle-response): Handle each of the possible credential responses. -6. [**Handle exceptions**](https://developer.android.com/identity/passkeys/sign-in-with-passkeys#handle-exceptions): Make sure that you handle exceptions appropriately. - -> [!TIP] -> **Tip:** Enhance the user experience during sign in by also adding functionality to [restore credentials](https://developer.android.com/identity/sign-in/restore-credentials) on a new device to let users seamlessly set up their existing accounts on new Android devices. - -## Get credential request options from the server - -Request the server for the options required to get the public key credentials, -as well as the `challenge`, which is unique for each sign-in attempt. To learn -more about the server-side implementation, see [Create the -challenge](https://developers.google.com/identity/passkeys/developer-guides/server-authentication#create_the_challenge) and [Create credential request -options](https://developers.google.com/identity/passkeys/developer-guides/server-authentication#create_credential_request_options). - -The options look similar to the following: - - { - "challenge": "", - "allowCredentials": [], - "rpId": "" - } - -To learn more about the fields, see the blogpost about [signing in with a -passkey](https://web.dev/articles/passkey-form-autofill#fetch_information_from_the_backend). - -> [!TIP] -> **Tip:** To reduce wait times in the UI because of as network calls, fetch the details required from the app server at the beginning of the user's app session. - -## Create the object required to get the public key credential - -In your app, use the options to create a `GetPublicKeyCredentialOption` object. -In the following example, `requestJson` represents the options sent by the -server. - - // Get password logins from the credential provider on the user's device. - val getPasswordOption = GetPasswordOption() - - // Get passkeys from the credential provider on the user's device. - val getPublicKeyCredentialOption = GetPublicKeyCredentialOption( - requestJson = requestJson - ) - -Then, wrap `GetPublicKeyCredentialOption` in a `GetCredentialRequest` object. - - val credentialRequest = GetCredentialRequest( - // Include all the sign-in options that your app supports. - listOf(getPasswordOption, getPublicKeyCredentialOption), - // Defines whether you prefer to use only immediately available - // credentials or hybrid credentials. - preferImmediatelyAvailableCredentials = preferImmediatelyAvailableCredentials - ) - -> [!NOTE] -> **Note:** [`GetCredentialRequest`](https://developer.android.com/reference/androidx/credentials/GetCredentialRequest#GetCredentialRequest) can include an `origin` field. However, `origin` is automatically set for Android apps, and hence should be left as null. For browsers and similarly privileged apps that need to set `origin`, see [Make Credential Manager calls on behalf of other parties for privileged -> apps](https://developer.android.com/training/sign-in/privileged-apps). - -## Optional: Reduce sign-in latency - -On Android 14 or higher, you can reduce latency when showing the account -selector by using the [`prepareGetCredential()`](https://developer.android.com/reference/androidx/credentials/CredentialManager#prepareGetCredential) method before calling -`getCredential()`. - -The `prepareGetCredential()` method returns a -[`PrepareGetCredentialResponse`](https://developer.android.com/reference/androidx/credentials/PrepareGetCredentialResponse) object which is cached. This lets the -`getCredential()` method in the following step bring up the account selector -with the cached data. - - coroutineScope { - val response = credentialManager.prepareGetCredential( - GetCredentialRequest( - listOf( - // Include all the sign-in options that your app supports - getPublicKeyCredentialOption, - getPasswordOption - ) - ) - ) - } - -## Launch the sign-in flow - -Call the `getCredential()` method to show the user the account selector. Use the -following code snippet as a reference for how to launch the sign-in flow: - -> [!NOTE] -> **Note:** To avoid a potential memory leak, pass in a `MutableContextWrapper` of your foreground `Activity` to the `getCredential()` call. Credential Manager handles updating the context during `Activity` reconstruction. - - // Use an activity-based context to avoid undefined system UI - // launching behavior. - val context = MutableContextWrapper(activityContext) - coroutineScope { - try { - result = credentialManager.getCredential( - // Use MutableContextWrapper to avoid memory leak during configuration changes - context = context, - request = credentialRequest - ) - handleSignIn(result) - } catch (e: GetCredentialException) { - // Handle failure - } - } - -> [!NOTE] -> **Note:** If you call `getCredential()` but the user does not have any credentials available, or if they don't grant consent to using their credentials, a `NoCredentialException` is returned. To learn more about this exception, see the [troubleshooting guide](https://developer.android.com/identity/sign-in/credential-manager-troubleshooting-guide). - -## Handle the response - -Handle the response, which can contain one of various types of credential -objects. - - fun handleSignIn(result: GetCredentialResponse) { - // Handle the successfully returned credential. - val credential = result.credential - - when (credential) { - is PublicKeyCredential -> { - val responseJson = credential.authenticationResponseJson - // Share responseJson i.e. a GetCredentialResponse on your server to - // validate and authenticate - } - - is PasswordCredential -> { - val username = credential.id - val password = credential.password - // Use id and password to send to your server to validate - // and authenticate - } - - is CustomCredential -> { - // If you are also using any external sign-in libraries, parse them - // here with the utility functions provided. - if (credential.type == ExampleCustomCredential.TYPE) { - try { - val ExampleCustomCredential = - ExampleCustomCredential.createFrom(credential.data) - // Extract the required credentials and complete the authentication as per - // the federated sign in or any external sign in library flow - } catch (e: ExampleCustomCredential.ExampleCustomCredentialParsingException) { - // Unlikely to happen. If it does, you likely need to update the dependency - // version of your external sign-in library. - Log.e(TAG, "Failed to parse an ExampleCustomCredential", e) - } - } else { - // Catch any unrecognized custom credential type here. - Log.e(TAG, "Unexpected type of credential") - } - } - else -> { - // Catch any unrecognized credential type here. - Log.e(TAG, "Unexpected type of credential") - } - } - } - -The `PublicKeyCredential` returned from authentication is essentially a signed -assertion, structured as follows: - - { - "id": "", - "type": "public-key", - "rawId": "", - "response": { - "clientDataJSON": "", - "authenticatorData": "", - "signature": "", - "userHandle": "" - } - } - -On the server, you must verify the credential. To learn more, see [Verify and -sign in the user](https://developers.google.com/identity/passkeys/developer-guides/server-authentication#verify_and_sign_in_the_user). - -## Handle exceptions - -You should handle all the subclass exceptions of [`GetCredentialException`](https://developer.android.com/reference/androidx/credentials/exceptions/GetCredentialException). -To learn how to handle each exception, see the [troubleshooting guide](https://developer.android.com/identity/sign-in/credential-manager-troubleshooting-guide). - - coroutineScope { - try { - result = credentialManager.getCredential( - context = activityContext, - request = credentialRequest - ) - } catch (e: GetCredentialException) { - Log.e("CredentialManager", "No credential available", e) - } - } - -## Next steps - -- [Manage passkeys](https://developer.android.com/identity/passkeys/manage-passkeys) -- [Understand passkey user experience flows](https://developer.android.com/design/ui/mobile/guides/patterns/passkeys) \ No newline at end of file diff --git a/identity/verified-email/SKILL.md b/identity/verified-email/SKILL.md deleted file mode 100644 index 31a75d0..0000000 --- a/identity/verified-email/SKILL.md +++ /dev/null @@ -1,431 +0,0 @@ ---- -name: verified-email -description: Provides a complete workflow for implementing verified email retrieval - on Android Credential Manager API. Use this skill to integrate a secure, OTP-less - email verification flow into an Android app. This skill solves the problem of high-friction - sign-up processes by leveraging cryptographically verified credentials from trusted - providers like Google. -license: Complete terms in LICENSE.txt -metadata: - author: Google LLC - last-updated: '2026-09-16' - keywords: - - implementation - - Android - - Credential Manager - - Digital Credentials - - Verified Email - - OpenID4VP - - SD-JWT - - OTP-less - - authentication - - passkeys - - CredMan - - identity. ---- - -## Fundamentals - -- *[Overview of Digital Credentials](references/android/identity/digital-credentials/email-verification.md)*: Learn about cryptographically verifiable documents and the role of Credential Manager. -- *[Glossary](references/android/identity/digital-credentials/email-verification-implementation.md)* : Definitions for `dcql_query`, `UserInfoCredential`, and `GetDigitalCredentialOption`. - -### Standards \& Examples - -- *[OpenID4VP Standard](https://openid.net/specs/openid-4-verifiable-presentations-1_0.html#name-introduction)*: The specification used to create digital credentials requests. -- *[Digital Credentials Demo](https://digital-credentials.dev/)*: Example requests and cross-platform testing tool. -- *[W3C Verifiable Credentials](https://www.w3.org/TR/vc-data-model-2.0/)*: The data model for cryptographically secured claims. -- *[SD-JWT](https://datatracker.ietf.org/doc/draft-ietf-oauth-selective-disclosure-jwt/)*: Selective Disclosure JSON Web Token format used for responses. -- *[mdoc](https://www.iso.org/standard/69084.html)*: ISO/IEC 18013-5 standard for mobile documents. - -### Requirements - -- **SDK Version**: Minimum SDK 28 (Android 9) is required. -- **GMS Version**: Google Play services version 25.49.x or higher. - -### Use Cases - -Email verification is applicable for the following use cases: - -- **Account Creation/Sign-up**: Remove friction by skipping manual email verification. -- **Account Recovery**: Securely verify email ownership during recovery flows. -- **Re-authentication**: Versatile verification for high-risk actions, independent of the initial sign-in method. - -### Limitations \& Nuances - -- **Workspace Accounts**: Google does not issue verifiable credentials for Google Workspace Accounts. -- **Freshness**: For non-@gmail.com addresses, Google verifies the email at account creation but there is no freshness claim; implement an additional challenge like an OTP. - -### Scope \& Pre-requisites - -**Crucial** : This skill focuses exclusively on the **Android client-side -integration** . It does **not** implement the app's server-side cryptographic -validation logic. Server-side validation of the returned credential is required -for security and must be implemented in your backend. - -## Codebase exploration for Use Cases - -Get started with the following queries in project source code to find relevant -screens with different use cases to implement verified email: - -- `SignUpScreen` -- `"Email address"` -- `"Recover Account"` -- `"Account Recovery"` -- `"Forgot password?"` -- `"Delete Account"` - -## Identifying Integration Points - -To implement this feature effectively, you must first locate the relevant -flows in your codebase. To initiate, start with the following strategies to -cater to different use cases using verified email: - -### 1. Search for Navigation Routes - -If your app uses Navigation, search for routes or destinations related -to authentication: - -Look for: - -- **Keywords** : `signup`, `registration`, `create_account`, `forgot_password`, `recovery`, `verify_email`. -- **Code Pattern** : Search for `NavHost` or `composable` destinations using these strings. - -### 2. Locate Authentication ViewModels - -Find the business logic handling user attributes and account creation, account -recovery: - -- **Keywords** : `SignUpViewModel`, `AuthViewModel`, `RegistrationRepository`. -- **Code Pattern** : Look for methods like `onCreateAccount`, `onRecoverAccount`, or `validateEmail`. - -### 3. Find instances of reauthentication for sensitive actions - -For reauthentication use cases, find areas where users perform sensitive -actions: - -- **Keywords** : `ChangePassword`, `UpdatePayment`, `DeleteAccount`, `UpdateDetails`, `EditUserDetails` - -## Important pointers for Implementation - -- Construct a Digital Credential Request and present it to the user. -- Make sure to follow the request JSON structure as mentioned in [documentation](references/android/identity/digital-credentials/email-verification-implementation.md). -- While presenting the request to the user, check if result credential is DigitalCredential and credential.credentialJson as responseJsonString -- Parse the response from the client. -- Offer a passkey creation option if one is not already present. -- Assume a local `SdJwtParser` to parse raw SD-JWT and return a `JSONObject`. -- Use a `VerifiedUserInfo` data class to store the parsed name and email. -- Leave a TODO for developers to handle the app's server-side validation and parsing. -- Direct users to the home screen after API call success and show a snackbar with user details for reference purpose only. - -## Overview - -This guide describes how to implement verified email retrieval using the -[Digital Credentials Verifier API](references/android/identity/digital-credentials/credential-verifier.md) through an [OpenID for Verifiable -Presentations (OpenID4VP)](https://openid.net/specs/openid-4-verifiable-presentations-1_0.html) request. - -## Add dependencies - -In your app's `build.gradle` file, add the following dependencies for Credential -Manager: - -### Kotlin - -```kotlin -dependencies { - implementation("androidx.credentials:credentials:1.7.0-alpha03") - implementation("androidx.credentials:credentials-play-services-auth:1.7.0-alpha03") -} -``` - -### Groovy - -```groovy -dependencies { - implementation "androidx.credentials:credentials:1.7.0-alpha03" - implementation "androidx.credentials:credentials-play-services-auth:1.7.0-alpha03" -} -``` - -## Initialize Credential Manager - -Use your app or activity context to create a `CredentialManager` object. - - // Use your app or activity context to instantiate a client instance of - // CredentialManager. - private val credentialManager = CredentialManager.create(context) - -## Construct the Digital Credential request - -To request a verified email, construct a [`GetCredentialRequest`](https://developer.android.com/reference/android/credentials/GetCredentialRequest) -containing a [`GetDigitalCredentialOption`](https://developer.android.com/reference/androidx/credentials/GetDigitalCredentialOption). This option requires a -`requestJson` string formatted as an OpenID for Verifiable Presentations -(OpenID4VP) request. - -The OpenID4VP request JSON must follow a specific structure. The current -providers support a JSON structure with an outer `"digital": {"requests": -[...]}` wrapper. - - val nonce = generateSecureRandomNonce() - - // This request follows the OpenID4VP spec - val openId4vpRequest = """ - { - "requests": [ - { - "protocol": "openid4vp-v1-unsigned", - "data": { - "response_type": "vp_token", - "response_mode": "dc_api", - "nonce": "$nonce", - "dcql_query": { - "credentials": [ - { - "id": "user_info_query", - "format": "dc+sd-jwt", - "meta": { - "vct_values": ["UserInfoCredential"] - }, - "claims": [ - {"path": ["email"]}, - {"path": ["name"]}, - {"path": ["given_name"]}, - {"path": ["family_name"]}, - {"path": ["picture"]}, - {"path": ["hd"]}, - {"path": ["email_verified"]} - ] - } - ] - } - } - } - ] - } - """ - - val getDigitalCredentialOption = GetDigitalCredentialOption(requestJson = openId4vpRequest) - val request = GetCredentialRequest(listOf(getDigitalCredentialOption)) - -The request contains the following key information: - -- **DCQL query** : The `dcql_query` specifies the credential type and the - claims being requested (`email_verified`). You can request other claims to - determine the level of verification. A few possible claims are as follows: - - - `email_verified`: In the response, this is a Boolean that indicates whether the email is verified. - - `hd` (hosted domain): In the response, this is empty. - - > [!NOTE] - > **Note:** If `email_verified` is `true` and `hd` is empty in the response, it implies that the account is an authorized Google Account. Google does not issue [verifiable credentials](references/android/identity/digital-credentials/index.md) for Google Workspace Accounts. However, the `hd` field is present in verifiable credentials issued for non-workspace accounts. You are encouraged to implement handling this field to future-proof your app. If the email is non-@gmail.com, Google verified this email when the Google Account was created, but there is no freshness claim. Therefore, for non-Google emails, you should consider an additional challenge, such as an OTP, to verify the user. To understand the schema of the credential and the specific rules for validating fields like `email_verified`, refer to the [Google - > Identity guides](https://developers.google.com/identity/gsi/web/guides/verify-google-id-token). - -- **nonce**: A unique, cryptographically secure random value is generated for - each request. This is critical for security, as it prevents replay attacks. - -- `UserInfoCredential`: This value implies a specific type of digital - credential that contains user attributes. Including this in the request is - pivotal to distinguish the email verification use case. - -Next, wrap the `openId4vpRequest` JSON in a `GetDigitalCredentialOption`, create -a `GetCredentialRequest`, and call `getCredential()`. - -> [!NOTE] -> **Note:** The `hd` and `email_verified` fields are hidden from users in Credential Manager's built-in UI. You cannot make a request with only these hidden fields- in case of such requests, the response is the [`GetCredentialCancellationException`](https://developer.android.com/reference/kotlin/androidx/credentials/exceptions/GetCredentialCancellationException). - -## Present the request to the user - -Present the user with the request, using the Credential Manager built-in UI. - - coroutineScope { - try { - // Requesting Digital Credential from user... - val result = credentialManager.getCredential(activity, request) - - when (val credential = result.credential) { - is DigitalCredential -> { - val responseJsonString = credential.credentialJson - - // Successfully received digital credential response. - - // Next, parse this response and send it to your server. - // ... - } - - else -> { - // handle Unexpected State() - Up to the developer - } - } - } catch (e: Exception) { - // handle exceptions - Up to the developer - } - } - -> [!NOTE] -> **Note:** There is no equivalent of Sign in with Google's `preferImmediatelyAvailableCredentials` for Digital Credentials. If no verifiable credential is found (for example, no eligible account on device), the user will be shown a "No options available" or similar system screen. - -## Parse the response on the client - -> [!WARNING] -> **Warning:** From August 2026, the [response JSON](https://developer.android.com/reference/androidx/credentials/DigitalCredential#getCredentialJson()) format has been updated to strictly match the W3C standards. It contains `data` and `protocol` keys, with the OpenID4VP `vp_token` nested in `data`, while legacy formats hold the `vp_token` directly. Ensure your client-side parsing and server-side validation handle both formats during the transition period, while the older implementation is phased out. Apps that begin to integrate the email verification flow after August 2026 need to use the new format only. - -After receiving the response, you can perform a preliminary parse on the client. -This is useful for immediately updating the UI, for example, by showing the -user's name. - -> [!IMPORTANT] -> **Important:** This step is not for validation. Full cryptographic verification must be performed on your server. - -The following code extracts the raw [Selective Disclosure JWT -(SD-JWT)](https://datatracker.ietf.org/doc/rfc9901/) and uses a helper to decode its claims. - - // 1. Parse the outer JSON wrapper to get the `vp_token` - val responseData = JSONObject(responseJsonString) - val dataObject = responseData.getJSONObject("data") - val vpToken = dataObject.getJSONObject("vp_token") - - // 2. Extract the raw SD-JWT string - val credentialId = vpToken.keys().next() - val rawSdJwt = vpToken.getJSONArray(credentialId).getString(0) - - // 3. Use your parser to get the verified claims - // Server-side validation/parsing is highly recommended. - - // Assumes a local parser like the one in our SdJwtParser.kt sample - val claims = SdJwtParser.parse(rawSdJwt) - Log.d("TAG", "Parsed Claims: ${claims.toString(2)}") - - // 4. Create your VerifiedUserInfo object with REAL data - val userInfo = VerifiedUserInfo( - email = claims.getString("email"), - displayName = claims.optString("name", claims.getString("email")) - ) - -## Handle the response - -The Credential Manager API will return a [`DigitalCredential`](https://developer.android.com/reference/androidx/credentials/DigitalCredential) -response. - -The following is an example of what the raw `responseJsonString` looks like, and -what the claims look like after parsing the inner SD-JWT where you get -additional metadata as well along with verified email: - - /* - // Example of the raw JSON response from credential.credentialJson: - { - "protocol": "openid4vp-v1-unsigned", - "data": { - "vp_token": { - // This key matches the 'id' you set in your dcql_query - "user_info_query": [ - // The SD-JWT string (Issuer JWT ~ Disclosures ~ Key Binding JWT) - "eyJhbGciOiJ...~WyI...IiwgImVtYWlsIiwgInVzZXJAZXhhbXBsZS5jb20iXQ~...~eyJhbGciOiJ..." - ] - } - } - } - - // Example of the parsed and verified claims from the SD-JWT on your server: - { - "cnf": { - "jwk": {..} - }, - "exp": 1775688222, - "iat": 1775083422, - "iss": "https://verifiablecredentials-pa.googleapis.com", - "vct": "UserInfoCredential", - "email": "jane.doe.246745@gmail.com", - "email_verified": true, - "given_name": "Jane", - "family_name": "Doe", - "name": "Jane Doe", - "picture": "http://example.com/janedoe/me.jpg", - "hd": "" - } - */ - -> [!IMPORTANT] -> **Important:** We highly recommend that after receiving the verified email, you trigger Credential Manager's [passkey creation](https://developer.android.com/identity/credential-manager/passkeys/create-passkeys). - -## Server-side validation for account creation - -Since the retrieved email is cryptographically verified, you can omit the email -OTP verification step, significantly reducing sign-up friction and potentially -increasing conversion. This process is best handled on your server. The client -sends the raw response (containing the `vp_token`) and the original nonce to a -new server endpoint. - -For verification, your application must send the full `responseJsonString` to -your server for cryptographic validation before creating an account or logging -the user in. - -The digital credential provides two critical levels of verification for your -server: - -- **Authenticity of the data** : Verifying the issuer (`iss`) URL and the `SD-JWT` signature proves that a trusted authority issued this data. -- **Identity of the presenter** : Verifying the `cnf` field and the Key Binding (`kb`) signature confirms that the credential is being shared by the same device it was originally issued to, preventing it from being intercepted or used on another device. - -The validation on the server must achieve the following: - -- **Verify issuer** : Ensure the `iss` (issuer) field matches `https://verifiablecredentials-pa.googleapis.com`. -- **Verify signature**: Check the signature of the SD-JWT using the public keys (JWKs) available at https://verifiablecredentials-pa.googleapis.com/.well-known/vc-public-jwks. - -> [!NOTE] -> **Note:** Use a standard library (such as [@sd-jwt/sd-jwt-vc](https://datatracker.ietf.org/doc/rfc9901/) for Node.js) to perform the verification steps as outlined in the [OpenID for Verifiable -> Presentations specification](https://openid.net/specs/openid-4-verifiable-presentations-1_0.html). - -For full security, make sure that you also validate the `nonce` to prevent -replay attacks. - -By combining these steps, your server can validate both the authenticity of the -data and the identity of the presenter, ensuring the credential wasn't -intercepted or spoofed before provisioning the new account. - -> [!WARNING] -> **Warning:** As mentioned in [Parse the response on the client](#parse-response), from August 2026, the [response JSON](https://developer.android.com/reference/androidx/credentials/DigitalCredential#getCredentialJson()) format has been updated to match W3C standards. Ensure your client-side parsing and server-side validation handle both formats during the transition period, while the older implementation is phased out. Apps that begin to integrate the email verification flow after August 2026 need to use the new format only. - - try { - // Send the raw credential response and the original nonce to your server. - // Your server must validate the response. createAccountWithVerifiedCredentials - // is a custom implementation per each RP for server side verification and account creation. - val serverResponse = createAccountWithVerifiedCredentials(responseJsonString, nonce) - - // Server returns the new account info (e.g., email, name) - val claims = JSONObject(serverResponse.json) - - val userInfo = VerifiedUserInfo( - email = claims.getString("email"), - displayName = claims.optString("name", claims.getString("email")) - ) - - // handle response - Up to the developer - } catch (e: Exception) { - // handle exceptions - Up to the developer - } - -## Passkey creation - -An optional but highly recommended next step after provisioning an account is to -immediately [create a passkey](references/android/identity/passkeys/create-passkeys.md) for that account. This provides a secure, -passwordless method for the user to sign in. This flow is identical to a -standard passkey registration. - -## WebView support - -For the flow to work on a [`WebView`](https://developer.android.com/reference/android/webkit/WebView), developers should implement a -[JavaScript bridge](references/android/identity/sign-in/credential-manager-webview.md) (JS Bridge) to facilitate the handoff. This bridge -allows the `WebView` object to signal the native app, which can then perform the -actual call to the Credential Manager API. - -## See also - -- [Overview of verified email retrieval](references/android/identity/digital-credentials/email-verification.md) -- [Credential Manager](references/android/identity/credential-manager/index.md) - -## Critical Security Guidelines - -To maintain the integrity of the email verification flow, the following security -requirements are mandatory: - -- **Server-side Validation** : Never trust claims parsed on the client for security-sensitive operations like account creation. Send the complete, raw `responseJsonString` and the original `nonce` to the app's server for full verification. -- **Nonce Integrity** : Generate a unique, cryptographically secure nonce for every request and **never** reuse a nonce across multiple requests to prevent replay attacks. -- **Cryptographic Checks** : The app's server must validate the issuer (`iss`) field, the SD-JWT signature, and the presenter identity using the `cnf` field. diff --git a/identity/verified-email/references/android/identity/credential-manager/index.md b/identity/verified-email/references/android/identity/credential-manager/index.md deleted file mode 100644 index c646f19..0000000 --- a/identity/verified-email/references/android/identity/credential-manager/index.md +++ /dev/null @@ -1,67 +0,0 @@ -[Credential Manager](https://developer.android.com/reference/kotlin/androidx/credentials/package-summary) is the recommended Jetpack API for credential exchange -in Android apps. The Credential Manager API streamlines credential exchange -across form factors for use cases spanning authentication and authorization. You -can also use Credential Manager to handle digital credentials and restore user -credentials on new Android devices. - -## Credential Manager features - -Credential Manager streamlines the sign-in process and enhances security by -offering a robust set of capabilities, including: - -- **Support for various authentication mechanisms** : Enables users to sign up or sign in to your app using: - - [Passkeys](https://developer.android.com/identity/passkeys) - - [Sign-in with Google](https://developer.android.com/identity/sign-in/credential-manager-siwg) and other federated sign-in mechanisms - - [Passwords](https://developer.android.com/identity/passwords) - - [Digital credentials](https://developer.android.com/identity/digital-credentials) -- **Cross-device credential restore** : Provides integration with [Restore - Credentials](https://developer.android.com/identity/sign-in/restore-credentials), allowing users to seamlessly start using your app on a new device. -- **Seamless integration with credential providers** : Offers support for [credential providers](https://developer.android.com/identity/sign-in/credential-provider), including password managers such as Google Password Manager. -- **Credential management**: Enables updating the metadata for user credentials, helping to keep credentials consistent across your app and credential providers. -- **WebView compatibility** : Works with apps that use [WebView](https://developer.android.com/identity/sign-in/credential-manager-webview). -- **Autofill integration** : Integrates with [autofill](https://developer.android.com/identity/autofill/credential-manager-autofill) to display credentials within the autofill UI. - -## Benefits of using Credential Manager - -Adopting Credential Manager provides several key advantages for both your -application and your users: - -- **Enhanced security**: Passkeys offer enhanced security and protect users from phishing attempts. -- **Support for various form factors** : The APIs work across Android [form - factors](https://developer.android.com/identity/form-factors), including mobile, Android XR, and Wear OS devices. -- **Simplified authentication across use cases** : [Digital credentials](https://developer.android.com/identity/digital-credentials), such as digital driver's licenses, corporate IDs, and national ID cards, enable authentication across various use cases, including those requiring [phone number verification](https://developer.android.com/identity/digital-credentials/phone-number-verification). -- **Improved user experience**: Credential Manager's unified interface gives users a familiar and consistent experience and improves registration and sign-in speeds. The bottom sheet UI appears inline to your app's content, so your users remain within your app's context during sign-in. The following image shows Credential Manager's built-in UI: - -![The Credential Manager user interface for passkeys, passwords, and federated sign-ins](https://developer.android.com/static/identity/credential-manager/images/credman-ui.png) **Figure 1.**Credential Manager's built-in unified interface for passkeys, passwords, and federated sign-ins - -## Authentication terminology - -An entity that requires authentication for its users is known as a **relying -party**. An authentication workflow typically includes the following components: - -- **Relying party client app**: The client---in this case, your Android app---that handles the user interface to create and use passkeys. -- **Relying party server**: An app server that helps with the creation, storage, and verification of passkeys. -- **Credential provider** : A component that stores and provides user credentials, such as Google Password Manager. Note that the [FIDO documentation](https://fidoalliance.org/passkeys/) refers to credential providers as credential managers. - -## Credential storage - -Credential providers, such as Google Password Manager, provide a centralized and -secure location for users to manage credentials across their devices, further -simplifying the authentication process. To integrate with Credential Manager as -a credential provider, see -[Implement authentication as a credential provider](https://developer.android.com/identity/sign-in/credential-provider). - -For more information about how Google Password Manager keeps your credentials -secure, see -[Security of Passkeys in the Google Password Manager](https://security.googleblog.com/2022/10/SecurityofPasskeysintheGooglePasswordManager.html). - -## Migrate to Credential Manager - -Credential Manager is intended to replace legacy Android authentication APIs and -local FIDO2 credentials. For more information about migrating to Credential -Manager, see the following guides: - -- [Google Sign-In](https://developer.android.com/identity/sign-in/legacy-gsi-migration) -- [One Tap](https://developer.android.com/identity/legacy/one-tap) -- [Smart Lock for Passwords](https://developer.android.com/identity/sign-in/smart-lock-migration) -- [FIDO2](https://developer.android.com/identity/sign-in/fido2-migration) \ No newline at end of file diff --git a/identity/verified-email/references/android/identity/digital-credentials/credential-verifier.md b/identity/verified-email/references/android/identity/digital-credentials/credential-verifier.md deleted file mode 100644 index c66bf50..0000000 --- a/identity/verified-email/references/android/identity/digital-credentials/credential-verifier.md +++ /dev/null @@ -1,166 +0,0 @@ -Digital credential verification within Android apps can be used to authenticate -and authorize a user's identity (such as a government ID), properties about that -user (such as a driver's license, academic degree, or attributes such as age or -address), or other scenarios where a credential needs to be issued and verified -to assert the authenticity of an entity. - -Digital Credentials is a public W3C standard that specifies how to access a -user's verifiable digital credentials from a digital wallet, and is implemented -for web use cases with the [W3C Credential Management API](https://www.w3.org/TR/credential-management-1/). On -Android, Credential Manager's [`DigitalCredential`](https://developer.android.com/reference/kotlin/androidx/credentials/DigitalCredential) API is used for -verifying digital credentials. - -### Android version compatibility - -The Verifier API is supported on Android 6 (API level 23) and higher. - -### Implementation - -To verify digital credentials in your Android project, do the following: - -1. Add dependencies to your app's build script and initialize a `CredentialManager` class. -2. Construct a digital credential request and use it to initialize a `DigitalCredentialOption`, followed by building the `GetCredentialRequest`. -3. Launch the `getCredential` flow with the constructed request to receive a successful `GetCredentialResponse` or handle any exceptions that may occur. Upon successful retrieval, validate the response. - -#### Add dependencies and initialize - -Add the following dependencies to your Gradle build script: - - dependencies { - implementation("androidx.credentials:credentials:1.6.0-beta01") - implementation("androidx.credentials:credentials-play-services-auth:1.6.0-beta01") - } - -Next, Initialize an instance of the `CredentialManager` class. - - val credentialManager = CredentialManager.create(context) - -#### Construct a digital credential request - -Construct a digital credential request and use it to initialize a -`DigitalCredentialOption`. - - // The request in the JSON format to conform with - // the JSON-ified Credential Manager - Verifier API request definition. - val requestJson = generateRequestFromServer() - val digitalCredentialOption = - GetDigitalCredentialOption(requestJson = requestJson) - - // Use the option from the previous step to build the `GetCredentialRequest`. - val getCredRequest = GetCredentialRequest( - listOf(digitalCredentialOption) - ) - -Here is an example of an OpenId4Vp request. A full reference can be found at -this [website](https://openid.net/specs/openid-4-verifiable-presentations-1_0.html). - - { - "requests": [ - { - "protocol": "openid4vp-v1-unsigned", - "data": { - "response_type": "vp_token", - "response_mode": "dc_api", - "nonce": "OD8eP8BYfr0zyhgq4QCVEGN3m7C1Ht_No9H5fG5KJFk", - "dcql_query": { - "credentials": [ - { - "id": "cred1", - "format": "mso_mdoc", - "meta": { - "doctype_value": "org.iso.18013.5.1.mDL" - }, - "claims": [ - { - "path": [ - "org.iso.18013.5.1", - "family_name" - ] - }, - { - "path": [ - "org.iso.18013.5.1", - "given_name" - ] - }, - { - "path": [ - "org.iso.18013.5.1", - "age_over_21" - ] - } - ] - } - ] - } - } - } - ] - } - -#### Get the credential - -Launch the `getCredential` flow with the constructed request. You will receive -either a successful `GetCredentialResponse`, or a `GetCredentialException` if -the request fails. - -The `getCredential` flow triggers Android system dialogs to present the user's -available credential options and collect their selection. Next, the wallet app -that contains the chosen credential option will display UIs to collect consent -and perform actions needed to generate a digital credential response. - - coroutineScope.launch { - try { - val result = credentialManager.getCredential( - context = activityContext, - request = getCredRequest - ) - verifyResult(result) - } catch (e : GetCredentialException) { - handleFailure(e) - } - } - - // Handle the successfully returned credential. - fun verifyResult(result: GetCredentialResponse) { - val credential = result.credential - when (credential) { - is DigitalCredential -> { - val responseJson = credential.credentialJson - validateResponseOnServer(responseJson) - } - else -> { - // Catch any unrecognized credential type here. - Log.e(TAG, "Unexpected type of credential ${credential.type}") - } - } - } - - // Handle failure. - fun handleFailure(e: GetCredentialException) { - when (e) { - is GetCredentialCancellationException -> { - // The user intentionally canceled the operation and chose not - // to share the credential. - } - is GetCredentialInterruptedException -> { - // Retry-able error. Consider retrying the call. - } - is NoCredentialException -> { - // No credential was available. - } - is CreateCredentialUnknownException -> { - // An unknown, usually unexpected, error has occurred. Check the - // message error for any additional debugging information. - } - is CreateCredentialCustomException -> { - // You have encountered a custom error thrown by the wallet. - // If you made the API call with a request object that's a - // subclass of CreateCustomCredentialRequest using a 3rd-party SDK, - // then you should check for any custom exception type constants - // within that SDK to match with e.type. Otherwise, drop or log the - // exception. - } - else -> Log.w(TAG, "Unexpected exception type ${e::class.java}") - } - } \ No newline at end of file diff --git a/identity/verified-email/references/android/identity/digital-credentials/email-verification-implementation.md b/identity/verified-email/references/android/identity/digital-credentials/email-verification-implementation.md deleted file mode 100644 index 4946eb4..0000000 --- a/identity/verified-email/references/android/identity/digital-credentials/email-verification-implementation.md +++ /dev/null @@ -1,315 +0,0 @@ -## Android skills - -[View on GitHub](https://github.com/android/skills/tree/main/identity/verified-email) - -### Retrieve verified email - -Use an [Android skill](https://developer.android.com/tools/agents/android-skills) to integrate a secure, OTP-less email verification flow into your app. To install the skill from the [Android CLI](https://developer.android.com/tools/agents/android-cli), run: - - android skills add verified-email - -
- -## Overview - -This guide describes how to implement verified email retrieval using the -[Digital Credentials Verifier API](https://developer.android.com/identity/digital-credentials/credential-verifier) through an [OpenID for Verifiable -Presentations (OpenID4VP)](https://openid.net/specs/openid-4-verifiable-presentations-1_0.html) request. - -## Add dependencies - -In your app's `build.gradle` file, add the following dependencies for Credential -Manager: - -### Kotlin - -```kotlin -dependencies { - implementation("androidx.credentials:credentials:1.7.0-alpha03") - implementation("androidx.credentials:credentials-play-services-auth:1.7.0-alpha03") -} -``` - -### Groovy - -```groovy -dependencies { - implementation "androidx.credentials:credentials:1.7.0-alpha03" - implementation "androidx.credentials:credentials-play-services-auth:1.7.0-alpha03" -} -``` - -## Initialize Credential Manager - -Use your app or activity context to create a `CredentialManager` object. - - // Use your app or activity context to instantiate a client instance of - // CredentialManager. - private val credentialManager = CredentialManager.create(context) - -## Construct the Digital Credential request - -To request a verified email, construct a [`GetCredentialRequest`](https://developer.android.com/reference/android/credentials/GetCredentialRequest) -containing a [`GetDigitalCredentialOption`](https://developer.android.com/reference/androidx/credentials/GetDigitalCredentialOption). This option requires a -`requestJson` string formatted as an OpenID for Verifiable Presentations -(OpenID4VP) request. - -The OpenID4VP request JSON must follow a specific structure. The current -providers support a JSON structure with an outer `"digital": {"requests": -[...]}` wrapper. - - val nonce = generateSecureRandomNonce() - - // This request follows the OpenID4VP spec - val openId4vpRequest = """ - { - "requests": [ - { - "protocol": "openid4vp-v1-unsigned", - "data": { - "response_type": "vp_token", - "response_mode": "dc_api", - "nonce": "$nonce", - "dcql_query": { - "credentials": [ - { - "id": "user_info_query", - "format": "dc+sd-jwt", - "meta": { - "vct_values": ["UserInfoCredential"] - }, - "claims": [ - {"path": ["email"]}, - {"path": ["name"]}, - {"path": ["given_name"]}, - {"path": ["family_name"]}, - {"path": ["picture"]}, - {"path": ["hd"]}, - {"path": ["email_verified"]} - ] - } - ] - } - } - } - ] - } - """ - - val getDigitalCredentialOption = GetDigitalCredentialOption(requestJson = openId4vpRequest) - val request = GetCredentialRequest(listOf(getDigitalCredentialOption)) - -The request contains the following key information: - -- **DCQL query** : The `dcql_query` specifies the credential type and the - claims being requested (`email_verified`). You can request other claims to - determine the level of verification. A few possible claims are as follows: - - - `email_verified`: In the response, this is a Boolean that indicates whether the email is verified. - - `hd` (hosted domain): In the response, this is empty. - - > [!NOTE] - > **Note:** If `email_verified` is `true` and `hd` is empty in the response, it implies that the account is an authorized Google Account. Google does not issue [verifiable credentials](https://developer.android.com/identity/digital-credentials#verifiable-credentials) for Google Workspace Accounts. However, the `hd` field is present in verifiable credentials issued for non-workspace accounts. You are encouraged to implement handling this field to future-proof your app. If the email is non-@gmail.com, Google verified this email when the Google Account was created, but there is no freshness claim. Therefore, for non-Google emails, you should consider an additional challenge, such as an OTP, to verify the user. To understand the schema of the credential and the specific rules for validating fields like `email_verified`, refer to the [Google - > Identity guides](https://developers.google.com/identity/gsi/web/guides/verify-google-id-token). - -- **nonce**: A unique, cryptographically secure random value is generated for - each request. This is critical for security, as it prevents replay attacks. - -- `UserInfoCredential`: This value implies a specific type of digital - credential that contains user attributes. Including this in the request is - pivotal to distinguish the email verification use case. - -Next, wrap the `openId4vpRequest` JSON in a `GetDigitalCredentialOption`, create -a `GetCredentialRequest`, and call `getCredential()`. - -> [!NOTE] -> **Note:** The `hd` and `email_verified` fields are hidden from users in Credential Manager's built-in UI. You cannot make a request with only these hidden fields- in case of such requests, the response is the [`GetCredentialCancellationException`](https://developer.android.com/reference/kotlin/androidx/credentials/exceptions/GetCredentialCancellationException). - -## Present the request to the user - -Present the user with the request, using the Credential Manager built-in UI. - - coroutineScope { - try { - // Requesting Digital Credential from user... - val result = credentialManager.getCredential(activity, request) - - when (val credential = result.credential) { - is DigitalCredential -> { - val responseJsonString = credential.credentialJson - - // Successfully received digital credential response. - - // Next, parse this response and send it to your server. - // ... - } - - else -> { - // handle Unexpected State() - Up to the developer - } - } - } catch (e: Exception) { - // handle exceptions - Up to the developer - } - } - -> [!NOTE] -> **Note:** There is no equivalent of Sign in with Google's `preferImmediatelyAvailableCredentials` for Digital Credentials. If no verifiable credential is found (for example, no eligible account on device), the user will be shown a "No options available" or similar system screen. - -## Parse the response on the client - -> [!WARNING] -> **Warning:** From August 2026, the [response JSON](https://developer.android.com/reference/androidx/credentials/DigitalCredential#getCredentialJson()) format has been updated to strictly match the W3C standards. It contains `data` and `protocol` keys, with the OpenID4VP `vp_token` nested in `data`, while legacy formats hold the `vp_token` directly. Ensure your client-side parsing and server-side validation handle both formats during the transition period, while the older implementation is phased out. Apps that begin to integrate the email verification flow after August 2026 need to use the new format only. - -After receiving the response, you can perform a preliminary parse on the client. -This is useful for immediately updating the UI, for example, by showing the -user's name. - -> [!IMPORTANT] -> **Important:** This step is not for validation. Full cryptographic verification must be performed on your server. - -The following code extracts the raw [Selective Disclosure JWT -(SD-JWT)](https://datatracker.ietf.org/doc/rfc9901/) and uses a helper to decode its claims. - - // 1. Parse the outer JSON wrapper to get the `vp_token` - val responseData = JSONObject(responseJsonString) - val dataObject = responseData.getJSONObject("data") - val vpToken = dataObject.getJSONObject("vp_token") - - // 2. Extract the raw SD-JWT string - val credentialId = vpToken.keys().next() - val rawSdJwt = vpToken.getJSONArray(credentialId).getString(0) - - // 3. Use your parser to get the verified claims - // Server-side validation/parsing is highly recommended. - - // Assumes a local parser like the one in our SdJwtParser.kt sample - val claims = SdJwtParser.parse(rawSdJwt) - Log.d("TAG", "Parsed Claims: ${claims.toString(2)}") - - // 4. Create your VerifiedUserInfo object with REAL data - val userInfo = VerifiedUserInfo( - email = claims.getString("email"), - displayName = claims.optString("name", claims.getString("email")) - ) - -## Handle the response - -The Credential Manager API will return a [`DigitalCredential`](https://developer.android.com/reference/androidx/credentials/DigitalCredential) -response. - -The following is an example of what the raw `responseJsonString` looks like, and -what the claims look like after parsing the inner SD-JWT where you get -additional metadata as well along with verified email: - - /* - // Example of the raw JSON response from credential.credentialJson: - { - "protocol": "openid4vp-v1-unsigned", - "data": { - "vp_token": { - // This key matches the 'id' you set in your dcql_query - "user_info_query": [ - // The SD-JWT string (Issuer JWT ~ Disclosures ~ Key Binding JWT) - "eyJhbGciOiJ...~WyI...IiwgImVtYWlsIiwgInVzZXJAZXhhbXBsZS5jb20iXQ~...~eyJhbGciOiJ..." - ] - } - } - } - - // Example of the parsed and verified claims from the SD-JWT on your server: - { - "cnf": { - "jwk": {..} - }, - "exp": 1775688222, - "iat": 1775083422, - "iss": "https://verifiablecredentials-pa.googleapis.com", - "vct": "UserInfoCredential", - "email": "jane.doe.246745@gmail.com", - "email_verified": true, - "given_name": "Jane", - "family_name": "Doe", - "name": "Jane Doe", - "picture": "http://example.com/janedoe/me.jpg", - "hd": "" - } - */ - -> [!IMPORTANT] -> **Important:** We highly recommend that after receiving the verified email, you trigger Credential Manager's [passkey creation](https://developer.android.com/identity/credential-manager/passkeys/create-passkeys). - -## Server-side validation for account creation - -Since the retrieved email is cryptographically verified, you can omit the email -OTP verification step, significantly reducing sign-up friction and potentially -increasing conversion. This process is best handled on your server. The client -sends the raw response (containing the `vp_token`) and the original nonce to a -new server endpoint. - -For verification, your application must send the full `responseJsonString` to -your server for cryptographic validation before creating an account or logging -the user in. - -The digital credential provides two critical levels of verification for your -server: - -- **Authenticity of the data** : Verifying the issuer (`iss`) URL and the `SD-JWT` signature proves that a trusted authority issued this data. -- **Identity of the presenter** : Verifying the `cnf` field and the Key Binding (`kb`) signature confirms that the credential is being shared by the same device it was originally issued to, preventing it from being intercepted or used on another device. - -The validation on the server must achieve the following: - -- **Verify issuer** : Ensure the `iss` (issuer) field matches `https://verifiablecredentials-pa.googleapis.com`. -- **Verify signature**: Check the signature of the SD-JWT using the public keys (JWKs) available at https://verifiablecredentials-pa.googleapis.com/.well-known/vc-public-jwks. - -> [!NOTE] -> **Note:** Use a standard library (such as [@sd-jwt/sd-jwt-vc](https://datatracker.ietf.org/doc/rfc9901/) for Node.js) to perform the verification steps as outlined in the [OpenID for Verifiable -> Presentations specification](https://openid.net/specs/openid-4-verifiable-presentations-1_0.html). - -For full security, make sure that you also validate the `nonce` to prevent -replay attacks. - -By combining these steps, your server can validate both the authenticity of the -data and the identity of the presenter, ensuring the credential wasn't -intercepted or spoofed before provisioning the new account. - -> [!WARNING] -> **Warning:** As mentioned in [Parse the response on the client](https://developer.android.com/identity/digital-credentials/email-verification-implementation#parse-response), from August 2026, the [response JSON](https://developer.android.com/reference/androidx/credentials/DigitalCredential#getCredentialJson()) format has been updated to match W3C standards. Ensure your client-side parsing and server-side validation handle both formats during the transition period, while the older implementation is phased out. Apps that begin to integrate the email verification flow after August 2026 need to use the new format only. - - try { - // Send the raw credential response and the original nonce to your server. - // Your server must validate the response. createAccountWithVerifiedCredentials - // is a custom implementation per each RP for server side verification and account creation. - val serverResponse = createAccountWithVerifiedCredentials(responseJsonString, nonce) - - // Server returns the new account info (e.g., email, name) - val claims = JSONObject(serverResponse.json) - - val userInfo = VerifiedUserInfo( - email = claims.getString("email"), - displayName = claims.optString("name", claims.getString("email")) - ) - - // handle response - Up to the developer - } catch (e: Exception) { - // handle exceptions - Up to the developer - } - -## Passkey creation - -An optional but highly recommended next step after provisioning an account is to -immediately [create a passkey](https://developer.android.com/identity/passkeys/create-passkeys) for that account. This provides a secure, -passwordless method for the user to sign in. This flow is identical to a -standard passkey registration. - -## WebView support - -For the flow to work on a [`WebView`](https://developer.android.com/reference/android/webkit/WebView), developers should implement a -[JavaScript bridge](https://developer.android.com/identity/sign-in/credential-manager-webview) (JS Bridge) to facilitate the handoff. This bridge -allows the `WebView` object to signal the native app, which can then perform the -actual call to the Credential Manager API. - -## See also - -- [Overview of verified email retrieval](https://developer.android.com/identity/digital-credentials/email-verification) -- [Credential Manager](https://developer.android.com/identity/credential-manager) \ No newline at end of file diff --git a/identity/verified-email/references/android/identity/digital-credentials/email-verification.md b/identity/verified-email/references/android/identity/digital-credentials/email-verification.md deleted file mode 100644 index 106c2ac..0000000 --- a/identity/verified-email/references/android/identity/digital-credentials/email-verification.md +++ /dev/null @@ -1,158 +0,0 @@ -This document describes using Credential Manager to get a cryptographically -verified email address from a user's device. This process removes the need for -your app users to verify their email with one-time passwords (OTPs) or magic -links. - -This document explains the following areas: - -- Android compatibility -- User experience -- Accounts supported -- Implication of email deliverability -- Comparison with [Sign in with Google](https://developer.android.com/identity/sign-in/credential-manager-siwg) - -This guide assumes you are familiar with the following concepts: - -- [Credential Manager](https://developer.android.com/identity/credential-manager) -- [Digital Credentials](https://developer.android.com/identity/digital-credentials) -- [Verifiable Credentials](https://developer.android.com/identity/digital-credentials#verifiable-credentials) - - -## Android skills - -[View on GitHub](https://github.com/android/skills/tree/main/identity/verified-email) - -### Retrieve verified email - -Use an [Android skill](https://developer.android.com/tools/agents/android-skills) to integrate a secure, OTP-less email verification flow into your app. To install the skill from the [Android CLI](https://developer.android.com/tools/agents/android-cli), run: - - android skills add verified-email - -
- -## Android compatibility - -This feature is supported on mobiles, tablets, and foldable devices running -Android 9 (API level 28) and higher. The minimum version of Google Play services -(GMS) required is 25.49.x. - -## User experience - -The following sections describe the user experience during the verification -flow, the need to include fallback verification methods, as well as the -recommended user experience for various use cases. - -### The verification flow - -The user experience for sharing a verified email is as follows: - -1. The user either focuses on an input field or taps a button that calls the - Credential Manager API. Depending on the design of the screen, you can also - call the API on your app's screen load. - -2. A bottom sheet appears, showing the information that will be shared with the - app. If no information is available on that device, the user sees a generic - error message. - -3. After the user taps **Agree and Continue**, display a success or failure - message. - - > [!NOTE] - > **Note:** If the verified email you receive doesn't match what you expect, inform the user about the mismatch and either ask them to try again with a different credential or provide an alternate verification method, such as through OTPs. - -4. Optional: If the user is signing up for your service, prompt the user to - [create](https://developer.android.com/identity/passkeys/create-passkeys) a [passkey](https://developer.android.com/identity/passkeys) to make it easier for them to sign in - subsequently. - - > [!NOTE] - > **Note:** The email verification process doesn't automatically trigger passkey creation. However, it's highly recommended to include the steps for passkey creation. Passkeys help users by making it easier and more secure for them to sign in, and remove the need for the conventional username and password interaction. - -### Include primary and fallback flows - -To ensure a streamlined user experience, include the following options on -screens that require email verification: - -- **Primary verification option**: An email field or button to trigger the Credential Manager API flow for quick verification. -- **Alternate verification options**: A link or button for users to "Verify another way" or with "Other options" for manual email entry in case of failures, such as no information available on the device, or a mismatch between the retrieved and expected email. This should allow users to try verification with a different credential or by providing a manual OTP. - -### Use cases - -The following sections describe the recommended use cases, as well as the -suggested user experience, for email verification. - -#### Sign up - -Users can immediately create an account with a verified email without a separate -verification step. Optionally, prompt the user to add a passkey. If they opt to -add a passkey, trigger the [passkey creation](https://developer.android.com/identity/passkeys/create-passkeys) flow. -![Using email verification during sign up, and then creating passkeys](https://developer.android.com/static/identity/digital-credentials/images/signup_ux.png) Email verification during sign up - -#### Account recovery - -To eliminate the frustration of users searching for recovery codes in their spam -folders, allow them to recover their account using the verified email securely -stored on their device. Additionally, suggest that they create a passkey for -future use. -![Using email verification during account recovery](https://developer.android.com/static/identity/digital-credentials/images/account_recovery_ux.png) Email verification during account recovery - -#### Reauthentication for sensitive actions - -Protect sensitive user actions, such as changing settings or updating profile -details, by requiring a quick reauthentication step. -![Using email verification during reauthentication](https://developer.android.com/static/identity/digital-credentials/images/reauthentication_ux.png) Email verification during reauthentication - -## Accounts supported - -Email verification through Credential Manager only supports verification of -consumer Google Accounts. [Workspace accounts](https://knowledge.workspace.google.com/admin/getting-started/set-up-google-workspace-for-your-organization) and [supervised -accounts](https://support.google.com/families/answer/9499054) aren't supported. - -A consumer Google Account can be created with an email address from any -provider, not necessarily @gmail.com. However, Google verifies these accounts -differently: - -- For @gmail.com accounts: Google is the authoritative source, and the email is known to be verified. -- For non-@gmail.com accounts: Google is not the authoritative source for these email addresses in the long term. While Google verifies the email when the account is created, the ownership of that email address might change over time. Therefore, for non-@gmail.com addresses, you should consider an additional verification step, such as sending an OTP, to ensure that the user still has access to the email account. - -For more information about what verification implies, see [Digital -Credentials](https://developer.android.com/identity/digital-credentials#verified). - -> [!NOTE] -> **Note:** Apart from a user's email information, you can request other unverified fields, such as the user's given name, family name, name, and the profile picture of their Google Account. However, only the email is verified by Google. -> For phone number verification, see [Verify phone numbers with digital credentials](https://developer.android.com/identity/digital-credentials/phone-number-verification). For a Firebase solution, see [Firebase Phone Number Verification](https://firebase.google.com/docs/phone-number-verification). Note that you can't verify a phone number and email in the same invocation as these are separate capabilities. - -## Validity and freshness - -The system issues [verifiable credentials](https://developer.android.com/identity/digital-credentials#verified) (VCs) based on the user's current -email from the active Google Accounts on the device. These credentials are -issued to the device in advance, typically while the device is idle. While these -credentials might remain valid for multiple days, the system performs a check at -the moment of sharing the credentials to ensure that the account still exists, -is on the device, and that the email address is valid---effectively prioritizing -the account's immediate status over the credential's validity window. - -To help ensure authenticity, a Key Binding (kb) signature is generated at the -time of sharing, incorporating the nonce. - -If a device is offline or the account is removed, the process fails rather than -providing an expired VC or a VC for an inactive Google Account. - -### Email deliverability - -While the process confirms the account's legitimacy, it doesn't guarantee inbox -delivery (for instance, the email might be diverted to spam). An OTP remains the -definitive method for confirming email deliverability. - -## Comparison with Sign in with Google - -While both Digital Credentials and [Sign in with Google](https://developer.android.com/identity/sign-in/credential-manager-siwg) solutions provide a -verified email, the user flows and use cases are different: - -- **Use cases**: The Credential Manager email verification flow is not exclusively used in sign up or sign in use cases, but rather can be used in any use case involving the retrieval of verified email. This could include account recovery as well. -- **Registration**: The Credential Manager flow doesn't require Google registration, unlike Sign in with Google. -- **Platform support**: The Credential Manager flow is an Android-only solution. -- **Scopes** : Unlike Sign in with Google, which can use OAuth 2.0 to request access to user data (such as Calendar or Drive through scopes), the Digital Credentials API is strictly for retrieving verified identity attributes. It can't be used to request additional [authorization scopes](https://developers.google.com/identity/protocols/oauth2/scopes). - -## Next steps - -To implement this feature in your app, see the [Implementation guide](https://developer.android.com/identity/digital-credentials/email-verification-implementation). \ No newline at end of file diff --git a/identity/verified-email/references/android/identity/digital-credentials/index.md b/identity/verified-email/references/android/identity/digital-credentials/index.md deleted file mode 100644 index 83b6156..0000000 --- a/identity/verified-email/references/android/identity/digital-credentials/index.md +++ /dev/null @@ -1,112 +0,0 @@ -Digital credentials are cryptographically verifiable digital documents that your -users can use to provide information about themselves, and use to authenticate -or authorize themselves. Digital credentials are based on the [open W3C Digital -Credentials API industry standard](https://www.w3.org/TR/digital-credentials/). On Android, this is -implemented through [Credential Manager's](https://developer.android.com/identity/credential-manager) [Digital Credentials API](https://developer.android.com/reference/kotlin/androidx/credentials/DigitalCredential). -![Image showing the flow of using a digital credential](https://developer.android.com/static/identity/digital-credentials/images/digital_credentials.png) **Figure 1.** Using a digital credential in a sample app. - -## Benefits of digital credentials - -Digital credentials offer several advantages over physical credentials and -non-standardized digital documents: - -- **Improved security and trust**: Digital credentials are encrypted, ensuring data integrity and authenticity. This asserts the surety that the credential was issued by a verifiable source and was not tampered with. -- **Enhanced privacy**: Many digital credential formats support selective disclosure, which lets users share only necessary information. For example, a user could share proof of driving qualification without revealing their birth date. -- **Consolidated storage**: Users can store various credentials from different issuers in digital holders all on their device, reducing the need to carry physical cards. -- **Interoperability**: By following open standards, digital credentials can work across different operating systems, devices, and platforms. - -## Use cases - -The Digital Credentials API can be used across a broad set of use cases such as -the following: - -- **Accept government-issued IDs**: Apps can request and use official government document attributes, for flows including age verification, account recovery, Know-Your-Customer (KYC) process. -- **Verify phone numbers** : Apps can use the API for phone number verification, by exchanging digital credentials derived from the phone's SIM card directly with the user's carrier. This removes the need for one-time passwords (OTPs), improves security, and reduces transmission costs. For more information, see the [phone number verification guide](https://developer.android.com/identity/digital-credentials/phone-number-verification). -- **Verify email addresses** : The API lets your app retrieve verified emails directly from the user's device, removing the need for OTPs, for frictionless sign-up, sign-in, and account recovery. For more information, see the [email verification guide](https://developer.android.com/identity/digital-credentials/email-verification). -- **Custom credentials**: The extensibility of the API lets any app begin issuing its own custom digital credentials, which corresponding verifiers can request. -- **Confirm payment credentials and transactions**: The API lets you secure payment authorizations and Digital Payment Credentials (DPC) with native, cryptographically bound wallet confirmation. - -## How digital credentials work - -The digital credential ecosystem involves three primary categories of apps: - -- **Issuers** : Issuers are apps that securely create and [issue](https://developer.android.com/identity/digital-credentials/credential-issuer/issue-credentials) credentials. -- **Holders (wallets)** : Holders are apps on a user's device that store credentials. They should be able to share these credentials with requesting apps through a [presentation](https://developer.android.com/identity/digital-credentials/credential-holder/credential-holder) process. -- **Verifiers**: Verifiers are apps that verify and use digital credentials. - -Credential Manager's Digital Credentials API orchestrates the interaction -between the issuers, holders, and verifier apps. - -When a verifier makes a request for a digital credential, it sends a request to -the Android system through the API. Credential Manager then displays eligible -digital credentials from various holders within a trusted system UI. Once the -user agrees to proceed, Credential Manager invokes the corresponding holder to -generate the response. - -> [!NOTE] -> **Note:** Issuers and holders don't have to be separate---an app can be both an issuer and holder. For example, if you use the Digital Credentials API for email verification, Google is the issuer and holder for the Gmail email address. - -## User experience - -Similarly to how Credential Manager has built-in user interfaces for -authentication flows, such as with passkeys, passwords, and Sign in with Google, -there are also standardized interfaces for the various use cases of the Digital -Credentials API. - -There are UI variants tailored to specific use cases. When using the API, the -Android system automatically renders context-aware bottom sheets, such as for -the following scenarios: - -- **Email verification**: The interface displays an account picker featuring the verified email address and identity provider. -- **Phone number verification**: The interface includes a verification card with network carriers represented. -- **Multi-credential**: This view stacks multiple cards, and allows bundling related items into a single tap. -- **Digital payments credentials**: This checkout sheet displays payment card details, merchant info, and total transaction amount for immediate confirmation. - -![Image showing the UX variants of digital credentials](https://developer.android.com/static/identity/digital-credentials/images/digital_credentials_ux_variants.png) **Figure 2.** The UX variants for digital credentials for email verification, phone verification, and multi-credential scenarios. - -## Industry standards - -Digital credentials rely on industry standards to ensure cross-platform -compatibility. The commonly used open standards are: - -- **Sharing** : Digital credentials are requested using the [OpenID4VP - standard](https://openid.net/specs/openid-4-verifiable-presentations-1_0.html#name-introduction). -- **Issuance** : Digital credentials are issued using the [OpenID4VCI - standard](https://openid.github.io/OpenID4VCI/openid-4-verifiable-credential-issuance-1_1-wg-draft.html). -- **Credential storage format** : Digital credentials are represented in standardized formats maintained by different standards bodies, primarily the following: - - IETF [selective disclosure of JSON web tokens (sd-jwt)](https://datatracker.ietf.org/doc/draft-ietf-oauth-selective-disclosure-jwt/) VC - - ISO [MDoc](https://www.iso.org/standard/69084.html) - -These standards are used widely on multiple platforms and operating systems, -enabling seamless operation across web browsers, mobile devices, and other form -factors. This allows developers to exchange digital credentials with other apps -regardless of the platform they're on. - -## Try it out - -You can test the cross-platform digital credentials flow by installing the -sample holder and verifier apps on an Android-powered device. - -To test the flow, complete the following steps: - -1. **Install the sample apps on the Android-powered device** : Use one of the following methods to install the apps on your first device: - - **Use the prebuilt APKs** : - - **Download the holder app** : Sign in to your GitHub account, navigate to the [holder app GitHub Actions page](https://github.com/digitalcredentialsdev/CMWallet/actions), select the latest successful build, and download the `app-debug.apk` file from the **Artifacts** section. This app is called **CMWallet**. - - **Download the verifier app** : For the sample verifier app, get the APK from the [Identity samples repository](https://github.com/android/identity-samples/actions). Then, install the APK on your Android-powered device. This app is called **Digital - Credentials Demo**. - - **Build from source**: Clone the repositories mentioned and install the app using Android Studio. -2. **Open the sample holder app**: Open the sample holder app. This registers the holder credentials with Credential Manager. -3. **Open the sample verifier app** : Open the sample verifier app. Then, select the option to request digital credentials from wallets. A bottomsheet displaying available credentials from **CMWallet** should appear. -4. **Select a credential**: Select a credential to send back to the verifier app. The verifier app should now display fields from the returned credential. - -> [!NOTE] -> **Note:** You can also initiate the request from a web browser on another device by navigating to and selecting **Request Credentials (OpenID4VP 1.0)**. - -## Resources - -- For more information about issuing digital credentials, see the [issuer - guide](https://developer.android.com/identity/digital-credentials/credential-issuer/issue-credentials). -- For more information about holder apps, see the [holder guide](https://developer.android.com/identity/digital-credentials/credential-holder/credential-holder). -- For more information about verifying users based on digital credentials, see the [verifier guide](https://developer.android.com/identity/digital-credentials/credential-verifier). -- For more information about digital credentials on the web, see [digital - credentials on the web](https://developer.chrome.com/blog/digital-credentials-api-shipped). \ No newline at end of file diff --git a/identity/verified-email/references/android/identity/passkeys/create-passkeys.md b/identity/verified-email/references/android/identity/passkeys/create-passkeys.md deleted file mode 100644 index 089cc21..0000000 --- a/identity/verified-email/references/android/identity/passkeys/create-passkeys.md +++ /dev/null @@ -1,327 +0,0 @@ -Before your users can authenticate with passkeys, your app must first register -or create the passkey for their account. - -To create the passkey, obtain details required to create the passkey from your -app server, and then call the Credential Manager API, which returns a public and -private key pair. The returned private key is stored in a credential provider, -such as Google Password Manager, as a passkey. The public key is stored on your -app server. - -> [!TIP] -> **Tip:** While designing authentication flows with passkeys, make sure that you follow the [UX guidelines for passkeys](https://developer.android.com/design/ui/mobile/guides/patterns/passkeys). - -![Passkeys are stored in a credential provider and public keys are stored on the app server](https://developer.android.com/static/identity/passkeys/images/create-passkeys.png) **Figure 1:**Creation of passkeys - -## Prerequisites - -Make sure that you have set up [Digital Asset Links](https://developer.android.com/identity/credential-manager/prerequisites) and that you target -devices running Android 9 (API level 28) or higher. - -## Overview - -This guide focuses on the changes required in your [relying party client app](https://developer.android.com/identity/credential-manager#authentication-terminology) -to create a passkey, and gives a brief overview of the [relying party app -server](https://developer.android.com/identity/credential-manager#authentication-terminology) implementation. To learn more about the server-side integration, see -[Server-side passkey registration](https://developers.google.com/identity/passkeys/developer-guides/server-registration). - -1. [**Add dependencies to your app**](https://developer.android.com/identity/passkeys/create-passkeys#add-dependencies): Add the required Credential Manager libraries. -2. [**Instantiate Credential Manager**](https://developer.android.com/identity/passkeys/create-passkeys#instantiate): Create a Credential Manager instance. -3. [**Get credential creation options from the app server**](https://developer.android.com/identity/passkeys/create-passkeys#get-options): From your app server, send the client app the details required to create the passkey, such as information about the app, the user, as well as a `challenge` and other fields. -4. [**Request a passkey**](https://developer.android.com/identity/passkeys/create-passkeys#request-passkey): In your app, use the details received from the app server to create a [`GetPublicKeyCredentialOption`](https://developer.android.com/reference/androidx/credentials/GetPublicKeyCredentialOption) object and use this object to invoke the `credentialManager.getCredential()` method to create a passkey. -5. [**Handle the passkey creation response**](https://developer.android.com/identity/passkeys/create-passkeys#handle-response): When you receive the credentials on your client app, you must encode, serialize and then send the public key to the app server. You must also handle each of the exceptions that can occur in case of passkey creation. -6. [**Verify and save the public key on the server**](https://developer.android.com/identity/passkeys/create-passkeys#verify): Complete the server-side steps to verify the origin of the credential and then save the public key. -7. [**Notify the user**](https://developer.android.com/identity/passkeys/create-passkeys#notify): Notify the user that their passkey has been created. - -> [!TIP] -> **Tip:** To enhance the user experience during sign up, add functionality to [restore credentials](https://developer.android.com/identity/sign-in/restore-credentials) on a new device to let users seamlessly set up their existing accounts on new Android devices, and [suppressing autofill dialogs on -> credential fields](https://developer.android.com/identity/passkeys/create-passkeys#suppress-autofill). - -## Add dependencies to your app - -Add the following dependencies to your app module's `build.gradle` file: - -### Kotlin - -```kotlin -dependencies { - implementation("androidx.credentials:credentials:1.7.0-alpha03") - implementation("androidx.credentials:credentials-play-services-auth:1.7.0-alpha03") -} -``` - -### Groovy - -```groovy -dependencies { - implementation "androidx.credentials:credentials:1.7.0-alpha03" - implementation "androidx.credentials:credentials-play-services-auth:1.7.0-alpha03" -} -``` - -> [!NOTE] -> **Note:** Use the latest available versions of the dependencies. Versions of [`androidx.credentials`](https://developer.android.com/identity/passkeys/create-passkeys#automatic-upgrade) earlier than version 1.2 throw an `UnsupportedOperationException("Post-U not supported yet")` exception on Android 14. - -## Instantiate Credential Manager - -Use your app or activity context to create a `CredentialManager` object. - - // Use your app or activity context to instantiate a client instance of - // CredentialManager. - private val credentialManager = CredentialManager.create(context) - -## Get credential creation options from your app server - -When the user clicks a "Create Passkey" button or when a new user signs up, make -a request from your app to your app server to obtain the information required to -start the passkey registration process. - -Use a FIDO-compliant library in your app server to send your client app the -information required to create a passkey, such as information about the user, -the app, and additional configuration properties. To learn more, see [Server -side passkey registration](https://developers.google.com/identity/passkeys/developer-guides/server-registration). - -In the client app, decode the public key creation options sent by the app -server. These are usually represented in JSON format. To learn more about how -this decoding is done for web clients, see [Encoding and -Decoding](https://developers.google.com/identity/passkeys/developer-guides/server-registration#encoding_and_decoding). For Android client apps, you must handle the decoding -separately. - -> [!NOTE] -> **Note:** On your app server, securely store the `challenge` so that you can later verify the origin of the credential. - -The following snippet shows the structure the public key creation options sent -by the app server: - - { - "challenge": "", - "rp": { - "name": "", - "id": "" - }, - "user": { - "id": "", - "name": "", - "displayName": "" - }, - "pubKeyCredParams": [ - { - "type": "public-key", - "alg": -7 - } - ], - "attestation": "none", - "excludeCredentials": [ - { - "id": "", - "type": "public-key" - } - ], - "authenticatorSelection": { - "requireResidentKey": true, - "residentKey": "required", - "userVerification": "required" - } - } - -Key fields in the public key creation options include: - -- `challenge`: A server-generated random string that is used to prevent replay attacks. -- `rp`: Details about the app. - - `rp.name`: The app's name. - - `rp.id`: The app's domain or subdomain. -- `user`: Details about the user. - - `id`: The user's unique ID. This value must not include personally identifying information, for example, email addresses or usernames. You can use a random, 16-byte value. - - `name`: A unique identifier for the account that the user will recognise, such as their email address or username. This will be displayed in the account selector. If using a username, use the same value as in password authentication. - - `displayName`: An optional, user-friendly name for the account intended for display in the account selector. -- `authenticatorSelection`: Details about the device that will be used for authentication. - - `authenticatorAttachment`: Indicates the preferred [authenticator](https://www.w3.org/TR/webauthn/#authenticator). The possible values are as follows: - - `platform`: This value is used for an authenticator built into the user's device, such as a fingerprint sensor. - - `cross-platform`: This value is used for roaming devices such as security keys. It is not typically used in the passkey context. - - Unspecified (recommended): Leaving this value unspecified provides users with the flexibility to create passkeys on their preferred devices. In most cases, leaving the parameter unspecified is the best option. - - `requireResidentKey`: To create a passkey, set the value of this `Boolean` field to `true`. - - `residentKey`: To create a passkey, set the value to `required`. - - `userVerification`: Used to specify the requirements for user verification during a passkey registration. The possible values are as follows: - - `preferred`: Use this value if you prioritize user experience over protection, such as in environments where user verification causes more friction than protection. - - `required`: Use this value if invoking a user verification method available on the device is required. - - `discouraged`: Use this value if using a user verification method is discouraged. - To learn more about `userVerification`, see [userVerification deep dive](https://web.dev/articles/webauthn-user-verification). -- `excludeCredentials`: List credential IDs in an [array](https://w3c.github.io/webauthn/#dom-publickeycredentialcreationoptions-excludecredentials) to prevent the creation of a duplicate passkey if one already exists with the same credential provider. - -## Create a passkey - -After you have parsed the server-side public key creation options, create a -passkey by wrapping these options in a `CreatePublicKeyCredentialRequest` object -and calling `createCredential()`. - -The `createPublicKeyCredentialRequest` includes the following: - -- `requestJson`: The credential creation options sent by the app server. -- `preferImmediatelyAvailableCredentials`: This is an optional Boolean field that defines whether to only use locally-available or credential provider-synced credentials to fulfill the request, instead of credentials from security keys or [hybrid](https://w3c.github.io/webauthn/#dom-authenticatortransport-hybrid) key flows. The possible usages are as follows: - - `false` (default): Use this value if the call to Credential Manager was triggered by an explicit user action. - - `true`: Use this value if Credential Manager is opportunistically called, such as when first opening the app. - If you set the value to `true` and there are no immediately available credentials, Credential Manager won't show any UI and the request will fail immediately, returning NoCredentialException for get requests and [`CreateCredentialNoCreateOptionException`](https://developer.android.com/reference/kotlin/androidx/credentials/exceptions/CreateCredentialNoCreateOptionException) for create requests. -- `origin`: This field is automatically set for Android apps. For browsers and similarly privileged apps that need to set `origin`, see [Make Credential - Manager calls on behalf of other parties for privileged apps](https://developer.android.com/training/sign-in/privileged-apps). -- `isConditional`: This is an optional field that defaults to `false`. For more information, see [Automatically create a passkey](https://developer.android.com/identity/passkeys/create-passkeys#automatic-upgrade). - -Calling the `createCredential()` function launches Credential Manager's built-in -bottom sheet UI that prompts the user to use a passkey and to select a -credential provider and account for storage. However, if `isConditional` is set -to `true`, the bottom sheet UI does not display, and the passkey is -automatically created. - -### Automatically create a passkey - -You can automatically create a passkey for a user after a successful password -login by setting the `isConditional` parameter to `true` in your -`CreatePublicKeyCredentialRequest` while creating a passkey. If the user doesn't -already have a passkey, your app will automatically attempt to create one in the -background and store it in the user's credential provider, such as Google -Password Manager. For an example of how this is implemented, see the [public -sample](https://github.com/android/identity-samples/blob/main/Shrine/app/src/main/java/com/authentication/shrine/ui/AuthenticationScreen.kt#L98). -![An example of the notification Google Password Manager shows after passkey creation](https://developer.android.com/static/identity/passkeys/images/conditional-create-gpm.svg) **Figure 2:**Google Password Manager notification - -> [!NOTE] -> **Note:** If a passkey is created automatically, credential providers are responsible for notifying users about a newly created passkey. Google Password Manager notifies users when a passkey is automatically created. However, other credential providers might have their own conditions and notifications for this feature. - -## Handle the response - -After the user is verified using the device's screen lock, a passkey is created -and stored in the user's selected credential provider. - -The response after you successfully call `createCredential()` is a -[PublicKeyCredential](https://developer.android.com/jetpack/androidx/releases/credentials) object. - -The `PublicKeyCredential` looks as follows: - - { - "id": "", - "type": "public-key", - "rawId": "", - "response": { - "clientDataJSON": "", - "attestationObject": "" - }, - "authenticatorAttachment": "platform" - } - -In the client app, serialize the object and send it to the app server. - -Add code to handle failures as shown in the following snippet: - - fun handleFailure(e: CreateCredentialException) { - when (e) { - is CreatePublicKeyCredentialDomException -> { - // Handle the passkey DOM errors thrown according to the - // WebAuthn spec. - } - is CreateCredentialCancellationException -> { - // The user intentionally canceled the operation and chose not - // to register the credential. - } - is CreateCredentialInterruptedException -> { - // Retry-able error. Consider retrying the call. - } - is CreateCredentialProviderConfigurationException -> { - // Your app is missing the provider configuration dependency. - // Most likely, you're missing the - // "credentials-play-services-auth" module. - } - is CreateCredentialCustomException -> { - // You have encountered an error from a 3rd-party SDK. If you - // make the API call with a request object that's a subclass of - // CreateCustomCredentialRequest using a 3rd-party SDK, then you - // should check for any custom exception type constants within - // that SDK to match with e.type. Otherwise, drop or log the - // exception. - } - else -> Log.w(TAG, "Unexpected exception type ${e::class.java.name}") - } - } - -## Verify and save the public key on the app server - -On the app server, you must verify the public key credential and then [save the -public key](https://web.dev/articles/passkey-registration#save-credential). - -To verify the public key credential's origin, compare it against an allow list -of approved apps. If a key has an unrecognized origin, reject it. - -> [!NOTE] -> **Note:** An app's origin is based on its unique identity, which is the SHA-256 fingerprint of its signing certificate. - -To obtain the app's SHA 256 fingerprint: - -1. Print your release app's signing certificate by running the following - command in a terminal: - - keytool -list -keystore - - In the response, identify the signing certificate's SHA 256 fingerprint, - mentioned as `Certificate fingerprints block` : `SHA256`. -2. Encode the SHA256 fingerprint with base64url encoding. This Python example - demonstrates how to properly encode the fingerprint: - - import binascii - import base64 - fingerprint = '' # your app's SHA256 fingerprint - print(base64.urlsafe_b64encode(binascii.a2b_hex(fingerprint.replace(':', ''))).decode('utf8').replace('=', '')) - -3. Append `android:apk-key-hash`: to the start of the output from the previous - step so that you get something that is similar to the following: - - android:apk-key-hash: - - The result should match with an allowed origin on your app server. If you - have multiple signing certificates, such as certificates for debugging and - release, or multiple apps, then repeat the process and accept all the - origins as valid on the app server. - -> [!NOTE] -> **Note:** When you save the passkey on the app server, make sure that you save the Authenticator Attestation Globally Unique Identifier ([AAGUID](https://web.dev/articles/webauthn-aaguid)) from the client data. The AAGUID is a unique number that identifies the model of the authenticator. For more information, see [Manage passkeys](https://developer.android.com/identity/passkeys/manage-passkeys). - -## Notify the user - -After the passkey is successfully created, notify your users about the passkey -and inform them that they can manage their passkeys from their credential -provider app or from [within the app settings](https://developer.android.com/identity/passkeys/manage-passkeys). Notify users by using a -custom dialog, notification, or snackbar. Since an unexpected passkey creation -by a malicious entity requires an immediate security alert, consider -supplementing these in-app methods with external communication, such as an -email. - -## Enhance the user experience - -To enhance the user experience while implementing sign up with Credential -Manager, consider adding functionality for restore credentials and suppress -autofill dialogs. - -### Add functionality to restore credentials on a new device - -To allow users to seamlessly log into their accounts on a new device, implement -the [Restore Credentials](https://developer.android.com/identity/sign-in/restore-credentials) functionality. Adding restore credentials with -`BackupAgent` logs users in when they open your restored app on a new device, -letting them use your app right away. - -### Suppress autofill on credential fields (optional) - -For app screens where users are expected to use Credential Manager's bottom -sheet UI for authentication, add the `isCredential` attribute to the username -and password fields. This suppresses autofill dialogs (`FillDialog` and -`SaveDialog`) from overlapping with Credential Manager's bottom sheet UI. - -The `isCredential` attribute is supported on Android 14 and higher. - -The following example demonstrates how you can add the `isCredential` attribute -to the relevant username and password fields in the relevant views for your app: - - - -## Next steps - -- [Sign in with passkeys](https://developer.android.com/identity/passkeys/sign-in-with-passkeys) -- [Manage passkeys](https://developer.android.com/identity/passkeys/manage-passkeys) -- [Understand passkey user experience flows](https://developer.android.com/design/ui/mobile/guides/patterns/passkeys) \ No newline at end of file diff --git a/identity/verified-email/references/android/identity/sign-in/credential-manager-webview.md b/identity/verified-email/references/android/identity/sign-in/credential-manager-webview.md deleted file mode 100644 index a953795..0000000 --- a/identity/verified-email/references/android/identity/sign-in/credential-manager-webview.md +++ /dev/null @@ -1,84 +0,0 @@ -This document describes how to integrate the Credential Manager API with an -Android app that uses WebView. Credential Manager is supported natively in the -`android.webkit.WebView` library in [version 1.12.0](https://developer.android.com/jetpack/androidx/releases/webkit#1.12.0) and later. - -## Prerequisites - -To use Credential Manager in WebView, add the following dependencies to your app -module's build script: - - dependencies { - implementation("androidx.credentials:credentials:1.6.0-beta02") - implementation("androidx.credentials:credentials-play-services-auth:1.6.0-beta02") - implementation("androidx.webkit:webkit:1.14.0") - } - -You will also need to associate your app with a website that your app owns using -digital asset linking. For more information, see -[adding digital asset linking](https://developer.android.com/identity/credential-manager/prerequisites). - -## Use the WebKit library - -To use the WebKit library, check for feature support, and then enable support by -calling `setWebAuthenticationSupport()`: - - class WebViewActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContent { - // Put your webview link here. - val url = "https://project-sesame-426206.appspot.com/passkey-signup" - AndroidView( - factory = { context -> - WebView(context).apply { - settings.javaScriptEnabled = true - - webViewClient = WebViewClientImpl() - } - }, - update = { webView -> - run { - webView.loadUrl(url) - if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_AUTHENTICATION)) { - WebSettingsCompat.setWebAuthenticationSupport( - webView.settings, - WebSettingsCompat.WEB_AUTHENTICATION_SUPPORT_FOR_APP, - ) - // Check if getWebauthenticationSupport may have been disabled by the WebView. - Log.e( - "WebViewPasskeyDemo", - "getWebAuthenticationSupport result: " + WebSettingsCompat.getWebAuthenticationSupport( - webView.settings - ), - ) - } else { - Log.e("WebViewPasskeyDemo", "WebView does not support passkeys.") - } - } - }, - ) - } - } - } - -> [!NOTE] -> **Note:** The WebKit library doesn't support `mediation:"conditional"` requests. - -## Web integration - -To learn how to build Web integration see -[Create a passkey for passwordless logins](https://web.dev/passkey-registration/). -You can also reference the [demo site source](https://github.com/deephand/webauthn-in-webview). - -## Testing and deployment - -Test the entire flow thoroughly in a controlled environment to verify proper -communication between the Android app, the web page, and the backend. - -Deploy the integrated solution to production, verifying that the backend can -handle incoming registration and authentication requests. The backend code -should generate initial JSON for registration (create) and authentication (get) -processes. It should also handle validation and verification of the responses -received from the web page. - -Verify the implementation corresponds to the [UX recommendations](https://developer.android.com/design/ui/mobile/guides/patterns/passkeys). \ No newline at end of file diff --git a/jetpack-compose/adaptive/SKILL.md b/jetpack-compose/adaptive/SKILL.md deleted file mode 100644 index 0f8eb7c..0000000 --- a/jetpack-compose/adaptive/SKILL.md +++ /dev/null @@ -1,301 +0,0 @@ ---- -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-09-30' - 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/studio/preview/compose-screenshot-testing-with-testsuites.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 space 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 displaying a full-screen photo. - -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. Use `isNavBarVisible` or `shouldShowNavBar` as the variable name. -- 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. The item must be clearly visible to the user at this width. -- For `LazyColumn`: change to a `LazyVerticalGrid` and follow the instruction later -- 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/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/flexbox/container-behavior.md b/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/flexbox/container-behavior.md deleted file mode 100644 index 41112fc..0000000 --- a/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/flexbox/container-behavior.md +++ /dev/null @@ -1,112 +0,0 @@ -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. -![Second item placed in the FlexBox container after the first item.](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/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/flexbox/get-started.md b/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/flexbox/get-started.md deleted file mode 100644 index 2f54571..0000000 --- a/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/flexbox/get-started.md +++ /dev/null @@ -1,69 +0,0 @@ -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.13.0-alpha03" - - [libraries] - androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "compose" } - -2. Add the library dependency to your app's `build.gradle.kts`. - - dependencies { - implementation(libs.androidx.compose.foundation.layout) - } - -## Create basic FlexBox layouts - -**Example 1** : `FlexBox` lays out two `Text` elements that are centrally -aligned. - - -```kotlin -FlexBox( - config = { - direction(FlexDirection.Column) - alignItems(FlexAlignItems.Center) - } -) { - Text(text = "Hello", fontSize = 48.sp) - Text(text = "World!", fontSize = 48.sp) -} -``` - -
- -![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/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/flexbox/index.md b/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/flexbox/index.md deleted file mode 100644 index 64bd05f..0000000 --- a/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/flexbox/index.md +++ /dev/null @@ -1,78 +0,0 @@ -[`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/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/flexbox/item-behavior.md b/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/flexbox/item-behavior.md deleted file mode 100644 index e4a5c0f..0000000 --- a/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/flexbox/item-behavior.md +++ /dev/null @@ -1,170 +0,0 @@ -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/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/container-properties.md b/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/container-properties.md deleted file mode 100644 index fc4e3a5..0000000 --- a/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/container-properties.md +++ /dev/null @@ -1,415 +0,0 @@ -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)") - PastelGreenCard("Percentage(0.2f)") - PastelBlueCard("Flex(1.fr)") - PastelYellowCard("Auto") - -} -``` - -
- -![Row heights defined using the four primary track sizing options.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/grid/track-sizes.png) **Figure 3** . Row heights defined using the four primary track sizing options in `Grid`. - -### Set the minimum size for flexible grid tracks - -When a grid container has no remaining space, -a standard flexible track can shrink to `0.dp`. -To prevent this and ensure content isn't crushed, -use [`GridTrackSize.MinMax`](https://developer.android.com/reference/kotlin/androidx/compose/foundation/layout/GridTrackSize#MinMax(androidx.compose.ui.unit.Dp,androidx.compose.foundation.layout.Fr)) -to enforce an explicit minimum size while keeping the track flexible. - -The following example allocates at least `100.dp` to the first row: - - -```kotlin -Grid( - config = { - column(1f) - // The first row has a minimum height of 100.dp and can expand to - // the half of the remaining space. - row(GridTrackSize.MinMax(100.dp, 1.fr)) - // The second row takes the half of the remaining space. - row(1.fr) - // The third row has a fixed height of 200.dp. - row(200.dp) - }, - modifier = Modifier.size(360.dp) // Total grid height is 360.dp -) { - PastelRedCard("MinMax(100.dp, 1.fr)") - PastelGreenCard("Flex(1.fr)") - PastelBlueCard("Fixed(200.dp)") -} -``` - -
- -![Row heights defined using the four primary track sizing options.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/grid/track-size-minmax.png) **Figure 4** . The first row has at least `100.dp` height. - -### Set the minimum grid track size to place lazy lists - -Standard flexible tracks automatically query the intrinsic sizes of -their children to establish a base size. -However, Jetpack Compose prohibits querying the intrinsic sizes of -[`SubcomposeLayout`](https://developer.android.com/reference/kotlin/androidx/compose/ui/layout/SubcomposeLayout.composable#SubcomposeLayout(androidx.compose.ui.Modifier,kotlin.Function2)), which backs components, -such as [`LazyColumn`](https://developer.android.com/reference/kotlin/androidx/compose/foundation/lazy/LazyColumn.composable) and [`LazyRow`](https://developer.android.com/reference/kotlin/androidx/compose/foundation/lazy/LazyRow.composable). - -Placing a lazy list inside a standard flexible track causes -an [`IllegalStateException`](https://developer.android.com/reference/java/lang/IllegalStateException) crash. -To safely place lazy lists inside a flexible grid track, -use `MinMax` with an explicit minimum size (such as `0.dp`) -to bypass the intrinsic measurement pass. - - -```kotlin -Grid( - config = { - column(1f) - // The first row's height is determined by the height of the Text composable. - row(GridTrackSize.Auto) - // The second row occupies the remaining space, allowing the LazyColumn to scroll. - row(GridTrackSize.MinMax(0.dp, 1.fr)) - - gap(8.dp) - }, - modifier = Modifier.size(width = 170.dp, height = 240.dp) -) { - Text("LazyColumn in a Grid") - // The LazyColumn is placed in the second row, filling the remaining space. - LazyColumn(verticalArrangement = Arrangement.spacedBy(4.dp)) { - items(100) { number -> - PastelGreenCard("Card $number") - } - } -} -``` - -
- -![Row heights defined using the four primary track sizing options.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/grid/lazy-column-in-grid.png) **Figure 5** . `LazyColumn` in a grid cell. - -### Determine grid track size intrinsically - -You can use [intrinsic sizing](https://developer.android.com/develop/ui/compose/layouts/intrinsic-measurements) for a `Grid` -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 5**. 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 6**. Gaps between rows and columns. - -You can also use the convenience function [`gap`](https://developer.android.com/reference/kotlin/androidx/compose/foundation/layout/GridConfigurationScope#gap(androidx.compose.ui.unit.Dp)) -to define gaps of the same column and row size, -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() -} -``` - -
- -## Define grid areas with named areas - -Named areas allow you to attach names to groups of grid cells, -which are called [grid areas](https://developer.android.com/develop/ui/compose/layouts/adaptive/grid#grid-area). -You can use these names instead of [coordinate indexes](https://developer.android.com/develop/ui/compose/layouts/adaptive/grid/item-properties#position) -when placing UI elements in the grid. - -Using named areas has two main benefits to code readability: - -- When defining the grid layout, the purpose and placement of the expected content is clear. -- When adding the content, the purpose of that content is clear. - -To organize complex layouts clearly, you can decouple your physical grid -structure from child placement by defining semantic grid areas. - -Inside the `config` lambda, use the [`area`](https://developer.android.com/reference/kotlin/androidx/compose/foundation/layout/GridConfigurationScope#area(kotlin.Any,kotlin.Int,kotlin.Int,kotlin.Int,kotlin.Int)) function in -`GridConfigurationScope` to register named areas in the grid. -You can then assign child composables to these areas -using the [`gridItem`](https://developer.android.com/reference/kotlin/androidx/compose/foundation/layout/GridScope#(androidx.compose.ui.Modifier).gridItem(kotlin.Any,androidx.compose.ui.Alignment)) modifier -with the corresponding area identifier. The `area` function maps -a semantic identifier (such as an `enum` class value or a string key) to -a set of physical grid coordinates. Grid lines and indexes are **1-based** -(that is, the first row is `1`, and the first column is `1`). - -For example, you define a grid that has four area IDs: - - -```kotlin -/** - * An enum representing the IDs for named areas within the grid. - */ -enum class GridAreaNames { - Area1, - Area2, - Area3, - Area4 -} -``` - -
- -Provide the name for the area using the `areaId` parameter along with the area's -cell coordinates and spans. -The `gridItem` modifier uses the `areaId` as a key -to assign each child item to its designated grid area, -as shown in the following example: - - -```kotlin -Grid( - config = { - // Define a single column that takes all available width. - repeat(2) { column(0.5f) } - - // Define four rows, each taking 25% of the total height. - repeat(4) { row(0.25f) } - - // Define named grid areas by associating an areaId with specific row and column indices. - // Row and column indices are 1-based. - area(areaId = GridAreaNames.Area1, row = 1, column = 1, columnSpan = 2) - area(areaId = GridAreaNames.Area2, row = 2, column = 1, rowSpan = 3) - area(areaId = GridAreaNames.Area3, rows = 2..3, columns = 2..2) - area(areaId = GridAreaNames.Area4, row = 4, column = 2) - - gap(4.dp) - }, - modifier = Modifier.size(360.dp) -) { - PastelRedCard( - "Area 1", - // Use Modifier.gridItem(areaId) to place this composable into the - // grid area defined with the matching ID in the config block. - modifier = Modifier.gridItem(areaId = GridAreaNames.Area1) - ) - PastelGreenCard( - "Area 2", - modifier = Modifier.gridItem(areaId = GridAreaNames.Area2) - ) - PastelBlueCard( - "Area 3", - modifier = Modifier.gridItem(areaId = GridAreaNames.Area3) - ) - PastelYellowCard( - "Area 4", - modifier = Modifier.gridItem(areaId = GridAreaNames.Area4) - ) -} -``` - -
- -By using named areas, you can reorganize or adjust the physical layout grid (for -example, changing rows, columns, or track sizes) in the `config` lambda -without needing to modify the order or parameters of the child composables. \ No newline at end of file diff --git a/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/get-started.md b/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/get-started.md deleted file mode 100644 index dc5bd59..0000000 --- a/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/get-started.md +++ /dev/null @@ -1,51 +0,0 @@ -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.13.0-alpha03" - - [libraries] - androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "compose" } - -2. Add the library dependency to your app's `build.gradle.kts`. - - dependencies { - implementation(libs.androidx.compose.foundation.layout) - } - -## Create a basic grid - -The following example creates a basic 2x3 grid, -with the columns and rows having a fixed size of `100.dp`. - - -```kotlin -Grid( - config = { - repeat(2) { - column(100.dp) - } - repeat(3) { - row(100.dp) - } - } -) { - Card1(containerColor = PastelRed) - Card2(containerColor = PastelGreen) - Card3(containerColor = PastelBlue) - Card4(containerColor = PastelPink) - Card5(containerColor = PastelOrange) - Card6(containerColor = PastelYellow) -} -``` - -
- -![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/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/index.md b/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/index.md deleted file mode 100644 index 0789e46..0000000 --- a/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/index.md +++ /dev/null @@ -1,70 +0,0 @@ -[`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 require 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/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/item-properties.md b/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/item-properties.md deleted file mode 100644 index bf4205d..0000000 --- a/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/item-properties.md +++ /dev/null @@ -1,170 +0,0 @@ -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. -Coordinate indexes work best for straightforward grid layouts. -For larger or more complex layouts, define [named areas](https://developer.android.com/develop/ui/compose/layouts/adaptive/grid/container-properties#named-area) instead. - -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 placed in 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/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/mediaquery/index.md b/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/mediaquery/index.md deleted file mode 100644 index 37128b7..0000000 --- a/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/mediaquery/index.md +++ /dev/null @@ -1,333 +0,0 @@ -> [!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. - -> [!NOTE] -> **Note:** "media" refers to the presentation medium that the UI is rendered on. The term "media" originates from [CSS media queries](https://www.w3.org/TR/mediaqueries-3/) which inspired the `mediaQuery` API. - -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/jetpack-compose/adaptive/references/android/guide/navigation/navigation-3/recipes/material-listdetail.md b/jetpack-compose/adaptive/references/android/guide/navigation/navigation-3/recipes/material-listdetail.md deleted file mode 100644 index fbaae79..0000000 --- a/jetpack-compose/adaptive/references/android/guide/navigation/navigation-3/recipes/material-listdetail.md +++ /dev/null @@ -1,141 +0,0 @@ -# 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/jetpack-compose/adaptive/references/android/studio/preview/compose-screenshot-testing-with-testsuites.md b/jetpack-compose/adaptive/references/android/studio/preview/compose-screenshot-testing-with-testsuites.md deleted file mode 100644 index 1691d1d..0000000 --- a/jetpack-compose/adaptive/references/android/studio/preview/compose-screenshot-testing-with-testsuites.md +++ /dev/null @@ -1,181 +0,0 @@ -> [!WARNING] -> **Experimental:** Compose Preview Screenshot Testing with AGP test suites is in active development. APIs and DSL syntax are subject to change. Report feedback and issues through the [issue tracker](https://issuetracker.google.com/issues/new?component=192708&template=840533). - -Starting with Android Gradle Plugin (AGP) 9.5.0-alpha03 and Compose Preview -Screenshot Testing engine `0.0.1-alpha16`, screenshot testing is integrated with -AGP's native [test suites](https://developer.android.com/reference/tools/gradle-api/9.5/com/android/build/api/dsl/AgpTestSuite) framework. - -This approach replaces the standalone screenshot plugin -(`com.android.compose.screenshot`). We recommend adopting AGP test suites for -the following reasons: - -- **Native Gradle task lifecycle**: Screenshot tests integrate directly into standard Gradle and AGP testing lifecycles, improving task isolation and test execution reliability. -- **Multi-variant and custom suite support** : You can create multiple distinct screenshot test suites (such as `screenshotTest`, `uiTests`, or `smokeTests`) within a single module and target specific build variants (such as `demoDebug` or `release`), rather than being restricted to a single pre-configured source set. -- **Enhanced build performance and isolation**: AGP test suites use built-in artifact transforms (such as Layoutlib runtime extraction) and isolated classloading, with full support for Gradle Configuration Caching and Project Isolation. - - -## Android skills - -[View on GitHub](https://github.com/android/skills/blob/main/testing/testing-setup) - -### Create screenshot tests - -Use the `testing-setup` [Android skill](https://developer.android.com/tools/agents/android-skills) to develop a testing strategy and create screenshot tests. To install the skill from the [Android CLI](https://developer.android.com/tools/agents/android-cli), run: - - android skills add testing-setup - -
- -## Requirements - -To use Compose Screenshot Testing with test suites, ensure your environment -meets the following requirements: - -- Android Studio Rabbit 1 Canary 4 or higher. -- Android Gradle Plugin (AGP) version 9.5.0-alpha03 or higher. -- Compose Screenshot Engine version 0.0.1-alpha16 or higher. -- JDK version 17 or higher. -- Compose enabled for your project. We recommend enabling Compose using the [Compose Compiler Gradle plugin](https://developer.android.com/develop/ui/compose/compiler). - -## Setup and configuration - -To configure Compose screenshot testing with test suites, complete the -following steps: - -### 1. Enable experimental flags - -In your project's root `gradle.properties` file, enable screenshot testing and -test suite support: - - android.experimental.enableScreenshotTest=true - android.experimental.testSuiteSupport=true - -### 2. Configure the test suite in the `build.gradle.kts` file - -In your module's `build.gradle.kts` file, define a screenshot test suite within -the `testOptions` block: - - android { - testOptions { - screenshotTests.create("screenshotTest") { // suiteName can be customized (for example, "uiTests") - engineVersion = "0.0.1-alpha16" - targetVariants.add("demoDebug") // Add specific variants to test - - dependencies { - implementation(libs.androidx.compose.ui.tooling) - implementation("com.android.tools.screenshot:screenshot-validation-api:0.0.1-alpha16") - } - } - } - } - -### 3. Create the test source set - -Create a dedicated source set directory matching your suite name: - -`{module}/src/{suiteName}/kotlin/` - -For example, for a suite named `screenshotTest`: - -`feature/foryou/impl/src/screenshotTest/kotlin/com/example/app/ForYouScreenTest.kt` - -### 4. Define composable preview tests - -Annotate composables with `@PreviewTest` and standard `@Preview` or -multi-preview annotations: - - 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 ForYouScreenPreview() { - AppTheme { - ForYouScreen(isSyncing = false) - } - } - -## Run screenshot tests - -AGP test suites generate dedicated Gradle tasks based on your suite name, -target, and variants. - -### 1. Generate or update reference images - -Render composable previews and store the golden baseline reference images: - -- **Linux and macOS** : `./gradlew update{SuiteName}{Target}{Variant}TestSuite` (for example, `./gradlew updateScreenshotTestDefaultDemoDebugTestSuite`) -- **Windows** : `gradlew updateScreenshotTestDefaultDemoDebugTestSuite` - -Reference images are generated and saved at: - -`{module}/src/{suiteName}{Target}{Variant}/reference/` - -### 2. Verify and run tests - -Render fresh screenshots and compare them against reference images: - -- **Linux and macOS** : `./gradlew test{SuiteName}{Target}{Variant}TestSuite` (for example, `./gradlew testScreenshotTestDefaultDemoDebugTestSuite`) -- **Windows** : `gradlew testScreenshotTestDefaultDemoDebugTestSuite` - -## Inspect test reports - -If differences are detected or tests fail, AGP generates an HTML test report. - -- **Report location** : `{module}/build/reports/tests/{taskName}/index.html` (for example, `app/build/reports/tests/testScreenshotTestDefaultDemoDebugTestSuite/index.html`) - -The updated report includes: - -- **Header metadata card**: Displays test name, preview method, variant, suite, and status badge. -- **Error categorization** : Clearly flags `Reference Image Missing`, `Image Size Mismatch`, or `Pixel Mismatch` with copyable stack traces. -- **Dynamic visual diff**: Highlights subtle modifications with lower intensity and major changes with high-contrast emphasis to prevent nested element swallowing. - -## Migrate from the legacy standalone plugin - -To migrate from the legacy standalone screenshot plugin to AGP test suites, -update your Gradle configuration and task commands. - -### Build configuration DSL comparison - -#### Legacy standalone plugin (deprecated) - - // In build.gradle.kts - plugins { - alias(libs.plugins.screenshot) - } - - dependencies { - screenshotTestImplementation(libs.androidx.compose.ui.tooling) - screenshotTestImplementation("com.android.tools.screenshot:screenshot-validation-api:0.0.1-alpha16") - } - -#### AGP test suites (recommended) - - // In build.gradle.kts - android { - testOptions { - screenshotTests.create("screenshotTest") { - engineVersion = "0.0.1-alpha16" - targetVariants.add("demoDebug") - - dependencies { - implementation(libs.androidx.compose.ui.tooling) - implementation("com.android.tools.screenshot:screenshot-validation-api:0.0.1-alpha16") - } - } - } - } - -### Tasks and paths mapping - -| Concept | Legacy setup (deprecated) | AGP test suites (recommended) | -|---|---|---| -| **Update task** | `./gradlew updateDebugScreenshotTest` | `./gradlew update{SuiteName}{Target}{Variant}TestSuite` | -| **Test task** | `./gradlew validateDebugScreenshotTest` | `./gradlew test{SuiteName}{Target}{Variant}TestSuite` | -| **Reference path** | `src/screenshotTestDebug/reference` | `src/{suiteName}{Target}{Variant}/reference` | -| **Report path** | `build/reports/screenshotTest/debug/` | `build/reports/tests/{taskName}/` | \ No newline at end of file diff --git a/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/SKILL.md b/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/SKILL.md index cc1eccd..ed21187 100644 --- a/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/SKILL.md +++ b/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/SKILL.md @@ -9,7 +9,6 @@ description: Provides a structured workflow for migrating an Android XML View to license: Complete terms in LICENSE.txt metadata: author: Google LLC - last-updated: '2026-09-18' keywords: - Jetpack Compose - migration @@ -121,4 +120,4 @@ 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. +remove code and resources that are not referenced by other parts of the project. \ No newline at end of file diff --git a/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/references/android/develop/ui/compose/designsystems/migrate-xml-theme-to-compose.md b/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/references/android/develop/ui/compose/designsystems/migrate-xml-theme-to-compose.md index 60b7968..d8230f8 100644 --- a/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/references/android/develop/ui/compose/designsystems/migrate-xml-theme-to-compose.md +++ b/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/references/android/develop/ui/compose/designsystems/migrate-xml-theme-to-compose.md @@ -15,9 +15,9 @@ Material 3 Compose theming. | 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`. | +| `Shape` | 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`. | +| `Color` | 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 @@ -79,8 +79,8 @@ Material 3 Compose. - 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`. + 1. In your Compose code, define a `Shape` object to replicate your XML shape definitions. + 2. Provide this `Shape` 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: @@ -111,7 +111,6 @@ Material 3 Compose. ## 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 @@ -119,14 +118,12 @@ XML styles (styles.xml) system defines styles and appearance of: 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. +Styles have no direct equivalent in Compose - instead styles are passed as: +parameters to composables, 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 @@ -142,11 +139,11 @@ components. | XML | Compose | |---|---| -| `Theme.Material3.*` | `MaterialTheme(colorScheme, typography, shapes) { }` | +| `Theme.MaterialComponents.*` | `MaterialTheme(colorScheme, typography, shapes) { }` | | `TextAppearance.Material3.BodyMedium` | `TextStyle(...)` defined in `Typography(bodyMedium = ...)` | | `ShapeAppearance.*.SmallComponent` | `Shapes(small = RoundedCornerShape(X.dp))` | -| `Widget.Material3.Button` | `Button(colors = ButtonDefaults.buttonColors(...))` | -| `Widget.Material3.CardView` | `Card(shape=..., elevation=..., colors=...)` | +| `Widget.MaterialComponents.Button` | `Button(colors = ButtonDefaults.buttonColors(...))` | +| `Widget.MaterialComponents.CardView` | `Card(shape=..., elevation=..., colors=...)` | | `Widget.*.TextInputLayout.OutlinedBox` | `OutlinedTextField(colors = OutlinedTextFieldDefaults.colors(...))` | | `Widget.*.Chip.Filter` | `FilterChip(colors = FilterChipDefaults.filterChipColors(...))` | | `Widget.*.Toolbar.Primary` | `TopAppBar(colors = TopAppBarDefaults.topAppBarColors(...))` | @@ -163,7 +160,7 @@ components. ## Step 6: Validate the theme migration Always use the existing theme values from the original XML theme as the source -of truth for the new Material Theme in Compose. +of truth for the new Material Theme in Compose Never invent new theme values during migration, to maintain brand consistency and avoid visual regressions. diff --git a/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/references/android/develop/ui/compose/migrate/interoperability-apis/compose-in-views.md b/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/references/android/develop/ui/compose/migrate/interoperability-apis/compose-in-views.md index 83c7188..0a0f7eb 100644 --- a/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/references/android/develop/ui/compose/migrate/interoperability-apis/compose-in-views.md +++ b/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/references/android/develop/ui/compose/migrate/interoperability-apis/compose-in-views.md @@ -1,6 +1,3 @@ -> [!NOTE] -> **Note:** Interoperability is supported for hybrid apps that use Compose and Views. For apps that are only in Compose, use the recommended Compose-only architecture with a single Activity and latest navigation libraries, like Navigation 3. - You can add Compose-based UI into an existing app that uses a View-based design. To create a new, entirely Compose-based screen, have your @@ -64,10 +61,7 @@ The table below summarizes the different scenarios you can use | [`DisposeOnLifecycleDestroyed`](https://developer.android.com/reference/kotlin/androidx/compose/ui/platform/ViewCompositionStrategy.DisposeOnLifecycleDestroyed) | The Composition will be disposed when the provided [`Lifecycle`](https://developer.android.com/reference/androidx/lifecycle/Lifecycle) is destroyed. Interop scenario \* `ComposeView` in a Fragment's View. | | [`DisposeOnViewTreeLifecycleDestroyed`](https://developer.android.com/reference/kotlin/androidx/compose/ui/platform/ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) | The Composition will be disposed when the `Lifecycle` owned by the `LifecycleOwner` returned by `ViewTreeLifecycleOwner.get` of the next window the View is attached to is destroyed. Interop scenario: \* `ComposeView` in a Fragment's View. \* `ComposeView` in a View wherein the Lifecycle is not known yet. | -## `ComposeView` in Fragments (Transitionary Step) - -> [!NOTE] -> **Note:** For hybrid apps with Views and Compose, that use Fragments, use `ComposeView` to wrap composables content and add to a Fragment during migration. For Compose-only apps, do not use Fragments and instead use the recommended Compose-only architecture with a single Activity and latest navigation libraries, like Navigation 3. +## `ComposeView` in Fragments If you want to incorporate Compose UI content in a fragment or an existing View layout, use [`ComposeView`](https://developer.android.com/reference/kotlin/androidx/compose/ui/platform/ComposeView) diff --git a/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/references/android/develop/ui/compose/migrate/interoperability-apis/views-in-compose.md b/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/references/android/develop/ui/compose/migrate/interoperability-apis/views-in-compose.md index dcf5163..9b6add2 100644 --- a/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/references/android/develop/ui/compose/migrate/interoperability-apis/views-in-compose.md +++ b/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/references/android/develop/ui/compose/migrate/interoperability-apis/views-in-compose.md @@ -4,11 +4,6 @@ Compose, like [`AdView`](https://developers.google.com/android/reference/com/google/android/gms/ads/AdView). This approach also lets you reuse custom views you may have designed. -> [!NOTE] -> **Note:** Use `AndroidView` as a wrapper only for missing SDK components without Compose support. Rewrite your custom Views in Compose wherever possible, starting the migration with the simplest custom Views and scaling to more complex ones. - -
- To include a view element or hierarchy, use the [`AndroidView`](https://developer.android.com/reference/kotlin/androidx/compose/ui/viewinterop/AndroidView.composable#AndroidView(kotlin.Function1,androidx.compose.ui.Modifier,kotlin.Function1)) composable. `AndroidView` is passed a lambda that returns a [`View`](https://developer.android.com/reference/android/view/View). `AndroidView` also provides an `update` @@ -68,9 +63,6 @@ To embed an XML layout, use the API, which is provided by the `androidx.compose.ui:ui-viewbinding` library. To do this, your project must enable [view binding](https://developer.android.com/topic/libraries/view-binding#setup). -> [!NOTE] -> **Note:** For Compose-only apps, don't use `AndroidViewBinding` to inflate full screen-level XML layouts, and instead use it only for smaller, legacy XML layouts during the incremental migration process. - ```kotlin @Composable @@ -121,15 +113,12 @@ fun AndroidViewInLazyList() {
-## Fragments in Compose (Transitionary Step) +## Fragments in Compose Use the `AndroidFragment` composable to add a `Fragment` in Compose. `AndroidFragment` has fragment-specific handling such as removing the fragment when the composable leaves the composition. -> [!NOTE] -> **Note:** Wrap existing Fragments in Compose only during incremental migration. For Compose-only apps, do not use Fragments and instead use the recommended Compose-only architecture with a single Activity and latest navigation libraries, like Navigation 3. - To include a fragment, use the [`AndroidFragment`](https://developer.android.com/reference/kotlin/androidx/fragment/compose/package-summary#AndroidFragment) composable. You pass a `Fragment` class to `AndroidFragment`, which then adds an instance of that class directly into the composition. `AndroidFragment` also @@ -152,39 +141,6 @@ fun FragmentInComposeExample() {
-### Cap Fragment lifecycle state - -In Fragment [1.9.0](https://developer.android.com/jetpack/androidx/releases/fragment#1.9.0) and higher, you can use the `maxLifecycle` -parameter to cap the lifecycle state of the embedded `Fragment`. - -For example, in a [`HorizontalPager`](https://developer.android.com/reference/kotlin/androidx/compose/foundation/pager/HorizontalPager.composable), you can dynamically set `maxLifecycle` -to [`RESUMED`](https://developer.android.com/reference/kotlin/androidx/lifecycle/Lifecycle.State#RESUMED) only when the corresponding page is active, keeping offscreen -pages capped at [`STARTED`](https://developer.android.com/reference/kotlin/androidx/lifecycle/Lifecycle.State#STARTED). When the user navigates to another page, the -previous page's fragment cap becomes `STARTED`. This pauses or cancels workloads -scoped to `RESUMED` (such as coroutines running in -[`repeatOnLifecycle(RESUMED)`](https://developer.android.com/reference/kotlin/androidx/lifecycle/package-summary#(androidx.lifecycle.LifecycleOwner).repeatOnLifecycle(androidx.lifecycle.Lifecycle.State,kotlin.coroutines.SuspendFunction1))), preventing offscreen pages from performing -foreground-only work while keeping their UI initialized at `STARTED`: - - -```kotlin -HorizontalPager(state = pagerState) { page -> - // Dynamically cap the lifecycle state based on whether the page is selected - val maxLifecycle = if (pagerState.settledPage == page) { - Lifecycle.State.RESUMED - } else { - Lifecycle.State.STARTED - } - - when (page) { - 0 -> AndroidFragment(maxLifecycle = maxLifecycle) - 1 -> AndroidFragment(maxLifecycle = maxLifecycle) - /* Other pages and corresponding fragments */ - } -} -``` - -
- ## Calling the Android framework from Compose Compose operates within the Android framework classes. For example, it's hosted diff --git a/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/references/android/develop/ui/compose/setup-compose-dependencies-and-compiler.md b/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/references/android/develop/ui/compose/setup-compose-dependencies-and-compiler.md index 4ec0a1c..22c39c5 100644 --- a/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/references/android/develop/ui/compose/setup-compose-dependencies-and-compiler.md +++ b/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/references/android/develop/ui/compose/setup-compose-dependencies-and-compiler.md @@ -1,59 +1,64 @@ -For Gradle, use the Compose Compiler Gradle plugin to set up and configure -Compose. +## Set up the Compose Compiler Gradle plugin + +For Gradle users, you can use the Compose Compiler Gradle plugin to make setting +up and configuring Compose easier. > [!NOTE] > **Note:** The Compose Compiler Gradle Plugin is only available from Kotlin 2.0+. For migration instructions, see ["Jetpack Compose compiler moving to the Kotlin -> repository"](https://android-developers.googleblog.com/2024/04/jetpack-compose-compiler-moving-to-kotlin-repository.html). +> repository"](https://android-developers.googleblog.com/2024/04/jetpack-compose-compiler-moving-to-kotlin-repository.html). For an example migration, see the [Compose Samples +> PR](https://github.com/android/compose-samples/pull/1354) in the Compose samples. ### Set up with Gradle version catalogs -Set up the Compose Compiler Gradle plugin: +The following instructions outline how you can set up the Compose Compiler +Gradle plugin: -1. In the `libs.versions.toml` file, remove any reference to the Compose Compiler. -2. In the `versions` and `plugins` sections, add the new dependency: +1. In your `libs.versions.toml` file, remove any reference to the Compose Compiler. +2. In the `versions` and `plugins` section, add the following new dependency: - [versions] - kotlin = "2.4.10" + [versions] + kotlin = "2.3.10" - [plugins] - org-jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } + [plugins] + org-jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } - // Add this line - compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } + // Add this line + compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } -3. In the project's root `build.gradle.kts` file, add the following to the - `plugins` section. +1. In your project's root `build.gradle.kts` file, add the following snippet to the `plugins` section. - plugins { + plugins { // Existing plugins alias(libs.plugins.compose.compiler) apply false - } + } -4. In each module that uses Compose, apply the plugin: +1. In each module that uses Compose, apply the plugin as follows. - plugins { + plugins { // Existing plugins alias(libs.plugins.compose.compiler) - } + } -The project should now build and compile if it was using the default set up. If -it had configured custom options on the Compose compiler, follow the next +Your app should now build and compile if you are using the default set up. If +you had configured custom options on the Compose compiler, see the following section. ### Set up the Compose Compiler without Gradle version catalogs -Add the plugin to `build.gradle.kts` files associated with modules where Compose -is used: +Add the +following plugin to `build.gradle.kts` files associated with modules you use +Compose: plugins { - id("org.jetbrains.kotlin.plugin.compose") version "2.4.10" // this version matches your Kotlin version + id("org.jetbrains.kotlin.plugin.compose") version "2.3.10" // this version matches your Kotlin version } -Add the classpath to your top-level project `build.gradle.kts` file: +You might also need to add this classpath to your top-level project +`build.gradle.kts` file: buildscript { dependencies { - classpath("org.jetbrains.kotlin.plugin.compose:org.jetbrains.kotlin.plugin.compose.gradle.plugin:2.4.10") + classpath("org.jetbrains.kotlin.plugin.compose:org.jetbrains.kotlin.plugin.compose.gradle.plugin:2.3.10") } } @@ -61,7 +66,7 @@ Add the classpath to your top-level project `build.gradle.kts` file: To configure the Compose compiler using the Gradle plugin, add the `composeCompiler` block to the module's `build.gradle.kts` file at the top -level: +level. android { ... } @@ -74,11 +79,6 @@ For the full list of available options, see the [documentation](https://www.jetb ## Set up Compose dependencies -Always use the latest Compose BOM version: `2026.09.00`. - -Set the `compose` flag to `true` inside the Android [`BuildFeatures`](https://developer.android.com/reference/tools/gradle-api/7.0/com/android/build/api/dsl/BuildFeatures) to -enable [Compose functionality](https://developer.android.com/develop/ui/compose/tooling) in Android Studio. - Add the following definition to your app's `build.gradle` file: ### Groovy @@ -97,13 +97,17 @@ Add the following definition to your app's `build.gradle` file: } } -Add the Compose BOM and the subset of Compose library dependencies: +Setting the `compose` flag to `true` inside the Android [`BuildFeatures`](https://developer.android.com/reference/tools/gradle-api/7.0/com/android/build/api/dsl/BuildFeatures) +block enables [Compose functionality](https://developer.android.com/develop/ui/compose/tooling) in Android Studio. + +Finally, add the Compose BOM and the subset of Compose library dependencies +you need to your dependencies from the following block: ### Groovy dependencies { - def composeBom = platform('androidx.compose:compose-bom:2026.09.00') + def composeBom = platform('androidx.compose:compose-bom:2026.03.00') implementation composeBom androidTestImplementation composeBom @@ -130,7 +134,7 @@ Add the Compose BOM and the subset of Compose library dependencies: // Optional - Integration with activities implementation 'androidx.activity:activity-compose:1.13.0' // Optional - Integration with ViewModels - implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.11.0' + implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0' // Optional - Integration with LiveData implementation 'androidx.compose.runtime:runtime-livedata' // Optional - Integration with RxJava @@ -142,7 +146,7 @@ Add the Compose BOM and the subset of Compose library dependencies: dependencies { - val composeBom = platform("androidx.compose:compose-bom:2026.09.00") + val composeBom = platform("androidx.compose:compose-bom:2026.03.00") implementation(composeBom) androidTestImplementation(composeBom) @@ -169,7 +173,7 @@ Add the Compose BOM and the subset of Compose library dependencies: // Optional - Integration with activities implementation("androidx.activity:activity-compose:1.13.0") // Optional - Integration with ViewModels - implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.11.0") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0") // Optional - Integration with LiveData implementation("androidx.compose.runtime:runtime-livedata") // Optional - Integration with RxJava @@ -179,19 +183,4 @@ Add the Compose BOM and the subset of Compose library dependencies: > [!NOTE] > **Note:** Jetpack Compose is shipped using a Bill of Materials (BOM), to keep the versions of all library groups in sync. Read more about it in the [Bill of -> Materials page](https://developer.android.com/develop/ui/compose/bom/bom). - -## `compileSdk` and Android Gradle Plugin compatibility - -Compose library releases continually adopt the latest `compileSdk` versions to -provide access to the latest Android features. Newer `compileSdk` versions -require newer versions of Android Gradle Plugin, so adopting new Compose -releases also requires projects to adopt new versions of the Android Gradle -Plugin. We recommend keeping your project's `compileSdk` up to date with the -latest released versions. `compileSdk` is unrelated from `targetSdk`. - -For example, starting with Compose 1.12.0, projects are required to use -`compileSdk 37` and Android Gradle Plugin (AGP) 9. - -To check which version of AGP is supported for different API levels, see the -[Android Gradle plugin API level support](https://developer.android.com/build/releases/about-agp#api-level-support) documentation. \ No newline at end of file +> Materials page](https://developer.android.com/develop/ui/compose/bom/bom). \ No newline at end of file diff --git a/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/references/xml-layout-migration.md b/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/references/xml-layout-migration.md index 5aeb6b3..8e93794 100644 --- a/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/references/xml-layout-migration.md +++ b/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/references/xml-layout-migration.md @@ -1,84 +1,72 @@ ## 1. Structural analysis \& mapping -**Identify the precise mapping** between XML elements and Compose equivalents. -You must determine: - -- The exact `@Composable` functions (e.g., `ConstraintLayout`, `Column`, `LazyColumn`) that replace the XML tag hierarchy. -- The specific parameters and `Modifier` extensions required to replicate XML attributes (e.g., `layout_width`, `padding`, `elevation`). -- The appropriate state management strategy for interactive elements. +**Identify the precise mapping** between XML elements and Compose equivalents. You must determine: +\* The exact `@Composable` functions (e.g., `ConstraintLayout`, `Column`, `LazyColumn`) that replace the XML tag hierarchy. +\* The specific parameters and `Modifier` extensions required to replicate XML attributes (e.g., `layout_width`, `padding`, `elevation`). +\* The appropriate state management strategy for interactive elements. ## 2. Migration execution -**Convert the XML layout code to Jetpack Compose**, ensuring the visual -hierarchy and layout logic are preserved while leveraging Compose's declarative -nature. +**Convert the XML layout code to Jetpack Compose**, ensuring the visual hierarchy and layout logic are preserved while leveraging Compose's declarative nature. ## 3. Theming \& design system integrity **Do not use hard-coded values.** Follow these rules for styling: - -- **Token Alignment:** Cross-reference XML dimensions, colors, and style attributes with the existing Compose `Theme` (e.g., `MaterialTheme.colorScheme` or custom design system tokens). -- **Reuse over Creation:** If matching values exist in the current Compose theme, reuse them. If a value is missing but required for the design, define it within the theme structure rather than hard-coding it in the Composable. -- **Project Consistency:** You **MUST** strictly adhere to existing code conventions, naming standards, and implementation patterns found in the project. **Prioritize** project-specific reusable components over generic Material defaults. +\* **Token Alignment:** Cross-reference XML dimension, color, and style attributes with the existing Compose `Theme` (e.g., `MaterialTheme.colorScheme` or custom design system tokens). +\* **Reuse over Creation:** If matching values exist in the current Compose theme, reuse them. If a value is missing but required for the design, define it within the theme structure rather than hard-coding it in the Composable. +\* **Project Consistency:** You **MUST** strictly adhere to existing code conventions, naming standards, and implementation patterns found in the project. **Prioritize** project-specific reusable components over generic Material defaults. ## 4. Component layering \& reusability -Evaluate if the XML layout serves as a foundation-level design system component -(reused across the app with a distinct role). If it is: - -- **Create a reusable composable:** Do not just inline the code. Define a new standalone `@Composable`. -- **Parameterization:** Expose specific parameters for variable data (text, colors, styles) and use `Modifier` for layout-specific customizations. -- **Feature parity \& restriction:** Ensure the new composable enforces the same UI constraints as the original XML component, preventing unauthorized style overrides while maintaining the intended flexibility. +Evaluate if the XML layout serves as a foundation-level design system component (reused across the app with a distinct role). If it is: +\* **Create a reusable composable:** Do not just inline the code. Define a new standalone `@Composable`. +\* **Parameterization:** Expose specific parameters for variable data (text, colors, styles) and use `Modifier` for layout-specific customizations. +\* **Feature parity \& restriction:** Ensure the new composable enforces the same UI constraints as the original XML component, preventing unauthorized style overrides while maintaining the intended flexibility. Example before migration: - - +`xml +` Example after migration: - -```kotlin +## ```kotlin @Composable fun RoundedBorderlessButton( - text: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true +text: String, +onClick: () -> Unit, +modifier: Modifier = Modifier, +enabled: Boolean = true ) { - TextButton( - onClick, modifier - .defaultMinSize(minWidth = dimensionResource(R.dimen.min_width)) - .padding( - start = dimensionResource(R.dimen.padding_2), - end = dimensionResource(R.dimen.padding_2) - ), enabled, shape = RoundedCornerShape(8.dp), - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.primary - ) - ) { - Text( - text = text, - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.SansSerif, - fontWeight = FontWeight.Medium - ) - ) - } +TextButton( +onClick, modifier +.defaultMinSize(minWidth = dimensionResource(R.dimen.min_width)) +.padding( +start = dimensionResource(R.dimen.padding_2), +end = dimensionResource(R.dimen.padding_2) +), enabled, shape = RoundedCornerShape(8.dp), +colors = ButtonDefaults.textButtonColors( +contentColor = MaterialTheme.colorScheme.primary +) +) { +Text( +text = text, +style = MaterialTheme.typography.bodyMedium.copy( +fontFamily = FontFamily.SansSerif, +fontWeight = FontWeight.Medium +) +) +} } ``` -
- -*** ** * ** *** - ## 5. Output requirements - Provide the full Kotlin file content. diff --git a/jetpack-compose/theming/styles/SKILL.md b/jetpack-compose/theming/styles/SKILL.md deleted file mode 100644 index 2af7e49..0000000 --- a/jetpack-compose/theming/styles/SKILL.md +++ /dev/null @@ -1,226 +0,0 @@ ---- -name: styles -description: Use this skill to integrate the Jetpack Compose Styles API into an Android - project. This skill guides you through upgrading dependencies, setting up component - themes, making custom components styleable, and migrating existing layout properties - to use unified styles. Migrate custom design system components, replace hard coded - parameters with Style attributes, and use Modifier.styleable for interaction states. -license: Complete terms in LICENSE.txt -metadata: - author: Google LLC - last-updated: '2026-09-08' - keywords: - - Jetpack Compose - - Styles - - Theming with Styles - - Migrate to Styles - - Modifier.styleable ---- - -## Limitations - -- Warn the user that this skill is EXPERIMENTAL and requires updating to alpha version of Compose and opting in to the Experimental APIs. -- This skill only supports custom UI components and custom themes. -- This skill does not support Material Design component Styles. - -## Prerequisites - -### 1. Upgrade dependencies - -- The project must use `compileSdk` version 37 or higher. -- The project must use `androidx.compose.foundation:foundation` version `1.12.0-alpha01` or higher. -- Alternatively, the project must use Compose BOM version `2026.04.01` or higher. -- The API requires this exact package: `import - androidx.compose.foundation.style.Style` - -### 2. Configure compiler options to enable experimental API - -You must opt-in to the experimental API at the project level. Add the following -block to your module's `build.gradle.kts`: - - kotlin { - compilerOptions { - jvmTarget = JvmTarget.fromTarget("17") - freeCompilerArgs.add("-opt-in=androidx.compose.foundation.style.ExperimentalFoundationStyleApi") - } - } - -## Core workflows and guides - -Refer to the official documentation to complete specific development tasks: - -- Basic Style Usage: To set backgrounds, sizes, and alignments on a component, follow the [Compose Styles Fundamentals - Guide](references/android/develop/ui/compose/styles/fundamentals.md). -- State and Transitions: To configure property changes for state shifts (like pressed or hovered), follow the [Animations and State-Based Styling - Guide](references/android/develop/ui/compose/styles/state-animations.md). -- Architecture Trade offs: To decide when to use a Style versus a standard Modifier, follow the [Styles versus Modifiers - Comparison](references/android/develop/ui/compose/styles/styles-vs-modifiers.md). -- Theme Level Integration: To connect style definitions with custom themes, follow [Theming with Styles](references/android/develop/ui/compose/styles/theming.md) and [Custom Themes in Compose](references/android/develop/ui/compose/designsystems/custom.md). - -## Step-by-Step Migration Workflow - -### Step 1: Analyze theme structure - -1. Locate your central theme file (such as `Theme.kt`). -2. Identify design tokens. Note references for colors, typography, and shapes (for example, `LocalColorScheme`, `LocalTypography`, or `LocalShapes`). -3. If the project lacks Jetpack Compose dependencies, stop. Instruct the user to migrate to Jetpack Compose first. -4. If the project imports `androidx.compose.material.MaterialTheme`, recommend migrating to Material 3 before proceeding. - -### Step 2: Establish `ComponentStyles` - -1. Create a new file named `ComponentStyles.kt` in your theme directory. -2. Define a top-level data class to hold your component styles, for example, the Jetsnack one is called `JetsnackStyles`: - - - ```kotlin - object ExampleComponentStyles { - val customButtonStyle: Style = { - - } - val customTextFieldStyle: Style = { - - } - } - ``` - -
- -3. Expose this class through your custom theme with a static reference, don't - use `CompositionLocals` here as it's not required. - - - ```kotlin - @Immutable - class JetsnackTheme( - // other Design system properties - ) { - companion object { - val colors: CustomThemingWithStyles.JetsnackColors - @Composable @ReadOnlyComposable - get() = LocalJetsnackTheme.current.colors - // ... - - // add helper static reference - val styles: ComponentStyles = ComponentStyles - } - } - ``` - -
- -4. Provide extensions on `StyleScope` to reference theme tokens directly if - they are exposed using `CompositionLocals`. For example: - - - ```kotlin - val StyleScope.colors: JetsnackColors - get() = LocalJetsnackTheme.currentValue.colors - - val StyleScope.typography: androidx.compose.material3.Typography - get() = LocalJetsnackTheme.currentValue.typography - - val StyleScope.shapes: Shapes - get() = LocalJetsnackTheme.currentValue.shapes - ``` - -
- -### Step 3: Migrate a component to Styles API - -For each custom component (for example, `CustomButton`), complete the following -sequence: - -1. **Establish a visual baseline (If an emulator is available):** - - **If you CANNOT run an Android emulator:** Skip this step entirely and proceed to Step 2. - - **If you CAN run an Android emulator:** Perform the following to capture a baseline screenshot: - - **Option A:** Locate and run an existing screenshot test for the component. - - **Option B (If no test exists):** Create a test using the project's existing testing framework, then run it. - - **Option C (If no framework exists):** Create a minimal screenshot test using UI Automator or Espresso, then run it. -2. **Remove individual styling parameters** : Remove styling parameters such as `backgroundColor`, `shape`, `textStyle`, and `contentPadding` from the signature - anything that `StyleScope` supports. -3. **Add the style parameter** : Add `style: Style = Style` to the function signature. Always ensure the default value is exactly `Style` (e.g., `style: - Style = Style`) and not a specific style default like `ChipStyleDefault` or any other value. -4. **Declare state tracking** : If the component is interactable, create a `MutableStyleState` using the interaction source. Update state fields (such as `isEnabled`) inside the Composable to track the state correctly. -5. **Apply styleable modifier** : Replace specific layout modifiers on the root element with `Modifier.styleable()`. -6. **Move defaults to ComponentStyles** : Move hardcoded values from the component definition to a dedicated `Style` instance in `ComponentStyles.kt`. -7. **Validate component:** Compare the baseline screenshot image taken at the start 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. - -#### Migration example - -Before Migration: - - -```kotlin -@Composable -fun CustomButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - backgroundColor: Color = JetsnackTheme.colors.brandLight, - disabledBackgroundColor: Color = JetsnackTheme.colors.brandSecondary, - shape: Shape = JetsnackTheme.shapes.extraLarge, - textStyle: TextStyle = JetsnackTheme.typography.labelLarge, - enabled: Boolean = true, - content: @Composable RowScope.() -> Unit, -) { - val interactionSource = remember { MutableInteractionSource() } - Row( - modifier - .clickable(onClick = onClick, indication = null, interactionSource = interactionSource) - .background(if (enabled) backgroundColor else disabledBackgroundColor, shape) - .defaultMinSize(58.dp, 40.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - content = content, - ) -} -``` - -
- -After Migration: - - -```kotlin -// Exposed via ComponentStyles.kt -object ComponentStyles { - val buttonStyle = Style { - background(colors.brandLight) - shape(shapes.extraLarge) - minWidth(58.dp) - minHeight(40.dp) - textStyle(typography.labelLarge) - disabled { - background(colors.brandSecondary) - } - } -} - -@Composable -fun CustomButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - style: Style = Style, - enabled: Boolean = true, - content: @Composable RowScope.() -> Unit, -) { - val interactionSource = remember { MutableInteractionSource() } - val styleState = rememberUpdatedStyleState(interactionSource) { - it.isEnabled = enabled - } - Row( - modifier - .clickable(onClick = onClick, indication = null, interactionSource = interactionSource) - .styleable(styleState, JetsnackTheme.styles.buttonStyle, style), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - content = content, - ) -} -``` - -
- -### Step 4: Validate Changes - -1. Build the project. Verify that there are no compilation errors. -2. Run your module's screenshot tests. -3. Compare visual outputs of the whole app between the previous and updated components. Verify that no visual layout regressions occur. diff --git a/jetpack-compose/theming/styles/references/android/develop/ui/compose/designsystems/custom.md b/jetpack-compose/theming/styles/references/android/develop/ui/compose/designsystems/custom.md deleted file mode 100644 index 59dbb25..0000000 --- a/jetpack-compose/theming/styles/references/android/develop/ui/compose/designsystems/custom.md +++ /dev/null @@ -1,459 +0,0 @@ -While Material is our recommended design system and Jetpack Compose ships an -implementation of Material, you are not forced to use it. Material is built -entirely on public APIs, so it's possible to create your own design system in -the same manner. - -There are several approaches you might take: - -- [Extend `MaterialTheme`](https://developer.android.com/develop/ui/compose/designsystems/custom#extending-material) with additional theming values. -- [Replace one or more Material systems](https://developer.android.com/develop/ui/compose/designsystems/custom#replacing-systems) --- `Colors`, `Typography`, or `Shapes` --- with custom implementations while keeping the others. -- [Implement a fully custom design system](https://developer.android.com/develop/ui/compose/designsystems/custom#implementing-fully-custom) to replace `MaterialTheme`. - -You may also want to continue using Material components with a custom design -system. It's possible to do this but there are things to keep in mind to suit -the approach you've taken. - -To learn more about the lower-level constructs and APIs used by `MaterialTheme` -and custom design systems, check out the [Anatomy of a theme in Compose](https://developer.android.com/develop/ui/compose/designsystems/anatomy) guide. - -## Extend Material Theming - -Compose Material closely models -[Material Theming](https://m3.material.io/) -to make it straightforward and type-safe to follow the Material guidelines. -However, it's possible to extend the color, typography, and shape sets with -additional values. The simplest approach is to add extension properties: - - -```kotlin -// Use with MaterialTheme.colorScheme.snackbarAction -val ColorScheme.snackbarAction: Color - @Composable - get() = if (isSystemInDarkTheme()) Red300 else Red700 - -// Use with MaterialTheme.typography.textFieldInput -val Typography.textFieldInput: TextStyle - get() = TextStyle(/* ... */) - -// Use with MaterialTheme.shapes.card -val Shapes.card: Shape - get() = RoundedCornerShape(size = 20.dp) -``` - -
- -This provides consistency with `MaterialTheme` usage APIs. An example of this -defined by Compose itself is -[`surfaceColorAtElevation`](https://developer.android.com/reference/kotlin/androidx/compose/material3/package-summary#(androidx.compose.material3.ColorScheme).surfaceColorAtElevation(androidx.compose.ui.unit.Dp)), -which determines the surface color that should be used depending on the -elevation. - -> [!NOTE] -> **Note:** This approach is only recommended for straightforward theming value additions, or for values that are the same in different themes. If you have multiple themes, it's better to define a class with new properties instead. - -Another approach is to define an extended theme that "wraps" `MaterialTheme` and -its values. - -Suppose you want to add two additional colors --- `caution` and `onCaution`, a -yellow color used for actions that are semi-dangerous --- whilst keeping the -existing Material colors: - - -```kotlin -@Immutable -data class ExtendedColors( - val caution: Color, - val onCaution: Color -) - -val LocalExtendedColors = staticCompositionLocalOf { - ExtendedColors( - caution = Color.Unspecified, - onCaution = Color.Unspecified - ) -} - -@Composable -fun ExtendedTheme( - /* ... */ - content: @Composable () -> Unit -) { - val extendedColors = ExtendedColors( - caution = Color(0xFFFFCC02), - onCaution = Color(0xFF2C2D30) - ) - CompositionLocalProvider(LocalExtendedColors provides extendedColors) { - MaterialTheme( - /* colors = ..., typography = ..., shapes = ... */ - content = content - ) - } -} - -// Use with eg. ExtendedTheme.colors.caution -object ExtendedTheme { - val colors: ExtendedColors - @Composable - get() = LocalExtendedColors.current -} -``` - -
- -This is similar to `MaterialTheme` usage APIs. It also supports multiple themes -as you can nest `ExtendedTheme`s in the same way as `MaterialTheme`. - -### Use Material components - -When extending Material Theming, existing `MaterialTheme` values are maintained -and Material components still have reasonable defaults. - -If you want to use extended values in components, wrap them in your own -composable functions, directly setting the values you want to alter, and -exposing others as parameters to the containing composable: - - -```kotlin -@Composable -fun ExtendedButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - content: @Composable RowScope.() -> Unit -) { - Button( - colors = ButtonDefaults.buttonColors( - containerColor = ExtendedTheme.colors.caution, - contentColor = ExtendedTheme.colors.onCaution - /* Other colors use values from MaterialTheme */ - ), - onClick = onClick, - modifier = modifier, - content = content - ) -} -``` - -
- -You would then replace usages of `Button` with `ExtendedButton` where -appropriate. - - -```kotlin -@Composable -fun ExtendedApp() { - ExtendedTheme { - /*...*/ - ExtendedButton(onClick = { /* ... */ }) { - /* ... */ - } - } -} -``` - -
- -## Replace Material subsystems - -Instead of extending Material Theming, you may want to replace one or more -systems --- `Colors`, `Typography`, or `Shapes` --- with a custom implementation, -while maintaining the others. - -Suppose you want to replace the type and shape systems while keeping the color -system: - - -```kotlin -@Immutable -data class ReplacementTypography( - val body: TextStyle, - val title: TextStyle -) - -@Immutable -data class ReplacementShapes( - val component: Shape, - val surface: Shape -) - -val LocalReplacementTypography = staticCompositionLocalOf { - ReplacementTypography( - body = TextStyle.Default, - title = TextStyle.Default - ) -} -val LocalReplacementShapes = staticCompositionLocalOf { - ReplacementShapes( - component = RoundedCornerShape(ZeroCornerSize), - surface = RoundedCornerShape(ZeroCornerSize) - ) -} - -@Composable -fun ReplacementTheme( - /* ... */ - content: @Composable () -> Unit -) { - val replacementTypography = ReplacementTypography( - body = TextStyle(fontSize = 16.sp), - title = TextStyle(fontSize = 32.sp) - ) - val replacementShapes = ReplacementShapes( - component = RoundedCornerShape(percent = 50), - surface = RoundedCornerShape(size = 40.dp) - ) - CompositionLocalProvider( - LocalReplacementTypography provides replacementTypography, - LocalReplacementShapes provides replacementShapes - ) { - MaterialTheme( - /* colors = ... */ - content = content - ) - } -} - -// Use with eg. ReplacementTheme.typography.body -object ReplacementTheme { - val typography: ReplacementTypography - @Composable - get() = LocalReplacementTypography.current - val shapes: ReplacementShapes - @Composable - get() = LocalReplacementShapes.current -} -``` - -
- -### Use Material components - -When one or more systems of `MaterialTheme` have been replaced, using Material -components as-is may result in unwanted Material color, type, or shape values. - -If you want to use replacement values in components, wrap them in your own -composable functions, directly setting the values for the relevant system, and -exposing others as parameters to the containing composable. - -> [!NOTE] -> **Note:** Not all values may be exposed as parameters in Material composables, in particular with `CompositionLocal` composables (such as `LocalTextStyle`). In such cases you may need to wrap `content` lambdas in provider functions (like `ProvideTextStyle`). - - -```kotlin -@Composable -fun ReplacementButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - content: @Composable RowScope.() -> Unit -) { - Button( - shape = ReplacementTheme.shapes.component, - onClick = onClick, - modifier = modifier, - content = { - ProvideTextStyle( - value = ReplacementTheme.typography.body - ) { - content() - } - } - ) -} -``` - -
- -You would then replace usages of `Button` with `ReplacementButton` where -appropriate. - - -```kotlin -@Composable -fun ReplacementApp() { - ReplacementTheme { - /*...*/ - ReplacementButton(onClick = { /* ... */ }) { - /* ... */ - } - } -} -``` - -
- -## Implement a fully custom design system - -You may want to replace Material Theming with a fully custom design system. -Consider that `MaterialTheme` provides the following systems: - -- `Colors`, `Typography`, and `Shapes`: Material Theming systems -- `TextSelectionColors`: Colors used for text selection by `Text` and `TextField` -- `Ripple` and `RippleTheme`: Material implementation of `Indication` - -If you want to continue using Material components, you must replace some of -these systems in your custom themes or handle the systems in your -components to avoid unwanted behavior. - -However, design systems are not limited to the concepts Material relies on. You -can modify existing systems and introduce entirely new ones --- with new classes -and types --- to make other concepts compatible with themes. - -In the following code, we model a custom color system that includes gradients -(`List`), include a type system, introduce a new elevation system, -and exclude other systems provided by `MaterialTheme`: - -![Screenshot of a mobile app UI demonstrating a custom design system with elements using gradients for colors, custom typography, and elevation.](https://developer.android.com/static/develop/ui/compose/images/themes/custom-color-gradients.png) - - -```kotlin -@Immutable -data class CustomColors( - val content: Color, - val component: Color, - val background: List -) - -@Immutable -data class CustomTypography( - val body: TextStyle, - val title: TextStyle -) - -@Immutable -data class CustomElevation( - val default: Dp, - val pressed: Dp -) - -val LocalCustomColors = staticCompositionLocalOf { - CustomColors( - content = Color.Unspecified, - component = Color.Unspecified, - background = emptyList() - ) -} -val LocalCustomTypography = staticCompositionLocalOf { - CustomTypography( - body = TextStyle.Default, - title = TextStyle.Default - ) -} -val LocalCustomElevation = staticCompositionLocalOf { - CustomElevation( - default = Dp.Unspecified, - pressed = Dp.Unspecified - ) -} - -@Composable -fun CustomTheme( - /* ... */ - content: @Composable () -> Unit -) { - val customColors = CustomColors( - content = Color(0xFFDD0D3C), - component = Color(0xFFC20029), - background = listOf(Color.White, Color(0xFFF8BBD0)) - ) - val customTypography = CustomTypography( - body = TextStyle(fontSize = 16.sp), - title = TextStyle(fontSize = 32.sp) - ) - val customElevation = CustomElevation( - default = 4.dp, - pressed = 8.dp - ) - CompositionLocalProvider( - LocalCustomColors provides customColors, - LocalCustomTypography provides customTypography, - LocalCustomElevation provides customElevation, - content = content - ) -} - -// Use with eg. CustomTheme.elevation.small -object CustomTheme { - val colors: CustomColors - @Composable - get() = LocalCustomColors.current - val typography: CustomTypography - @Composable - get() = LocalCustomTypography.current - val elevation: CustomElevation - @Composable - get() = LocalCustomElevation.current -} -``` - -
- -### Use Material components - -When no `MaterialTheme` is present, using Material components as-is will result -in unwanted Material color, type, and shape values and indication behavior. - -If you want to use custom values in components, wrap them in your own composable -functions, directly setting the values for the relevant system, and exposing -others as parameters to the containing composable. - -We recommend that you access values you set from your custom theme. -Alternatively, if your theme doesn't provide `Color`, `TextStyle`, `Shape`, or -other systems, you can hardcode them. - - -```kotlin -@Composable -fun CustomButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - content: @Composable RowScope.() -> Unit -) { - Button( - colors = ButtonDefaults.buttonColors( - containerColor = CustomTheme.colors.component, - contentColor = CustomTheme.colors.content, - disabledContainerColor = CustomTheme.colors.content - .copy(alpha = 0.12f) - .compositeOver(CustomTheme.colors.component), - disabledContentColor = CustomTheme.colors.content - .copy(alpha = 0.38f) - - ), - shape = ButtonShape, - elevation = ButtonDefaults.elevatedButtonElevation( - defaultElevation = CustomTheme.elevation.default, - pressedElevation = CustomTheme.elevation.pressed - /* disabledElevation = 0.dp */ - ), - onClick = onClick, - modifier = modifier, - content = { - ProvideTextStyle( - value = CustomTheme.typography.body - ) { - content() - } - } - ) -} - -val ButtonShape = RoundedCornerShape(percent = 50) -``` - -
- -> [!NOTE] -> **Note:** `Button` uses `rememberRipple()` internally to provide a `Ripple` `Indication`. It's a good idea to check the source code when implementing other custom components that wrap existing components. - -If you've introduced new class types --- such as `List` to represent -gradients --- then it may be better to implement components from scratch instead -of wrapping them. For an example, take a look at -[`JetsnackButton`](https://github.com/android/compose-samples/blob/main/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Button.kt) -from the Jetsnack sample. - -## Recommended for you - -- Note: link text is displayed when JavaScript is off -- [Material Design 3 in Compose](https://developer.android.com/develop/ui/compose/designsystems/material3) -- [Migrate from Material 2 to Material 3 in Compose](https://developer.android.com/develop/ui/compose/designsystems/material2-material3) -- [Anatomy of a theme in Compose](https://developer.android.com/develop/ui/compose/designsystems/anatomy) \ No newline at end of file diff --git a/jetpack-compose/theming/styles/references/android/develop/ui/compose/styles/fundamentals.md b/jetpack-compose/theming/styles/references/android/develop/ui/compose/styles/fundamentals.md deleted file mode 100644 index ab0d26d..0000000 --- a/jetpack-compose/theming/styles/references/android/develop/ui/compose/styles/fundamentals.md +++ /dev/null @@ -1,421 +0,0 @@ -There are three ways you can adopt Styles throughout your app: - -1. Use directly on existing components that expose a [`Style`](https://developer.android.com/reference/kotlin/androidx/compose/foundation/style/Style) parameter. -2. Apply a style with [`Modifier.styleable`](https://developer.android.com/reference/kotlin/androidx/compose/foundation/style/styleable.modifier#(androidx.compose.ui.Modifier).styleable(androidx.compose.foundation.style.StyleState,androidx.compose.foundation.style.Style)) on layout composables that don't accept a `Style` parameter. -3. In your own custom design system, use `Modifier.styleable{}` and expose a style parameter on your own components. - -## Available properties on Styles - -Styles support many of the same properties that modifiers support; however, not -everything that is a modifier can be replicated with a Style. You still need -modifiers for certain behaviors, like interactions, custom drawing, or stacking -of properties. - -| Grouping | Properties | Inherited by children | -|---|---|---| -| **Layout and sizing** | | | -| Content Padding (inner) | - `contentPadding(all: Dp)` - `contentPadding(horizontal: Dp, vertical: Dp)` - `contentPadding(start: Dp, top: Dp, end: Dp, bottom: Dp)` - `contentPaddingHorizontal(value: Dp)` / `contentPaddingVertical(value: Dp)` - `contentPaddingStart(value: Dp)` / `contentPaddingTop(value: Dp)` / `contentPaddingEnd(value: Dp)` / `contentPaddingBottom(value: Dp)` | No | -| External Padding (outer) | - `externalPadding(all: Dp)` - `externalPadding(horizontal: Dp, vertical: Dp)` - `externalPadding(start: Dp, top: Dp, end: Dp, bottom: Dp)` - `externalPaddingHorizontal(value: Dp)` / `externalPaddingVertical(value: Dp)` - `externalPaddingStart(value: Dp)` / `externalPaddingTop(value: Dp)` / `externalPaddingEnd(value: Dp)` / `externalPaddingBottom(value: Dp)` | No | -| Dimensions | `fillWidth()/fillHeight()/fillSize()` and `width`, `height`, and `size` (supports `Dp`, `DpSize`, or `Float` fractions). | No | -| Positioning | `left/top/right/bottom` offsets. | No | -| **Visual Appearance** | | | -| Fills | `background` and `foreground` (supports `Color` or `Brush`). | No | -| Borders | `borderWidth`, `borderColor`, and `borderBrush`. | No | -| Shape | `shape` | No - but used in conjunction with other properties. `clip` and `border` use this defined shape. | -| Shadows | `dropShadow`, `innerShadow` | No | -| **Transformations** | | | -| Graphics layer spatial movement | `translationX`, `translationY`, `scaleX/scaleY`, `rotationX/rotationY/rotationZ` | No | -| Control | `alpha`, `zIndex` (stacking order), and `transformOrigin` (pivot point) | No | -| **Typography** | | | -| Styling | `textStyle`, `fontSize`, `fontWeight`, `fontStyle`, and `fontFamily` | Yes | -| Coloration | `contentColor` and `contentBrush`. This is also used for Icons styling. | Yes | -| Paragraph | `lineHeight`, `letterSpacing`, `textAlign`, `textDirection`, `lineBreak`, and `hyphens`. | Yes | -| Decoration | `textDecoration`, `textIndent`, and `baselineShift`. | Yes | - -## Use Styles directly on components with Style parameters - -Components that expose a `Style` parameter allow you to set their styling: - - -```kotlin -BaseButton( - onClick = { }, - style = { } -) { - BaseText("Click me") -} -``` - -
- -Within the style lambda, you can set various properties, such as `externalPadding` -or `background`: - - -```kotlin -BaseButton( - onClick = { }, - style = { background(Color.Blue) } -) { - BaseText("Click me") -} -``` - -
- -For the full list of supported properties, see [Available properties on -Styles](https://developer.android.com/develop/ui/compose/styles/fundamentals#properties-styles). - -## Apply Styles using modifiers for components with no existing parameter - -For components that lack a built-in style parameter, you can still apply styles -with the `styleable` modifier. This approach is also useful when developing your -own custom components. - - -```kotlin -Row( - modifier = Modifier.styleable { } -) { - BaseText("Content") -} -``` - -
- -Similar to the `style` parameter, you can include properties like `background`, -`contentPadding`, or `externalPadding` inside the lambda. - - -```kotlin -Row( - modifier = Modifier.styleable { - background(Color.Blue) - } -) { - BaseText("Content") -} -``` - -
- -> [!NOTE] -> **Note:** When using `Modifier.styleable`, the child composables won't have those properties applied to them, unless they are inherited properties. Only the container with the `styleable` modifier has those properties applied. - -Multiple chained `Modifier.styleable` modifiers are additive with non-inherited -properties on the applied composable, behaving similarly to multiple modifiers -defining the same properties. For inherited properties, these are overridden, -and the last `styleable` modifier in the chain sets the values. - -When using `Modifier.styleable`, you may also want to create and supply a -`StyleState` to be used with the modifier to apply state-based styling. For more -details, see [State and animations with -Styles](https://developer.android.com/develop/ui/compose/styles/state-animations). - -## Define a standalone Style - -You can define a standalone Style for reusability purposes: - - -```kotlin -val style = Style { background(Color.Blue) } -``` - -
- -You can then pass that defined style into a composable's style parameter or with -`Modifier.styleable`. When using `Modifier.styleable`, you also need to create a -`StyleState` object. `StyleState` is covered in detail in the [State and -animations with Styles](https://developer.android.com/develop/ui/compose/styles/state-animations) documentation. - -The following example shows how you can apply a Style either directly through a -component's built-in parameters, or through a `Modifier.styleable`: - - -```kotlin -val style = Style { background(Color.Blue) } - -// built in parameter -BaseButton(onClick = { }, style = style) { - BaseText("Button") -} - -// modifier styleable -val styleState = remember { MutableStyleState(null) } -Column( - Modifier.styleable(styleState, style) -) { - BaseText("Column content") -} -``` - -
- -You can also pass that Style into multiple components: - - -```kotlin -val style = Style { background(Color.Blue) } - -// built in parameter -BaseButton(onClick = { }, style = style) { - BaseText("Button") -} -BaseText("Different text that uses the same style parameter", style = style) - -// modifier styleable -val columnStyleState = remember { MutableStyleState(null) } -Column( - Modifier.styleable(columnStyleState, style) -) { - BaseText("Column") -} -val rowStyleState = remember { MutableStyleState(null) } -Row( - Modifier.styleable(rowStyleState, style) -) { - BaseText("Row") -} -``` - -
- -## Add multiple Style properties - -You can add multiple Style properties by setting different properties on each -line: - - -```kotlin -BaseButton( - onClick = { }, - style = { - background(Color.Blue) - contentPaddingStart(16.dp) - } -) { - BaseText("Button") -} -``` - -
- -> [!IMPORTANT] -> **Important:** Unlike modifier-based styling, properties in Styles override one another; the last property defined takes precedence. - -Properties in Styles are not additive, unlike modifier-based styling. Styles -take the last set value in the list of properties within one style block. In the -following example, with the background set twice, the `TealColor` is the applied -background. For padding, `contentPaddingTop` overrides the top -padding set by `contentPadding` and does not combine the values. - - -```kotlin -BaseButton( - style = { - background(Color.Red) - // Background of Red is now overridden with TealColor instead - background(TealColor) - // All directions of padding are set to 64.dp (top, start, end, bottom) - contentPadding(64.dp) - // Top padding is now set to 16.dp, all other paddings remain at 64.dp - contentPaddingTop(16.dp) - }, - onClick = { - // - } -) { - BaseText("Click me!") -} -``` - -
- -![Button with two background colors set, and two contentPadding -overrides](https://developer.android.com/static/develop/ui/compose/styles/images/basic_style_button.png) **Figure 1.** Button with two background colors set and two `contentPadding` overrides. - -## Merge multiple style objects - -You can create multiple Style objects and pass them into the style parameter of -your composable. - - -```kotlin -val style1 = Style { background(TealColor) } -val style2 = Style { contentPaddingTop(16.dp) } - -BaseButton( - style = style1 then style2, - onClick = { - - }, -) { - BaseText("Click me!") -} -``` - -
- -![Button with background color and contentPaddingTop -set](https://developer.android.com/static/develop/ui/compose/styles/images/button_content_padding_top.png) **Figure 2.** Button with background color and `contentPaddingTop` set. - -When multiple Styles specify the same property, the last set -property is chosen. Because properties are not additive in Styles, the last -padding passed in overrides the `contentPaddingHorizontal` set by the initial -`contentPadding`. Additionally, the last background color overrides the -background color set by the initial style passed in. - - -```kotlin -val style1 = Style { - background(Color.Red) - contentPadding(32.dp) -} - -val style2 = Style { - contentPaddingHorizontal(8.dp) - background(Color.LightGray) -} - -BaseButton( - style = style1 then style2, - onClick = { - - }, -) { - BaseText("Click me!") -} -``` - -
- -In this case, the styling applied has a light gray background and `32.dp` padding, -except for the left and right padding, which has a value of `8.dp`. -![Button with contentPadding that's overridden by different -Styles](https://developer.android.com/static/develop/ui/compose/styles/images/button_content_padding_overrides.png) **Figure 3.** Button with `contentPadding` that's overridden by different Styles. - -## Style inheritance - -> [!NOTE] -> **Note:** While the Style APIs are experimental, you need to opt-in to enable Style inheritance by setting the flag `ComposeFoundationFlags.isInheritedTextStyleEnabled = true`. - -Certain style properties, such as `contentColor` and text style-related -properties, propagate to the child composables. A style set on a child -composable overrides the inherited parent styling for that specific child. -![Style propagation with Style, styleable, and direct -parameters](https://developer.android.com/static/develop/ui/compose/styles/images/styles_modifiers_precedence_ordering.png) **Figure 4.** Style propagation with `Style`, `styleable`, and direct parameters. - -| Priority | Method | Effect | -|---|---|---| -| 1 (Highest) | Direct arguments on a composable | Overrides everything; for example, `Text(color = Color.Red)` | -| 2 | Style parameter | Local style overrides `Text(style = Style { contentColor(Color.Red)}` | -| 3 | Modifier chain | `Modifier.styleable{ contentColor(Color.Red)` on the component itself. | -| 4 (Lowest) | Parent styles | For properties that can be inherited (Typography/Color) passed down from the parent. | - -> [!NOTE] -> **Note:** Multiple chained `Modifier.styleable` modifiers are additive with non-inherited properties on the applied composable, similar to having multiple modifiers defining the same properties. For inherited properties, these are overridden; the last `styleable` modifier in the chain sets the values. - -### Parent styling - -You can set text properties (such as `contentColor`) from the parent composable, -and they propagate to all child `Text` composables. - - -```kotlin -val styleState = remember { MutableStyleState(null) } -Column( - modifier = Modifier.styleable(styleState) { - background(Color.LightGray) - val blue = Color(0xFF4285F4) - val purple = Color(0xFFA250EA) - val colors = listOf(blue, purple) - contentBrush(Brush.linearGradient(colors)) - }, -) { - BaseText("Children inherit", style = { width(60.dp) }) - BaseText("certain properties") - BaseText("from their parents") -} -``` - -
- -![Child composables' property -inheritance](https://developer.android.com/static/develop/ui/compose/styles/images/children_inherit_styles_parents.png) **Figure 5.** Child composables' property inheritance. - -### Child override of properties - -You can also set styling on a specific `Text` composable. If the parent composable -has styling set, the styling set on the child composable overrides the -parent composable's styling. - - -```kotlin -val styleState = remember { MutableStyleState(null) } -Column( - modifier = Modifier.styleable(styleState) { - background(Color.LightGray) - val blue = Color(0xFF4285F4) - val purple = Color(0xFFA250EA) - val colors = listOf(blue, purple) - contentBrush(Brush.linearGradient(colors)) - }, -) { - BaseText("Children can ", style = { - contentBrush(Brush.linearGradient(listOf(Color.Red, Color.Blue))) - }) - BaseText("override properties") - BaseText("set by their parents") -} -``` - -
- -![Child composables override parent -properties](https://developer.android.com/static/develop/ui/compose/styles/images/children_override_styles.png) **Figure 6.** Child composables override parent properties. - -## Implement custom Style properties - -You can create custom properties that map to existing Style definitions by using -extension functions on the `StyleScope`, as shown in the following example: - - -```kotlin -fun StyleScope.outlinedBackground(color: Color) { - border(1.dp, color) - background(color) -} -``` - -
- -Apply this new property within a Style definition: - - -```kotlin -val customExtensionStyle = Style { - outlinedBackground(Color.Blue) -} -``` - -
- -Creating new styleable properties is unsupported. If your use case -requires such support, submit a [feature request](https://issuetracker.google.com/issues/new?component=612128). - -## Read `CompositionLocal` values - -It's a common pattern to store design system tokens within a `CompositionLocal`, -to access the variables without needing to pass them as parameters. Styles -can access `CompositionLocal`s to retrieve system-wide values within a style: - - -```kotlin -val buttonStyle = Style { - contentPadding(12.dp) - shape(RoundedCornerShape(50)) - background(Brush.verticalGradient(LocalCustomColors.currentValue.background)) -} -``` - -
\ No newline at end of file diff --git a/jetpack-compose/theming/styles/references/android/develop/ui/compose/styles/state-animations.md b/jetpack-compose/theming/styles/references/android/develop/ui/compose/styles/state-animations.md deleted file mode 100644 index 01683bd..0000000 --- a/jetpack-compose/theming/styles/references/android/develop/ui/compose/styles/state-animations.md +++ /dev/null @@ -1,461 +0,0 @@ -
- -The Styles API offers a declarative and streamlined approach to managing UI -changes during interaction states like `hovered`, `focused`, and `pressed`. With -this API, you can significantly decrease the boilerplate code typically required -when using modifiers. - -To facilitate reactive styling, `StyleState` acts as a stable, read-only -interface that tracks the active state of an element (such as its enabled, -pressed, or focused status). Within a `StyleScope`, you can access this through -the `state` property to implement conditional logic directly in your Style -definitions. - -## State-based interaction: Hovered, focused, pressed, selected, enabled, toggled - -Styles come with built-in support for common interactions: - -- Pressed -- Hovered -- Selected -- Enabled -- Toggled - -It's also possible to support custom states. See the [Custom State Styling with -StyleState](https://developer.android.com/develop/ui/compose/styles/state-animations#custom-state) section for more information. - -### Handle interaction states with Style parameters - -The following example demonstrates modifying the `background` and `borderColor` -in response to interaction states, specifically switching to purple when hovered -and blue when focused: - - -```kotlin -@Preview -@Composable -private fun OpenButton() { - BaseButton( - style = outlinedButtonStyle then { - background(Color.White) - hovered { - background(lightPurple) - border(2.dp, lightPurple) - } - focused { - background(lightBlue) - } - }, - onClick = { }, - content = { - BaseText("Open in Studio", style = { - contentColor(Color.Black) - fontSize(26.sp) - textAlign(TextAlign.Center) - }) - } - ) -} -``` - -
- -**Figure 1.** Changing background color based on hovered and focused states. - -You can also create nested state definitions. For example, you can define a -specific style for when a button is being both pressed and hovered -simultaneously: - - -```kotlin -@Composable -private fun OpenButton_CombinedStates() { - BaseButton( - style = outlinedButtonStyle then { - background(Color.White) - hovered { - // light purple - background(lightPurple) - pressed { - // When running on a device that can hover, whilst hovering and then pressing the button this would be invoked - background(lightOrange) - } - } - pressed { - // when running on a device without a mouse attached, this would be invoked as you wouldn't be in a hovered state only - background(lightRed) - } - focused { - background(lightBlue) - } - }, - onClick = { }, - content = { - BaseText("Open in Studio", style = { - contentColor(Color.Black) - fontSize(26.sp) - textAlign(TextAlign.Center) - }) - } - ) -} -``` - -
- -**Figure 2.** Hovered and pressed state together on a button. - -### Custom composables with Modifier.styleable - -When creating your own `styleable` components, you must connect an -`interactionSource` to a `styleState`. Then, pass this state into -`Modifier.styleable` to utilize it. - -Consider a scenario where your design system includes a `GradientButton`. You -may want to create a `LoginButton` that inherits from `GradientButton`, but -alters its colors during interactions, like being pressed. - -- To enable `interactionSource` style updates, include an `interactionSource` as a parameter within your composable. Use the provided parameter or, if one is not supplied, initialize a new `MutableInteractionSource`. -- Initialize the `styleState` by providing the `interactionSource`. Make sure the `styleState`'s enabled status reflects the value of the provided enabled parameter. -- Assign the `interactionSource` to the `focusable` and `clickable` modifiers. Finally, apply the `styleState` to the modifier's `styleable` parameter. - - -```kotlin -@Composable -private fun GradientButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - style: Style = Style, - enabled: Boolean = true, - interactionSource: MutableInteractionSource? = null, - content: @Composable RowScope.() -> Unit, -) { - val interactionSource = interactionSource ?: remember { MutableInteractionSource() } - val styleState = rememberUpdatedStyleState(interactionSource) { - it.isEnabled = enabled - } - Row( - modifier = - modifier - .clickable( - onClick = onClick, - enabled = enabled, - interactionSource = interactionSource, - indication = null, - ) - .styleable(styleState, baseGradientButtonStyle then style), - content = content, - ) -} -``` - -
- -You can now use the `interactionSource` state to drive style modifications with -the pressed, focused, and hovered options inside the style block: - - -```kotlin -@Preview -@Composable -fun LoginButton() { - val loginButtonStyle = Style { - pressed { - background( - Brush.linearGradient( - listOf(Color.Magenta, Color.Red) - ) - ) - } - } - GradientButton(onClick = { - // Login logic - }, style = loginButtonStyle) { - BaseText("Login") - } -} -``` - -
- -**Figure 3.** Changing a custom composable state based on `interactionSource`. - -## Animate style changes - -Styles state changes come with built-in animation support. You can wrap the new -property within any state change block with `animate` to automatically add -animations between different states. This is similar to the `animate*AsState` -APIs. The following example animates the `borderColor` from black to blue when -the state changes to focused: - - -```kotlin -val animatingStyle = Style { - externalPadding(48.dp) - border(3.dp, Color.Black) - background(Color.White) - size(100.dp) - - pressed { - animate { - borderColor(Color.Magenta) - background(Color(0xFFB39DDB)) - } - } -} - -@Preview -@Composable -private fun AnimatingStyleChanges() { - val interactionSource = remember { MutableInteractionSource() } - val styleState = remember(interactionSource) { MutableStyleState(interactionSource) } - Box(modifier = Modifier - .clickable( - interactionSource, - enabled = true, - indication = null, - onClick = { - - } - ) - .styleable(styleState, animatingStyle)) { - - } -} -``` - -
- -**Figure 4.** Animating color changes on press. - -The `animate` API accepts an `animationSpec` to change the duration or shape of -the animation curve. The following example animates the size of the box with a -`spring` spec: - - -```kotlin -val animatingStyleSpec = Style { - externalPadding(48.dp) - border(3.dp, Color.Black) - background(Color.White) - size(100.dp) - transformOrigin(TransformOrigin.Center) - pressed { - animate { - borderColor(Color.Magenta) - background(Color(0xFFB39DDB)) - } - animate(spring(dampingRatio = Spring.DampingRatioMediumBouncy)) { - scale(1.2f) - } - } -} - -@Preview(showBackground = true) -@Composable -fun AnimatingStyleChangesSpec() { - val interactionSource = remember { MutableInteractionSource() } - val styleState = remember(interactionSource) { MutableStyleState(interactionSource) } - Box(modifier = Modifier - .clickable( - interactionSource, - enabled = true, - indication = null, - onClick = { - - } - ) - .styleable(styleState, animatingStyleSpec)) -} -``` - -
- -**Figure 5.** Animating size and color changes on press. - -## Custom state styling with StyleState - -Depending on your composable use case, you may have different styles that are -backed by custom states. For example, if you have a media app, you may want to -have different styling for the buttons in your `MediaPlayer` composable -depending on the playback state of the player. Follow these steps to create and -use your own custom state: - -1. Define custom key -2. Create `StyleState` extension -3. Link to custom state - -### Define custom key - -To create a custom state-based style, first create a -[`StyleStateKey`](https://developer.android.com/reference/kotlin/androidx/compose/foundation/style/StyleStateKey) and pass in the default state value. When the -app launches, the media player is in the `Stopped` state, so it's initialized in -this way: - - -```kotlin -enum class PlayerState { - Stopped, - Playing, - Paused -} - -val playerStateKey = StyleStateKey(PlayerState.Stopped) -``` - -
- -### Create StyleState extension functions - -Define an extension function on `StyleState` to query the current `playState`. -Then, create extension functions on `StyleScope` with your custom states passing -in the `playStateKey`, a lambda with the specific state, and the style. - - -```kotlin -// Extension Function on MutableStyleState to query and set the current playState -var MutableStyleState.playerState - get() = this[playerStateKey] - set(value) { this[playerStateKey] = value } - -fun StyleScope.playerPlaying(block: () -> Unit) { - state(playerStateKey, block, { key, state -> state[key] == PlayerState.Playing }) -} -fun StyleScope.playerPaused(block: () -> Unit) { - state(playerStateKey, block, { key, state -> state[key] == PlayerState.Paused }) -} -``` - -
- -### Link to custom state - -Define the `styleState` in your composable and set the `styleState.playState` -equal to incoming state. Pass `styleState` into the `styleable` function on the -modifier. - - -```kotlin -@Composable -fun MediaPlayer( - url: String, - modifier: Modifier = Modifier, - style: Style = Style, - state: PlayerState = remember { PlayerState.Paused } -) { - // Hoist style state, set playstate as a parameter, - val styleState = remember { MutableStyleState(null) } - // Set equal to incoming state to link the two together - styleState.playerState = state - Box( - modifier = modifier.styleable(styleState, style)) { - ///.. - } -} -``` - -
- -Within the `style` lambda, you can apply state-based styling for custom states, -using the previously defined extension functions. - - -```kotlin -@Composable -fun StyleStateKeySample() { - // Using the extension function to change the border color to green while playing - val style = Style { - borderColor(Color.Gray) - playerPlaying { - animate { - borderColor(Color.Green) - } - } - playerPaused { - animate { - borderColor(Color.Blue) - } - } - } - val styleState = remember { MutableStyleState(null) } - styleState[playerStateKey] = PlayerState.Playing - - // Using the style in a composable that sets the state -> notice if you change the state parameter, the style changes. You can link this up to an ViewModel and change the state from there too. - MediaPlayer(url = "https://example.com/media/video", - style = style, - state = PlayerState.Stopped) -} -``` - -
- -The following code is the full snippet for this example: - - -```kotlin -enum class PlayerState { - Stopped, - Playing, - Paused -} -val playerStateKey = StyleStateKey(PlayerState.Stopped) -var MutableStyleState.playerState - get() = this[playerStateKey] - set(value) { this[playerStateKey] = value } - -fun StyleScope.playerPlaying(block: () -> Unit) { - state(playerStateKey, block, { key, state -> state[key] == PlayerState.Playing }) -} -fun StyleScope.playerPaused(block: () -> Unit) { - state(playerStateKey, block, { key, state -> state[key] == PlayerState.Paused }) - -} - -@Composable -fun MediaPlayer( - url: String, - modifier: Modifier = Modifier, - style: Style = Style, - state: PlayerState = remember { PlayerState.Paused } -) { - // Hoist style state, set playstate as a parameter, - val styleState = remember { MutableStyleState(null) } - // Set equal to incoming state to link the two together - styleState.playerState = state - Box( - modifier = modifier.styleable(styleState, Style { - size(100.dp) - border(2.dp, Color.Red) - - }, style, )) { - - ///.. - } -} -@Composable -fun StyleStateKeySample() { - // Using the extension function to change the border color to green while playing - val style = Style { - borderColor(Color.Gray) - playerPlaying { - animate { - borderColor(Color.Green) - } - } - playerPaused { - animate { - borderColor(Color.Blue) - } - } - } - val styleState = remember { MutableStyleState(null) } - styleState[playerStateKey] = PlayerState.Playing - - // Using the style in a composable that sets the state -> notice if you change the state parameter, the style changes. You can link this up to an ViewModel and change the state from there too. - MediaPlayer(url = "https://example.com/media/video", - style = style, - state = PlayerState.Stopped) -} -``` - -
\ No newline at end of file diff --git a/jetpack-compose/theming/styles/references/android/develop/ui/compose/styles/styles-vs-modifiers.md b/jetpack-compose/theming/styles/references/android/develop/ui/compose/styles/styles-vs-modifiers.md deleted file mode 100644 index cd9bc50..0000000 --- a/jetpack-compose/theming/styles/references/android/develop/ui/compose/styles/styles-vs-modifiers.md +++ /dev/null @@ -1,48 +0,0 @@ -Styles differ from modifiers by design. Styles don't replace modifiers; instead, -the two systems coexist with different goals. Internally, a Style is a modifier. -You can do everything Styles can do with modifiers, but not all functionality in -modifiers is available in Styles. -**Important:** - -- **Choose Styles if:** You need to override a default of an existing component, perform high-performance animations, or define a theme-wide set of properties for a component. -- **Choose Modifiers if:** You need to add behavior (for example, clickable, gestures), define unique one-off layouts, or need additive properties. - -The following is a comparison between Styles versus modifiers: - -| Feature | Modifiers | Styles | -|---|---|---| -| **Primary Goal** | Define behaviors, semantics, and complex layouts. Modifiers manipulate individual elements on the fly for a particular composable and don't trickle down from the theme. | Define visual appearance, individual item sizing and themeable properties. Styles operate at a theme level and are over-writeable at a component level. They trickle down and apply styling across different composables. | -| **Logic** | Additive - the modifiers combine together to form a new result. | Over-writable - the last property set in the Style wins. Styles act as a single layer of properties that override each other based on a defined precedence hierarchy. | -| **Theming** | Challenging to lift into a theme, normally used individually. | By design, Styles are themeable (they can access `CompositionLocal`s) and can be defined once and used across components. | -| **Performance** | Updates often require all three phases of Compose: composition, layout and draw. Achieving good animation performance of modifiers often requires writing lambda-based versions. | Skips composition phase, only active in layout and draw phase, reducing recompositions. Requires less object allocation. | -| **Animations** | Requires using separate animation primitives like `animate*AsState` | Features built-in `animate { }` API that handles some animations for you. | - -## Limitations of modifiers - -Modifiers have many benefits in the current Compose landscape. However, Styles -address some limitations of modifiers, which the following list describes: - -- Modifiers are typically created in the Composition phase. Updates can force a full rerun of Composition, Layout, and Draw, even for small visual changes like color, unless you create lambda-based modifiers. -- Conditional modifiers require disruptive if-else logic within fluent chains. Animating them requires manual state boilerplate and lacks a high-performance "auto-animate" mechanism. -- Modifiers stack rather than replace. You can't override a component's default border; you can only draw a second one on top. -- Modifiers are difficult to abstract into global themes. Consequently, themes usually store raw values instead of reusable modifier configurations. - -## Limitations of Styles - -While Styles can fill in some of the gaps that modifiers have, they also have -some limitations, which show how they cannot entirely replace modifiers: - -- Styles are specialized Modifiers. While a modifier can do anything a Style does, the reverse is not true. Consequently, Styles can supplement, but cannot replace, modifiers. -- Styles are limited to visual configuration (backgrounds, padding, borders). They cannot handle behaviors like click logic, gesture detection, or accessibility semantics. -- Resolving a Style into its final state is *more expensive than applying a - single modifier*. The system must generate a data structure containing all possible property values, and the lookup of inherited properties further complicates this. - -## When to use Styles over modifiers - -While the choice to use Styles is largely dependent on your app and use cases, -the following guidance helps determine when to prefer a style over a modifier: - -- **To achieve theme-wide consistency:** Styles are designed to be "lifted" into a global theme. Instead of passing repetitive Modifiers to every component, you can define a single Style in your theme to create a unified look across the entire app. -- **When performing frequent animations:** Styles evaluate during the Layout and Draw phases, allowing properties like color or scale to animate while bypassing the Composition phase entirely. This significantly reduces performance overhead. Use a Style instead of a modifier when doing visual property animations. -- **Overriding vs. stacking:** Use Styles when you need to replace a default property. Modifiers are additive (adding a border stacks a second one), whereas Styles use "last-write-wins" logic, making it easier to swap out backgrounds or padding without visual clutter. -- **Customizing Material components:** If a Material component provides a Style parameter, it is the suggested approach for customization. These styles allow you to access and modify specific properties within the composable's internal structure that might otherwise be inaccessible. \ No newline at end of file diff --git a/jetpack-compose/theming/styles/references/android/develop/ui/compose/styles/theming.md b/jetpack-compose/theming/styles/references/android/develop/ui/compose/styles/theming.md deleted file mode 100644 index 6b551d0..0000000 --- a/jetpack-compose/theming/styles/references/android/develop/ui/compose/styles/theming.md +++ /dev/null @@ -1,269 +0,0 @@ -> [!NOTE] -> **Note:** Styles are `@Experimental` and likely to change in upcoming releases, with Material support for Styles added in future releases. If you have any feedback, [file Styles issues](https://issuetracker.google.com/issues/new?component=612128). - -There are several ways you can build out your apps using Styles. What you choose -depends on where your app sits in relation to its adoption of Material Design: - -1. Fully custom design system, not using Material Design - - **Recommendation**: Define component styles that consume values from the theme, and expose style parameters on design system components. -2. Using Material Design - - **Recommendation**: Await Material adoption to integrate with Styles. Use styles on your own components where possible. - - -## Android skills - -[View on GitHub](https://github.com/android/skills/blob/main/jetpack-compose/theming/styles) - -### Use the Jetpack Compose Styles API - -Use the `styles` [Android skill](https://developer.android.com/tools/agents/android-skills) to create and customize components with the Styles API. To install the skill from the [Android CLI](https://developer.android.com/tools/agents/android-cli), run: - - android skills add styles - -
- -## The Style layer - -In the traditional Compose model, customization often relies heavily on -overriding global tokens (colors and typography) provided by `MaterialTheme`, or -wrapping and overriding properties of a design system composable where possible. -Sometimes, there are properties within the Material layer that are not exposed -through the subsystems or parameters, but are hardcoded defaults on the -component itself. - -With the Styles API, there's a new layer of abstraction that's a bridge between -subsystems and components: **Styles**. - -| Layer | Responsibility | Example | -|---|---|---| -| **Subsystem values** | Named values | `val Primary = Color(0xFF34A85E)` | -| **Atomic Styles** | Style that does exactly one property change | `val largeSizeAtomic = Style { size(100.dp, 40.dp) }` | -| **Component Styles** | Component-specific configurations | A Button with Primary background and 16dp padding. `val buttonStyle = Style { contentPadding(16.dp) shape(RoundedCornerShape(8.dp)) background(Color.Blue) }` | -| **Components** | The functional UI element that consumes a Style. | `Button(style = buttonStyle) { ... }` | - -![Diagram showing Theming with Styles with the new layer introduction](https://developer.android.com/static/develop/ui/compose/styles/images/theming_styles_layer.png) **Figure 1.** An example of a component and how it accesses styles from a theme. - -### Atomic versus monolithic Styles - -With the Styles API, you can break down a Style into separate atomic styles. -Instead of defining complex, component-specific styles like `baseButtonStyle`, -you can also create small, single-purpose utility styles. These act as your -"atoms". - - -```kotlin -// Define single-purpose "atomic" styles -val paddingAtomic = Style { - contentPadding(16.dp) -} -val roundedCornerShapeAtomic = Style { - shape(RoundedCornerShape(8.dp)) -} -val primaryBackgroundAtomic = Style { - background(Color.Blue) -} -val largeSizeAtomic = Style { - size(100.dp, 40.dp) -} -val interactiveShadowAtomic = Style { - hovered { - animate { - dropShadow( - Shadow( - offset = DpOffset( - 0.dp, - 0.dp - ), - radius = 2.dp, - spread = 0.dp, - color = Color.Blue, - ) - ) - } - } -} -``` - -
- -#### Composition using "then" - -The `then` operator in the Styles API lets you merge multiple `Style` objects. -This lets you build a component using atomic utility classes. - -**Traditional (non-atomic)**: - - -```kotlin -// One large monolithic style -val buttonStyle = Style { - contentPadding(16.dp) - shape(RoundedCornerShape(8.dp)) - background(Color.Blue) -} -``` - -
- -**Atomic refactor**: - - -```kotlin -// Combine atoms to create the final appearance -val buttonStyle = paddingAtomic then roundedCornerShapeAtomic then primaryBackgroundAtomic then interactiveShadowAtomic -``` - -
- -## Adopt Styles in your design system - -Consider the following options when adopting Styles within your design system, -depending on where in the spectrum your design system lies. - -### Custom design system with Styles - -***Consider when**: You've been handed an extensive brand guide that is not -based on Material Design, and you are not planning to use Material Design*. - -***Strategy**: Implement a fully custom design system, and expose styles as part -of the theme*. - -This option is the custom path if you don't use Material as your main design -system language. You bypass `MaterialTheme` entirely for visual definitions and -have created your [own custom theme already](https://developer.android.com/develop/ui/compose/designsystems/custom#implementing-fully-custom). You build a `CompanyTheme` that -acts as a container for your Styles. - -- **How it works** : Create a `CompanyTheme` object that holds `Style` objects for every component in your system. Your components (either wrappers around Material logic or custom `Box` or `Layout` implementations) consume these styles directly, and expose a `Style` parameter for consumers of your design system. -- **The Style layer**: Styles are the primary definition of your design system. Tokens are named variables fed into these styles. This allows for deep customization, such as defining unique animations for state changes (for example, animating scale and color on press). - -If you are building out your own [custom theme](https://developer.android.com/develop/ui/compose/designsystems/custom) without using Material, and -want to adopt styles, add your list of styles to your Theme. This lets you -access your base styles from anywhere in your project. - -1. Create a `Styles` class that stores the various styles in your application - and create the defaults. For example, in the Jetsnack app - the class is - named `JetsnackStyles`: - - - ```kotlin - object JetsnackStyles{ - val buttonStyle: Style = Style { - shape(shapes.medium) - background(colors.brand) - contentColor(colors.textPrimary) - contentPaddingVertical(8.dp) - contentPaddingHorizontal(24.dp) - textStyle(typography.labelLarge) - disabled { - animate { - background(colors.brandSecondary) - } - } - } - val cardStyle: Style = Style { - shape(shapes.medium) - background(colors.uiBackground) - contentColor(colors.textPrimary) - } - } - ``` - -
- -2. Provide `Styles` as part of your overall theme, and expose helper extension - functions on `StyleScope` to access the subsystems: - - - ```kotlin - @Immutable - class JetsnackTheme( - val colors: JetsnackColors = LightJetsnackColors, - val typography: androidx.compose.material3.Typography = androidx.compose.material3.Typography(), - val shapes: Shapes = Shapes() - ) { - companion object { - val colors: JetsnackColors - @Composable @ReadOnlyComposable - get() = LocalJetsnackTheme.current.colors - - val typography: androidx.compose.material3.Typography - @Composable @ReadOnlyComposable - get() = LocalJetsnackTheme.current.typography - - val shapes: Shapes - @Composable @ReadOnlyComposable - get() = LocalJetsnackTheme.current.shapes - - val styles: JetsnackStyles = JetsnackStyles - - val LocalJetsnackTheme: ProvidableCompositionLocal - get() = LocalJetsnackThemeInstance - } - } - - val StyleScope.colors: JetsnackColors - get() = LocalJetsnackTheme.currentValue.colors - - val StyleScope.typography: androidx.compose.material3.Typography - get() = LocalJetsnackTheme.currentValue.typography - - val StyleScope.shapes: Shapes - get() = LocalJetsnackTheme.currentValue.shapes - - internal val LocalJetsnackThemeInstance = staticCompositionLocalOf { JetsnackTheme() } - - @Composable - fun JetsnackTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { - val colors = if (darkTheme) DarkJetsnackColors else LightJetsnackColors - val theme = JetsnackTheme(colors = colors) - - CompositionLocalProvider( - LocalJetsnackTheme provides theme, - ) { - MaterialTheme( - typography = LocalJetsnackTheme.current.typography, - shapes = LocalJetsnackTheme.current.shapes, - content = content, - ) - } - } - ``` - -
- -3. Access `JetsnackStyles` within your composable: - - - ```kotlin - @Composable - fun CustomButton(modifier: Modifier, - style: Style = Style, - text: String) { - val interactionSource = remember { MutableInteractionSource() } - val styleState = remember(interactionSource) { MutableStyleState(interactionSource) } - - // Apply style to top level container in combination with incoming style from parameter. - Box(modifier = modifier - .clickable( - interactionSource = interactionSource, - indication = null, - enabled = true, - role = Role.Button, - onClick = { - - }, - ) - .styleable(styleState, JetsnackTheme.styles.buttonStyle, style)) { - Text(text) - } - } - ``` - -
- -Beyond global theme adoption, there are alternative strategies for incorporating -`Styles` into your apps. You can use `Styles` inline for specific call sites or -use static definitions when full theming capabilities aren't necessary. -`Styles` shouldn't be swapped conditionally unless the whole style is -fundamentally different. Prefer accessing dynamic tokens inside a visual -definition rather than switching between distinct style objects. \ No newline at end of file diff --git a/media/media3-cast-integration/SKILL.md b/media/media3-cast-integration/SKILL.md deleted file mode 100644 index d4ce98a..0000000 --- a/media/media3-cast-integration/SKILL.md +++ /dev/null @@ -1,233 +0,0 @@ ---- -name: media3-cast-integration -description: Implements Google Cast support in Android apps using Jetpack Media3. - Handles adding build dependencies, updating manifest, configuring OptionsProvider, - and managing CastPlayer or RemoteCastPlayer for playback in both Compose and View-based - UIs. Use when adding Cast functionality or migrating from legacy Cast SDK to Media3 - Cast. -license: Complete terms in LICENSE.txt -metadata: - author: Google LLC - last-updated: '2026-09-16' - keywords: - - Android - - Media3 - - Cast - - Integration - - Migration - - ExoPlayer - - MediaSession - - Jetpack Media3 ---- - -## Prerequisites - -- Jetpack Media3 version must be `>= 1.9.0`. Cast isn't available in lower versions. - -## Glossary - -- **`CastPlayer`** : Media3 `Player` that controls playback on both local and remote Cast devices. -- **`RemoteCastPlayer`** : Media3 `Player` that communicates with a Cast receiver, only used for remote playback. -- **Google Cast SDK**: Legacy casting SDK in maintenance mode, superseded by Jetpack Media3. -- **`OptionsProvider`** : Interface providing configuration options to initialize GMS `CastContext`. - -## Common guidelines - -- Legacy [Google Cast SDK](https://developers.google.com/cast) is in maintenance mode. -- For new Cast setups: - - You must use [Jetpack Media3 Cast](references/android/media/media3/cast/index.md). - - You mustn't use legacy Cast SDK unless explicitly requested. - -## Step 1: Set up dependencies - -To complete this step, you **MUST** ensure the following: - -- In the app-level build file, declare the `media3-cast` dependency version - 1.9.0 or higher. - - implementation("androidx.media3:media3-cast:1.11.1") - -- Ensure required Media3 dependencies are present: - - - `androidx.media3:media3-exoplayer` - - `androidx.media3:media3-session` - - `androidx.media3:media3-ui-compose` -- If the application uses legacy Views, add `media3-ui`. - -- Enforce the same versions across all Media3 dependencies. - -- Use configurations in "Add build dependencies" section of [Getting started - with CastPlayer](references/android/media/media3/cast/create-castplayer.md) as the source of - truth. - -- **For apps without an existing Cast integration:** - - - Verify legacy Cast SDK (`libs.play.services.cast.framework`) is absent. -- **If Migrating from Legacy Cast SDK:** - - - Add Media3 Cast dependencies first. - - Keep existing legacy dependencies untouched at this stage to prevent compilation errors. - -## Step 2: Update the manifest - -To complete this step, you **MUST** ensure the following: - -- Inside the manifest's `` tag, declare the Cast options provider. -- Use `DefaultCastOptionsProvider` by default. See the "OptionsProvider" section in [Getting started with - CastPlayer](references/android/media/media3/cast/create-castplayer.md). -- Declare a custom `OptionsProvider` only if explicitly requested. See [Customize CastOptions](references/android/media/media3/cast/customize-castoptions.md). -- Ensure `INTERNET` permission is present. Don't add any unnecessary permissions. -- **If Migrating from Legacy Cast SDK:** - - Don't delete existing custom options provider files or manifest entries. - -## Step 3: Implement the player and service - -### Architecture baseline - -Before integrating Media3 Cast, an existing app follows one of two setups: - -- **Local-only playback:** Uses Media3 `ExoPlayer` only to support local playback. -- **Legacy Cast setup:** Uses `ExoPlayer` for local playback, alongside a `Player` wrapper over the legacy `RemoteMediaClient` for remote playback. The UI interfaces with a `MediaSession` interacting with a `ForwardingPlayer`, which finally routes controls to either local or remote playback. - -To complete this step, you **MUST** ensure the following: - -- Inside the application's `MediaSessionService` (or `MediaLibraryService`) `onCreate()` method, initialize `ExoPlayer` and `CastPlayer`. -- Use `CastPlayer` by default unless `RemoteCastPlayer` is explicitly requested. See the "Build a CastPlayer" section in [Getting started with - CastPlayer](references/android/media/media3/cast/create-castplayer.md). -- For `CastPlayer`, pass the instance directly to `MediaSession.Builder`. -- Replace all legacy forwarding player wrappers. -- Don't delete legacy class files yet to prevent compilation errors during migration. - -### Advanced: `RemoteCastPlayer` - -- Use `RemoteCastPlayer` only if explicitly requested by user. -- Initialize `MediaSession` with `localPlayer` and set a `SessionAvailabilityListener` on `RemoteCastPlayer` to transfer playback state on Cast session availability changes: - - class PlaybackService : MediaSessionService() { - private var mediaSession: MediaSession? = null - private lateinit var localPlayer: ExoPlayer - private lateinit var remotePlayer: RemoteCastPlayer - - override fun onCreate() { - super.onCreate() - - localPlayer = ExoPlayer.Builder(this).build() - remotePlayer = RemoteCastPlayer.Builder(this).build() - mediaSession = MediaSession.Builder(this, localPlayer).build() - - remotePlayer.setSessionAvailabilityListener( - object : SessionAvailabilityListener { - override fun onCastSessionAvailable() { - transferPlaybackState(localPlayer, remotePlayer) - } - - override fun onCastSessionUnavailable() { - transferPlaybackState(remotePlayer, localPlayer) - } - } - ) - } - - private fun transferPlaybackState(previousPlayer: Player, newPlayer: Player) { - if (previousPlayer.mediaItemCount > 0) { - val transferStateBuilder = PlayerTransferState.builderFromPlayer(previousPlayer) - if (previousPlayer.playbackState == Player.STATE_ENDED || - previousPlayer.currentPosition == C.TIME_END_OF_SOURCE) { - transferStateBuilder.setCurrentMediaItemIndex(0) - transferStateBuilder.setCurrentPosition(0) - } - transferStateBuilder.build().setToPlayer(newPlayer) - } - - previousPlayer.stop() - previousPlayer.clearMediaItems() - newPlayer.prepare() - mediaSession?.setPlayer(newPlayer) - } - } - -## Step 4: Set up the UI - -### Compose-based UI - -To complete this step, you **MUST** ensure the following: - -- See the "Add a MediaRouteButton Composable to the Player" section in [Getting started with CastPlayer](references/android/media/media3/cast/create-castplayer.md) for Compose integration guidelines. -- Use the [`MediaRouteButton` composable](https://developer.android.com/reference/kotlin/androidx/media3/cast/MediaRouteButton.composable) from `androidx.media3.cast` package. -- Don't use `AndroidView` in the Compose UI hierarchy. -- Place `MediaRouteButton` in an area next to playback controls. Don't hide it behind system UI. -- Don't use `PlayerSurface` for custom player UI. Use the Material3 [`Player` - composable](https://developer.android.com/reference/kotlin/androidx/media3/ui/compose/material3/Player.composable). -- Force recomposition on playback location shifts to ensure UI sync. Use key - constraints on `DeviceInfo` changes: - - @OptIn(UnstableApi::class) - @Composable - fun MainScreen() { - val player = rememberMediaController() - val deviceInfo = rememberDeviceInfo(player) - player?.let { activePlayer -> key(deviceInfo) { PlayerScreen(player = activePlayer) } } - } - - @Composable - private fun rememberMediaController(): Player? { - // Logic to connect MediaController to MediaSession and release it - } - - @Composable - private fun rememberDeviceInfo(player: Player?): DeviceInfo? { - var deviceInfo by remember(player) { mutableStateOf(player?.deviceInfo) } - DisposableEffect(player) { - val activePlayer = player ?: return@DisposableEffect onDispose {} - deviceInfo = activePlayer.deviceInfo - val listener = object : Player.Listener { - override fun onDeviceInfoChanged(info: DeviceInfo) { - deviceInfo = info - } - } - activePlayer.addListener(listener) - onDispose { activePlayer.removeListener(listener) } - } - return deviceInfo - } - -### View-based UI - -To complete this step, you **MUST** ensure the following: - -- For View-based UI setups, see the "Add UI elements" section in [Getting - started with CastPlayer](references/android/media/media3/cast/create-castplayer.md). -- Casting Activities must extend `AppCompatActivity` or `FragmentActivity` and use a `Theme.AppCompat` descendant. -- Ensure the `AppCompat` theme has a visible `ActionBar` if adding `MediaRouteButton` to the options menu. -- Replace all instances and imports of `CastButtonFactory` with `MediaRouteButtonFactory`. -- Rebind `PlayerView.player` references upon `onDeviceInfoChanged` events to - prevent black screens or UI freezes: - - private val playerListener: Player.Listener = - object : Player.Listener { - override fun onDeviceInfoChanged(deviceInfo: DeviceInfo) { - // Resetting to null bypasses PlayerView.setPlayer()'s instance equality check - // (this.player == player), forcing it to re-bind the video surface to the controller. - playerView.player = null - playerView.player = controller - } - } - -- **Migration to Compose:** - - - Don't use `AndroidView` to wrap the legacy `PlayerView`. - - Implement Material3 [`Player` composable](https://developer.android.com/reference/kotlin/androidx/media3/ui/compose/material3/Player.composable) and [`MediaRouteButton` composable](https://developer.android.com/reference/kotlin/androidx/media3/cast/MediaRouteButton.composable) as per [Getting started with CastPlayer](references/android/media/media3/cast/create-castplayer.md). - - Remove legacy XML layout declarations, menu files, and View component references. - -## Step 5: Clean up legacy Cast SDK code - -> [!WARNING] -> **Warning:** Don't perform cleanup directly. Remove legacy files and dependencies only when explicitly requested by the user. - -To complete this step, you **MUST** ensure the following: - -- Remove legacy GMS Cast SDK (`libs.play.services.cast.framework`) and MediaRouter (`libs.androidx.mediarouter`) dependencies. -- Delete custom `OptionsProvider` classes and manifest entries if `DefaultCastOptionsProvider` is adopted. -- Remove legacy `MediaTransferReceiver` manifest declarations if present. -- Remove all references to legacy Cast SDK components such as legacy helper wrappers, forwarding players, and `RemoteMediaClient` interfaces. -- Delete legacy View XML layouts, menu files, and references to `PlayerView` if the migration to Compose is complete. diff --git a/media/media3-cast-integration/references/android/media/media3/cast/create-castplayer.md b/media/media3-cast-integration/references/android/media/media3/cast/create-castplayer.md deleted file mode 100644 index 0099bc5..0000000 --- a/media/media3-cast-integration/references/android/media/media3/cast/create-castplayer.md +++ /dev/null @@ -1,390 +0,0 @@ -The [`CastPlayer`](https://developer.android.com/reference/androidx/media3/cast/CastPlayer) is a Jetpack Media3 [Player](https://developer.android.com/reference/kotlin/androidx/media3/common/Player) implementation that supports -both local playback and casting to a remote Cast-enabled device. [`CastPlayer`](https://developer.android.com/reference/androidx/media3/cast/CastPlayer) -simplifies adding cast functionality to your app and provides rich features to -seamlessly switch between local and remote playback. This guide shows you how to -integrate [`CastPlayer`](https://developer.android.com/reference/androidx/media3/cast/CastPlayer) into your media app. - -To integrate Cast with other platforms, see the [Cast SDK](https://developers.google.com/cast/docs/developers). - -## Get a Cast-enabled device - -To test `CastPlayer`, you need a [Cast-enabled device](https://store.google.com/gb/category/connected_home?hl=en-GB). Choices include Android -TV, Chromecast, smart speakers, and smart displays. Verify that your device is -set up and connected to the same Wi-Fi network as your development mobile for -discovery. - -## Add build dependencies - -To start using `CastPlayer`, add the AndroidX Media3 and `CastPlayer` -dependencies to the `build.gradle` file of your app module. - -### Kotlin - - implementation("androidx.media3:media3-exoplayer:1.11.1") - implementation("androidx.media3:media3-ui:1.11.1") - implementation("androidx.media3:media3-session:1.11.1") - implementation("androidx.media3:media3-cast:1.11.1") - -### Groovy - - implementation "androidx.media3:media3-exoplayer:1.11.1" - implementation "androidx.media3:media3-ui:1.11.1" - implementation "androidx.media3:media3-session:1.11.1" - implementation "androidx.media3:media3-cast:1.11.1" - -## Configure your CastPlayer - -To configure the [`CastPlayer`](https://developer.android.com/reference/androidx/media3/cast/CastPlayer), update your `AndroidManifest.xml` file with an -options provider. - -### Options provider - -The [`CastPlayer`](https://developer.android.com/reference/androidx/media3/cast/CastPlayer) requires an options provider to configure its behavior. For a -basic setup, you can use the [`DefaultCastOptionsProvider`](https://developer.android.com/reference/androidx/media3/cast/DefaultCastOptionsProvider) by adding it to your -`AndroidManifest.xml` file. This uses default settings, including the default -receiver application. - - - ... - - ... - - -To customize the configuration, implement your own custom `OptionsProvider`. See -the [CastOptions](https://developer.android.com/media/media3/cast/customize-castoptions) guide to learn how. - -### Add a receiver for media transfers - -Adding a `MediaTransferReceiver` to your manifest enables the System UI to -discover Cast-enabled devices on the network and reroute media without opening -the app activity. For example, a user can change the device playing your app's -media from the [media notification](https://developer.android.com/media/implement/surfaces/mobile). - - - ... - - ... - - -## Build a CastPlayer - -For remote playback with Cast, your app should be able to manage playback even -when the user isn't interacting with an Activity from your app, such as through -the system media notification. For this reason, you should create your -`ExoPlayer` (for local playback) and `CastPlayer` (for remote playback) -instances in a service, such as [MediaSessionService](https://developer.android.com/media/media3/session/background-playback#service-lifecycle) or [MediaLibraryService](https://developer.android.com/guide/topics/media/session/medialibraryservice). -First, create your `ExoPlayer` instance and then when building your -[`CastPlayer`](https://developer.android.com/reference/androidx/media3/cast/CastPlayer) instance, set `ExoPlayer` as the local player instance. You can -then switch media playback between your mobile and the Cast-enabled device from -the media notification or the lock screen notification. Media3 uses the **Output -Switcher** feature to handle player transfers when the output route changes from -local to remote or from remote to local. -![Screenshot showing the Output Switcher UI in notifications.](https://developer.android.com/static/media/media3/cast/images/output_switcher.jpeg) Figure 1: (a) Device chip on Media notification (b) Cast-enabled devices shown on tapping the device chip (c) Device chip on Lock screen notification - - -### Kotlin - -```kotlin -override fun onCreate() { - super.onCreate() - - val exoPlayer = ExoPlayer.Builder(context).build() - val castPlayer = CastPlayer.Builder(context).setLocalPlayer(exoPlayer).build() - - mediaSession = MediaSession.Builder(context, castPlayer).build() -} -``` - -### Java - -```java -@Override -public void onCreate() { - super.onCreate(); - - ExoPlayer exoPlayer = new ExoPlayer.Builder(context).build(); - CastPlayer castPlayer = new CastPlayer.Builder(context).setLocalPlayer(exoPlayer).build(); - - mediaSession = - new MediaSession.Builder(/* context= */ context, /* player= */ castPlayer).build(); -} -``` - -
- -> [!IMPORTANT] -> **Important:** The preceding code snippet shows the `onCreate` method of a `MediaSessionService`. In an `Activity`, players should be created in `onStart` or `onResume`. - -## Add UI elements - -Add a [`MediaRouteButton`](https://developer.android.com/reference/androidx/mediarouter/app/MediaRouteButton) to your app's UI. Tapping the [`MediaRouteButton`](https://developer.android.com/reference/androidx/mediarouter/app/MediaRouteButton) -opens a dialog displaying a list of available Cast-enabled devices on the -network. When the user selects a device, the media playback is transferred from -the mobile to the selected receiver device. This section shows you how to add -the button and listen for events to update your UI when playback switches -between local and remote devices. - -### Set the MediaRouteButton - -There are four ways to add the [`MediaRouteButton`](https://developer.android.com/reference/androidx/mediarouter/app/MediaRouteButton) to your activity's UI. The -best choice depends on your app's design and requirements. - -- **Compose UI**: Add a button composable. -- **Views UI** : - - Add the button to the app bar menu. - - Add the button inside `PlayerView`. - - Add the button as a standard `View`. - -![Screenshot showing the MediaRouteButton in the UI.](https://developer.android.com/static/media/media3/cast/images/cast.jpeg) Figure 2: (a) MediaRouteButton in menu bar, (b) as a View, (c) in PlayerView, and (d) Dialog of Cast-enabled devices. - -> [!IMPORTANT] -> **Important:** To use the [`MediaRouteButton`](https://developer.android.com/reference/androidx/mediarouter/app/MediaRouteButton) in Views UI, the containing activity must be a subclass of `FragmentActivity`. - -> [!TIP] -> **Tip:** These screenshots are from the [demo-session](https://github.com/androidx/media/tree/release/demos/session) apps in Media3. You can checkout the app to see an example of `CastPlayer` implementation. - -#### Add a Composable `MediaRouteButton` to the Player - -You can add the [`MediaRouteButton`](https://developer.android.com/reference/androidx/mediarouter/app/MediaRouteButton) Composable to your player's UI. For more -information, see the [Compose](https://developer.android.com/media/media3/ui/compose) guide. - - -```kotlin -@Composable -fun PlayerComposeView(player: Player, modifier: Modifier = Modifier) { - var controlsVisible by remember { mutableStateOf(false) } - - Box( - modifier = modifier.clickable { controlsVisible = true }, - contentAlignment = Alignment.Center, - ) { - PlayerSurface(player = player, modifier = modifier) - AnimatedVisibility(visible = controlsVisible, enter = fadeIn(), exit = fadeOut()) { - Box(modifier = Modifier.fillMaxSize()) { - MediaRouteButton(modifier = Modifier.align(Alignment.TopEnd)) - PrimaryControls(player = player, modifier = Modifier.align(Alignment.Center)) - } - } - } -} - -@Composable -fun PrimaryControls(player: Player, modifier: Modifier = Modifier) { - // ... -} -``` - -
- -#### Add the `MediaRouteButton` to the PlayerView - -You can add the [`MediaRouteButton`](https://developer.android.com/reference/androidx/mediarouter/app/MediaRouteButton) directly within the [PlayerView](https://developer.android.com/guide/topics/media/ui/playerview)'s UI -controls. After setting the [MediaController](https://developer.android.com/guide/topics/media/session/mediacontroller) as the player for your -`PlayerView`, provide a `MediaRouteButtonViewProvider` to display the Cast -button on the Player. - - -### Kotlin - -```kotlin -override fun onStart() { - super.onStart() - - playerView.player = mediaController - playerView.setMediaRouteButtonViewProvider(MediaRouteButtonViewProvider()) -} -``` - -### Java - -```java -@Override -public void onStart() { - super.onStart(); - - playerView.setPlayer(mediaController); - playerView.setMediaRouteButtonViewProvider(new MediaRouteButtonViewProvider()); -} -``` - -
- -#### Add the `MediaRouteButton` to the app bar menu - -To set up a [`MediaRouteButton`](https://developer.android.com/reference/androidx/mediarouter/app/MediaRouteButton) in the app bar menu, create an XML menu and -override `onCreateOptionsMenu` in your `Activity`. - - - - - - -### Kotlin - -```kotlin -override fun onCreateOptionsMenu(menu: Menu): Boolean { - // ... - menuInflater.inflate(R.menu.sample_media_route_button_menu, menu) - val menuItemFuture: ListenableFuture = - MediaRouteButtonFactory.setUpMediaRouteButton(context, menu, R.id.media_route_menu_item) - Futures.addCallback( - menuItemFuture, - object : FutureCallback { - override fun onSuccess(menuItem: MenuItem?) { - // Do something with the menu item. - } - - override fun onFailure(t: Throwable) { - // Handle the failure. - } - }, - executor, - ) - // ... - return true -} -``` - -### Java - -```java -@Override -public boolean onCreateOptionsMenu(Menu menu) { - // ... - getMenuInflater().inflate(R.menu.sample_media_route_button_menu, menu); - ListenableFuture menuItemFuture = - MediaRouteButtonFactory.setUpMediaRouteButton(context, menu, R.id.media_route_menu_item); - Futures.addCallback( - menuItemFuture, - new FutureCallback() { - @Override - public void onSuccess(MenuItem menuItem) { - // Do something with the menu item. - } - - @Override - public void onFailure(Throwable t) { - // Handle the failure. - } - }, - executor); - // ... - return true; -} -``` - -
- -#### Add the `MediaRouteButton` as a View - -You can set up a [`MediaRouteButton`](https://developer.android.com/reference/androidx/mediarouter/app/MediaRouteButton) in your activity layout.xml. - - - -To complete the setup for the [`MediaRouteButton`](https://developer.android.com/reference/androidx/mediarouter/app/MediaRouteButton), use the Media3 Cast -`MediaRouteButtonFactory` in your `Activity` code. - - -### Kotlin - -```kotlin -override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - findViewById(R.id.media_route_button)?.also { - val unused = MediaRouteButtonFactory.setUpMediaRouteButton(context, it) - } -} -``` - -### Java - -```java -@Override -public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - // ... - MediaRouteButton button = findViewById(R.id.media_route_button); - ListenableFuture setUpFuture = - MediaRouteButtonFactory.setUpMediaRouteButton(context, button); -} -``` - -
- -### Activity Listener - -Create a `Player.Listener` in your `Activity` to listen for changes to media -playback location. When the `playbackType` changes between `PLAYBACK_TYPE_LOCAL` -and `PLAYBACK_TYPE_REMOTE`, you can adjust your UI as needed. To prevent memory -leaks and to confine listener activity to only when your app is visible, -register the listener in `onStart` and unregister it in `onStop`: - - -### Kotlin - -```kotlin -private val playerListener: Player.Listener = - object : Player.Listener { - override fun onDeviceInfoChanged(deviceInfo: DeviceInfo) { - if (deviceInfo.playbackType == DeviceInfo.PLAYBACK_TYPE_LOCAL) { - // Add UI changes for local playback. - } else if (deviceInfo.playbackType == DeviceInfo.PLAYBACK_TYPE_REMOTE) { - // Add UI changes for remote playback. - } - } - } - -override fun onStart() { - super.onStart() - mediaController.addListener(playerListener) -} - -override fun onStop() { - super.onStop() - mediaController.removeListener(playerListener) -} -``` - -### Java - -```java -private final Player.Listener playerListener = - new Player.Listener() { - @Override - public void onDeviceInfoChanged(DeviceInfo deviceInfo) { - if (deviceInfo.playbackType == DeviceInfo.PLAYBACK_TYPE_LOCAL) { - // Add UI changes for local playback. - } else if (deviceInfo.playbackType == DeviceInfo.PLAYBACK_TYPE_REMOTE) { - // Add UI changes for remote playback. - } - } - }; - -@Override -protected void onStart() { - super.onStart(); - mediaController.addListener(playerListener); -} - -@Override -protected void onStop() { - super.onStop(); - mediaController.removeListener(playerListener); -} -``` - -
- -For more information about listening and responding to playback events, see the -[player events](https://developer.android.com/media/media3/exoplayer/listening-to-player-events) guide. \ No newline at end of file diff --git a/media/media3-cast-integration/references/android/media/media3/cast/customize-castoptions.md b/media/media3-cast-integration/references/android/media/media3/cast/customize-castoptions.md deleted file mode 100644 index e5e5b3e..0000000 --- a/media/media3-cast-integration/references/android/media/media3/cast/customize-castoptions.md +++ /dev/null @@ -1,93 +0,0 @@ -To configure your app's Cast session, provide an [`OptionsProvider`](https://developers.google.com/android/reference/com/google/android/gms/cast/framework/OptionsProvider). Use the -[`CastOptions`](https://developers.google.com/android/reference/com/google/android/gms/cast/framework/CastOptions) object built by the provider to set the receiver application ID, -manage session lifecycles, and customize media playback behavior. - -## Use the default options provider - -For a basic setup that uses the default Cast receiver application, add the -`DefaultCastOptionsProvider` to your app's `AndroidManifest.xml` file: - - - ... - - ... - - -## Create a custom options provider - -For more advanced configurations, such as setting a custom receiver application -ID, you need to create your own `OptionsProvider`. - -### 1. Declare the provider in your manifest - -First, declare your custom provider in `AndroidManifest.xml`. Make sure to use -the fully qualified class name. - - - ... - - ... - - -### 2. Implement the OptionsProvider interface - -Next, create a class that implements the `OptionsProvider` interface. In this -class, you must override `getCastOptions()` to return a `CastOptions` instance. -The custom `OptionsProvider` class is where you configure your Cast session, for -example, by setting your custom receiver application ID. - -For more information, see [CastOptions.Builder](https://developers.google.com/android/reference/com/google/android/gms/cast/framework/CastOptions.Builder). - - -### Kotlin - -```kotlin -class MyCustomCastOptionsProvider : OptionsProvider { - - override fun getCastOptions(context: Context): CastOptions { - return CastOptions.Builder() - .setReceiverApplicationId(APP_ID) - .setRemoteToLocalEnabled(true) - .build() - } - - override fun getAdditionalSessionProviders(context: Context): List? { - return null - } - - companion object { - // Add your receiver app ID in . - private const val APP_ID = "" - } -} -``` - -### Java - -```java -public static final class MyCustomCastOptionsProvider implements OptionsProvider { - - // Add your receiver app ID in . - public static final String APP_ID = ""; - - @Override - public CastOptions getCastOptions(Context context) { - return new CastOptions.Builder() - .setReceiverApplicationId(APP_ID) - .setRemoteToLocalEnabled(true) - .build(); - } - - @Override - @Nullable - public List getAdditionalSessionProviders(Context context) { - return null; - } -} -``` - -
\ No newline at end of file diff --git a/media/media3-cast-integration/references/android/media/media3/cast/index.md b/media/media3-cast-integration/references/android/media/media3/cast/index.md deleted file mode 100644 index 801a0e9..0000000 --- a/media/media3-cast-integration/references/android/media/media3/cast/index.md +++ /dev/null @@ -1,21 +0,0 @@ -The Media3 [`cast`](https://developer.android.com/reference/androidx/media3/cast/package-summary) module extends your media playback experience to -Cast devices. It lets your app cast content to [compatible devices](https://developer.android.com/media/media3/cast/create-castplayer#get-device), -such as TVs and speakers. The [sender](https://developers.google.com/cast/glossary#sender) app acts as a remote controller for media -playback on the [receiver](https://developers.google.com/cast/glossary#receiver) device. This module integrates with the [Jetpack -Media3](https://developer.android.com/guide/topics/media/media3) library, so you can use the same `Player` interface and UI -components for both local and remote playback. - -## Media3 CastPlayer - -The `CastPlayer` is a media player API included in Media3 that supports both -local and remote media playback. It implements the `Player` interface to manage -playback, simplifying the integration of casting into your media experience. - -[Go to Media3 CastPlayer](https://developer.android.com/media/media3/cast/create-castplayer) - -## Other Cast integrations - -The Cast SDK overview explains how to integrate Cast with other platforms, such -as building a receiver app or integrating with the web sender SDK. - -[Explore Cast SDK](https://developers.google.com/cast/docs/developers) \ No newline at end of file diff --git a/navigation/navigation-3/SKILL.md b/navigation/navigation-3/SKILL.md index 5948db2..743782a 100644 --- a/navigation/navigation-3/SKILL.md +++ b/navigation/navigation-3/SKILL.md @@ -3,12 +3,11 @@ name: navigation-3 description: Learn how to install and migrate to Jetpack Navigation 3, and how to implement features and patterns such as deep links, multiple backstacks, scenes (dialogs, bottom sheets, list-detail, two-pane, supporting pane), conditional navigation - (such as logged-in navigation versus anonymous), returning results from flows, integration + (such as logged-in navigation vs anonymous), returning results from flows, integration with Hilt, ViewModel, Kotlin, and view interoperability. license: Complete terms in LICENSE.txt metadata: author: Google LLC - last-updated: '2026-09-24' keywords: - recipe - Android @@ -33,8 +32,6 @@ metadata: - View interop. --- -*** ** * ** *** - ## Migration guide - *[Navigation 2 to Navigation 3 migration guide](references/android/guide/navigation/navigation-3/migration-guide.md)*: Step-by-step guide to migrate an Android application from Navigation 2 to Navigation 3, covering dependency updates, route changes, state management, and UI component replacements. @@ -45,7 +42,7 @@ metadata: ## Developer documentation -- *[Navigation 3](references/android/guide/navigation/navigation-3/index.md)*. Search documentation for more information on basics, saving and managing navigation state, modularizing navigation code, creating custom layouts using Scenes, animating between destinations, or applying logic or wrappers to destinations. +- \*[Navigation 3](references/android/guide/navigation/navigation-3/index.md). Search documentation for more information on basics, saving and managing navigation state, modularizing navigation code, creating custom layouts using Scenes, animating between destinations, or applying logic or wrappers to destinations. ## Recipes @@ -63,10 +60,8 @@ Code examples showcasing common patterns. ### Deep links -- *[Static URI](references/android/guide/navigation/navigation-3/recipes/deeplinks-staticuri.md)*: Shows how to handle simple static URI deep links. -- *[URI with Arguments](references/android/guide/navigation/navigation-3/recipes/deeplinks-uriarguments.md)*: Shows how to parse path and query arguments from a deep link. -- *[Synthetic BackStack](references/android/guide/navigation/navigation-3/recipes/deeplinks-syntheticbackstack.md)*: Shows how to handle deep links with a synthetic back stack. -- *[Custom Matcher](references/android/guide/navigation/navigation-3/recipes/deeplinks-custommatcher.md)*: Shows how to implement custom deep link matching logic. +- *[Basic](references/android/guide/navigation/navigation-3/recipes/deeplinks-basic.md)*: Shows how to parse a deep link URL from an Android Intent into a navigation key. +- *[Advanced](references/android/guide/navigation/navigation-3/recipes/deeplinks-advanced.md)*: Shows how to handle deep links with a synthetic back stack and correct "Up" navigation behavior. ### Scenes @@ -88,7 +83,6 @@ Code examples showcasing common patterns. ### Animations - *[Animations](references/android/guide/navigation/navigation-3/recipes/animations.md)*: Shows how to override the default animations for all destinations and a single destination. -- *[Conditional Transitions](references/android/guide/navigation/navigation-3/recipes/conditional-transitions.md)*: Shows how to implement conditional transition animations. ### Common back stack behavior @@ -98,10 +92,6 @@ Code examples showcasing common patterns. - *[Conditional navigation](references/android/guide/navigation/navigation-3/recipes/conditional.md)*: Switch to a different navigation flow when a condition is met. For example, for authentication or first-time user onboarding. -### Lifecycle - -- *[Lifecycle Owner](references/android/guide/navigation/navigation-3/recipes/lifecycle-owner.md)*: Shows how to use and observe Lifecycle in Navigation 3. - ### Architecture - *[Modularized navigation code (Hilt)](references/android/guide/navigation/navigation-3/recipes/modular-hilt.md)*: Demonstrates how to decouple navigation code into separate modules using Hilt or Dagger for DI. @@ -116,4 +106,4 @@ Code examples showcasing common patterns. ### Returning results - *[Returning Results as Events](references/android/guide/navigation/navigation-3/recipes/results-event.md)* : Returning results as events to content in another `NavEntry` -- *[Returning Results as State](references/android/guide/navigation/navigation-3/recipes/results-state.md)* : Returning results as state stored in a `CompositionLocal` +- *[Returning Results as State](references/android/guide/navigation/navigation-3/recipes/results-state.md)* : Returning results as state stored in a `CompositionLocal` \ No newline at end of file diff --git a/navigation/navigation-3/references/android/guide/navigation/navigation-3/index.md b/navigation/navigation-3/references/android/guide/navigation/navigation-3/index.md index 59ca01f..b4cefbf 100644 --- a/navigation/navigation-3/references/android/guide/navigation/navigation-3/index.md +++ b/navigation/navigation-3/references/android/guide/navigation/navigation-3/index.md @@ -1,26 +1,13 @@ -## Android skills - -[View on GitHub](https://github.com/android/skills/tree/main/navigation/navigation-3) - -### Jetpack Navigation 3 - -Use an Android skill to help you build using Jetpack Navigation 3. To install the skill from the [Android CLI](https://developer.android.com/tools/agents/android-cli), run: - - android skills add navigation-3 - -
- -Navigation 3 is a navigation library designed to work with Compose. With -Navigation 3, you have full control over your back stack, and you navigate -between destinations by adding and removing items from a list. It creates a -flexible app navigation system by providing: +Navigation 3 is a new navigation library designed to work with Compose. With +Navigation 3, you have full control over your back stack, and navigating to and +from destinations is as simple as adding and removing items from a list. It +creates a flexible app navigation system by providing: - Conventions for modeling a back stack, where each entry on the back stack represents content that the user has navigated to -- A UI that automatically updates with back stack changes (including [animations](https://developer.android.com/guide/navigation/navigation-3/animate-destinations)) +- A UI that automatically updates with back stack changes (including animations) - A scope for items in the back stack, allowing state to be retained while an item is in the back stack -- An adaptive layout system that allows multiple destinations to be displayed at the same time, enabling seamless switching between those layouts -- A mechanism for content to communicate with its parent layout ([metadata](https://developer.android.com/guide/navigation/navigation-3/metadata)) -- Inter-destination communication to [return results](https://developer.android.com/guide/navigation/navigation-3/return-results) between screens +- An adaptive layout system that allows multiple destinations to be displayed at the same time, and allowing seamless switching between those layouts +- A mechanism for content to communicate with its parent layout (metadata) At a high level, you implement Navigation 3 in the following ways: @@ -30,7 +17,6 @@ At a high level, you implement Navigation 3 in the following ways: 3. Use a [`NavDisplay`](https://developer.android.com/reference/kotlin/androidx/navigation3/ui/NavDisplay.composable) to display your app's back stack. Whenever the back stack changes, it updates the UI to display relevant content. See [Display the back stack](https://developer.android.com/guide/navigation/navigation-3/basics#display-back). 4. Modify `NavDisplay`'s [scene strategies](https://developer.android.com/guide/navigation/navigation-3/custom-layouts) as needed to support adaptive layouts and different platforms. -5. [Support deep links](https://developer.android.com/guide/navigation/navigation-3/deep-links) to navigate directly to destinations. You can see the [full source code](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:navigation3/) for Navigation 3 on AOSP. @@ -43,8 +29,8 @@ ways: - Offers you full control of the back stack - Makes it possible to create layouts that can read more than one destination from the back stack at the same time, allowing them to adapt to changes in window size and other inputs. -Read more about Navigation 3's principles and API design choices in the -[announcement blog post](https://android-developers.googleblog.com/2025/05/announcing-jetpack-navigation-3-for-compose.html). +Read more about Navigation 3's principles and API design choices in [this blog +post](https://android-developers.googleblog.com/2025/05/announcing-jetpack-navigation-3-for-compose.html). ## Code samples diff --git a/navigation/navigation-3/references/android/guide/navigation/navigation-3/migration-guide.md b/navigation/navigation-3/references/android/guide/navigation/navigation-3/migration-guide.md index 81c7024..88223f8 100644 --- a/navigation/navigation-3/references/android/guide/navigation/navigation-3/migration-guide.md +++ b/navigation/navigation-3/references/android/guide/navigation/navigation-3/migration-guide.md @@ -1,15 +1,3 @@ -## Android skills - -[View on GitHub](https://github.com/android/skills/tree/main/navigation/navigation-3) - -### Jetpack Navigation 3 - -Use an Android skill to help you build and migrate to Jetpack Navigation 3. To install the skill from the [Android CLI](https://developer.android.com/tools/agents/android-cli), run: - - android skills add navigation-3 - -
- To migrate your app from [Navigation 2](https://developer.android.com/guide/navigation) to Navigation 3, follow these steps: 1. Add the Navigation 3 dependencies. @@ -20,6 +8,29 @@ To migrate your app from [Navigation 2](https://developer.android.com/guide/navi 6. Replace `NavHost` with `NavDisplay`. 7. Remove Navigation 2 dependencies. +
+ + +## AI Prompt + +### Migrate from Navigation 2 to Navigation 3 + +This prompt will use this guide to migrate to navigation 3. + + Migrate from Navigation 2 to Navigation 3 using the official + migration guide. + +### Using AI prompts + +AI prompts are intended to be used within Gemini in Android Studio. + +Learn more about Gemini in Studio here: [https://developer.android.com/studio/gemini/overview](https://developer.android.com/studio/gemini/overview) + + +
+ +If you run into problems [file an issue here](https://issuetracker.google.com/issues/new?component=1750212&template=2102223&title=%5BMigration%5D). + ## Preparation The following sections describe the prerequisites for migration and assumptions @@ -71,7 +82,7 @@ understand how to implement them. **AI Agent**: Before changing any code, check if the project contains any features supported through recipes. If it does, check the recipe's README and -source code. Create a migration plan based on the recipe. Don't proceed without +source code. Create a migration plan based on the recipe. Do not proceed without confirming the plan with the user. ### Unsupported features @@ -83,9 +94,10 @@ they are just not covered here. - More than one level of nested navigation - Shared destinations: screens that can move between different back stacks - [Custom destination types](https://developer.android.com/guide/navigation/design/kotlin-dsl#custom) +- Deep links **AI Agent**: Before changing any code, check if the project contains any of the -unsupported features. If it does, don't proceed. Inform the user of the +unsupported features. If it does, do not proceed. Inform the user of the unsupported feature and ask for further instructions. ## Step 1: Add Navigation 3 dependencies @@ -96,10 +108,10 @@ project. The core dependencies are provided for you to copy. **lib.versions.toml** [versions] - nav3Core = "1.2.0" + nav3Core = "1.0.0" # If your screens depend on ViewModels, add the Nav3 Lifecycle ViewModel add-on library - lifecycleViewmodelNav3 = "2.11.0" + lifecycleViewmodelNav3 = "2.10.0-rc01" [libraries] # Core Navigation 3 libraries @@ -130,21 +142,11 @@ navigation state](https://developer.android.com/guide/navigation/navigation-3/sa Before: - -```kotlin -@Serializable data object RouteA -``` - -
+ @Serializable data object RouteA After: - -```kotlin -@Serializable data object RouteA : NavKey -``` - -
+ @Serializable data object RouteA : NavKey > [!NOTE] > **Note:** The `@Serializable` annotation is provided by the KotlinX Serialization plugin. You can add this by following [these project setup steps](https://developer.android.com/guide/navigation/navigation-3/get-started#project-setup). @@ -156,104 +158,99 @@ After: Copy the following code into a file named `NavigationState.kt`. Add your package name to match your project structure. + // package com.example.project -```kotlin -// package com.example.project + import androidx.compose.runtime.Composable + import androidx.compose.runtime.MutableState + import androidx.compose.runtime.getValue + import androidx.compose.runtime.mutableStateOf + import androidx.compose.runtime.remember + import androidx.compose.runtime.saveable.rememberSerializable + import androidx.compose.runtime.setValue + import androidx.compose.runtime.snapshots.SnapshotStateList + import androidx.compose.runtime.toMutableStateList + import androidx.navigation3.runtime.NavBackStack + import androidx.navigation3.runtime.NavEntry + import androidx.navigation3.runtime.NavKey + import androidx.navigation3.runtime.rememberDecoratedNavEntries + import androidx.navigation3.runtime.rememberNavBackStack + import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator + import androidx.navigation3.runtime.serialization.NavKeySerializer + import androidx.savedstate.compose.serialization.serializers.MutableStateSerializer -import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSerializable -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshots.SnapshotStateList -import androidx.compose.runtime.toMutableStateList -import androidx.navigation3.runtime.NavBackStack -import androidx.navigation3.runtime.NavEntry -import androidx.navigation3.runtime.NavKey -import androidx.navigation3.runtime.rememberDecoratedNavEntries -import androidx.navigation3.runtime.rememberNavBackStack -import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator -import androidx.navigation3.runtime.serialization.NavKeySerializer -import androidx.savedstate.compose.serialization.serializers.MutableStateSerializer + /** + * Create a navigation state that persists config changes and process death. + */ + @Composable + fun rememberNavigationState( + startRoute: NavKey, + topLevelRoutes: Set + ): NavigationState { -/** - * Create a navigation state that persists config changes and process death. - */ -@Composable -fun rememberNavigationState( - startRoute: NavKey, - topLevelRoutes: Set -): NavigationState { - - val topLevelRoute = rememberSerializable( - startRoute, topLevelRoutes, - serializer = MutableStateSerializer(NavKeySerializer()) - ) { - mutableStateOf(startRoute) - } - - val backStacks = topLevelRoutes.associateWith { key -> rememberNavBackStack(key) } - - return remember(startRoute, topLevelRoutes) { - NavigationState( - startRoute = startRoute, - topLevelRoute = topLevelRoute, - backStacks = backStacks - ) - } -} - -/** - * State holder for navigation state. - * - * @param startRoute - the start route. The user will exit the app through this route. - * @param topLevelRoute - the current top level route - * @param backStacks - the back stacks for each top level route - */ -class NavigationState( - val startRoute: NavKey, - topLevelRoute: MutableState, - val backStacks: Map> -) { - var topLevelRoute: NavKey by topLevelRoute - val stacksInUse: List - get() = if (topLevelRoute == startRoute) { - listOf(startRoute) - } else { - listOf(startRoute, topLevelRoute) + val topLevelRoute = rememberSerializable( + startRoute, topLevelRoutes, + serializer = MutableStateSerializer(NavKeySerializer()) + ) { + mutableStateOf(startRoute) } -} -/** - * Convert NavigationState into NavEntries. - */ -@Composable -fun NavigationState.toEntries( - entryProvider: (NavKey) -> NavEntry -): SnapshotStateList> { + val backStacks = topLevelRoutes.associateWith { key -> rememberNavBackStack(key) } - val decoratedEntries = backStacks.mapValues { (_, stack) -> - val decorators = listOf( - rememberSaveableStateHolderNavEntryDecorator(), - ) - rememberDecoratedNavEntries( - backStack = stack, - entryDecorators = decorators, - entryProvider = entryProvider - ) + return remember(startRoute, topLevelRoutes) { + NavigationState( + startRoute = startRoute, + topLevelRoute = topLevelRoute, + backStacks = backStacks + ) + } } - return stacksInUse - .flatMap { decoratedEntries[it] ?: emptyList() } - .toMutableStateList() -} -``` + /** + * State holder for navigation state. + * + * @param startRoute - the start route. The user will exit the app through this route. + * @param topLevelRoute - the current top level route + * @param backStacks - the back stacks for each top level route + */ + class NavigationState( + val startRoute: NavKey, + topLevelRoute: MutableState, + val backStacks: Map> + ) { + var topLevelRoute: NavKey by topLevelRoute + val stacksInUse: List + get() = if (topLevelRoute == startRoute) { + listOf(startRoute) + } else { + listOf(startRoute, topLevelRoute) + } + } -
+ /** + * Convert NavigationState into NavEntries. + */ + @Composable + fun NavigationState.toEntries( + entryProvider: (NavKey) -> NavEntry + ): SnapshotStateList> { -**AI Agent** : `rememberSerializable` is correct. Don't change it to + val decoratedEntries = backStacks.mapValues { (_, stack) -> + val decorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + ) + rememberDecoratedNavEntries( + backStack = stack, + entryDecorators = decorators, + entryProvider = entryProvider + ) + } + + return stacksInUse + .flatMap { decoratedEntries[it] ?: emptyList() } + .toMutableStateList() + } + +**AI Agent** : `rememberSerializable` is correct. Do not change it to `rememberSaveable`. This file contains a state holder class named `NavigationState` and associated @@ -267,42 +264,37 @@ back stacks for each top-level route. Copy the following code into a file named `Navigator.kt`. Add your package name to match your project structure. + // package com.example.project -```kotlin -// package com.example.project + import androidx.navigation3.runtime.NavKey -import androidx.navigation3.runtime.NavKey + /** + * Handles navigation events (forward and back) by updating the navigation state. + */ + class Navigator(val state: NavigationState){ + fun navigate(route: NavKey){ + if (route in state.backStacks.keys){ + // This is a top level route, just switch to it. + state.topLevelRoute = route + } else { + state.backStacks[state.topLevelRoute]?.add(route) + } + } -/** - * Handles navigation events (forward and back) by updating the navigation state. - */ -class Navigator(val state: NavigationState) { - fun navigate(route: NavKey) { - if (route in state.backStacks.keys) { - // This is a top level route, just switch to it. - state.topLevelRoute = route - } else { - state.backStacks[state.topLevelRoute]?.add(route) + fun goBack(){ + val currentStack = state.backStacks[state.topLevelRoute] ?: + error("Stack for ${state.topLevelRoute} not found") + val currentRoute = currentStack.last() + + // If we're at the base of the current route, go back to the start route stack. + if (currentRoute == state.topLevelRoute){ + state.topLevelRoute = state.startRoute + } else { + currentStack.removeLastOrNull() + } } } - fun goBack() { - val currentStack = state.backStacks[state.topLevelRoute] - ?: error("Stack for ${state.topLevelRoute} not found") - val currentRoute = currentStack.last() - - // If we're at the base of the current route, go back to the start route stack. - if (currentRoute == state.topLevelRoute) { - state.topLevelRoute = state.startRoute - } else { - currentStack.removeLastOrNull() - } - } -} -``` - -
- The `Navigator` class provides two navigation event methods: - `navigate` to a specific route. @@ -321,19 +313,12 @@ Both methods modify the `NavigationState`. Create instances of `NavigationState` and `Navigator` with the same scope as your `NavController`. + val navigationState = rememberNavigationState( + startRoute = , + topLevelRoutes = + ) -```kotlin -val navigationState = rememberNavigationState( - // ... - startRoute = , - topLevelRoutes = - // ... -) - -val navigator = remember { Navigator(navigationState) } -``` - -
+ val navigator = remember { Navigator(navigationState) } ## Step 4: Replace `NavController` @@ -343,7 +328,6 @@ Replace `NavController` navigation event methods with `Navigator` equivalents. |---|---| | `navigate()` | `navigate()` | | `popBackStack()` | `goBack()` | -| `previousBackStackEntry.savedStateHandle.set()` | `ResultEventBus.sendResult()` | Replace `NavController` fields with `NavigationState` fields. @@ -351,7 +335,6 @@ Replace `NavController` fields with `NavigationState` fields. |---|---| | `currentBackStack` | `backStacks[topLevelRoute]` | | `currentBackStackEntry` `currentBackStackEntryAsState()` `currentBackStackEntryFlow` `currentDestination` | `backStacks[topLevelRoute].last()` | -| `currentBackStackEntry.savedStateHandle.getLiveData()` `currentBackStackEntry.savedStateHandle.getStateFlow()` | `ResultEffect` (event-based) `ResultEventBus.conflateAsState()` (state-based) | | Get the top level route: Traverse up the hierarchy from the current back stack entry to find it. | `topLevelRoute` | Use `NavigationState.topLevelRoute` to determine the item that is currently @@ -359,138 +342,20 @@ selected in a navigation bar. Before: + val isSelected = navController.currentBackStackEntryAsState().value?.destination.isRouteInHierarchy(key::class) -```kotlin -// ... -val isSelected = navController.currentBackStackEntryAsState().value?.destination.isRouteInHierarchy(key::class) -// ... - -fun NavDestination?.isRouteInHierarchy(route: KClass<*>) = - this?.hierarchy?.any { - it.hasRoute(route) - } ?: false -``` - -
+ fun NavDestination?.isRouteInHierarchy(route: KClass<*>) = + this?.hierarchy?.any { + it.hasRoute(route) + } ?: false After: - -```kotlin -val isSelected = key == navigationState.topLevelRoute -``` - -
+ val isSelected = key == navigationState.topLevelRoute Verify that you have removed all references to `NavController`, including any imports. -### Step 4.1 Migrate lifecycle-aware logic - -In Navigation 2, `NavBackStackEntry` implements `LifecycleOwner`, letting you -listen to lifecycle events or collect flows in a lifecycle-aware manner using -`navController.currentBackStackEntry`. - -In Navigation 3, `NavDisplay` provides an entry-scoped `LifecycleOwner` -through `LocalLifecycleOwner.current` to each destination's composable -content. See [Destination lifecycle](https://developer.android.com/guide/navigation/navigation-3/basics#destination-lifecycle) for more information. - -You should perform lifecycle-aware operations directly inside your destination's -composable content by referencing `LocalLifecycleOwner.current`. - -For example, if you collect a flow in a lifecycle-aware manner using the back -stack entry: - -Before: - - -```kotlin -// In your destination screen or host -val lifecycleOwner = navController.currentBackStackEntry!! -val state by flow.collectAsStateWithLifecycle(lifecycleOwner = lifecycleOwner) -``` - -
- -After: - - -```kotlin -// Inside the destination composable -val state by flow.collectAsStateWithLifecycle() -``` - -
- -### Step 4.2 Migrate result passing - -In Navigation 2, destinations passed results back to previous destinations using -the `SavedStateHandle` on `NavBackStackEntry`. Because `SavedStateHandle` is -backed by saved instance state, returned data survived process death -automatically. - -Before: - - -```kotlin -// Sender destination: -navController.previousBackStackEntry?.savedStateHandle?.set("contact_key", contact) -navController.popBackStack() - -// Receiver destination: -val lifecycleOwner = LocalLifecycleOwner.current -navController.currentBackStackEntry?.savedStateHandle - ?.getLiveData("contact_key") - ?.observe(lifecycleOwner) { contact -> - viewModel.onRecipientSelected(contact) - } -``` - -
- -In Navigation 3, add -[`rememberResultEventBusNavEntryDecorator()`](https://developer.android.com/reference/kotlin/androidx/navigation3/runtime/result/rememberResultEventBusNavEntryDecorator.composable) to -your `NavDisplay.entryDecorators`. In your sender destination's `entryProvider` -mapping, retrieve [`LocalResultEventBus.current`](https://developer.android.com/reference/kotlin/androidx/navigation3/runtime/result/LocalResultEventBus) -and call `sendResult()`. In the receiving destination, use -[`ResultEffect`](https://developer.android.com/reference/kotlin/androidx/navigation3/runtime/result/ResultEffect.composable) to forward the event to a `ViewModel` or -trigger a side effect. - -After: - - -```kotlin -// Sender destination (in entryProvider): -entry { - val resultBus = LocalResultEventBus.current - - ContactPickerScreen( - onContactSelected = { contact -> - resultBus.sendResult(result = contact) - navigator.goBack() - } - ) -} - -// Receiver destination: -@Composable -fun ComposeMessageScreen(viewModel: ComposeMessageViewModel = viewModel()) { - ResultEffect { contact -> - viewModel.onRecipientSelected(contact) - } - - ComposeMessageContent(recipient = viewModel.recipient) -} -``` - -
- -> [!CAUTION] -> **Caution:** `ResultEventBus` is an in-memory event bus; unlike Navigation 2's `SavedStateHandle`, results don't survive process death automatically. If a result must persist across process death, save it using `rememberSaveable`, pass it into a scoped `ViewModel` with `SavedStateHandle`, or embed the data into the destination's `NavKey`. - -For state-based observation, you can call `resultBus.conflateAsState()`. For -more details, see [Return results](https://developer.android.com/guide/navigation/navigation-3/return-results). - ## Step 5: Move your destinations from `NavHost`'s `NavGraph` into an `entryProvider` In Navigation 2, you [define your destinations](https://developer.android.com/guide/navigation/design#compose) @@ -511,21 +376,16 @@ as follows: > [!NOTE] > **Note:** If your app needs to navigate from an entry in one stack to another, you need to define the parent-child relationships for the routes and update the navigation logic in `Navigator` to support this. -### Step 5.1: Create an `entryProvider` +## Step 5.1: Create an `entryProvider` Create an `entryProvider` [using the DSL](https://developer.android.com/guide/navigation/navigation-3/basics#entry-provider-DSL) at the same scope as the `NavigationState`. + val entryProvider = entryProvider { -```kotlin -val entryProvider = entryProvider { + } -} -``` - -
- -### Step 5.2: Move destinations into the `entryProvider` +## Step 5.2: Move destinations into the `entryProvider` For each destination defined inside `NavHost`, do the following based on the destination type: @@ -550,77 +410,60 @@ Obtain navigation arguments using the key provided to `entry`'s trailing lambda. For example: + import androidx.navigation.NavDestination + import androidx.navigation.NavDestination.Companion.hasRoute + import androidx.navigation.NavDestination.Companion.hierarchy + import androidx.navigation.NavGraphBuilder + import androidx.navigation.compose.NavHost + import androidx.navigation.compose.composable + import androidx.navigation.compose.currentBackStackEntryAsState + import androidx.navigation.compose.dialog + import androidx.navigation.compose.navigation + import androidx.navigation.compose.rememberNavController + import androidx.navigation.navOptions + import androidx.navigation.toRoute -```kotlin -// ... -import androidx.navigation.NavDestination -import androidx.navigation.NavDestination.Companion.hasRoute -import androidx.navigation.NavDestination.Companion.hierarchy -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.currentBackStackEntryAsState -import androidx.navigation.compose.dialog -import androidx.navigation.compose.navigation -import androidx.navigation.compose.rememberNavController -import androidx.navigation.navOptions -import androidx.navigation.toRoute -// ... + @Serializable data object BaseRouteA + @Serializable data class RouteA(val id: String) + @Serializable data object BaseRouteB + @Serializable data object RouteB + @Serializable data object RouteD -@Serializable data object BaseRouteA -@Serializable data class RouteA(val id: String) -@Serializable data object BaseRouteB -@Serializable data object RouteB -@Serializable data object RouteD - -@Composable -fun NavHostSnippet(navController: NavHostController) { NavHost(navController = navController, startDestination = BaseRouteA){ - composable{ entry -> + composable{ val id = entry.toRoute().id ScreenA(title = "Screen has ID: $id") } featureBSection() dialog{ ScreenD() } } -} -fun NavGraphBuilder.featureBSection() { - navigation(startDestination = RouteB) { - composable { ScreenB() } + fun NavGraphBuilder.featureBSection() { + navigation(startDestination = RouteB) { + composable { ScreenB() } + } } -} -``` - -
becomes: + import androidx.navigation3.runtime.EntryProviderScope + import androidx.navigation3.runtime.NavKey + import androidx.navigation3.runtime.entryProvider + import androidx.navigation3.scene.DialogSceneStrategy -```kotlin -// ... -import androidx.navigation3.runtime.EntryProviderScope -import androidx.navigation3.runtime.NavKey -import androidx.navigation3.runtime.entryProvider -import androidx.navigation3.scene.DialogSceneStrategy -// ... + @Serializable data class RouteA(val id: String) : NavKey + @Serializable data object RouteB : NavKey + @Serializable data object RouteD : NavKey -@Serializable data class RouteA(val id: String) : NavKey -@Serializable data object RouteB : NavKey -@Serializable data object RouteD : NavKey + val entryProvider = entryProvider { + entry{ key -> ScreenA(title = "Screen has ID: ${key.id}") } + featureBSection() + entry(metadata = DialogSceneStrategy.dialog()){ ScreenD() } + } -val entryProvider = entryProvider { - entry{ key -> ScreenA(title = "Screen has ID: ${key.id}") } - featureBSection() - entry(metadata = DialogSceneStrategy.dialog()){ ScreenD() } -} - -fun EntryProviderScope.featureBSection() { - entry { ScreenB() } -} -``` - -
+ fun EntryProviderScope.featureBSection() { + entry { ScreenB() } + } ## Step 6: Replace `NavHost` with `NavDisplay` @@ -633,104 +476,15 @@ Replace `NavHost` with `NavDisplay`. For example: + import androidx.navigation3.ui.NavDisplay -```kotlin -NavDisplay( - entries = navigationState.toEntries(entryProvider), - onBack = { navigator.goBack() }, - sceneStrategies = remember { listOf(DialogSceneStrategy()) } -) -``` - -
- -## Step 7: Migrate deep links - -In Navigation 2, deep links were defined directly inside the navigation graph -using the `deepLinks` parameter of destinations. - -In Navigation 3, deep links are managed independently of the navigation UI. You -define `DeepLinkMatcher`s and match incoming requests in your Activity to -construct the initial back stack. - -Before: - -In Navigation 2, you might have defined a deep link like this: - - -```kotlin -composable( - deepLinks = listOf( - navDeepLink { uriPattern = "www.example.com/user/{id}" } + NavDisplay( + entries = navigationState.toEntries(entryProvider), + onBack = { navigator.goBack() }, + sceneStrategies = remember { listOf(DialogSceneStrategy()) } ) -) { - // ... -} -``` -
- -After: - -In Navigation 3, you define a `UriDeepLinkMatcher` for the route: - - -```kotlin -val userMatcher = UriDeepLinkMatcher( - DeepLinkUri("www.example.com/user/{id}"), - serializer() -) -``` - -
- -Then, in your Activity's `onCreate` (and `onNewIntent`), you match the incoming -intent and initialize your back stack: - - -```kotlin -val deepLinkMatchers: List> = listOf( - userMatcher, -) - -class MainActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - val request = DeepLinkRequest(intent = intent) - val matchResult = deepLinkMatchers - .mapNotNull { it.match(request) } - .maxOrNull() - - val backStack = when (matchResult) { - null -> listOf(HomeKey) - is BackStackMatchResult<*, *> -> { - @Suppress("UNCHECKED_CAST") - matchResult.backStack as List - } - else -> listOf(matchResult.key) - } - - // Use backStack with NavDisplay - } -} -``` - -
- -### Custom argument types - -In Navigation 2, you handled custom or third-party argument types (such as -`LocalDateTime`) using custom `NavType` implementations and `typeMap`. - -In Navigation 3, define a [`DeepLinkSerializer`](https://developer.android.com/guide/navigation/navigation-3/deep-links/uri-matcher#deep-link-serializer) to deserialize custom or -third-party types from URI parameters. See -[Custom serialization with DeepLinkSerializer](https://developer.android.com/guide/navigation/navigation-3/deep-links/uri-matcher#deep-link-serializer) for details. - -For more advanced use cases, including synthetic back stacks and custom -matchers, see the [Support deep links](https://developer.android.com/guide/navigation/navigation-3/deep-links) guide. - -## Step 8: Remove Navigation 2 dependencies +## Step 7: Remove Navigation 2 dependencies Remove all Navigation 2 imports and library dependencies. diff --git a/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/conditional-transitions.md b/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/conditional-transitions.md deleted file mode 100644 index c83cdca..0000000 --- a/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/conditional-transitions.md +++ /dev/null @@ -1,182 +0,0 @@ -# Conditional Transitions Recipe - -This recipe demonstrates how to create route-dependent screen transitions in Navigation 3 using `transitionSpec` and `popTransitionSpec`. The slide directions (right, left, bottom, top) are conditionally selected based on pairs of `initialState` and `targetState` navigation keys. - -## How it works - -- **Route Definitions** : Navigation destinations (`Step1`, `Step2`, `Step3`, `Step4`) are defined using a sealed class hierarchy implementing `NavKey` and marked with `@Serializable`. -- **Conditional Forward Transitions (`transitionSpec`)** : Matches pairs of `(initialKey to targetKey)` to determine the direction of the slide animation: - - `Step1` $\\rightarrow$ `Step2`: Swipes to the left - - `Step2` $\\rightarrow$ `Step3`: Swipes to the up - - `Step3` $\\rightarrow$ `Step4`: Swipes to the right - - `Step4` $\\rightarrow$ `Step1`: Slides to the bottom (restarts flow) -- **Conditional Pop Transitions (`popTransitionSpec`)**: Handles reverse slide directions when navigating back or when clearing the backstack. -- **Backstack Control** : Demonstrates clearing the navigation stack on the final step (`backStack.clear()` \& `backStack.add(Step1)`) while executing a seamless top-slide transition. - -[![](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/conditionaltransitions) - -``` -package com.example.nav3recipes.conditionaltransitions - -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.compose.animation.ContentTransform -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.slideOutVertically -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ElevatedButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -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.ContentGreen -import com.example.nav3recipes.content.ContentOrange -import com.example.nav3recipes.content.ContentPurple -import com.example.nav3recipes.content.ContentRed -import com.example.nav3recipes.sharedviewmodel.toContentKey -import com.example.nav3recipes.ui.setEdgeToEdgeConfig -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.serialization.Serializable - -@Serializable -sealed class Step(val firstButtonTitle: String, val secondButtonTitle: String) : NavKey - -@Serializable -data object Step1 : Step("Home", "Swipe left") - -@Serializable -data object Step2 : Step("Swipe right", "Swipe up") - -@Serializable -data object Step3 : Step("Swipe down", "Swipe right") - -@Serializable -data object Step4 : Step("Swipe left", "Swipe down") - -@AndroidEntryPoint -class ConditionalTransitionsActivity : ComponentActivity() { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setEdgeToEdgeConfig() - - setContent { - Scaffold { paddingValues -> - val backStack = rememberNavBackStack(Step1) - - NavDisplay( - backStack = backStack, - modifier = Modifier.padding(paddingValues), - onBack = backStack::removeLastOrNull, - entryProvider = entryProvider { - entry { - ContentGreen(title = "Screen 1") { - StepContent( - step = it, - onNext = { backStack += Step2 }, - onBack = ::finish, // closing the activity - ) - } - - } - entry { - ContentRed(title = "Screen 2") { - StepContent( - step = it, - onNext = { backStack += Step3 }, - onBack = backStack::removeLastOrNull, - ) - } - } - entry { - ContentOrange(title = "Screen 3") { - StepContent( - step = it, - onNext = { backStack += Step4 }, - onBack = backStack::removeLastOrNull, - ) - } - } - entry { - ContentPurple(title = "Screen 4") { - StepContent( - step = it, - onNext = { - backStack.clear() - backStack.add(Step1) - }, - onBack = backStack::removeLastOrNull, - ) - } - } - }, - transitionSpec = { - val initialKey = initialState.entries.lastOrNull()?.contentKey - val targetKey = targetState.entries.lastOrNull()?.contentKey - - when (initialKey to targetKey) { - Step1.toContentKey() to Step2.toContentKey() -> swipeLeft() - Step2.toContentKey() to Step3.toContentKey() -> swipeUp() - Step3.toContentKey() to Step4.toContentKey() -> swipeRight() - Step4.toContentKey() to Step1.toContentKey() -> swipeDown() - else -> swipeRight() - } - }, - popTransitionSpec = { - val initialKey = initialState.entries.lastOrNull()?.contentKey - val targetKey = targetState.entries.lastOrNull()?.contentKey - - when (initialKey to targetKey) { - Step4.toContentKey() to Step1.toContentKey() -> swipeDown() // via backstack clearing - Step2.toContentKey() to Step1.toContentKey() -> swipeRight() - Step3.toContentKey() to Step2.toContentKey() -> swipeDown() - Step4.toContentKey() to Step3.toContentKey() -> swipeLeft() - else -> swipeLeft() - } - }, - ) - } - } - } -} - -@Composable -fun StepContent(step: Step, onNext: () -> Unit, onBack: () -> Unit) { - Column( - verticalArrangement = Arrangement.SpaceEvenly, - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxSize() - ) { - Row(horizontalArrangement = Arrangement.SpaceEvenly, modifier = Modifier.fillMaxWidth()) { - ElevatedButton(onBack) { Text(text = step.firstButtonTitle) } - ElevatedButton(onNext) { Text(text = step.secondButtonTitle) } - } - } -} - -private fun swipeLeft(): ContentTransform = - slideInHorizontally(initialOffsetX = { it }) togetherWith slideOutHorizontally(targetOffsetX = { -it }) - -private fun swipeUp(): ContentTransform = - slideInVertically(initialOffsetY = { it }) togetherWith slideOutVertically(targetOffsetY = { -it }) - -private fun swipeRight(): ContentTransform = - slideInHorizontally(initialOffsetX = { -it }) togetherWith slideOutHorizontally(targetOffsetX = { it }) - -private fun swipeDown(): ContentTransform = - slideInVertically(initialOffsetY = { -it }) togetherWith slideOutVertically(targetOffsetY = { it }) -``` \ No newline at end of file diff --git a/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/conditional.md b/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/conditional.md index 60848b3..7ff23db 100644 --- a/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/conditional.md +++ b/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/conditional.md @@ -48,11 +48,11 @@ import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSerializable -import androidx.compose.runtime.setValue import androidx.lifecycle.compose.dropUnlessResumed import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey @@ -63,7 +63,6 @@ import androidx.navigation3.ui.NavDisplay import com.example.nav3recipes.content.ContentBlue import com.example.nav3recipes.content.ContentGreen import com.example.nav3recipes.content.ContentYellow -import com.example.nav3recipes.ui.setEdgeToEdgeConfig import kotlinx.serialization.Serializable @@ -104,7 +103,6 @@ private data class Login( class ConditionalActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { - setEdgeToEdgeConfig() super.onCreate(savedInstanceState) setContent { diff --git a/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/deeplinks-syntheticbackstack.md b/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/deeplinks-advanced.md similarity index 65% rename from navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/deeplinks-syntheticbackstack.md rename to navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/deeplinks-advanced.md index 5ccc370..62a9439 100644 --- a/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/deeplinks-syntheticbackstack.md +++ b/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/deeplinks-advanced.md @@ -1,63 +1,34 @@ -# Deep Link Synthetic BackStack Recipe +# Deep Link Advanced Recipe This recipe demonstrates how to apply the principles of navigation in the context of deep links by managing a synthetic backStack and Task stacks. # Recipe Structure -This recipe simulates a real-world scenario where "App A" deep links +This recipe simulates a real-world scenario where "App A" deeplinks into "App B". -"App A" is simulated by the module [syntheticbackstack](https://developer.android.com/app/src/main/java/com/example/nav3recipes/deeplink/handlerequests/syntheticbackstack), which -contains the `SyntheticBackStackDeepLinkActivity` that allows you to create a deeplink intent and +"App A" is simulated by the module [com.example.nav3recipes.deeplink.advanced](https://developer.android.com/app/src/main/java/com/example/nav3recipes/deeplink/advanced), which +contains the `CreateAdvancedDeepLinkActivity` that allows you to create a deeplink intent and trigger that in either the existing Task, or in a new Task. -"App B" is simulated by the module [syntheticbackstackapp](https://developer.android.com/syntheticbackstackapp/src/main/java/com/example/nav3recipes/deeplink/syntheticbackstack), which contains -the `SyntheticBackStackAppActivity` that you deeplink into. That module shows you how to build a synthetic backStack +"App B" is simulated by the module [advanceddeeplinkapp](https://developer.android.com/advanceddeeplinkapp/src/main/java/com/example/nav3recipes/deeplink/advanced), which contains +the MainActivity that you deeplink into. That module shows you how to build a synthetic backStack and how to manage the Task stack properly in order to support both Back and Up buttons. -# How to Use - -Ensure both the main `app` and `syntheticbackstackapp` are installed on the emulator or connected device. Ensure that the installed `syntheticbackstackapp` supports -the `"www.nav3deeplink.com"` link. - -On the recipe's landing page, choose the filters and click the button to deep link. It should bring you to the Activity of `syntheticbackstackapp`. - -# How it Works - -The recipe follows the deep link guideline summarized [here](https://developer.android.com/docs/deeplink-guide#summary). - -To see behavior of `Existing Task`: - -1. Open deep link using current task -2. On the device, swipe up to see all recent apps -3. Notice that the new Activity is opened within the Nav3Recipes app -4. Click back button to go back to the original Activity -5. Repeat step 1 -6. Click the up button to go to parent screen -7. On the device, swipe up to see all recent apps -8. Notice that the new Activity is now opened within the Nav3SyntheticBackStack app - -To see behavior of `New Task`: - -1. Open deep link using new task -2. On the device, swipe up to see all recent apps -3. Notice that the new Activity is opened within the Nav3SyntheticBackStack app -4. Click Up or Back button to go to parent screen - # Core implementation The core helper functions for navigateUp and building synthetic backStack can be -found [here](https://developer.android.com/syntheticbackstackapp/src/main/java/com/example/nav3recipes/deeplink/syntheticbackstack/util) +found [here](https://developer.android.com/static/advanceddeeplinkapp/src/main/java/com/example/nav3recipes/deeplink/advanced/util/DeepLinkBackStackUtil.kt) # Further Read Check out the [deep link guide](https://developer.android.com/docs/deeplink-guide) for a comprehensive guide on Deep linking principles and how to apply them in Navigation 3. -[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/deeplink/handlerequests/syntheticbackstack) +[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/deeplink/advanced) ``` -package com.example.nav3recipes.deeplink.handlerequests.syntheticbackstack +package com.example.nav3recipes.deeplink.advanced import android.content.Intent import android.os.Bundle @@ -75,30 +46,28 @@ import com.example.nav3recipes.common.deeplink.LIST_LOCATIONS import com.example.nav3recipes.common.deeplink.MenuDropDown import com.example.nav3recipes.common.deeplink.PaddedButton import com.example.nav3recipes.common.deeplink.TextContent -import com.example.nav3recipes.ui.setEdgeToEdgeConfig internal const val ADVANCED_PATH_BASE = "https://www.nav3deeplink.com" /** * The recipe entry point that allows users to create a deep link and make a request with it. * - * **HOW THIS RECIPE WORKS** This recipe simulates a real-world scenario where "App A" deep links + * **HOW THIS RECIPE WORKS** This recipe simulates a real-world scenario where "App A" deeplinks * into "App B". * - * "App A" is simulated by this current module `syntheticbackstack`, which - * contains the [SyntheticBackStackDeepLinkActivity] that allows you to create a deeplink intent and + * "App A" is simulated by this current module [com.example.nav3recipes.deeplink.advanced], which + * contains the [AdvancedCreateDeepLinkActivity] that allows you to create a deeplink intent and * trigger that in either the existing Task, or in a new Task. * - * "App B" is simulated by the module `syntheticbackstackapp`, which contains - * the SyntheticBackStackAppActivity that you deeplink into. That module shows you how to build a synthetic backStack + * "App B" is simulated by the module [com.example.nav3recipes.deeplink.advanced], which contains + * the MainActivity that you deeplink into. That module shows you how to build a synthetic backStack * and how to manage the Task stack properly in order to support both Back and Up buttons. * * See the [README](README.md) file of current module for more info on advanced deep linking. */ -class SyntheticBackStackDeepLinkActivity: ComponentActivity() { +class AdvancedCreateDeepLinkActivity: ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { - setEdgeToEdgeConfig() super.onCreate(savedInstanceState) setContent { diff --git a/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/deeplinks-basic.md b/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/deeplinks-basic.md new file mode 100644 index 0000000..f96f642 --- /dev/null +++ b/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/deeplinks-basic.md @@ -0,0 +1,740 @@ +# Deep Link Basic Recipe + +This recipe demonstrates how to parse a deep link URL from an Android Intent into a Navigation key. + +## How it works + +It consists of two activities - `CreateDeepLinkActivity` to construct and trigger the deeplink request, and the `MainActivity` to show how an app can handle that request. + +## Demonstrated forms of deeplink + +The `MainActivity` has several backStack keys to demonstrate different types of supported deeplinks: + +1. `HomeKey` - deeplink with an exact url (no deeplink arguments) +2. `UsersKey` - deeplink with path arguments +3. `SearchKey` - deeplink with query arguments + +See `MainActivity.deepLinkPatterns` for the actual url pattern of each. + +## Recipe structure + +This recipe consists of three main packages: + +1. `basic.deeplink` - Contains the two activities +2. `basic.deeplink.ui` - Contains the activity UI code, i.e. global string variables, deeplink URLs etc +3. `basic.deeplink.util` - Contains the classes and helper methods to parse and match the deeplinks + +[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/deeplink/basic) + +``` +package com.example.nav3recipes.deeplink.basic + +import androidx.navigation3.runtime.NavKey +import com.example.nav3recipes.deeplink.basic.ui.STRING_LITERAL_FILTER +import com.example.nav3recipes.deeplink.basic.ui.STRING_LITERAL_HOME +import com.example.nav3recipes.deeplink.basic.ui.STRING_LITERAL_SEARCH +import com.example.nav3recipes.deeplink.basic.ui.STRING_LITERAL_USERS +import kotlinx.serialization.Serializable + +internal interface NavRecipeKey: NavKey { + val name: String +} + +@Serializable +internal object HomeKey: NavRecipeKey { + override val name: String = STRING_LITERAL_HOME +} + +@Serializable +internal data class UsersKey( + val filter: String, +): NavRecipeKey { + override val name: String = STRING_LITERAL_USERS + companion object { + const val FILTER_KEY = STRING_LITERAL_FILTER + const val FILTER_OPTION_RECENTLY_ADDED = "recentlyAdded" + const val FILTER_OPTION_ALL = "all" + } +} + +@Serializable +internal data class SearchKey( + val firstName: String? = null, + val ageMin: Int? = null, + val ageMax: Int? = null, + val location: String? = null, +): NavRecipeKey { + override val name: String = STRING_LITERAL_SEARCH +} +``` + +``` +package com.example.nav3recipes.deeplink.basic + +import android.net.Uri +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.core.net.toUri +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.ui.NavDisplay +import com.example.nav3recipes.deeplink.basic.util.DeepLinkMatcher +import com.example.nav3recipes.deeplink.basic.util.DeepLinkPattern +import com.example.nav3recipes.deeplink.basic.util.DeepLinkRequest +import com.example.nav3recipes.deeplink.basic.util.DeepLinkMatchResult +import com.example.nav3recipes.deeplink.basic.util.KeyDecoder +import com.example.nav3recipes.common.deeplink.TextContent +import com.example.nav3recipes.deeplink.basic.ui.URL_HOME_EXACT +import com.example.nav3recipes.deeplink.basic.ui.URL_SEARCH +import com.example.nav3recipes.deeplink.basic.ui.URL_USERS_WITH_FILTER +import com.example.nav3recipes.common.deeplink.EntryScreen +import com.example.nav3recipes.common.deeplink.FriendsList +import com.example.nav3recipes.common.deeplink.LIST_USERS + +/** + * Parses a target deeplink into a NavKey. There are several crucial steps involved: + * + * STEP 1.Parse supported deeplinks (URLs that can be deeplinked into) into a readily readable + * format (see [DeepLinkPattern]) + * STEP 2. Parse the requested deeplink into a readily readable, format (see [DeepLinkRequest]) + * **note** the parsed requested deeplink and parsed supported deeplinks should be cohesive with each + * other to facilitate comparison and finding a match + * STEP 3. Compare the requested deeplink target with supported deeplinks in order to find a match + * (see [DeepLinkMatchResult]). The match result's format should enable conversion from result + * to backstack key, regardless of what the conversion method may be. + * STEP 4. Associate the match results with the correct backstack key + * + * This recipes provides an example for each of the above steps by way of kotlinx.serialization. + * + * **This recipe is designed to focus on parsing an intent into a key, and therefore these additional + * deeplink considerations are not included in this scope** + * - Create synthetic backStack + * - Multi-modular setup + * - DI + * - Managing TaskStack + * - Up button ves Back Button + * + */ +class MainActivity : ComponentActivity() { + /** STEP 1. Parse supported deeplinks */ + // internal so that landing activity can link to this in the kdocs + internal val deepLinkPatterns: List> = listOf( + // "https://www.nav3recipes.com/home" + DeepLinkPattern(HomeKey.serializer(), (URL_HOME_EXACT).toUri()), + // "https://www.nav3recipes.com/users/with/{filter}" + DeepLinkPattern(UsersKey.serializer(), (URL_USERS_WITH_FILTER).toUri()), + // "https://www.nav3recipes.com/users/search?{firstName}&{age}&{location}" + DeepLinkPattern(SearchKey.serializer(), (URL_SEARCH.toUri())), + ) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // retrieve the target Uri + val uri: Uri? = intent.data + // associate the target with the correct backstack key + val key: NavKey = uri?.let { + /** STEP 2. Parse requested deeplink */ + val request = DeepLinkRequest(uri) + /** STEP 3. Compared requested with supported deeplink to find match*/ + val match = deepLinkPatterns.firstNotNullOfOrNull { pattern -> + DeepLinkMatcher(request, pattern).match() + } + /** STEP 4. If match is found, associate match to the correct key*/ + match?.let { + //leverage kotlinx.serialization's Decoder to decode + // match result into a backstack key + KeyDecoder(match.args) + .decodeSerializableValue(match.serializer) + } + } ?: HomeKey // fallback if intent.uri is null or match is not found + + /** + * Then pass starting key to backstack + */ + setContent { + val backStack: NavBackStack = rememberNavBackStack(key) + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + entryProvider = entryProvider { + entry { key -> + EntryScreen(key.name) { + TextContent("") + } + } + entry { key -> + EntryScreen("${key.name} : ${key.filter}") { + TextContent("") + val list = when { + key.filter.isEmpty() -> LIST_USERS + key.filter == UsersKey.FILTER_OPTION_ALL -> LIST_USERS + else -> LIST_USERS.take(5) + } + FriendsList(list) + } + } + entry { search -> + EntryScreen(search.name) { + TextContent("") + val matchingUsers = LIST_USERS.filter { user -> + (search.firstName == null || user.firstName == search.firstName) && + (search.location == null || user.location == search.location) && + (search.ageMin == null || user.age >= search.ageMin) && + (search.ageMax == null || user.age <= search.ageMax) + } + FriendsList(matchingUsers) + } + } + } + ) + } + } +} +``` + +``` +package com.example.nav3recipes.deeplink.basic + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.core.net.toUri +import androidx.lifecycle.compose.dropUnlessResumed +import com.example.nav3recipes.common.deeplink.PaddedButton +import com.example.nav3recipes.common.deeplink.EMPTY +import com.example.nav3recipes.common.deeplink.EntryScreen +import com.example.nav3recipes.common.deeplink.FIRST_NAME_JOHN +import com.example.nav3recipes.common.deeplink.FIRST_NAME_JULIE +import com.example.nav3recipes.common.deeplink.FIRST_NAME_MARY +import com.example.nav3recipes.common.deeplink.FIRST_NAME_TOM +import com.example.nav3recipes.common.deeplink.LOCATION_BC +import com.example.nav3recipes.common.deeplink.LOCATION_BR +import com.example.nav3recipes.common.deeplink.LOCATION_CA +import com.example.nav3recipes.common.deeplink.LOCATION_US +import com.example.nav3recipes.common.deeplink.MenuDropDown +import com.example.nav3recipes.common.deeplink.MenuTextInput +import com.example.nav3recipes.deeplink.basic.ui.PATH_BASE +import com.example.nav3recipes.deeplink.basic.ui.PATH_INCLUDE +import com.example.nav3recipes.deeplink.basic.ui.PATH_SEARCH +import com.example.nav3recipes.deeplink.basic.ui.STRING_LITERAL_HOME +import com.example.nav3recipes.common.deeplink.TextContent + +/** + * This activity allows the user to create a deep link and make a request with it. + * + * **HOW THIS RECIPE WORKS** it consists of two activities - [CreateDeepLinkActivity] to construct + * and trigger the deeplink request, and the [MainActivity] to show how an app can handle + * that request. + * + * **DEMONSTRATED FORMS OF DEEPLINK** The [MainActivity] has a several backStack keys to + * demonstrate different types of supported deeplinks: + * 1. [HomeKey] - deeplink with an exact url (no deeplink arguments) + * 2. [UsersKey] - deeplink with path arguments + * 3. [SearchKey] - deeplink with query arguments + * See [MainActivity.deepLinkPatterns] for the actual url pattern of each. + * + * **RECIPE STRUCTURE** This recipe consists of three main packages: + * 1. basic.deeplink - Contains the two activities + * 2. basic.deeplink.ui - Contains the activity UI code, i.e. global string variables, deeplink URLs etc + * 3. basic.deeplink.util - Contains the classes and helper methods to parse and match + * the deeplinks + * + * See [MainActivity] for how the requested deeplink is handled. + */ +class CreateDeepLinkActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + /** + * UI for deeplink sandbox + */ + EntryScreen("Sandbox - Build Your Deeplink") { + TextContent("Base url:\n${PATH_BASE}/") + var showFilterOptions by remember { mutableStateOf(false) } + val selectedPath = remember { mutableStateOf(MENU_OPTIONS_PATH[KEY_PATH]?.first()) } + + var showQueryOptions by remember { mutableStateOf(false) } + var selectedFilter by remember { mutableStateOf("") } + val selectedSearchQuery = remember { mutableStateMapOf() } + + // manage path options + MenuDropDown( + menuOptions = MENU_OPTIONS_PATH, + ) { _, selection -> + selectedPath.value = selection + when (selection) { + PATH_SEARCH -> { + showQueryOptions = true + showFilterOptions = false + } + + PATH_INCLUDE -> { + showQueryOptions = false + showFilterOptions = true + } + + else -> { + showQueryOptions = false + showFilterOptions = false + } + } + } + + // manage path filter options, reset state if menu is closed + LaunchedEffect(showFilterOptions) { + selectedFilter = if (showFilterOptions) { + MENU_OPTIONS_FILTER.values.first().first() + } else { + "" + } + } + if (showFilterOptions) { + MenuDropDown( + menuOptions = MENU_OPTIONS_FILTER, + ) { _, selected -> + selectedFilter = selected + } + } + + // manage query options, reset state if menu is closed + LaunchedEffect(showQueryOptions) { + if (showQueryOptions) { + val initEntry = MENU_OPTIONS_SEARCH.entries.first() + selectedSearchQuery[initEntry.key] = initEntry.value.first() + } else { + selectedSearchQuery.clear() + } + } + if (showQueryOptions) { + MenuTextInput( + menuLabels = MENU_LABELS_SEARCH, + ) { label, selected -> + selectedSearchQuery[label] = selected + } + MenuDropDown( + menuOptions = MENU_OPTIONS_SEARCH, + ) { label, selected -> + selectedSearchQuery[label] = selected + } + } + + // form final deeplink url + val arguments = when (selectedPath.value) { + PATH_INCLUDE -> "/${selectedFilter}" + PATH_SEARCH -> { + buildString { + selectedSearchQuery.forEach { entry -> + if (entry.value.isNotEmpty()) { + val prefix = if (isEmpty()) "?" else "&" + append("$prefix${entry.key}=${entry.value}") + } + } + } + } + + else -> "" + } + val finalUrl = "${PATH_BASE}/${selectedPath.value}$arguments" + TextContent("Final url:\n$finalUrl") + // deeplink to target + PaddedButton("Deeplink Away!", onClick = dropUnlessResumed { + val intent = Intent( + this@CreateDeepLinkActivity, + MainActivity::class.java + ) + // start activity with the url + intent.data = finalUrl.toUri() + startActivity(intent) + }) + } + } + } +} + +private const val KEY_PATH = "path" +private val MENU_OPTIONS_PATH = mapOf( + KEY_PATH to listOf( + STRING_LITERAL_HOME, + PATH_INCLUDE, + PATH_SEARCH, + ), +) + +private val MENU_OPTIONS_FILTER = mapOf( + UsersKey.FILTER_KEY to listOf(UsersKey.FILTER_OPTION_RECENTLY_ADDED, UsersKey.FILTER_OPTION_ALL), +) + +private val MENU_OPTIONS_SEARCH = mapOf( + SearchKey::firstName.name to listOf( + EMPTY, + FIRST_NAME_JOHN, + FIRST_NAME_TOM, + FIRST_NAME_MARY, + FIRST_NAME_JULIE + ), + SearchKey::location.name to listOf(EMPTY, LOCATION_CA, LOCATION_BC, LOCATION_BR, LOCATION_US) +) + +private val MENU_LABELS_SEARCH = listOf(SearchKey::ageMin.name, SearchKey::ageMax.name) + +``` + +``` +package com.example.nav3recipes.deeplink.basic.util + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.AbstractDecoder +import kotlinx.serialization.encoding.CompositeDecoder +import kotlinx.serialization.modules.EmptySerializersModule +import kotlinx.serialization.modules.SerializersModule + +/** + * Decodes the list of arguments into a a back stack key + * + * **IMPORTANT** This decoder assumes that all argument types are Primitives. + */ +@OptIn(ExperimentalSerializationApi::class) +internal class KeyDecoder( + private val arguments: Map, +) : AbstractDecoder() { + + override val serializersModule: SerializersModule = EmptySerializersModule() + private var elementIndex: Int = -1 + private var elementName: String = "" + + /** + * Decodes the index of the next element to be decoded. Index represents a position of the + * current element in the [descriptor] that can be found with [descriptor].getElementIndex. + * + * The returned index will trigger deserializer to call [decodeValue] on the argument at that + * index. + * + * The decoder continually calls this method to process the next available argument until this + * method returns [CompositeDecoder.DECODE_DONE], which indicates that there are no more + * arguments to decode. + * + * This method should sequentially return the element index for every element that has its value + * available within [arguments]. + */ + override fun decodeElementIndex(descriptor: SerialDescriptor): Int { + var currentIndex = elementIndex + while (true) { + // proceed to next element + currentIndex++ + // if we have reached the end, let decoder know there are not more arguments to decode + if (currentIndex >= descriptor.elementsCount) return CompositeDecoder.DECODE_DONE + val currentName = descriptor.getElementName(currentIndex) + // Check if bundle has argument value. If so, we tell decoder to process + // currentIndex. Otherwise, we skip this index and proceed to next index. + if (arguments.contains(currentName)) { + elementIndex = currentIndex + elementName = currentName + return elementIndex + } + } + } + + /** + * Returns argument value from the [arguments] for the argument at the index returned by + * [decodeElementIndex] + */ + override fun decodeValue(): Any { + val arg = arguments[elementName] + checkNotNull(arg) { "Unexpected null value for non-nullable argument $elementName" } + return arg + } + + override fun decodeNull(): Nothing? = null + + // we want to know if it is not null, so its !isNull + override fun decodeNotNullMark(): Boolean = arguments[elementName] != null +} +``` + +``` +package com.example.nav3recipes.deeplink.basic.util + +import android.net.Uri + +/** + * Parse the requested Uri and store it in a easily readable format + * + * @param uri the target deeplink uri to link to + */ +internal class DeepLinkRequest( + val uri: Uri +) { + /** + * A list of path segments + */ + val pathSegments: List = uri.pathSegments + + /** + * A map of query name to query value + */ + val queries = buildMap { + uri.queryParameterNames.forEach { argName -> + this[argName] = uri.getQueryParameter(argName)!! + } + } + + // TODO add parsing for other Uri components, i.e. fragments, mimeType, action +} +``` + +```` +package com.example.nav3recipes.deeplink.basic.util + +import android.net.Uri +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.SerialKind +import kotlinx.serialization.encoding.CompositeDecoder +import java.io.Serializable + +/** + * Parse a supported deeplink and stores its metadata as a easily readable format + * + * The following notes applies specifically to this particular sample implementation: + * + * The supported deeplink is expected to be built from a serializable backstack key [T] that + * supports deeplink. This means that if this deeplink contains any arguments (path or query), + * the argument name must match any of [T] member field name. + * + * One [DeepLinkPattern] should be created for each supported deeplink. This means if [T] + * supports two deeplink patterns: + * ``` + * val deeplink1 = www.nav3recipes.com/home + * val deeplink2 = www.nav3recipes.com/profile/{userId} + * ``` + * Then two [DeepLinkPattern] should be created + * ``` + * val parsedDeeplink1 = DeepLinkPattern(T.serializer(), deeplink1) + * val parsedDeeplink2 = DeepLinkPattern(T.serializer(), deeplink2) + * ``` + * + * This implementation assumes a few things: + * 1. all path arguments are required/non-nullable - partial path matches will be considered a non-match + * 2. all query arguments are optional by way of nullable/has default value + * + * @param T the backstack key type that supports the deeplinking of [uriPattern] + * @param serializer the serializer of [T] + * @param uriPattern the supported deeplink's uri pattern, i.e. "abc.com/home/{pathArg}" + */ +internal class DeepLinkPattern( + val serializer: KSerializer, + val uriPattern: Uri +) { + /** + * Help differentiate if a path segment is an argument or a static value + */ + private val regexPatternFillIn = Regex("\\{(.+?)\\}") + + // TODO make these lazy + /** + * parse the path into a list of [PathSegment] + * + * order matters here - path segments need to match in value and order when matching + * requested deeplink to supported deeplink + */ + val pathSegments: List = buildList { + uriPattern.pathSegments.forEach { segment -> + // first, check if it is a path arg + var result = regexPatternFillIn.find(segment) + if (result != null) { + // if so, extract the path arg name (the string value within the curly braces) + val argName = result.groups[1]!!.value + // from [T], read the primitive type of this argument to get the correct type parser + val elementIndex = serializer.descriptor.getElementIndex(argName) + if (elementIndex == CompositeDecoder.UNKNOWN_NAME) { + throw IllegalArgumentException( + "Path parameter '{$argName}' defined in the DeepLink $uriPattern does not exist in the Serializable class '${serializer.descriptor.serialName}'." + ) + } + + val elementDescriptor = serializer.descriptor.getElementDescriptor(elementIndex) + // finally, add the arg name and its respective type parser to the map + add(PathSegment(argName, true, getTypeParser(elementDescriptor.kind))) + } else { + // if its not a path arg, then its just a static string path segment + add(PathSegment(segment, false, getTypeParser(PrimitiveKind.STRING))) + } + } + } + + /** + * Parse supported queries into a map of queryParameterNames to [TypeParser] + * + * This will be used later on to parse a provided query value into the correct KType + */ + val queryValueParsers: Map = buildMap { + uriPattern.queryParameterNames.forEach { paramName -> + val elementIndex = serializer.descriptor.getElementIndex(paramName) + // Ignore static query parameters that are not in the Serializable class + if (elementIndex != CompositeDecoder.UNKNOWN_NAME) { + val elementDescriptor = serializer.descriptor.getElementDescriptor(elementIndex) + this[paramName] = getTypeParser(elementDescriptor.kind) + } + } + } + + /** + * Metadata about a supported path segment + */ + class PathSegment( + val stringValue: String, + val isParamArg: Boolean, + val typeParser: TypeParser + ) +} + +/** + * Parses a String into a Serializable Primitive + */ +private typealias TypeParser = (String) -> Serializable + +private fun getTypeParser(kind: SerialKind): TypeParser { + return when (kind) { + PrimitiveKind.STRING -> Any::toString + PrimitiveKind.INT -> String::toInt + PrimitiveKind.BOOLEAN -> String::toBoolean + PrimitiveKind.BYTE -> String::toByte + PrimitiveKind.CHAR -> String::toCharArray + PrimitiveKind.DOUBLE -> String::toDouble + PrimitiveKind.FLOAT -> String::toFloat + PrimitiveKind.LONG -> String::toLong + PrimitiveKind.SHORT -> String::toShort + else -> throw IllegalArgumentException( + "Unsupported argument type of SerialKind:$kind. The argument type must be a Primitive." + ) + } +} +```` + +``` +package com.example.nav3recipes.deeplink.basic.util + +import android.util.Log +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.KSerializer + +internal class DeepLinkMatcher( + val request: DeepLinkRequest, + val deepLinkPattern: DeepLinkPattern +) { + /** + * Match a [DeepLinkRequest] to a [DeepLinkPattern]. + * + * Returns a [DeepLinkMatchResult] if this matches the pattern, returns null otherwise + */ + fun match(): DeepLinkMatchResult? { + if (request.uri.scheme != deepLinkPattern.uriPattern.scheme) return null + if (!request.uri.authority.equals(deepLinkPattern.uriPattern.authority, ignoreCase = true)) return null + if (request.pathSegments.size != deepLinkPattern.pathSegments.size) return null + // exact match (url does not contain any arguments) + if (request.uri == deepLinkPattern.uriPattern) + return DeepLinkMatchResult(deepLinkPattern.serializer, mapOf()) + + val args = mutableMapOf() + // match the path + request.pathSegments + .asSequence() + // zip to compare the two objects side by side, order matters here so we + // need to make sure the compared segments are at the same position within the url + .zip(deepLinkPattern.pathSegments.asSequence()) + .forEach { it -> + // retrieve the two path segments to compare + val requestedSegment = it.first + val candidateSegment = it.second + // if the potential match expects a path arg for this segment, try to parse the + // requested segment into the expected type + if (candidateSegment.isParamArg) { + val parsedValue = try { + candidateSegment.typeParser.invoke(requestedSegment) + } catch (e: IllegalArgumentException) { + Log.e(TAG_LOG_ERROR, "Failed to parse path value:[$requestedSegment].", e) + return null + } + args[candidateSegment.stringValue] = parsedValue + } else if(requestedSegment != candidateSegment.stringValue){ + // if it's path arg is not the expected type, its not a match + return null + } + } + // match queries (if any) + request.queries.forEach { query -> + val name = query.key + // If the pattern does not define this query parameter, ignore it. + // This prevents a NullPointerException. + val queryStringParser = deepLinkPattern.queryValueParsers[name]?: return@forEach + + val queryParsedValue = try { + queryStringParser.invoke(query.value) + } catch (e: IllegalArgumentException) { + Log.e(TAG_LOG_ERROR, "Failed to parse query name:[$name] value:[${query.value}].", e) + return null + } + args[name] = queryParsedValue + } + // provide the serializer of the matching key and map of arg names to parsed arg values + return DeepLinkMatchResult(deepLinkPattern.serializer, args) + } +} + + +/** + * Created when a requested deeplink matches with a supported deeplink + * + * @param [T] the backstack key associated with the deeplink that matched with the requested deeplink + * @param serializer serializer for [T] + * @param args The map of argument name to argument value. The value is expected to have already + * been parsed from the raw url string back into its proper KType as declared in [T]. + * Includes arguments for all parts of the uri - path, query, etc. + * */ +internal data class DeepLinkMatchResult( + val serializer: KSerializer, + val args: Map +) + +const val TAG_LOG_ERROR = "Nav3RecipesDeepLink" +``` + +``` +package com.example.nav3recipes.deeplink.basic.ui + +import com.example.nav3recipes.deeplink.basic.SearchKey + +/** + * String resources + */ +internal const val STRING_LITERAL_FILTER = "filter" +internal const val STRING_LITERAL_HOME = "home" +internal const val STRING_LITERAL_USERS = "users" +internal const val STRING_LITERAL_SEARCH = "search" +internal const val STRING_LITERAL_INCLUDE = "include" +internal const val PATH_BASE = "https://www.nav3recipes.com" +internal const val PATH_INCLUDE = "$STRING_LITERAL_USERS/$STRING_LITERAL_INCLUDE" +internal const val PATH_SEARCH = "$STRING_LITERAL_USERS/$STRING_LITERAL_SEARCH" +internal const val URL_HOME_EXACT = "$PATH_BASE/$STRING_LITERAL_HOME" + +internal const val URL_USERS_WITH_FILTER = "$PATH_BASE/$PATH_INCLUDE/{$STRING_LITERAL_FILTER}" +internal val URL_SEARCH = "$PATH_BASE/$PATH_SEARCH" + + "?${SearchKey::ageMin.name}={${SearchKey::ageMin.name}}" + + "&${SearchKey::ageMax.name}={${SearchKey::ageMax.name}}" + + "&${SearchKey::firstName.name}={${SearchKey::firstName.name}}" + + "&${SearchKey::location.name}={${SearchKey::location.name}}" +``` \ No newline at end of file diff --git a/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/deeplinks-custommatcher.md b/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/deeplinks-custommatcher.md deleted file mode 100644 index fcfea49..0000000 --- a/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/deeplinks-custommatcher.md +++ /dev/null @@ -1,176 +0,0 @@ -# Custom DeepLinkMatcher Recipe - -This recipe demonstrates how to create a custom `DeepLinkMatcher` in Navigation 3 using custom request extras and Kotlinx Serialization. - -## How it works - -This recipe consists of two activities: - -- `CustomDeepLinkMatcherActivity`: Accepts user input, serializes a `HomeKey` instance into JSON, attaches it to an `Intent` extra via a `RequestExtrasKey`, and launches `MainActivity`. -- `MainActivity`: Constructs a `DeepLinkRequest(intent)`, evaluates it with `JsonDeepLinkMatcher`, decodes the `HomeKey`, and sets it as the starting route in `NavDisplay`. - -## Key Concepts - -1. **Custom `RequestExtrasKey`** : - `JsonDeepLinkMatcherKey` defines a custom extra key implementing `RequestExtrasKey` to type-safely store and read serialized JSON payloads in `DeepLinkRequest.extras`. - -2. **Custom `DeepLinkMatcher`** : - `JsonDeepLinkMatcher` extends `DeepLinkMatcher>` and implements `matchRequest(request)` to extract `request.extras[JsonDeepLinkMatcherKey]` and decode it into a strongly typed `NavKey` using Kotlinx Serialization. - -[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/deeplink/usecases/matcher) - -``` -package com.example.nav3recipes.deeplink.usecases.matcher - -import android.content.Intent -import android.os.Bundle -import android.util.Log -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.material3.ElevatedButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.dropUnlessResumed -import androidx.navigation3.runtime.NavKey -import androidx.navigation3.runtime.deeplink.DeepLinkMatcher -import androidx.navigation3.runtime.deeplink.DeepLinkRequest -import androidx.navigation3.runtime.deeplink.RequestExtrasKey -import androidx.navigation3.runtime.deeplink.get -import com.example.nav3recipes.common.deeplink.EntryScreen -import com.example.nav3recipes.ui.setEdgeToEdgeConfig -import kotlinx.serialization.KSerializer -import kotlinx.serialization.SerializationException -import kotlinx.serialization.json.Json - -class CustomDeepLinkMatcherActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - setEdgeToEdgeConfig() - super.onCreate(savedInstanceState) - - setContent { - EntryScreen { - Column( - verticalArrangement = Arrangement.spacedBy(32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - var text by remember { mutableStateOf("") } - OutlinedTextField( - placeholder = { Text("Your name...", color = Color.Black.copy(alpha = 0.5f)) }, - value = text, - singleLine = true, - onValueChange = { text = it }, - ) - - ElevatedButton( - onClick = - dropUnlessResumed { - val intent = Intent( - this@CustomDeepLinkMatcherActivity, - MainActivity::class.java - ) - val json = Json.encodeToString(HomeKey.serializer(), HomeKey(text)) - intent.putExtra(JsonDeepLinkMatcherKey.toString(), json) - startActivity(intent) - } - ) { - Text("Sign up") - } - } - } - } - } -} - -internal data object JsonDeepLinkMatcherKey: RequestExtrasKey - -internal class JsonDeepLinkMatcher(val serializer: KSerializer): DeepLinkMatcher>() { - override fun matchRequest(request: DeepLinkRequest): MatchResult? { - val json = request.extras[JsonDeepLinkMatcherKey] ?: return null - return try { - val result = Json.decodeFromString(serializer, json) - MatchResult(result) - } catch (e: SerializationException) { - Log.v("DeepLinkMatcher", "Failed to decode json", e) - return null - } - } -} -``` - -``` -package com.example.nav3recipes.deeplink.usecases.matcher - -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.navigation3.runtime.NavBackStack -import androidx.navigation3.runtime.NavKey -import androidx.navigation3.runtime.deeplink.DeepLinkRequest -import androidx.navigation3.runtime.deeplink.invoke -import androidx.navigation3.runtime.entryProvider -import androidx.navigation3.runtime.rememberNavBackStack -import androidx.navigation3.ui.NavDisplay -import com.example.nav3recipes.common.deeplink.EntryScreen -import com.example.nav3recipes.common.deeplink.TextContent -import com.example.nav3recipes.ui.setEdgeToEdgeConfig -import kotlinx.serialization.Serializable -import kotlinx.serialization.serializer - -@Serializable -internal data class HomeKey(val name: String): NavKey - -@Serializable -internal object FallbackKey: NavKey - -class MainActivity : ComponentActivity() { - - override fun onCreate(savedInstanceState: Bundle?) { - setEdgeToEdgeConfig() - super.onCreate(savedInstanceState) - - val request = DeepLinkRequest(intent) - val deepLinkMatcher = createJsonDeepLinkMatcher() - - val matchResult = deepLinkMatcher.match(request) - val key = matchResult?.key ?: FallbackKey - - setContent { - val backStack: NavBackStack = rememberNavBackStack(key) - NavDisplay( - backStack = backStack, - onBack = backStack::removeLastOrNull, - entryProvider = entryProvider { - entry { key -> - EntryScreen("Welcome") { - TextContent(key.name) - } - } - entry { key -> - EntryScreen("Fallback Key") { - TextContent( - "Failed to deep link - DeepLinkRequest " + - "did not match with any DeepLinkMatcher" - ) - } - } - } - ) - } - } -} - -// Optional JsonDeepLinkMatcher factory function that automatically captures KSerializer for T. -private inline fun createJsonDeepLinkMatcher(): JsonDeepLinkMatcher { - val serializer = serializer() - return JsonDeepLinkMatcher(serializer) -} -``` \ No newline at end of file diff --git a/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/deeplinks-staticuri.md b/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/deeplinks-staticuri.md deleted file mode 100644 index 0f35b99..0000000 --- a/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/deeplinks-staticuri.md +++ /dev/null @@ -1,140 +0,0 @@ -# Deep Link Static URI Recipe - -This recipe demonstrates how deep link with a static Uri. - -## Recipe components - -The recipe contains two activities: - -1. `StaticUriDeepLinkActivity` to construct and start an Intent with the deep link uri -2. `MainActivity` is the target Activity of the deep link, represents an app that users can deep link to. - -## How the demonstrated deep link works - -1. The deep link source (`StaticUriDeepLinkActivity`) defines the uri and creates an Intent to deep link with. -2. The app (`MainActivity`) declares a navigation key (`HomeKey`). To indicate that `HomeKey` supports deep linking, the app declares a `UriDeepLinkMatcher` with the `HomeKey` serializer along with the uri pattern that `HomeKey` supports. -3. `MainActivity` onCreate instantiates a `DeepLinkRequest` with the intent and matches it with the `UriDeepLinkMatcher` to get a `MatchResult`. If the `MatchResult` is non-null, the app navigates to the key returned by the result. Otherwise, the deep link is not supported and the app navigates to a `Fallback` screen. - -[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/deeplink/handlerequests/staticuri) - -``` -package com.example.nav3recipes.deeplink.handlerequests.staticuri - -import android.content.Intent -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.core.net.toUri -import androidx.lifecycle.compose.dropUnlessResumed -import com.example.nav3recipes.common.deeplink.EntryScreen -import com.example.nav3recipes.common.deeplink.PaddedButton -import com.example.nav3recipes.common.deeplink.TextContent -import com.example.nav3recipes.deeplink.handlerequests.uriarguments.ui.PATH_BASE -import com.example.nav3recipes.ui.setEdgeToEdgeConfig - -const val HOME_URI = "$PATH_BASE/home" - -class StaticUriDeepLinkActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - setEdgeToEdgeConfig() - super.onCreate(savedInstanceState) - - setContent { - EntryScreen("Deep link url:") { - TextContent(HOME_URI) - PaddedButton("Deeplink Away!", onClick = dropUnlessResumed { - val intent = Intent( - this@StaticUriDeepLinkActivity, - MainActivity::class.java - ) - // the uri to deep link with - intent.data = HOME_URI.toUri() - startActivity(intent) - }) - } - } - } -} -``` - -``` -package com.example.nav3recipes.deeplink.handlerequests.staticuri - -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.navigation3.runtime.NavBackStack -import androidx.navigation3.runtime.NavKey -import androidx.navigation3.runtime.entryProvider -import androidx.navigation3.runtime.rememberNavBackStack -import androidx.navigation3.ui.NavDisplay -import com.example.nav3recipes.common.deeplink.EntryScreen -import com.example.nav3recipes.common.deeplink.TextContent -import com.example.nav3recipes.deeplink.handlerequests.uriarguments.HomeKey -import com.example.nav3recipes.deeplink.handlerequests.uriarguments.NavRecipeKey -import androidx.navigation3.runtime.deeplink.DeepLinkRequest -import androidx.navigation3.runtime.deeplink.DeepLinkUri -import androidx.navigation3.runtime.deeplink.UriDeepLinkMatcher -import androidx.navigation3.runtime.deeplink.invoke -import com.example.nav3recipes.ui.setEdgeToEdgeConfig -import kotlinx.serialization.Serializable -import kotlinx.serialization.serializer - - -@Serializable -internal object FallbackKey: NavRecipeKey { - override val name: String = "Fallback Key" -} - -class MainActivity : ComponentActivity() { - - override fun onCreate(savedInstanceState: Bundle?) { - setEdgeToEdgeConfig() - super.onCreate(savedInstanceState) - - // create a DeepLinkRequest with the intent - val request = DeepLinkRequest(intent) - - // try to match DeepLinkRequest to a DeepLinkMatcher - val matchResult = HOME_MATCHER.match(request) - val key = matchResult?.key ?: FallbackKey - - /** - * Then pass starting key to backstack - */ - setContent { - val backStack: NavBackStack = rememberNavBackStack(key) - NavDisplay( - backStack = backStack, - onBack = { backStack.removeLastOrNull() }, - entryProvider = entryProvider { - entry { key -> - EntryScreen(key.name) { - TextContent("Deep linked to Home") - } - } - entry { key -> - EntryScreen("${key.name} ") { - TextContent( - "Failed to deep link - DeepLinkRequest " + - "did not match with any DeepLinkMatcher" - ) - } - } - - } - ) - } - } -} - -/** - * Each matcher is associated with a navigation key that supports this deep link. - * - * A navigation key can be associated with multiple DeepLinkMatchers if it supports more than one deep link. - */ -private val HOME_MATCHER = UriDeepLinkMatcher( - uriPattern = DeepLinkUri(HOME_URI), - serializer = serializer(), -) -``` \ No newline at end of file diff --git a/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/deeplinks-uriarguments.md b/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/deeplinks-uriarguments.md deleted file mode 100644 index 2a670b8..0000000 --- a/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/deeplinks-uriarguments.md +++ /dev/null @@ -1,390 +0,0 @@ -# Deep Link URI Arguments Recipe - -This recipe demonstrates how to parse a deep link URL from an Android Intent into a Navigation key. - -## Recipe components - -It consists of two activities - -1. `UriWithArgumentsDeepLinkActivity` constructs and triggers the deeplink request -2. `MainActivity` parses the intent into the target navigation key. - -## How it works - -The `MainActivity` handles the request with these steps - -1. Declare a `UriDeepLinkMatcher` for each url pattern that can be deep linked into. Each matcher accepts a uri pattern and the KSerializer of the NavKey that supports this deep link. - -2. Create a `DeepLinkRequest` with the incoming intent. - -3. Match all candidate `UriDeepLinkMatchers` with the request and compare the resulting `UriMatchResults` for the best match. - -4. Read the matching key from `UriMatchResult.key` or use default key if no match. - -This recipe focuses on handing an intent and does not include these considerations: - -- Create synthetic backStack -- Multi-modular setup -- DI -- Managing TaskStack -- Up button vs Back Button - -## Demonstrated forms of deeplink - -The `MainActivity` has several backStack keys to demonstrate different types of supported deep links: - -1. `HomeKey` - deeplink with an exact url (no deeplink arguments) -2. `UsersKey` - deeplink with path arguments -3. `SearchKey` - deeplink with query arguments - -See `MainActivity.deepLinkMatchers` for the actual url pattern of each. -[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/deeplink/handlerequests/uriarguments) - -``` -package com.example.nav3recipes.deeplink.handlerequests.uriarguments - -import androidx.navigation3.runtime.NavKey -import com.example.nav3recipes.deeplink.handlerequests.uriarguments.ui.STRING_LITERAL_FILTER -import com.example.nav3recipes.deeplink.handlerequests.uriarguments.ui.STRING_LITERAL_HOME -import com.example.nav3recipes.deeplink.handlerequests.uriarguments.ui.STRING_LITERAL_SEARCH -import com.example.nav3recipes.deeplink.handlerequests.uriarguments.ui.STRING_LITERAL_USERS -import kotlinx.serialization.Serializable - -internal interface NavRecipeKey: NavKey { - val name: String -} - -@Serializable -internal object HomeKey: NavRecipeKey { - override val name: String = STRING_LITERAL_HOME -} - -@Serializable -internal data class UsersKey( - val filter: String, -): NavRecipeKey { - override val name: String = STRING_LITERAL_USERS - companion object { - const val FILTER_KEY = STRING_LITERAL_FILTER - const val FILTER_OPTION_RECENTLY_ADDED = "recentlyAdded" - const val FILTER_OPTION_ALL = "all" - } -} - -@Serializable -internal data class SearchKey( - val firstName: String? = null, - val ageMin: Int? = null, - val ageMax: Int? = null, - val location: String? = null, -): NavRecipeKey { - override val name: String = STRING_LITERAL_SEARCH -} -``` - -``` -package com.example.nav3recipes.deeplink.handlerequests.uriarguments - -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.core.net.toUri -import androidx.navigation3.runtime.NavBackStack -import androidx.navigation3.runtime.NavKey -import androidx.navigation3.runtime.deeplink.DeepLinkRequest -import androidx.navigation3.runtime.deeplink.UriDeepLinkMatcher -import androidx.navigation3.runtime.deeplink.invoke -import androidx.navigation3.runtime.entryProvider -import androidx.navigation3.runtime.rememberNavBackStack -import androidx.navigation3.ui.NavDisplay -import com.example.nav3recipes.common.deeplink.EntryScreen -import com.example.nav3recipes.common.deeplink.FriendsList -import com.example.nav3recipes.common.deeplink.LIST_USERS -import com.example.nav3recipes.common.deeplink.TextContent -import com.example.nav3recipes.deeplink.handlerequests.uriarguments.ui.URL_HOME_EXACT -import com.example.nav3recipes.deeplink.handlerequests.uriarguments.ui.URL_SEARCH -import com.example.nav3recipes.deeplink.handlerequests.uriarguments.ui.URL_USERS_WITH_FILTER -import com.example.nav3recipes.ui.setEdgeToEdgeConfig -import kotlinx.serialization.serializer - -/** - * See README.md for how this recipe works. - */ -class MainActivity : ComponentActivity() { - /** STEP 1. Declare supported deep links */ - internal val deepLinkMatchers: List> = listOf( - // "https://www.nav3recipes.com/home" - UriDeepLinkMatcher(URL_HOME_EXACT.toUri(), serializer()), - // "https://www.nav3recipes.com/users/with/{filter}" - UriDeepLinkMatcher(URL_USERS_WITH_FILTER.toUri(), serializer()), - // "https://www.nav3recipes.com/users/search?{firstName}&{age}&{location}" - UriDeepLinkMatcher(URL_SEARCH.toUri(), serializer()), - ) - - override fun onCreate(savedInstanceState: Bundle?) { - setEdgeToEdgeConfig() - super.onCreate(savedInstanceState) - - /** STEP 2. Create a [DeepLinkRequest] from the intent */ - val request = DeepLinkRequest(intent) - - /** STEP 3. Match the request to the DeepLinkMatchers*/ - // First get all the possible matching UriMatchResult - val matches = deepLinkMatchers.mapNotNull { - // returns null if no match - it.match(request) - } - // compare all matches to find best match - val bestMatch = matches.maxOrNull() - /** STEP 4. Get the key from the match or use default key if no match*/ - val key = bestMatch?.key ?: HomeKey - - /** - * STEP 5. pass the initial key to backstack - */ - setContent { - val backStack: NavBackStack = rememberNavBackStack(key) - NavDisplay( - backStack = backStack, - onBack = { backStack.removeLastOrNull() }, - entryProvider = entryProvider { - entry { key -> - EntryScreen(key.name) { - TextContent("") - } - } - entry { key -> - EntryScreen("${key.name} : ${key.filter}") { - TextContent("") - val list = when { - key.filter.isEmpty() -> LIST_USERS - key.filter == UsersKey.FILTER_OPTION_ALL -> LIST_USERS - else -> LIST_USERS.take(5) - } - FriendsList(list) - } - } - entry { search -> - EntryScreen(search.name) { - TextContent("") - val matchingUsers = LIST_USERS.filter { user -> - (search.firstName == null || user.firstName == search.firstName) && - (search.location == null || user.location == search.location) && - (search.ageMin == null || user.age >= search.ageMin) && - (search.ageMax == null || user.age <= search.ageMax) - } - FriendsList(matchingUsers) - } - } - } - ) - } - } -} -``` - -``` -package com.example.nav3recipes.deeplink.handlerequests.uriarguments - -import android.content.Intent -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.core.net.toUri -import androidx.lifecycle.compose.dropUnlessResumed -import com.example.nav3recipes.common.deeplink.EMPTY -import com.example.nav3recipes.common.deeplink.EntryScreen -import com.example.nav3recipes.common.deeplink.FIRST_NAME_JOHN -import com.example.nav3recipes.common.deeplink.FIRST_NAME_JULIE -import com.example.nav3recipes.common.deeplink.FIRST_NAME_MARY -import com.example.nav3recipes.common.deeplink.FIRST_NAME_TOM -import com.example.nav3recipes.common.deeplink.LOCATION_BC -import com.example.nav3recipes.common.deeplink.LOCATION_BR -import com.example.nav3recipes.common.deeplink.LOCATION_CA -import com.example.nav3recipes.common.deeplink.LOCATION_US -import com.example.nav3recipes.common.deeplink.MenuDropDown -import com.example.nav3recipes.common.deeplink.MenuTextInput -import com.example.nav3recipes.common.deeplink.PaddedButton -import com.example.nav3recipes.common.deeplink.TextContent -import com.example.nav3recipes.deeplink.handlerequests.uriarguments.ui.PATH_BASE -import com.example.nav3recipes.deeplink.handlerequests.uriarguments.ui.PATH_INCLUDE -import com.example.nav3recipes.deeplink.handlerequests.uriarguments.ui.PATH_SEARCH -import com.example.nav3recipes.deeplink.handlerequests.uriarguments.ui.STRING_LITERAL_HOME -import com.example.nav3recipes.ui.setEdgeToEdgeConfig - -/** - * See README.md for how this recipe works. - * - * See [MainActivity] for how the requested deeplink is handled. - */ -class UriWithArgumentsDeepLinkActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - setEdgeToEdgeConfig() - super.onCreate(savedInstanceState) - - setContent { - /** - * UI for deeplink sandbox - */ - EntryScreen("Sandbox - Build Your Deeplink") { - TextContent("Base url:\n${PATH_BASE}/") - var showFilterOptions by remember { mutableStateOf(false) } - val selectedPath = remember { mutableStateOf(MENU_OPTIONS_PATH[KEY_PATH]?.first()) } - - var showQueryOptions by remember { mutableStateOf(false) } - var selectedFilter by remember { mutableStateOf("") } - val selectedSearchQuery = remember { mutableStateMapOf() } - - // manage path options - MenuDropDown( - menuOptions = MENU_OPTIONS_PATH, - ) { _, selection -> - selectedPath.value = selection - when (selection) { - PATH_SEARCH -> { - showQueryOptions = true - showFilterOptions = false - } - - PATH_INCLUDE -> { - showQueryOptions = false - showFilterOptions = true - } - - else -> { - showQueryOptions = false - showFilterOptions = false - } - } - } - - // manage path filter options, reset state if menu is closed - LaunchedEffect(showFilterOptions) { - selectedFilter = if (showFilterOptions) { - MENU_OPTIONS_FILTER.values.first().first() - } else { - "" - } - } - if (showFilterOptions) { - MenuDropDown( - menuOptions = MENU_OPTIONS_FILTER, - ) { _, selected -> - selectedFilter = selected - } - } - - // manage query options, reset state if menu is closed - LaunchedEffect(showQueryOptions) { - if (showQueryOptions) { - val initEntry = MENU_OPTIONS_SEARCH.entries.first() - selectedSearchQuery[initEntry.key] = initEntry.value.first() - } else { - selectedSearchQuery.clear() - } - } - if (showQueryOptions) { - MenuTextInput( - menuLabels = MENU_LABELS_SEARCH, - ) { label, selected -> - selectedSearchQuery[label] = selected - } - MenuDropDown( - menuOptions = MENU_OPTIONS_SEARCH, - ) { label, selected -> - selectedSearchQuery[label] = selected - } - } - - // form final deeplink url - val arguments = when (selectedPath.value) { - PATH_INCLUDE -> "/${selectedFilter}" - PATH_SEARCH -> { - buildString { - selectedSearchQuery.forEach { entry -> - if (entry.value.isNotEmpty()) { - val prefix = if (isEmpty()) "?" else "&" - append("$prefix${entry.key}=${entry.value}") - } - } - } - } - - else -> "" - } - val finalUrl = "${PATH_BASE}/${selectedPath.value}$arguments" - TextContent("Final url:\n$finalUrl") - // deeplink to target - PaddedButton("Deeplink Away!", onClick = dropUnlessResumed { - val intent = Intent( - this@UriWithArgumentsDeepLinkActivity, - MainActivity::class.java - ) - // start activity with the url - intent.data = finalUrl.toUri() - startActivity(intent) - }) - } - } - } -} - -private const val KEY_PATH = "path" -private val MENU_OPTIONS_PATH = mapOf( - KEY_PATH to listOf( - STRING_LITERAL_HOME, - PATH_INCLUDE, - PATH_SEARCH, - ), -) - -private val MENU_OPTIONS_FILTER = mapOf( - UsersKey.FILTER_KEY to listOf(UsersKey.FILTER_OPTION_RECENTLY_ADDED, UsersKey.FILTER_OPTION_ALL), -) - -private val MENU_OPTIONS_SEARCH = mapOf( - SearchKey::firstName.name to listOf( - EMPTY, - FIRST_NAME_JOHN, - FIRST_NAME_TOM, - FIRST_NAME_MARY, - FIRST_NAME_JULIE - ), - SearchKey::location.name to listOf(EMPTY, LOCATION_CA, LOCATION_BC, LOCATION_BR, LOCATION_US) -) - -private val MENU_LABELS_SEARCH = listOf(SearchKey::ageMin.name, SearchKey::ageMax.name) - -``` - -``` -package com.example.nav3recipes.deeplink.handlerequests.uriarguments.ui - -import com.example.nav3recipes.deeplink.handlerequests.uriarguments.SearchKey - -/** - * String resources - */ -internal const val STRING_LITERAL_FILTER = "filter" -internal const val STRING_LITERAL_HOME = "home" -internal const val STRING_LITERAL_USERS = "users" -internal const val STRING_LITERAL_SEARCH = "search" -internal const val STRING_LITERAL_INCLUDE = "include" -internal const val PATH_BASE = "https://www.nav3recipes.com" -internal const val PATH_INCLUDE = "$STRING_LITERAL_USERS/$STRING_LITERAL_INCLUDE" -internal const val PATH_SEARCH = "$STRING_LITERAL_USERS/$STRING_LITERAL_SEARCH" -internal const val URL_HOME_EXACT = "$PATH_BASE/$STRING_LITERAL_HOME" - -internal const val URL_USERS_WITH_FILTER = "$PATH_BASE/$PATH_INCLUDE/{$STRING_LITERAL_FILTER}" -internal val URL_SEARCH = "$PATH_BASE/$PATH_SEARCH" + - "?${SearchKey::ageMin.name}={${SearchKey::ageMin.name}}" + - "&${SearchKey::ageMax.name}={${SearchKey::ageMax.name}}" + - "&${SearchKey::firstName.name}={${SearchKey::firstName.name}}" + - "&${SearchKey::location.name}={${SearchKey::location.name}}" -``` \ No newline at end of file diff --git a/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/lifecycle-owner.md b/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/lifecycle-owner.md deleted file mode 100644 index 055ed6e..0000000 --- a/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/lifecycle-owner.md +++ /dev/null @@ -1,185 +0,0 @@ -# LocalLifecycleOwner Recipe - -This recipe demonstrates how to use `LifecycleResumeEffect` in Navigation 3 entries to pause and resume work based on the entry's lifecycle state. - -## How it works - -In Navigation 3, by default each `NavEntry` is provided its own `LifecycleOwner` via `LocalLifecycleOwner.current`. This means that any lifecycle-aware components inside the entry is automatically scoped to the `NavEntry`. - -### `LifecycleResumeEffect` with Dialog Scenes - -1. **RouteA (Screen)**: - - - Uses `LifecycleResumeEffect(Unit)` scoped to the `NavEntry`'s `LocalLifecycleOwner.current` to advance the`LinearProgressIndicator` while in the `RESUMED` state. - - Automatically resets `progressValue` back to `0f` whenever it hits `1f`. -2. **RouteB (Dialog)**: - - - Configured as a dialog using `DialogSceneStrategy.dialog()`. - - When the user opens the RouteB dialog, RouteA remains visible behind the dialog in the `STARTED` state (leaving `RESUMED`). - - `LifecycleResumeEffect` calls `onPauseOrDispose`, pausing the progress indicator. - - When the dialog is dismissed, RouteA returns to `RESUMED`, and `LifecycleResumeEffect` resumes the progress indicator automatically. - -[![](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/lifecycleowner) - -``` -/* - * Copyright 2026 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.nav3recipes.lifecycleowner - -import android.os.Bundle -import android.util.Log -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.snap -import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.compose.LifecycleEventEffect -import androidx.lifecycle.compose.LifecycleResumeEffect -import androidx.lifecycle.compose.dropUnlessResumed -import androidx.navigation3.runtime.NavKey -import androidx.navigation3.runtime.entryProvider -import androidx.navigation3.runtime.rememberNavBackStack -import androidx.navigation3.scene.DialogSceneStrategy -import androidx.navigation3.ui.NavDisplay -import com.example.nav3recipes.content.ContentGreen -import com.example.nav3recipes.ui.setEdgeToEdgeConfig -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.serialization.Serializable -import kotlin.time.Duration.Companion.milliseconds - -@Serializable -private data object RouteA : NavKey - -@Serializable -private data object RouteB : NavKey - -class LifecycleOwnerActivity : ComponentActivity() { - - override fun onCreate(savedInstanceState: Bundle?) { - setEdgeToEdgeConfig() - super.onCreate(savedInstanceState) - setContent { - val backStack = rememberNavBackStack(RouteA) - val dialogStrategy = remember { DialogSceneStrategy() } - - NavDisplay( - backStack = backStack, - onBack = backStack::removeLastOrNull, - sceneStrategies = listOf(dialogStrategy), - entryProvider = entryProvider { - entry { - LogLifecycleEffect("ScreenA") - ContentGreen("Screen A") { - var progressValue by remember { mutableFloatStateOf(0f) } - val coroutineScope = rememberCoroutineScope() - - val animatedProgress by animateFloatAsState( - targetValue = progressValue, - animationSpec = if (progressValue == 0f) snap() else tween(durationMillis = 100, easing = LinearEasing), - label = "ProgressAnimation" - ) - - // LifecycleResumeEffect runs only while ScreenA is in the RESUMED state. - // When the dialog opens, ScreenA transitions to PAUSED, which calls onPauseOrDispose. - LifecycleResumeEffect(Unit) { - val job = coroutineScope.launch { - while (true) { - delay(100.milliseconds) - progressValue += 0.01f - if (progressValue >= 1f) { - progressValue = 0f - } - } - } - onPauseOrDispose { - job.cancel() - } - } - - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(16.dp) - ) { - LinearProgressIndicator(progress = { animatedProgress }) - Spacer(modifier = Modifier.height(16.dp)) - Button(onClick = dropUnlessResumed { backStack.add(RouteB) }) { - Text("Open Dialog") - } - } - } - } - entry(metadata = DialogSceneStrategy.dialog()) { - LogLifecycleEffect("Dialog") - AlertDialog( - onDismissRequest = backStack::removeLastOrNull, - title = { Text("Dialog") }, - text = { - Text("Screen A is no longer in resumed state, pausing the progress indicator.") - }, - confirmButton = { - Button(onClick = dropUnlessResumed { backStack.removeLastOrNull() }) { - Text("Dismiss Dialog") - } - } - ) - } - } - ) - } - } -} - -@Composable -private fun LogLifecycleEffect(screenName: String) { - LifecycleEventEffect(Lifecycle.Event.ON_CREATE) { - Log.d("LifecycleOwner", "$screenName: ON_CREATE") - } - LifecycleEventEffect(Lifecycle.Event.ON_START) { - Log.d("LifecycleOwner", "$screenName: ON_START") - } - LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { - Log.d("LifecycleOwner", "$screenName: ON_RESUME") - } - LifecycleEventEffect(Lifecycle.Event.ON_PAUSE) { - Log.d("LifecycleOwner", "$screenName: ON_PAUSE") - } - LifecycleEventEffect(Lifecycle.Event.ON_STOP) { - Log.d("LifecycleOwner", "$screenName: ON_STOP") - } - // Note that ON_DESTROY event is not observable from composables. -} -``` \ No newline at end of file diff --git a/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/material-listdetail.md b/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/material-listdetail.md index fbaae79..60a7fc6 100644 --- a/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/material-listdetail.md +++ b/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/material-listdetail.md @@ -47,7 +47,7 @@ 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.currentWindowAdaptiveInfo import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy import androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneStrategy @@ -88,7 +88,7 @@ class MaterialListDetailActivity : ComponentActivity() { // Override the defaults so that there isn't a horizontal space between the panes. // See b/418201867 - val windowAdaptiveInfo = currentWindowAdaptiveInfoV2() + val windowAdaptiveInfo = currentWindowAdaptiveInfo() val directive = remember(windowAdaptiveInfo) { calculatePaneScaffoldDirective(windowAdaptiveInfo) .copy(horizontalPartitionSpacerSize = 0.dp) diff --git a/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/material-supportingpane.md b/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/material-supportingpane.md index 58a6a7c..97727bf 100644 --- a/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/material-supportingpane.md +++ b/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/material-supportingpane.md @@ -49,7 +49,7 @@ 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.currentWindowAdaptiveInfo import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior import androidx.compose.material3.adaptive.navigation3.SupportingPaneSceneStrategy @@ -90,7 +90,7 @@ class MaterialSupportingPaneActivity : ComponentActivity() { // Override the defaults so that there isn't a horizontal or vertical space between the panes. // See b/444438086 - val windowAdaptiveInfo = currentWindowAdaptiveInfoV2() + val windowAdaptiveInfo = currentWindowAdaptiveInfo() val directive = remember(windowAdaptiveInfo) { calculatePaneScaffoldDirective(windowAdaptiveInfo) .copy(horizontalPartitionSpacerSize = 0.dp, verticalPartitionSpacerSize = 0.dp) diff --git a/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/modular-hilt.md b/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/modular-hilt.md index fcc94a2..e22b0c1 100644 --- a/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/modular-hilt.md +++ b/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/modular-hilt.md @@ -86,8 +86,8 @@ import androidx.activity.compose.setContent import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.ui.Modifier -import androidx.navigation3.runtime.entryProvider import androidx.navigation3.ui.NavDisplay +import androidx.navigation3.runtime.entryProvider import com.example.nav3recipes.ui.setEdgeToEdgeConfig import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject diff --git a/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/multiple-backstacks.md b/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/multiple-backstacks.md index aca3404..b7f7ebb 100644 --- a/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/multiple-backstacks.md +++ b/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/multiple-backstacks.md @@ -2,7 +2,7 @@ This recipe demonstrates how to create multiple back stacks. -The app has three top level routes: `RouteA`, `RouteB` and `RouteC`. These routes have sub routes `RouteA1`, `RouteB1` and `RouteC1` respectively. `RouteA1` contains a 100-item scrollable list, while `RouteB1` and `RouteC1` contain counters used to verify state retention through configuration changes and process death. +The app has three top level routes: `RouteA`, `RouteB` and `RouteC`. These routes have sub routes `RouteA1`, `RouteB1` and `RouteC1` respectively. The content for the sub routes is a counter that can be used to verify state retention through configuration changes and process death. The app's navigation state is held in the `NavigationState` class. The state itself is created using `rememberNavigationState`. @@ -14,7 +14,6 @@ Key behaviors: - This app follows the "exit through home" pattern where the user always exits through the starting back stack. This means that `RouteA`'s entries are *always* in the list of entries. - Navigating to a top level route that is not the starting route *replaces* the other entries. For example, navigating A-\>B-\>C would result in entries for A+C, B's entries are removed. -- When a top-level route is reselected, for example if the user is on `RouteA` and taps `RouteA` on the navigation bar again, the `NavigationBar` signals the reselected key to `Navigator`, which emits it to a `Flow`. `RouteA1` observes this flow and, when it receives `RouteA`, resets its list scroll position to index 0. Important implementation details: @@ -42,16 +41,11 @@ Important implementation details: package com.example.nav3recipes.multiplestacks import androidx.navigation3.runtime.NavKey -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow /** * Handles navigation events (forward and back) by updating the navigation state. */ class Navigator(val state: NavigationState){ - private val _reselectEvents = MutableSharedFlow(extraBufferCapacity = 1) - val reselectEvents = _reselectEvents.asSharedFlow() - fun navigate(route: NavKey){ if (route in state.backStacks.keys){ // This is a top level route, just switch to it @@ -61,10 +55,6 @@ class Navigator(val state: NavigationState){ } } - fun onReselect(route: NavKey) { - _reselectEvents.tryEmit(route) - } - fun goBack(){ val currentStack = state.backStacks[state.topLevelRoute] ?: error("Stack for ${state.topLevelRoute} not found") @@ -235,11 +225,10 @@ class NavigationState( package com.example.nav3recipes.multiplestacks +import android.annotation.SuppressLint import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Camera import androidx.compose.material.icons.filled.Face @@ -250,9 +239,7 @@ import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.unit.dp import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider import androidx.navigation3.ui.NavDisplay @@ -290,6 +277,7 @@ data class NavBarItem( ) class MultipleStacksActivity : ComponentActivity() { + @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") override fun onCreate(savedInstanceState: Bundle?) { setEdgeToEdgeConfig() super.onCreate(savedInstanceState) @@ -302,26 +290,18 @@ class MultipleStacksActivity : ComponentActivity() { val navigator = remember { Navigator(navigationState) } val entryProvider = entryProvider { - featureASection( - reselectEvents = navigator.reselectEvents, - onSubRouteClick = { navigator.navigate(RouteA1) } - ) + featureASection(onSubRouteClick = { navigator.navigate(RouteA1) }) featureBSection(onSubRouteClick = { navigator.navigate(RouteB1) }) featureCSection(onSubRouteClick = { navigator.navigate(RouteC1) }) } - Scaffold(contentWindowInsets = WindowInsets(0.dp), bottomBar = { + Scaffold(bottomBar = { NavigationBar { TOP_LEVEL_ROUTES.forEach { (key, value) -> val isSelected = key == navigationState.topLevelRoute NavigationBarItem( selected = isSelected, - onClick = { - navigator.navigate(key) - if (isSelected) { - navigator.onReselect(key) - } - }, + onClick = { navigator.navigate(key) }, icon = { Icon( imageVector = value.icon, @@ -332,11 +312,10 @@ class MultipleStacksActivity : ComponentActivity() { ) } } - }) { innerPadding -> + }) { NavDisplay( entries = navigationState.toDecoratedEntries(entryProvider), - onBack = { navigator.goBack() }, - modifier = Modifier.padding(innerPadding) + onBack = { navigator.goBack() } ) } } @@ -364,17 +343,13 @@ class MultipleStacksActivity : ComponentActivity() { package com.example.nav3recipes.multiplestacks import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Button import androidx.compose.material3.Text -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment -import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.dropUnlessResumed import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey @@ -384,34 +359,27 @@ import com.example.nav3recipes.content.ContentOrange import com.example.nav3recipes.content.ContentPink import com.example.nav3recipes.content.ContentPurple import com.example.nav3recipes.content.ContentRed -import kotlinx.coroutines.flow.Flow fun EntryProviderScope.featureASection( - reselectEvents: Flow, onSubRouteClick: () -> Unit, ) { entry { ContentRed("Route A") { - Button(onClick = dropUnlessResumed(block = onSubRouteClick)) { - Text("Go to A1") + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Button(onClick = dropUnlessResumed(block = onSubRouteClick)) { + Text("Go to A1") + } } } } entry { - val scrollState = rememberLazyListState() - LaunchedEffect(reselectEvents) { - reselectEvents.collect { route -> - if (route == RouteA) { - scrollState.scrollToItem(0) - } - } - } - ContentPink("Route A1") { - LazyColumn(state = scrollState) { - items(100) { index -> - Text("Route A item ${index + 1}", fontSize = 24.sp) - } + var count by rememberSaveable { + mutableIntStateOf(0) + } + + Button(onClick = dropUnlessResumed { count++ }) { + Text("Value: $count") } } } @@ -434,7 +402,7 @@ fun EntryProviderScope.featureBSection( var count by rememberSaveable { mutableIntStateOf(0) } - Button(onClick = { count++ }) { + Button(onClick = dropUnlessResumed { count++ }) { Text("Value: $count") } } @@ -459,7 +427,7 @@ fun EntryProviderScope.featureCSection( mutableIntStateOf(0) } - Button(onClick = { count++ }) { + Button(onClick = dropUnlessResumed { count++ }) { Text("Value: $count") } } diff --git a/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/passingarguments.md b/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/passingarguments.md index 2640a22..292bee3 100644 --- a/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/passingarguments.md +++ b/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/passingarguments.md @@ -293,12 +293,17 @@ import androidx.navigation3.ui.NavDisplay import com.example.nav3recipes.content.ContentBlue import com.example.nav3recipes.content.ContentGreen import com.example.nav3recipes.ui.setEdgeToEdgeConfig +import org.koin.android.ext.koin.androidContext import org.koin.compose.KoinApplication import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.context.GlobalContext +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin import org.koin.core.module.dsl.viewModelOf import org.koin.core.parameter.parametersOf import org.koin.dsl.koinConfiguration import org.koin.dsl.module +import org.koin.mp.KoinPlatform data object RouteA data class RouteB(val id: String) diff --git a/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/results-event.md b/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/results-event.md index ea7d27d..068d0e5 100644 --- a/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/results-event.md +++ b/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/results-event.md @@ -6,10 +6,9 @@ This recipe demonstrates how to return a result from one screen to a previous sc This example uses a `ResultEventBus` to facilitate communication between the screens. -1. **ResultEventBusNavEntryDecorator** : A `NavEntryDecorator` that provides a `ResultEventBus` via `LocalResultEventBus`. -2. **`ResultEventBus`** : A `ResultEventBus` is created and made available to the composables via `LocalResultEventBus`. This EventBus sends and receives the results. -3. **Sending the result** : The screen that produces the result calls `resultBus.sendResult(person)` to send the data back as a one-time event. -4. **Receiving the result** : The screen that needs the result uses a `ResultEffect` composable to listen for results of a specific type. When a result is received, the effect's lambda is triggered. +1. **`ResultEventBus`**: A simple event bus is created and made available to the composables. +2. **Sending the result** : The screen that produces the result calls `resultBus.sendResult(person)` to send the data back as a one-time event. +3. **Receiving the result** : The screen that needs the result uses a `ResultEffect` composable to listen for results of a specific type. When a result is received, the effect's lambda is triggered. This approach is useful for results that are transient and should be handled as one-time events. [![](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/results/event) @@ -69,7 +68,7 @@ import kotlinx.serialization.Serializable data object Home : NavKey @Serializable -class PersonDetailsForm : NavKey +class PersonDetailsForm : NavKeyhttps://github.com/android/nav3-recipes/blob/d2a2288a393dfa373e02b04c48c483cd9add9dbf/app/src/main/java/com/example/nav3recipes/results/common/NavKeys.kt ``` ``` @@ -91,10 +90,11 @@ class PersonDetailsForm : NavKey package com.example.nav3recipes.results.common -import kotlinx.serialization.Serializable +import android.os.Parcelable +import kotlinx.parcelize.Parcelize -@Serializable -data class Person(val name: String, val favoriteColor: String) +@Parcelize +data class Person(val name: String, val favoriteColor: String) : Parcelablehttps://github.com/android/nav3-recipes/blob/d2a2288a393dfa373e02b04c48c483cd9add9dbf/app/src/main/java/com/example/nav3recipes/results/common/Person.kt ``` ``` @@ -201,19 +201,63 @@ fun PersonDetailsScreen( package com.example.nav3recipes.results.event +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect + +/** + * An Effect to provide a result even between different screens + * + * The trailing lambda provides the result from a flow of results. + * + * @param resultEventBus the ResultEventBus to retrieve the result from. The default value + * is read from the `LocalResultEventBus` composition local. + * @param resultKey the key that should be associated with this effect + * @param onResult the callback to invoke when a result is received + */ +@Composable +inline fun ResultEffect( + resultEventBus: ResultEventBus = LocalResultEventBus.current, + resultKey: String = T::class.toString(), + crossinline onResult: suspend (T) -> Unit +) { + LaunchedEffect(resultKey, resultEventBus.channelMap[resultKey]) { + resultEventBus.getResultFlow(resultKey)?.collect { result -> + onResult.invoke(result as T) + } + } +} +``` + +``` +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.nav3recipes.results.event + import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack -import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator -import androidx.navigation3.runtime.result.LocalResultEventBus -import androidx.navigation3.runtime.result.ResultEffect -import androidx.navigation3.runtime.result.rememberResultEventBusNavEntryDecorator import androidx.navigation3.ui.NavDisplay import com.example.nav3recipes.results.common.Home import com.example.nav3recipes.results.common.HomeScreen @@ -221,15 +265,15 @@ import com.example.nav3recipes.results.common.HomeViewModel import com.example.nav3recipes.results.common.Person import com.example.nav3recipes.results.common.PersonDetailsForm import com.example.nav3recipes.results.common.PersonDetailsScreen -import com.example.nav3recipes.ui.setEdgeToEdgeConfig class ResultEventActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { - setEdgeToEdgeConfig() super.onCreate(savedInstanceState) setContent { + val resultBus = remember { ResultEventBus() } + Scaffold { paddingValues -> val backStack = rememberNavBackStack(Home) @@ -238,14 +282,10 @@ class ResultEventActivity : ComponentActivity() { backStack = backStack, modifier = Modifier.padding(paddingValues), onBack = { backStack.removeLastOrNull() }, - entryDecorators = listOf( - rememberSaveableStateHolderNavEntryDecorator(), - rememberResultEventBusNavEntryDecorator() - ), entryProvider = entryProvider { entry { val viewModel = viewModel(key = Home.toString()) - ResultEffect { person -> + ResultEffect(resultBus) { person -> viewModel.person = person } @@ -256,7 +296,6 @@ class ResultEventActivity : ComponentActivity() { ) } entry { - val resultBus = LocalResultEventBus.current PersonDetailsScreen( onSubmit = { person -> resultBus.sendResult(result = person) @@ -270,4 +309,92 @@ class ResultEventActivity : ComponentActivity() { } } } +``` + +``` +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.nav3recipes.results.event + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.ProvidedValue +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.mutableStateMapOf +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.BUFFERED +import kotlinx.coroutines.flow.receiveAsFlow + +/** + * Local for receiving results in a [ResultEventBus] + */ +object LocalResultEventBus { + private val LocalResultEventBus: ProvidableCompositionLocal = + compositionLocalOf { null } + + /** + * The current [ResultEventBus] + */ + val current: ResultEventBus + @Composable + get() = LocalResultEventBus.current ?: error("No ResultEventBus has been provided") + + /** + * Provides a [ResultEventBus] to the composition + */ + infix fun provides( + bus: ResultEventBus + ): ProvidedValue { + return LocalResultEventBus.provides(bus) + } +} +/** + * An EventBus for passing results between multiple sets of screens. + * + * It provides a solution for event based results. + */ +class ResultEventBus { + /** + * Map from the result key to a channel of results. + */ + val channelMap = mutableStateMapOf>() + + /** + * Provides a flow for the given resultKey. + */ + inline fun getResultFlow(resultKey: String = T::class.toString()) = + channelMap[resultKey]?.receiveAsFlow() + + /** + * Sends a result into the channel associated with the given resultKey. + */ + inline fun sendResult(resultKey: String = T::class.toString(), result: T) { + if (!channelMap.contains(resultKey)) { + channelMap[resultKey] = Channel(capacity = BUFFERED, onBufferOverflow = BufferOverflow.SUSPEND) + } + channelMap[resultKey]?.trySend(result) + } + + /** + * Removes all results associated with the given key from the store. + */ + inline fun removeResult(resultKey: String = T::class.toString()) { + channelMap.remove(resultKey) + } +} ``` \ No newline at end of file diff --git a/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/results-state.md b/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/results-state.md index a73e148..1f9323e 100644 --- a/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/results-state.md +++ b/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/results-state.md @@ -4,14 +4,13 @@ This recipe demonstrates how to return a result from one screen to a previous sc ## How it works -This example uses a `ResultEventBus` to manage the result as state. +This example uses a `ResultStore` to manage the result as state. -1. **ResultEventBusNavEntryDecorator** : A `NavEntryDecorator` that provides a `ResultEventBus` via `LocalResultEventBus`. -2. **`ResultEventBus`** : A `ResultEventBus` is created and made available to the composables via `LocalResultEventBus`. This EventBus sends and receives the results. -3. **Setting the result** : The screen that produces the result calls `resultBus.sendResult(person)` to send the data back. -4. **Observing the result** : The screen that needs the result calls `resultBus.conflateAsState()` to get a `State` object representing the result. The UI then observes this state and recomposes whenever the result changes. +1. **`ResultStore`** : A `ResultStore` is created and made available to the composables. This store holds the results. +2. **Setting the result** : The screen that produces the result calls `resultStore.setResult(person)` to save the data in the store. +3. **Observing the result** : The screen that needs the result calls `resultStore.getResultState()` to get a `State` object representing the result. The UI then observes this state and recomposes whenever the result changes. -This approach is suitable when only the latest result is required. The result state does not survive configuration change or process death. +This approach is suitable when the result should be treated as persistent state that survives recomposition and configuration changes. [![](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/results/state) ``` @@ -69,7 +68,7 @@ import kotlinx.serialization.Serializable data object Home : NavKey @Serializable -class PersonDetailsForm : NavKey +class PersonDetailsForm : NavKeyhttps://github.com/android/nav3-recipes/blob/d2a2288a393dfa373e02b04c48c483cd9add9dbf/app/src/main/java/com/example/nav3recipes/results/common/NavKeys.kt ``` ``` @@ -91,10 +90,11 @@ class PersonDetailsForm : NavKey package com.example.nav3recipes.results.common -import kotlinx.serialization.Serializable +import android.os.Parcelable +import kotlinx.parcelize.Parcelize -@Serializable -data class Person(val name: String, val favoriteColor: String) +@Parcelize +data class Person(val name: String, val favoriteColor: String) : Parcelablehttps://github.com/android/nav3-recipes/blob/d2a2288a393dfa373e02b04c48c483cd9add9dbf/app/src/main/java/com/example/nav3recipes/results/common/Person.kt ``` ``` @@ -209,50 +209,40 @@ import androidx.compose.material3.Scaffold import androidx.compose.ui.Modifier import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack -import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator -import androidx.navigation3.runtime.result.LocalResultEventBus -import androidx.navigation3.runtime.result.rememberResultEventBusNavEntryDecorator import androidx.navigation3.ui.NavDisplay import com.example.nav3recipes.results.common.Home import com.example.nav3recipes.results.common.HomeScreen import com.example.nav3recipes.results.common.Person import com.example.nav3recipes.results.common.PersonDetailsForm import com.example.nav3recipes.results.common.PersonDetailsScreen -import com.example.nav3recipes.ui.setEdgeToEdgeConfig class ResultStateActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { - setEdgeToEdgeConfig() super.onCreate(savedInstanceState) setContent { + val resultStore = rememberResultStore() + Scaffold { paddingValues -> val backStack = rememberNavBackStack(Home) + NavDisplay( backStack = backStack, modifier = Modifier.padding(paddingValues), onBack = { backStack.removeLastOrNull() }, - entryDecorators = listOf( - rememberSaveableStateHolderNavEntryDecorator(), - rememberResultEventBusNavEntryDecorator() - ), entryProvider = entryProvider { entry { - val resultState = LocalResultEventBus - .current - .conflateAsState(null) - val person = resultState.value + val person = resultStore.getResultState() HomeScreen( person = person, onNext = { backStack.add(PersonDetailsForm()) } ) } entry { - val resultBus = LocalResultEventBus.current PersonDetailsScreen( onSubmit = { person -> - resultBus.sendResult(result = person) + resultStore.setResult(result = person) backStack.removeLastOrNull() } ) @@ -263,4 +253,117 @@ class ResultStateActivity : ComponentActivity() { } } } +``` + +``` +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.nav3recipes.results.state + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.ProvidedValue +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.mapSaver +import androidx.compose.runtime.saveable.rememberSaveable + +/** + * Local for storing results in a [ResultStore] + */ +object LocalResultStore { + private val LocalResultStore: ProvidableCompositionLocal = + compositionLocalOf { null } + + /** + * The current [ResultStore] + */ + val current: ResultStore + @Composable + get() = LocalResultStore.current ?: error("No ResultStore has been provided") + + /** + * Provides a [ResultStore] to the composition + */ + infix fun provides( + store: ResultStore + ): ProvidedValue { + return LocalResultStore.provides(store) + } +} + +/** + * Provides a [ResultStore] that will be remembered across configuration changes. + */ +@Composable +fun rememberResultStore(): ResultStore { + return rememberSaveable(saver = ResultStoreSaver()) { + ResultStore() + } +} + +/** + * A store for passing results between multiple sets of screens. + * + * It provides a solution for state based results. + */ +class ResultStore { + + /** + * Map from the result key to a mutable state of the result. + */ + val resultStateMap = mutableStateMapOf>() + + /** + * Retrieves the current result of the given resultKey. + */ + inline fun getResultState(resultKey: String = T::class.toString()) = + resultStateMap[resultKey]?.value as T + + /** + * Sets the result for the given resultKey. + */ + inline fun setResult(resultKey: String = T::class.toString(), result: T) { + resultStateMap[resultKey] = mutableStateOf(result) + } + + /** + * Removes all results associated with the given key from the store. + */ + inline fun removeResult(resultKey: String = T::class.toString()) { + resultStateMap.remove(resultKey) + } +} + +/** Saver to save and restore the ResultStore across config change and process death. */ +private fun ResultStoreSaver(): Saver = + mapSaver( + save = { resultStore -> + resultStore.resultStateMap.mapValues { it.value.value } + }, + restore = { restoredMap -> + ResultStore().apply { + restoredMap.forEach { (key, value) -> + resultStateMap[key] = mutableStateOf(value) + } + } + } + ) ``` \ No newline at end of file diff --git a/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/scenes-listdetail.md b/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/scenes-listdetail.md index 32063b3..5ed49a8 100644 --- a/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/scenes-listdetail.md +++ b/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/scenes-listdetail.md @@ -39,7 +39,7 @@ import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2 +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.compositionLocalOf @@ -123,7 +123,7 @@ val LocalBackButtonVisibility = compositionLocalOf { true } @Composable fun rememberListDetailSceneStrategy(): ListDetailSceneStrategy { - val windowSizeClass = currentWindowAdaptiveInfoV2().windowSizeClass + val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass return remember(windowSizeClass) { ListDetailSceneStrategy(windowSizeClass) diff --git a/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/scenes-twopane.md b/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/scenes-twopane.md index fed0bf9..a613c9d 100644 --- a/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/scenes-twopane.md +++ b/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/scenes-twopane.md @@ -16,7 +16,7 @@ package com.example.nav3recipes.scenes.twopane import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2 +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -67,7 +67,7 @@ data class TwoPaneScene( @Composable fun rememberTwoPaneSceneStrategy(): TwoPaneSceneStrategy { - val windowSizeClass = currentWindowAdaptiveInfoV2().windowSizeClass + val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass return remember(windowSizeClass) { TwoPaneSceneStrategy(windowSizeClass) @@ -148,6 +148,7 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.SharedTransitionScope import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.material3.Button diff --git a/navigation/navigation-event/SKILL.md b/navigation/navigation-event/SKILL.md deleted file mode 100644 index dda0b69..0000000 --- a/navigation/navigation-event/SKILL.md +++ /dev/null @@ -1,309 +0,0 @@ ---- -name: navigation-event -description: Intercept back gestures and run Predictive Back animations using the - NavigationEvent (androidx.navigationevent) library in Compose Android. Handles Activity - setup, parent-child dispatcher scoping in `ViewPagers` or tabs, Compose `NavigationBackHandler`, - and migration from legacy `BackHandler` on SDK 36+. -license: Complete terms in LICENSE.txt -metadata: - author: Google LLC - last-updated: '2026-09-01' - keywords: - - Android - - Navigation Event - - Jetpack Compose - - Back Navigation - - Dispatcher - - Guidelines - - Troubleshooting - - ComponentActivity - - Dialog - - ViewPager ---- - -## Common guidelines - -- **For architecture concepts** : To understand the foundational architecture, continuous gesture event lifecycles, or class definitions of the Navigation Event library, read [Navigation Event overview](references/android/guide/navigation/navigation-event/index.md). -- **For Android target** : If compile SDK is lower than 36, set it to `36` or higher in `build.gradle.kts`. -- **For Compose Android target**: The project must use Jetpack Compose for Compose-specific APIs. This skill is scoped exclusively to Compose Android (Android Views and non-Compose implementations are excluded). -- **For activity dispatchers** : `ComponentActivity` automatically implements `NavigationEventDispatcherOwner` out-of-the-box. You must use the built-in `navigationEventDispatcher` without creating anonymous delegate owners or overriding member properties. -- **For dialog scoping** : Floating windows (Compose `Dialog`, `ModalBottomSheet`, `ComponentDialog`) automatically provide a `NavigationEventDispatcherOwner`. You don't need manual `CompositionLocalProvider` propagation for dialogs. -- **For parent-child dispatcher hierarchies** : When scoping navigation handling to `ViewPagers`, tabbed interfaces, or nested navigation containers in Compose, use `rememberNavigationEventDispatcherOwner()` to create a child owner linked to the parent. Disabling the owner (`enabled = false`) automatically cascades to disable all child handlers. -- **For Compose handlers** : A one-to-one relationship between `NavigationEventState` and handlers is strictly enforced. Never bind the same `NavigationEventState` to multiple active `NavigationBackHandler` instances (`IllegalArgumentException`). - -## Step 1: Plan - -To complete this step, you **MUST** ensure the following: - -1. **Identify the target platform** : Verify the app is targeting Compose Android. If `compileSdk` is lower than 36, set it to `36` or higher in `build.gradle.kts`. -2. **Navigation check**: Check if Navigation 3 is in use. If it is in use, use Navigation 3's built-in back navigation support rather than manually implementing low-level dispatchers from this skill. -3. **Hierarchy check** : Identify host Activities, `ViewPagers`, tabbed interfaces, or nested navigation hosts that require back gesture interception or parent-child dispatcher linking. -4. **Migration check** : Check if the project is migrating from back handling (`OnBackPressedCallback`, `BackHandler`, `onBackPresser`) to `NavigationEvent` and `NavigationBackHandler`. -5. **Input interception** : Detect where the app is intercepting navigation events from gestures or hardware button presses requiring translation to `NavigationEvent`. - -## Step 2: Set up dependencies - -To complete this step, you **MUST** ensure the following: - -- For setting up compile SDKs, declaring catalog versions, and adding dependencies, follow [setup guide](references/android/guide/navigation/navigation-event/setup.md). - -## Step 3: Configure dispatcher and inputs - -To complete this step, you **MUST** ensure the following: - -- To configure your dispatcher, leverage automatic `ComponentActivity` or `ComponentDialog` owner resolution. -- Link parent-child dispatchers in Compose following [dispatcher guide](references/android/guide/navigation/navigation-event/dispatcher.md). - -## Step 4: Handle back navigation and UI transitions - -To complete this step, you **MUST** ensure the following: - -- To create navigation event handlers, integrate back gesture interception in Compose, animate UI components during swipes, and migrate from legacy back handlers, follow [handle back guide](references/android/guide/navigation/navigation-event/handle-back.md). - -## Step 5: Clean up resources - -> [!WARNING] -> **Warning:** Compose APIs perform teardown automatically. When using Compose APIs such as `NavigationBackHandler` and `rememberNavigationEventDispatcherOwner()`, handler removal and dispatcher disposal occur automatically when the composable leaves the composition. - -You **MUST** perform explicit manual cleanup only when managing custom -dispatchers or non-Compose handlers: - -- Call `remove()` on active handlers during teardown. -- Call `isEnabled = false` to temporarily disable navigation subtrees. -- Call `dispose()` on dispatcher instances when hosting components are destroyed. Disposing a parent dispatcher automatically cascades to all child dispatchers. - -## Core troubleshooting guidelines - -### 1. Activity dispatcher setup (StackOverflowError recursion) - -`ComponentActivity` implements `NavigationEventDispatcherOwner` automatically -out-of-the-box. Don't override `navigationEventDispatcher` or wrap it in an -anonymous delegate owner. - -#### RIGHT - -**Why this is RIGHT** : Compose apps use `ComponentActivity` as the host. -`LocalNavigationEventDispatcherOwner.current` automatically resolves the -Activity's built-in dispatcher. - - -```kotlin -// RIGHT -class MainActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContent { - MyApplicationContent() - } - } -} -``` - -
- -#### WRONG - -**Why this is WRONG** : Implementing `NavigationEventDispatcherOwner` directly on -`MainActivity` and overriding `navigationEventDispatcher` with a new instance -shadows the library's extension property, causing a recursive infinite loop -crash on launch (`StackOverflowError`). Creating redundant anonymous delegate -owners (`object : NavigationEventDispatcherOwner`) is unnecessary. - - -```kotlin -// WRONG -class MainActivity : ComponentActivity(), NavigationEventDispatcherOwner { - override val navigationEventDispatcher = NavigationEventDispatcher() // Shadow loop crash - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContent { - MyApplicationContent() - } - } -} -``` - -
- -### 2. Floating window and dialog scoping (automatic ComponentDialog owner) - -Floating windows (Compose `Dialog`, `ModalBottomSheet`, and any window backed by -`ComponentDialog`) automatically provide a `NavigationEventDispatcherOwner`. -Don't manually re-provide `LocalNavigationEventDispatcherOwner` using -`CompositionLocalProvider` inside dialogs. - -#### RIGHT - -**Why this is RIGHT** : `ComponentDialog` handles navigation dispatchers -automatically. Compose `Dialog` components resolve their dispatcher owner -out-of-the-box without manual propagation. - - -```kotlin -// RIGHT -@Composable -fun MyDialog(onDismiss: () -> Unit) { - Dialog(onDismissRequest = onDismiss) { - val navigationState = rememberNavigationEventState(currentInfo = NavigationEventInfo.None) - NavigationBackHandler( - state = navigationState, - onBackCompleted = onDismiss - ) - } -} -``` - -
- -#### WRONG - -**Why this is WRONG** : Wrapping dialog content in a manual -`CompositionLocalProvider` creates redundant boilerplate and obscures the -automatic dispatcher resolution provided by `ComponentDialog`. - - -```kotlin -// WRONG -@Composable -fun MyDialog(onDismiss: () -> Unit) { - val dispatcherOwner = LocalNavigationEventDispatcherOwner.current!! - Dialog(onDismissRequest = onDismiss) { - // Redundant: ComponentDialog provides NavigationEventDispatcherOwner automatically - CompositionLocalProvider( LocalNavigationEventDispatcherOwner provides dispatcherOwner) { - val navigationState = rememberNavigationEventState(currentInfo = NavigationEventInfo.None) - NavigationBackHandler( - state = navigationState, - onBackCompleted = onDismiss - ) - } - } -} -``` - -
- -### 3. Parent-child dispatcher hierarchy (`ViewPagers` and nested navigation) - -When managing nested UI hierarchies such as `ViewPagers`, tabbed -interfaces, or custom navigation containers in Compose, use -`rememberNavigationEventDispatcherOwner()` to create a child owner linked to the -composition hierarchy. Setting `enabled = false` on the child owner -automatically disables its dispatcher and all registered child handlers. - -#### RIGHT - -**Why this is RIGHT** : Using `rememberNavigationEventDispatcherOwner(enabled = -isSelected)` creates a scoped child dispatcher linked to the parent from -`LocalNavigationEventDispatcherOwner.current`. Providing it using -`CompositionLocalProvider` ensures non-visible tabs or pages automatically stop -intercepting back gestures without leaking handlers. - - -```kotlin -// RIGHT: Scoping child navigation in a ViewPager or Tab interface -@Composable -fun TabPage(isSelected: Boolean) { - val childOwner = rememberNavigationEventDispatcherOwner(enabled = isSelected) - CompositionLocalProvider(LocalNavigationEventDispatcherOwner provides childOwner) { - val navigationState = rememberNavigationEventState(currentInfo = NavigationEventInfo.None) - NavigationBackHandler( - state = navigationState, - onBackCompleted = { /* Handle page back navigation */ } - ) - // Page content - } -} -``` - -
- -#### WRONG - -**Why this is WRONG** : Creating unlinked standalone dispatchers, instantiating -raw dispatchers without remembering them across recompositions, or attempting to -use non-existent methods like `.addChild()` breaks hierarchy routing and leaves -child handlers active even when the page is inactive. - - -```kotlin -// WRONG -@Composable -fun TabPage(isSelected: Boolean) { - val parentDispatcher = LocalNavigationEventDispatcherOwner.current?.navigationEventDispatcher - val childDispatcher = NavigationEventDispatcher() // Unlinked and not remembered across recompositions - // WRONG: Method does not exist - parentDispatcher?.addChild(childDispatcher) -} -``` - -
- -### 4. Compose multi-handler registration (IllegalArgumentException) - -You must not bind the same `NavigationEventState` to multiple active -`NavigationBackHandler` instances, as this throws an `IllegalArgumentException` -at runtime. To handle conditional workflows (such as checking for unsaved -changes versus navigating back immediately), you must register a single unified -handler and branch logic inside `onBackCompleted`. - -#### RIGHT - -**Why this is RIGHT** : Using a single `NavigationBackHandler` with internal -branching logic inside `onBackCompleted` maintains a strict 1:1 mapping between -`NavigationEventState` and the handler, preventing state collisions. - - -```kotlin -// RIGHT -val navigationState = rememberNavigationEventState(currentInfo = NavigationEventInfo.None) -NavigationBackHandler( - state = navigationState, - isBackEnabled = true, - onBackCompleted = { - if (hasUnsavedChanges) { - showDiscardDialog() - } else { - onNavigateUp() - } - } -) -``` - -
- -#### WRONG - -**Why this is WRONG** : Attaching multiple `NavigationBackHandler` composables to -the same `navigationState` instance attempts to bind duplicate handlers to a -single state object, which throws an `IllegalArgumentException` at runtime. - - -```kotlin -// WRONG -val navigationState = rememberNavigationEventState(currentInfo = NavigationEventInfo.None) -NavigationBackHandler( - state = navigationState, - isBackEnabled = hasUnsavedChanges, - onBackCompleted = { /* Discard changes */ } -) -NavigationBackHandler( - state = navigationState, - isBackEnabled = !hasUnsavedChanges, - onBackCompleted = { /* Navigate up */ } -) -``` - -
- -## Checklist - -**For Compose Android targets:** - -- \[ \] Is compile SDK set to `36` or higher? (If compile SDK is lower than 36, set it to `36` or higher in `build.gradle.kts`). -- \[ \] Is `android:enableOnBackInvokedCallback` NOT explicitly set to `"false"` in `AndroidManifest.xml`? (On API 36+, it defaults to `"true"`; on API 33--35, ensure it is set to `"true"`). -- \[ \] Does the Activity rely on the built-in `ComponentActivity` dispatcher owner without redundant anonymous delegate wrapping? -- \[ \] Do dialogs or sheets rely on automatic `ComponentDialog` dispatcher resolution without redundant `CompositionLocalProvider` wrapping? -- \[ \] Are parent-child dispatcher relationships in Compose scoped using `rememberNavigationEventDispatcherOwner()` when managing nested hierarchies? -- \[ \] Is conditional back logic handled within a single unified `NavigationBackHandler` to avoid duplicate registration (`IllegalArgumentException`)? -- \[ \] Are legacy `BackHandler` usages migrated to `NavigationBackHandler` with predictive progress support? -- \[ \] Does the project build and pass tests successfully? diff --git a/navigation/navigation-event/references/android/guide/navigation/navigation-event/dispatcher.md b/navigation/navigation-event/references/android/guide/navigation/navigation-event/dispatcher.md deleted file mode 100644 index 27b6a96..0000000 --- a/navigation/navigation-event/references/android/guide/navigation/navigation-event/dispatcher.md +++ /dev/null @@ -1,134 +0,0 @@ -To implement a robust navigation system, your app needs a centralized way to -handle back gestures and other navigation signals. This page describes how to -use [`NavigationEventDispatcher`](https://developer.android.com/reference/kotlin/androidx/navigationevent/NavigationEventDispatcher) to coordinate and distribute these -navigation events across your application. - -## Declare a `NavigationEventDispatcher` - -The `NavigationEventDispatcher` is the central component of the -`NavigationEvent` library. It acts as an event hub that dispatches -navigation-related events, such as back gestures and navigation transitions, to -registered listeners within your app. Components can subscribe to these events -to react to navigation changes or other system-driven navigation actions. - -You should provide `NavigationEventDispatcher` instances through a -[`NavigationEventDispatcherOwner`](https://developer.android.com/reference/androidx/navigationevent/NavigationEventDispatcherOwner). This ensures that different parts of your -app can access the same dispatcher and observe navigation events in a consistent -and coordinated way. - - -```kotlin -class MyComponent: NavigationEventDispatcherOwner { - override val navigationEventDispatcher: NavigationEventDispatcher = - NavigationEventDispatcher() -} -``` - -
- -If you are inside of a `ComponentActivity`, instead of implementing your own -dispatcher, you can retrieve the one provided for you. - - -```kotlin -class MyCustomActivity : ComponentActivity() { - fun addMyHandler() { - // navigationEventDispatcher provided by the ComponentActivity - navigationEventDispatcher.addHandler(myNavigationEventHandler) - } -} -``` - -
- -## Add a `NavigationEventInput` - -Now that you've registered the handler, you are set up to receive events. -However, you need to provide a source from which the events are generated with -`NavigationEventInput`. - -`NavigationEventInput` is the platform-specific component that receives -raw system input and translates it into a standard `NavigationEvent` to be sent -to the `NavigationEventDispatcher`. - -The following example is a custom implementation of a `NavigationEventInput`: - - -```kotlin -public class MyInput : NavigationEventInput() { - @MainThread - public fun backStarted(event: NavigationEvent) { - dispatchOnBackStarted(event) - } - - @MainThread - public fun backProgressed(event: NavigationEvent) { - dispatchOnBackProgressed(event) - } - - @MainThread - public fun backCancelled() { - dispatchOnBackCancelled() - } - - @MainThread - public fun backCompleted() { - dispatchOnBackCompleted() - } -} -``` - -
- -Next, provide that input to your dispatcher: - - -```kotlin -navigationEventDispatcher.addInput(MyInput()) -``` - -
- -> [!NOTE] -> **Note:** To provide a simple input, use the [`DirectNavigationEventInput`](https://developer.android.com/reference/androidx/navigationevent/DirectNavigationEventInput) class. - -## Clean up resources with `dispose()` - -To prevent memory leaks in a dynamic UI, every created -`NavigationEventDispatcher` instance must be explicitly removed from the -hierarchy using the `dispose()` method when the component it is tied to is -destroyed: - - -```kotlin -navigationEventDispatcher.dispose() -``` - -
- -The `dispose()` method ensures a *cascading cleanup* by iteratively removing -the dispatcher and all of its descendants (children and grandchildren), -guaranteeing that all associated handlers are unregistered from the shared -system. - -### Dispatcher hierarchy and control - -The `NavigationEventDispatcher` supports a parent-child hierarchy, enabling -components nested deep within a UI (such as nested `NavHost`s or dialogs) to -participate in navigation event handling. - -#### Create a child dispatcher - -A child dispatcher is created by passing a reference to its parent dispatcher -during construction. All dispatchers in a hierarchy share the same -`NavigationEventProcessor` to maintain a global **Last-In, First-Out (LIFO)** -event ordering based on priority. - -#### Hierarchical enabling - -The dispatcher includes an `isEnabled` property that allows developers to enable -or disable an entire subtree of handlers at once. - -When a parent dispatcher is disabled (`isEnabled = false`), all handlers -associated with that parent and any of its children will be ignored, regardless -of their individual enabled state. \ No newline at end of file diff --git a/navigation/navigation-event/references/android/guide/navigation/navigation-event/handle-back.md b/navigation/navigation-event/references/android/guide/navigation/navigation-event/handle-back.md deleted file mode 100644 index 685cc69..0000000 --- a/navigation/navigation-event/references/android/guide/navigation/navigation-event/handle-back.md +++ /dev/null @@ -1,217 +0,0 @@ -You can extend the abstract class `NavigationEventHandler` to handle navigation -events across platforms. This class provides methods corresponding to the -lifecycle of a navigation gesture. - - -```kotlin -val myHandler = object: NavigationEventHandler( - initialInfo = NavigationEventInfo.None, - isBackEnabled = true -) { - override fun onBackStarted(event: NavigationEvent) { - // Prepare for the back event - } - - override fun onBackProgressed(event: NavigationEvent) { - // Use event.progress for predictive animations - } - - // This is the required method for final event handling - override fun onBackCompleted() { - // Complete the back event - } - - override fun onBackCancelled() { - // Cancel the back event - } -} -``` - -
- -The `addHandler` function connects the handler to the dispatcher: - - -```kotlin -navigationEventDispatcher.addHandler(myHandler) -``` - -
- -Call `myHandler.remove()` to remove the handler from the dispatcher: - - -```kotlin -myHandler.remove() -``` - -
- -Handlers are invoked based on priority, and then by recency. All -[`PRIORITY_OVERLAY`](https://developer.android.com/reference/kotlin/androidx/navigationevent/NavigationEventDispatcher#PRIORITY_OVERLAY()) handlers are called before any [`PRIORITY_DEFAULT`](https://developer.android.com/reference/kotlin/androidx/navigationevent/NavigationEventDispatcher#PRIORITY_DEFAULT()) -handlers. Within each priority group, handlers are invoked in a Last-In, -First-Out (LIFO) order --- the most recently added handler is called first. - -## Intercept back with Jetpack Compose - -For Jetpack Compose, the library provides a utility composable to manage the -dispatcher hierarchy. - -The `NavigationBackHandler` composable creates a `NavigationEventHandler` for -its content and links it to the `LocalNavigationEventDispatcherOwner`. It uses -Compose's `DisposableEffect` to automatically call the dispatcher's `dispose()` -method when the composable leaves the screen, safely managing resources. - - -```kotlin -@Composable -public fun NavigationBackHandler( - state: NavigationEventState, - isBackEnabled: Boolean = true, - onBackCancelled: () -> Unit = {}, - onBackCompleted: () -> Unit, -){ - -} -``` - -
- -This function lets you control event handling precisely within localized UI -subtrees. - - -```kotlin -@Composable -fun HandlingBackWithTransitionState( - onNavigateUp: () -> Unit -) { - val navigationState = rememberNavigationEventState( - currentInfo = NavigationEventInfo.None - ) - val transitionState = navigationState.transitionState - // React to predictive back transition updates - when (transitionState) { - is NavigationEventTransitionState.InProgress -> { - val progress = transitionState.latestEvent.progress - // Use progress (0f..1f) to update UI during the gesture - } - is NavigationEventTransitionState.Idle -> { - // Reset any temporary UI state if the gesture is cancelled - } - } - NavigationBackHandler( - state = navigationState, - onBackCancelled = { - // Called if the back gesture is cancelled - }, - onBackCompleted = { - // Called when the back gesture fully completes - onNavigateUp() - } - ) -} -``` - -
- -This example shows how to observe predictive back gesture updates using -[`NavigationEventTransitionState`](https://developer.android.com/reference/kotlin/androidx/navigationevent/NavigationEventTransitionState). The `progress` value can be used to -update UI elements in response to the back gesture, while handling completion -and cancellation through `NavigationBackHandler`. - -### Access the back gesture or swipe edge in Compose - -> [!NOTE] -> **Note:** For Android, if you're already using a navigation library with built-in Predictive Back support, like [Navigation 3](https://developer.android.com/guide/navigation/navigation-3/animate-destinations), use that instead of implementing the guidance here. The following section shows how to create a Predictive Back animation using only `NavigationEvent` and Compose. - -**Figure 1** . A predictive back animation built with `NavigationEvent` and Compose. - -To animate the screen while the user swipes back, you'll need to (a) check if -the `NavigationEventTransitionState` is `InProgress`, and (b) observe the -progress and swipe edge state with `rememberNavigationEventState`: - -- `progress`: A Float from `0.0` to `1.0` indicating how far the user has swiped. -- `swipeEdge`: An integer constant (`EDGE_LEFT` or `EDGE_RIGHT`) indicating where the gesture started. - -The following snippet is a simplified example of how to implement a scale and -shift animation: - - -```kotlin -object Routes { - const val SCREEN_A = "Screen A" - const val SCREEN_B = "Screen B" -} - -class MainActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContent { - var state by remember { mutableStateOf(Routes.SCREEN_A) } - val backEventState = rememberNavigationEventState(currentInfo = NavigationEventInfo.None) - when (state) { - Routes.SCREEN_A -> { - ScreenA(onNavigate = { state = Routes.SCREEN_B }) - } - else -> { - if (backEventState.transitionState is NavigationEventTransitionState.InProgress) { - ScreenA(onNavigate = { }) - } - ScreenB( - backEventState = backEventState, - onBackCompleted = { state = Routes.SCREEN_A } - ) - } - } - } - } -} - -@Composable -fun ScreenB( - backEventState: NavigationEventState, - onBackCompleted: () -> Unit = {}, -) { - val transitionState = backEventState.transitionState - val latestEvent = - (transitionState as? NavigationEventTransitionState.InProgress) - ?.latestEvent - val backProgress = latestEvent?.progress ?: 0f - val swipeEdge = latestEvent?.swipeEdge ?: NavigationEvent.EDGE_LEFT - if (transitionState is NavigationEventTransitionState.InProgress) { - Log.d("BackGesture", "Progress: ${transitionState.latestEvent.progress}") - } else if (transitionState is NavigationEventTransitionState.Idle) { - Log.d("BackGesture", "Idle") - } - val animatedScale by animateFloatAsState( - targetValue = 1f - (backProgress * 0.1f), - label = "ScaleAnimation" - ) - val windowInfo = LocalWindowInfo.current - val density = LocalDensity.current - val maxShift = remember(windowInfo, density) { - val widthDp = with(density) { windowInfo.containerSize.width.toDp() } - (widthDp.value / 20f) - 8 - } - val offsetX = when (swipeEdge) { - NavigationEvent.EDGE_LEFT -> (backProgress * maxShift).dp - NavigationEvent.EDGE_RIGHT -> (-backProgress * maxShift).dp - else -> 0.dp - } - NavigationBackHandler( - state = backEventState, - onBackCompleted = onBackCompleted, - isBackEnabled = true - ) - Box( - modifier = Modifier - .offset(x = offsetX) - .scale(animatedScale) - ){ - // Rest of UI - } -} -``` - -
\ No newline at end of file diff --git a/navigation/navigation-event/references/android/guide/navigation/navigation-event/index.md b/navigation/navigation-event/references/android/guide/navigation/navigation-event/index.md deleted file mode 100644 index 59d8ad4..0000000 --- a/navigation/navigation-event/references/android/guide/navigation/navigation-event/index.md +++ /dev/null @@ -1,83 +0,0 @@ -Navigation Event is a library that provides a Kotlin Multiplatform (KMP) -solution for integrating system-level navigation events into your application. -It is designed to be the foundational layer for handling navigation directions -across various [supported platforms](https://developer.android.com/kotlin/multiplatform#kotlin-multiplatform-and-jetpack-libraries). - -## Key concepts - -The Navigation Event system is built around a centralized dispatcher-handler -model, often used in a parent-child hierarchy to map to complex UI structures, -such as those found in Jetpack Compose. - -### `NavigationEventDispatcher` - -The [`NavigationEventDispatcher`](https://developer.android.com/reference/kotlin/androidx/navigationevent/NavigationEventDispatcher) is the central class responsible for -managing all registered navigation event consumers -([`NavigationEventHandler`](https://developer.android.com/reference/kotlin/androidx/navigationevent/NavigationEventHandler))) and orchestrating the flow of events. - -In a hierarchical setup, all dispatchers within the same chain share a single -`NavigationEventProcessor`, which manages the global state and ensures a single, -unified dispatching order across the entire tree. - -### `NavigationEventHandler` - -`NavigationEventHandler` is an abstract class that receives and handles -navigation events dispatched by a `NavigationEventDispatcher`. It defines -callback methods that correspond to different stages of a navigation gesture -lifecycle, such as when a gesture starts, progresses, completes, or is -cancelled. - -Handlers can respond to these events to update UI or application state in -response to user navigation actions. Multiple handlers can be registered with a -dispatcher and are invoked based on priority and registration order. - -### `NavigationEvent` - -[`NavigationEvent`](https://developer.android.com/reference/androidx/navigationevent/NavigationEvent) is a data class that carries the details of the -navigation gesture. - -### `NavigationEventInfo` - -[`NavigationEventInfo`](https://developer.android.com/reference/androidx/navigationevent/NavigationEventInfo) is an abstract class that provides contextual -information about a navigation state. - -### `NavigationEventInput` - -[`NavigationEventInput`](https://developer.android.com/reference/androidx/navigationevent/NavigationEventInput) is an abstract class for components that generate -and dispatch navigation events. It acts as the "input" side of the navigation -system, translating platform-specific events (like system back gestures or -button clicks) into standardized events that can be sent to a -`NavigationEventDispatcher`. - -## Supported navigation directions and triggers - -The Navigation Event system is designed to encompass more than just the system -back button, with designs supporting multiple navigation directions and input -methods across platforms. - -### Supported directions - -Different platforms support varying navigation directions: - -|---|---|---|---|---| -| **Platform** | **Back** | **Up** | **Forward** | **Home** | -| **Android phone** | ✅ | ✅ | 🚫 | ✅ | -| **Android tablet** | ✅ | ✅ | 🚫 | ✅ | -| **Web (Browser)** | ✅ | ✅ | ✅ | 🚫 | -| **iOS (iPhone/iPad)** | ✅ | 🚫 | ✅ | ✅ | - -### Supported triggers - -Input handling is achieved through various mechanisms on each platform: - -|---|---|---|---| -| **Trigger** | **Android Phone** | **Web (Browser)** | **iOS (iPhone/iPad)** | -| **Keyboard back button** | ✅ Back | ❓ | ✅ Back | -| **Software back button** | 🚫 | ✅ Back | ✅ Back | -| **Software up button** | ✅ Up | 🚫 | 🚫 | -| **Gesture from left** | ✅ Back | ❓ | ✅ Back | -| **Gesture from right** | ✅ Back | ❓ | ✅ Forward | -| **Gesture from bottom** | ✅ Home | 🚫 | ✅ Home | - -> [!NOTE] -> **Note:** The **Web** platform has unique navigation handling, where the browser controls the back stack state. This requires synchronization between the browser window and the application's navigation stack. The question mark represents behavior that is inconsistent because web browsers don't have a single, unified "back" button or gesture. \ No newline at end of file diff --git a/navigation/navigation-event/references/android/guide/navigation/navigation-event/setup.md b/navigation/navigation-event/references/android/guide/navigation/navigation-event/setup.md deleted file mode 100644 index 7981cf1..0000000 --- a/navigation/navigation-event/references/android/guide/navigation/navigation-event/setup.md +++ /dev/null @@ -1,32 +0,0 @@ -To set up your development environment for `NavigationEvent`, follow these -steps. - -## Declare dependencies - -1. Add the `navigationevent` artifact to your project. This is the core library - containing the shared `NavigationEventDispatcher` and `NavigationEventHandler` - classes. - - For Jetpack Compose integration, you also need to add the corresponding - Compose artifact: - - [versions] - navigationevent = "1.0.0" - - [libraries] - # NavigationEvent libraries - androidx-navigationevent = { module = "androidx.navigationevent:navigationevent", version.ref = "navigationevent" } - androidx-navigationevent-compose = { module = "androidx.navigationevent:navigationevent-compose", version.ref = "navigationevent" } - -2. Update your compile SDK to 36 or above: - - [versions] - compileSdk = "36" - -3. Add the following to your app build file, `app/build.gradle.kts`: - - dependencies { - ... - implementation(libs.androidx.navigationevent) - implementation(libs.androidx.navigationevent.compose) - } \ No newline at end of file diff --git a/performance/r8-analyzer/SKILL.md b/performance/r8-analyzer/SKILL.md index 91d33ca..7573aeb 100644 --- a/performance/r8-analyzer/SKILL.md +++ b/performance/r8-analyzer/SKILL.md @@ -7,7 +7,6 @@ description: Analyzes Android build files and R8 keep rules to identify redundan license: Complete terms in LICENSE.txt metadata: author: Google LLC - last-updated: '2026-09-22' keywords: - R8 - proguard @@ -16,54 +15,27 @@ metadata: - optimization --- -## Step 1. Setup and configuration check +## Core workflow -- Inspect `build.gradle`, `build.gradle.kts`, and `gradle.properties`. -- Use [references/CONFIGURATION.md](references/CONFIGURATION.md) to identify missing optimizations. -- **AGP** : If version is lower than 9.0, suggest migration to 9.0 for [build-time performance improvement](references/android/topic/performance/app-optimization/enable-app-optimization.md). -- **Full Mode** : Verify `android.enableR8.fullMode=false` is removed from gradle.properties. +- \[ \] Step 1: Create a file called R8_Configuration_Analysis.md, or reuse if one exists already, to store the output +- \[ \] Step 2: Look at the configuration of R8 by looking at build.gradle, build.gradle.kts, gradle.properties in the codebase using [references/CONFIGURATION.md](references/CONFIGURATION.md) as the reference. Inform the developer and add the analysis to the report file +- \[ \] Step 3: If the AGP version is less than 9, suggest moving to AGP 9.0 version as AGP 9.0 includes [optimizations](references/android/topic/performance/app-optimization/enable-app-optimization.md). + - \[ \] Step 4: Look at the proguard files in the codebase and evaluate each keep rule in the following specific order: a. **Libraries check** : Check rules against [references/REDUNDANT-RULES.md](references/REDUNDANT-RULES.md). If the app has keep rules targeting libraries - Google, AndroidX, Kotlin, Kotlinx, Room, Gson, Retrofit, inform the user that these are not required and suggest removal of these rules. b. **Impact analysis** : For the remaining keep rules, assess them based on the impact hierarchy defined in [references/KEEP-RULES-IMPACT-HIERARCHY.md](references/KEEP-RULES-IMPACT-HIERARCHY.md). (Note: Do NOT assess the impact of keep rules already covered in the libraries check step). +- \[ \] Step 5: Identify subsuming keep rules in the remaining keep rules based on the hierarchy defined in [references/KEEP-RULES-IMPACT-HIERARCHY.md](references/KEEP-RULES-IMPACT-HIERARCHY.md) and suggest removing the broader keep rules. +- \[ \] Step 6: For each remaining keep rule, analyze in detail the code affected by the rule by examining the code and adjacent files to understand why it was written. Look for reflection usage in those packages, and suggest a narrow and specific keep rule for the scenario using [references/REFLECTION-GUIDE.md](references/REFLECTION-GUIDE.md). +- \[ \] Step 7: For every keep rule inform concisely and to the point what action needs to be taken - whether the rule needs to be removed/refined. + - If refining the rule, give instructions on finding a narrower and specific keep rule using the [/references/REFLECTION-GUIDE.md](references/REFLECTION-GUIDE.md). + - If removing, provide reasoning on why it needs to be removed. +- \[ \] Step 8: After keep analysis, order the keep rule analysis based on the impact to the codebase hierarchy defined in [references/KEEP-RULES-IMPACT-HIERARCHY.md](references/KEEP-RULES-IMPACT-HIERARCHY.md) +- \[ \] Step 9: Advise the user to run tests using [UI + automator](https://developer.android.com/training/testing/other-components/ui-automator) to assess that there is no issue with the suggested changes, concentrating on the packages where keep rules will be affected. -## Step 2. Analysis path selection +## Mandatory rules -- Inspect `build.gradle`, `build.gradle.kts`, and `gradle.properties` and - `libs.versions.toml` to get the AGP and R8 versions. - -- **If AGP \>= 9.3.0** : Proceed to **Path A (Standalone Task)**. - -- **If AGP \< 9.3.0 and R8 \>= 9.3.7-dev** : Proceed to **Path B (Quantitative)**. - -- If none of the conditions are met, proceed to **Path C (Heuristic)**. - -### Path A: Standalone Gradle task (AGP \>= 9.3.0) - -- **Step 1: Run standalone task** : Run `./gradlew :app:analyzeReleaseR8Config` to evaluate the R8 configuration. You MUST wait for this command to finish before proceeding. -- **Step 2: Convert to JSON** : The report is generated at `app/build/reports/r8/r8-config-analyzer-release.pb`. You MUST explicitly run the conversion script by executing: `python3 .agents/skills/r8-analyzer/scripts/convert_pb_to_json.py`. Wait for this command to finish. -- **Step 3: Analyze** : You MUST explicitly run the analysis script by executing: `python3 .agents/skills/r8-analyzer/scripts/analyze.py`. This outputs `tmp/keepradius/analysis_result.txt`. Wait for this command to finish. - -### Path B: Quantitative data generation (R8 \>= 9.3.7-dev and AGP \< 9.3.0) - -- **Step 1: Check requirements** : Python and `protobuf` package are mandatory. -- **Step 2: Generate and analyze** : You MUST run the shell commands described in [references/CONFIGURATION-ANALYZER.md](references/CONFIGURATION-ANALYZER.md) to generate the proto file using R8 configuration analyzer, convert it to JSON and analyze the result. -- **Step 3: Analyze** : You MUST ensure the analysis produces `tmp/keepradius/analysis_result.txt` for scores and rule impact metrics. - -### Path C: Heuristic evaluation and recommendation (R8 \< 9.3.7-dev) - -*(Use ONLY if quantitative data generation is not possible)* - -- **Step 1: Manual evaluation** : Inspect `proguard-rules.pro`. -- **Step 2: Library check** : Compare rules against [references/REDUNDANT-RULES.md](references/REDUNDANT-RULES.md). Suggest **Remove** for bundled rules. -- **Step 3: Custom rule check** : Use [references/KEEP-RULES-IMPACT-HIERARCHY.md](references/KEEP-RULES-IMPACT-HIERARCHY.md) and [references/REFLECTION-GUIDE.md](references/REFLECTION-GUIDE.md) to prioritize and evaluate. Suggest **Refine** for broad rules (for example, package-wide). -- **Step 4: Validation** : Suggest Macrobenchmark tests using [UI Automator](references/android/training/testing/other-components/ui-automator.md) for any proposed changes. Proceed to Step 3. - -## Step 3. Report generation - -- **Format** : Follow [references/REPORT_FORMAT.md](references/REPORT_FORMAT.md) strictly. -- **Input**: Extract metrics (Scores, Impacts, Example Classes) directly from generated file analysis.txt if using Path A, or from manual findings if using Path B. -- **Output** : Output ONLY the raw Markdown report in the chat. Do NOT output conversational filler (for example, "Here is your report..."). Do NOT provide recommendations, next steps, or any other text outside of the sections defined in [references/REPORT_FORMAT.md](references/REPORT_FORMAT.md) Do NOT mention the path used for analysis of the configuration - -## Constraints - -- **Strict output limit**: The final output MUST strictly be the Markdown report and nothing else. -- **No code changes**: Research and suggest only; Do not modify files. -- **No redundancy**: Do not explain R8 benefits or reference skill internal files in the report. -- **Focus**: Omit sections (for example, Subsumed Rules, Configuration) if no issues or items are found. +- Don't make any changes in keep rule files +- Don't say about what level each keep rule is. +- Don't generate parts of the report if there is no keep rule to report in that section. +- Don't mention the generated files. +- Don't mention exceptions that occur during execution. +- Don't mention the benefits of R8 +- Don't mention any files of this skill \ No newline at end of file diff --git a/performance/r8-analyzer/references/CONFIGURATION-ANALYZER.md b/performance/r8-analyzer/references/CONFIGURATION-ANALYZER.md deleted file mode 100644 index 4039dcf..0000000 --- a/performance/r8-analyzer/references/CONFIGURATION-ANALYZER.md +++ /dev/null @@ -1,287 +0,0 @@ -On each step, keep the user informed of the progress by displaying the output. - -### 1. Requirements - -- R8 Version: 9.3.7-dev or later - -### 2. Generate proto - -The report and files must be generated at `{project_root}/tmp/r8analysis`. If -the folder is not present, create it. For example: - - mkdir -p "$PWD/tmp/r8analysis" - -### 3. Remove existing files - -To make sure that this invocation doesn't source data from previous runs, remove -the intermediate files `keepruleradius.json` and `analysis_result.txt` and -remove the proto files in the `{project_root}/tmp/r8analysis` folder. Example -bash commands: - - # Remove the intermediate JSON and the directory containing protobuf files - rm tmp/r8analysis/keepruleradius.json - rm tmp/r8analysis/*.pb - - # Copy the previous result to history before deleting the analysis - if [ -f tmp/r8analysis/analysis_result.txt ]; - then cat tmp/r8analysis/analysis_result.txt > tmp/r8analysis/history.txt && - rm tmp/r8analysis/analysis_result.txt; fi - -### 4. Generate the Configuration Analyzer report - -Run the R8 enabled build with the system property -"-Dcom.android.tools.r8.dumpkeepradiustodirectory=$PWD/tmp/r8analysis" to -generate Configuration Analyzer report - - ./gradlew assembleRelease \ - -Dcom.android.tools.r8.dumpkeepradiustodirectory=$PWD/tmp/r8analysis - -### 5. Convert to JSON - -To convert the generated protobuf files in `{project_root}/tmp/r8analysis` into -json, run the following script. The json must be generated in -`{project_root}/tmp/r8analysis`. Ensure `keep_radius_pb2.py` (from Step 10) is -in the same directory. - - import sys - import os - import glob - from google.protobuf import json_format - import keep_radius_pb2 - - def convert_pb_to_json(input_pb_path, output_json_path): - bundle = keep_radius_pb2.BlastRadiusContainer() - - try: - with open(input_pb_path, "rb") as pb_file: - binary_data = pb_file.read() - except Exception as e: - print(f"Error reading file {input_pb_path}: {e}", file=sys.stderr) - return False - - try: - bundle.ParseFromString(binary_data) - except Exception as e: - print(f"Error parsing protobuf: {e}", file=sys.stderr) - return False - - try: - json_string = json_format.MessageToJson( - bundle, - always_print_fields_with_no_presence=True, - preserving_proto_field_name=True, - indent=4 - ) - with open(output_json_path, "w", encoding="utf-8") as json_file: - json_file.write(json_string) - return True - except Exception as e: - print(f"Error writing JSON: {e}", file=sys.stderr) - return False - - if __name__ == "__main__": - input_pb = sys.argv[1] if len(sys.argv) > 1 else None - if not input_pb: - pb_files = glob.glob("tmp/r8analysis/*.pb") - if not pb_files: - print("Error: No .pb file found in tmp/r8analysis", file=sys.stderr) - sys.exit(1) - input_pb = sorted(pb_files)[-1] # Use the most recent one - output_json = sys.argv[2] if len(sys.argv) > 2 else "tmp/r8analysis/keepruleradius.json" - if not convert_pb_to_json(input_pb, output_json): - sys.exit(1) - -### 6. Analyze - -Run the following analysis script on the generated JSON to get the impact of the -keep rules and sort it. - - import json, sys - - def analyze(path): - try: - with open(path, 'r') as f: - d = json.load(f) - except Exception as e: - print(f"Error loading JSON: {e}") - return - - # Build reference map - c_map = {c.get('id'): set(c.get('constraints', [])) for c in d.get('keep_constraints_table', [])} - r_map = {r.get('id'): c_map.get(r.get('constraints_id'), set()) for r in d.get('keep_rule_blast_radius_table', [])} - - tot_opt = tot_obf = tot_shr = tot_items = 0 - - # Tally constraints across all kept items - for tbl in ('kept_class_info_table', 'kept_field_info_table', 'kept_method_info_table'): - for i in d.get(tbl, []): - tot_items += 1 - kb = i.get('kept_by', []) - if any('DONT_OPTIMIZE' in r_map.get(r, set()) for r in kb): tot_opt += 1 - if any('DONT_OBFUSCATE' in r_map.get(r, set()) for r in kb): tot_obf += 1 - if any('DONT_SHRINK' in r_map.get(r, set()) for r in kb): tot_shr += 1 - - # Find denominator - bi = d.get('build_info', {}) - live = sum(int(bi.get(k, 0)) for k in ('live_class_count', 'live_field_count', 'live_method_count')) - denom = live if live > 0 else tot_items - - # Check for globals - globals_src = [g.get('source', '').lower() for g in d.get('global_keep_rule_blast_radius_table', [])] - def score(cnt, flag): - if any(flag in src for src in globals_src): return 0.0 - return max(0.0, 100.0 - ((cnt / denom * 100) if denom > 0 else 0)) - - result = [ - f"Optimization Score: {score(tot_opt, '-dontoptimize'):.2f}%", - f"Obfuscation Score: {score(tot_obf, '-dontobfuscate'):.2f}%", - f"Shrinking Score: {score(tot_shr, '-dontshrink'):.2f}%" - ] - for line in result: - print(line) - with open("tmp/r8analysis/analysis_result.txt", "w") as f: - f.write("\n".join(result)) - - if __name__ == "__main__": - path = sys.argv[1] if len(sys.argv) > 1 else "tmp/r8analysis/keepruleradius.json" - analyze(path) - -Outputs `analysis_result.txt` containing scores and rule impacts. - -### 7. Report impactful rules - -Identify the keep rules with the highest impact and the subsumed rules using the -following script. - - import json, sys - - def report(path): - try: - with open(path, 'r') as f: - data = json.load(f) - except Exception as e: - print(f"Error loading JSON: {e}") - return - - # Calculate denominator for percentage - bi = data.get('build_info', {}) - live = sum(int(bi.get(k, 0)) for k in ('live_class_count', 'live_field_count', 'live_method_count')) - denom = live if live > 0 else sum(len(data.get(tbl, [])) for tbl in ('kept_class_info_table', 'kept_field_info_table', 'kept_method_info_table')) - - processed = [] - for r in data.get('keep_rule_blast_radius_table', []): - br = r.get('blast_radius', {}) - c, f, m = len(br.get('class_blast_radius', [])), len(br.get('field_blast_radius', [])), len(br.get('method_blast_radius', [])) - impact = c + f + m - if impact == 0: - continue - impact_pct = (impact / denom * 100) if denom > 0 else 0.0 - processed.append({ - 'id': r.get('id'), - 'source': r.get('source'), - 'impact': impact, - 'impact_pct': f"{impact_pct:.2f}%", - 'classes': c, - 'fields': f, - 'methods': m, - 'subsumed_by': br.get('subsumed_by', []) - }) - - processed.sort(key=lambda x: x['impact'], reverse=True) - - # Output JSON for the agent to fetch and process - print(json.dumps({ - "top_5_impact_keep_rules": [r for r in processed if not r['subsumed_by']][:5], - "subsumed": [r for r in processed if r['subsumed_by']] - }, indent=2)) - - if __name__ == "__main__": - report("tmp/r8analysis/keepruleradius.json") - -Add this data to the `analysis_result.txt` with the top impactful rules and -subsumed rules. - -### 8. Compare with previous report - -If `{project_root}/tmp/r8analysis/history.txt` exists, use the following script -to compare the previous run. Use this to compare with the current values - -### 9. Remove generated files - -After the final report and analysis results are generated, remove the -intermediate files `keepruleradius.json` and `analysis_result.txt` and remove -the proto files in "{project_root}/tmp/r8analysis" folder - - rm tmp/r8analysis/keepruleradius.json - rm tmp/r8analysis/*.pb - -### 10. Protobuf Python bindings - -The following script `keep_radius_pb2.py` is required by the conversion script -in Step 5. - - from google.protobuf import descriptor as _descriptor - from google.protobuf import descriptor_pool as _descriptor_pool - from google.protobuf import runtime_version as _runtime_version - from google.protobuf import symbol_database as _symbol_database - from google.protobuf.internal import builder as _builder - _runtime_version.ValidateProtobufRuntimeVersion( - _runtime_version.Domain.PUBLIC, - 6, - 33, - 4, - '', - 'keep_radius.proto' - ) - # @@protoc_insertion_point(imports) - - _sym_db = _symbol_database.Default() - DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x11keep_radius.proto\x12&com.android.tools.r8.blastradius.proto\"\x9f\x02\n\x13KeepRuleBlastRadius\x12\n\n\x02id\x18\x01 \x01(\x05\x12\x0e\n\x06source\x18\x02 \x01(\t\x12\x16\n\x0e\x63onstraints_id\x18\x03 \x01(\x05\x12\x46\n\x06origin\x18\x04 \x01(\x0b\x32\x36.com.android.tools.r8.blastradius.proto.TextFileOrigin\x12I\n\x0c\x62last_radius\x18\x05 \x01(\x0b\x32\x33.com.android.tools.r8.blastradius.proto.BlastRadius\x12\x41\n\x04tags\x18\x06 \x03(\x0e\x32\x33.com.android.tools.r8.blastradius.proto.KeepRuleTag\"w\n\x0b\x42lastRadius\x12\x13\n\x0bsubsumed_by\x18\x01 \x03(\x05\x12\x1a\n\x12\x63lass_blast_radius\x18\x02 \x03(\x05\x12\x1a\n\x12\x66ield_blast_radius\x18\x03 \x03(\x05\x12\x1b\n\x13method_blast_radius\x18\x04 \x03(\x05\"\x7f\n\x19GlobalKeepRuleBlastRadius\x12\n\n\x02id\x18\x01 \x01(\x05\x12\x0e\n\x06source\x18\x02 \x01(\t\x12\x46\n\x06origin\x18\x03 \x01(\x0b\x32\x36.com.android.tools.r8.blastradius.proto.TextFileOrigin\"j\n\x0fKeepConstraints\x12\n\n\x02id\x18\x01 \x01(\x05\x12K\n\x0b\x63onstraints\x18\x02 \x03(\x0e\x32\x36.com.android.tools.r8.blastradius.proto.KeepConstraint\"`\n\rKeptClassInfo\x12\n\n\x02id\x18\x01 \x01(\x05\x12\x1a\n\x12\x63lass_reference_id\x18\x02 \x01(\x05\x12\x16\n\x0e\x66ile_origin_id\x18\x03 \x01(\x05\x12\x0f\n\x07kept_by\x18\x04 \x03(\x05\"`\n\rKeptFieldInfo\x12\n\n\x02id\x18\x01 \x01(\x05\x12\x1a\n\x12\x66ield_reference_id\x18\x02 \x01(\x05\x12\x16\n\x0e\x66ile_origin_id\x18\x03 \x01(\x05\x12\x0f\n\x07kept_by\x18\x04 \x03(\x05\"b\n\x0eKeptMethodInfo\x12\n\n\x02id\x18\x01 \x01(\x05\x12\x1b\n\x13method_reference_id\x18\x02 \x01(\x05\x12\x16\n\x0e\x66ile_origin_id\x18\x03 \x01(\x05\x12\x0f\n\x07kept_by\x18\x04 \x03(\x05\"a\n\x0e\x46ieldReference\x12\n\n\x02id\x18\x01 \x01(\x05\x12\x1a\n\x12\x63lass_reference_id\x18\x02 \x01(\x05\x12\x19\n\x11type_reference_id\x18\x03 \x01(\x05\x12\x0c\n\x04name\x18\x04 \x01(\t\"c\n\x0fMethodReference\x12\n\n\x02id\x18\x01 \x01(\x05\x12\x1a\n\x12\x63lass_reference_id\x18\x02 \x01(\x05\x12\x1a\n\x12proto_reference_id\x18\x03 \x01(\x05\x12\x0c\n\x04name\x18\x04 \x01(\t\"K\n\x0eProtoReference\x12\n\n\x02id\x18\x01 \x01(\x05\x12\x15\n\rparameters_id\x18\x02 \x01(\x05\x12\x16\n\x0ereturn_type_id\x18\x03 \x01(\x05\"4\n\rTypeReference\x12\n\n\x02id\x18\x01 \x01(\x05\x12\x17\n\x0fjava_descriptor\x18\x02 \x01(\t\";\n\x11TypeReferenceList\x12\n\n\x02id\x18\x01 \x01(\x05\x12\x1a\n\x12type_reference_ids\x18\x02 \x03(\x05\"\x9f\x01\n\nFileOrigin\x12\n\n\x02id\x18\x01 \x01(\x05\x12\x10\n\x08\x66ilename\x18\x02 \x01(\t\x12Q\n\x10maven_coordinate\x18\x03 \x01(\x0b\x32\x37.com.android.tools.r8.blastradius.proto.MavenCoordinate\x12 \n\x18provided_by_build_system\x18\x04 \x01(\x08\"I\n\x14\x43lassFileInJarOrigin\x12\n\n\x02id\x18\x01 \x01(\x05\x12\x16\n\x0e\x66ile_origin_id\x18\x02 \x01(\x05\x12\r\n\x05\x65ntry\x18\x03 \x01(\t\"T\n\x0eTextFileOrigin\x12\x16\n\x0e\x66ile_origin_id\x18\x01 \x01(\x05\x12\x13\n\x0bline_number\x18\x02 \x01(\x05\x12\x15\n\rcolumn_number\x18\x03 \x01(\x05\"U\n\x0fMavenCoordinate\x12\n\n\x02id\x18\x01 \x01(\x05\x12\x10\n\x08group_id\x18\x02 \x01(\t\x12\x13\n\x0b\x61rtifact_id\x18\x03 \x01(\t\x12\x0f\n\x07version\x18\x04 \x01(\t\"\x9a\x01\n\tBuildInfo\x12\x13\n\x0b\x63lass_count\x18\x01 \x01(\x05\x12\x13\n\x0b\x66ield_count\x18\x02 \x01(\x05\x12\x14\n\x0cmethod_count\x18\x03 \x01(\x05\x12\x18\n\x10live_class_count\x18\x04 \x01(\x05\x12\x18\n\x10live_field_count\x18\x05 \x01(\x05\x12\x19\n\x11live_method_count\x18\x06 \x01(\x05\"\xd5\n\n\x14\x42lastRadiusContainer\x12M\n\x11\x66ile_origin_table\x18\x01 \x03(\x0b\x32\x32.com.android.tools.r8.blastradius.proto.FileOrigin\x12\x64\n\x1e\x63lass_file_in_jar_origin_table\x18\x02 \x03(\x0b\x32<.com.android.tools.r8.blastradius.proto.ClassFileInJarOrigin\x12W\n\x16maven_coordinate_table\x18\x03 \x03(\x0b\x32\x37.com.android.tools.r8.blastradius.proto.MavenCoordinate\x12U\n\x15\x66ield_reference_table\x18\x04 \x03(\x0b\x32\x36.com.android.tools.r8.blastradius.proto.FieldReference\x12W\n\x16method_reference_table\x18\x05 \x03(\x0b\x32\x37.com.android.tools.r8.blastradius.proto.MethodReference\x12U\n\x15proto_reference_table\x18\x06 \x03(\x0b\x32\x36.com.android.tools.r8.blastradius.proto.ProtoReference\x12S\n\x14type_reference_table\x18\x07 \x03(\x0b\x32\x35.com.android.tools.r8.blastradius.proto.TypeReference\x12\\\n\x19type_reference_list_table\x18\x08 \x03(\x0b\x32\x39.com.android.tools.r8.blastradius.proto.TypeReferenceList\x12T\n\x15kept_class_info_table\x18\t \x03(\x0b\x32\x35.com.android.tools.r8.blastradius.proto.KeptClassInfo\x12T\n\x15kept_field_info_table\x18\n \x03(\x0b\x32\x35.com.android.tools.r8.blastradius.proto.KeptFieldInfo\x12V\n\x16kept_method_info_table\x18\x0b \x03(\x0b\x32\x36.com.android.tools.r8.blastradius.proto.KeptMethodInfo\x12W\n\x16keep_constraints_table\x18\x0c \x03(\x0b\x32\x37.com.android.tools.r8.blastradius.proto.KeepConstraints\x12\x61\n\x1ckeep_rule_blast_radius_table\x18\r \x03(\x0b\x32;.com.android.tools.r8.blastradius.proto.KeepRuleBlastRadius\x12n\n#global_keep_rule_blast_radius_table\x18\x0e \x03(\x0b\x32\x41.com.android.tools.r8.blastradius.proto.GlobalKeepRuleBlastRadius\x12\x45\n\nbuild_info\x18\x0f \x01(\x0b\x32\x31.com.android.tools.r8.blastradius.proto.BuildInfo*\x1f\n\x0bKeepRuleTag\x12\x10\n\x0cPACKAGE_WIDE\x10\x00*H\n\x0eKeepConstraint\x12\x12\n\x0e\x44ONT_OBFUSCATE\x10\x00\x12\x11\n\rDONT_OPTIMIZE\x10\x01\x12\x0f\n\x0b\x44ONT_SHRINK\x10\x02\x42\x45\n&com.android.tools.r8.blastradius.protoB\x19KeepRuleBlastRadiusProtosP\001\x62\x06proto3') - - _globals = globals() - _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) - _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'keep_radius_pb2', _globals) - if not _descriptor._USE_C_DESCRIPTORS: - _globals['DESCRIPTOR']._loaded_options = None - _globals['DESCRIPTOR']._serialized_options = b'\n&com.android.tools.r8.blastradius.protoB\031KeepRuleBlastRadiusProtosP\001' - _globals['_KEEPRULETAG']._serialized_start = 3332 - _globals['_KEEPRULETAG']._serialized_end = 3363 - _globals['_KEEPCONSTRAINT']._serialized_start = 3365 - _globals['_KEEPCONSTRAINT']._serialized_end = 3437 - _globals['_KEEPRULEBLASTRADIUS']._serialized_start = 62 - _globals['_KEEPRULEBLASTRADIUS']._serialized_end = 349 - _globals['_BLASTRADIUS']._serialized_start = 351 - _globals['_BLASTRADIUS']._serialized_end = 470 - _globals['_GLOBALKEEPRULEBLASTRADIUS']._serialized_start = 472 - _globals['_GLOBALKEEPRULEBLASTRADIUS']._serialized_end = 599 - _globals['_KEEPCONSTRAINTS']._serialized_start = 601 - _globals['_KEEPCONSTRAINTS']._serialized_end = 707 - _globals['_KEPTCLASSINFO']._serialized_start = 709 - _globals['_KEPTCLASSINFO']._serialized_end = 805 - _globals['_KEPTFIELDINFO']._serialized_start = 807 - _globals['_KEPTFIELDINFO']._serialized_end = 903 - _globals['_KEPTMETHODINFO']._serialized_start = 905 - _globals['_KEPTMETHODINFO']._serialized_end = 1003 - _globals['_FIELDREFERENCE']._serialized_start = 1005 - _globals['_FIELDREFERENCE']._serialized_end = 1102 - _globals['_METHODREFERENCE']._serialized_start = 1104 - _globals['_METHODREFERENCE']._serialized_end = 1203 - _globals['_PROTOREFERENCE']._serialized_start = 1205 - _globals['_PROTOREFERENCE']._serialized_end = 1280 - _globals['_TYPEREFERENCE']._serialized_start = 1282 - _globals['_TYPEREFERENCE']._serialized_end = 1334 - _globals['_TYPEREFERENCELIST']._serialized_start = 1336 - _globals['_TYPEREFERENCELIST']._serialized_end = 1395 - _globals['_FILEORIGIN']._serialized_start = 1398 - _globals['_FILEORIGIN']._serialized_end = 1557 - _globals['_CLASSFILEINJARORIGIN']._serialized_start = 1559 - _globals['_CLASSFILEINJARORIGIN']._serialized_end = 1632 - _globals['_TEXTFILEORIGIN']._serialized_start = 1634 - _globals['_TEXTFILEORIGIN']._serialized_end = 1718 - _globals['_MAVENCOORDINATE']._serialized_start = 1720 - _globals['_MAVENCOORDINATE']._serialized_end = 1805 - _globals['_BUILDINFO']._serialized_start = 1808 - _globals['_BUILDINFO']._serialized_end = 1962 - _globals['_BLASTRADIUSCONTAINER']._serialized_start = 1965 - _globals['_BLASTRADIUSCONTAINER']._serialized_end = 3330 - # @@protoc_insertion_point(module_scope) \ No newline at end of file diff --git a/performance/r8-analyzer/references/CONFIGURATION.md b/performance/r8-analyzer/references/CONFIGURATION.md index ae04947..24e1301 100644 --- a/performance/r8-analyzer/references/CONFIGURATION.md +++ b/performance/r8-analyzer/references/CONFIGURATION.md @@ -1,9 +1,9 @@ To achieve maximum utilization of R8, the codebase must be configured correctly -depending on the build script language (Kotlin DSL versus Groovy DSL). +depending on the build script language (Kotlin DSL vs. Groovy DSL). ## 1. App Modules (`com.android.application`) -The app's `build.gradle` or `build.gradle.kts` file must enable minification +The app's `build.gradle` or `build.gradle.kts` file should enable minification and resource shrinking within the `release` build type or the apps custom build type for release and performance testing. It MUST use the optimized default file (`proguard-android-optimize.txt`). @@ -36,7 +36,7 @@ type for release and performance testing. It MUST use the optimized default file **Full Mode:** R8 Full Mode enables the entire optimizations - **AGP 8.0+** : Enabled by default. Ensure `android.enableR8.fullMode=false` is **NOT** present. -- **Pre-AGP 8.0** : Explicitly enable with `android.enableR8.fullMode=true`. +- **Pre-AGP 8.0** : Should be explicitly enabled with `android.enableR8.fullMode=true`. **Optimized Resource Shrinking:** If the AGP version of the project is less than 9.0 and more than 8.6, explicitly enable the new resource shrinker: diff --git a/performance/r8-analyzer/references/REPORT_FORMAT.md b/performance/r8-analyzer/references/REPORT_FORMAT.md deleted file mode 100644 index 629c777..0000000 --- a/performance/r8-analyzer/references/REPORT_FORMAT.md +++ /dev/null @@ -1,51 +0,0 @@ -## 1. Configuration - -*(Optional section for the report, omit if no relevant findings are present.)* - -- **AGP Version**: \[Current\] -\> Upgrade to 9.0. -- **Full Mode** : Not enabled. Remove `android.enableR8.fullMode=false` from `gradle.properties`. - -## 2. Global disable rules - -*(Optional section for the report, omit if no relevant findings are present.)* - -- \[Rule\]: Disables R8 globally. **Action**: Remove. - -If there is -dontobfuscate, -dontoptimize or -dontshrink in the codebase, -mention in this section - -## 3. Optimization summary - -- **Optimization score**: \[X\]% code is available for R8 optimizations (e.g., inlining, merging). \[100-X\]% of codebase can't be optimized by R8. -- **Shrinking score**: \[X\]% of code will be optimized by R8 by removing unused classes, fields and methods. \[100-X\]% of codebase contains redundant classes, fields and methods that can't be removed by R8. -- **Obfuscation score**: \[X\]% of the codebase is available for R8 to obfuscate. - -Increasing these scores increases the codebase available to R8 for -optimizations. - -## 4. Keep rules evaluation - -### \[Rule text\] - -- **Keeps**: \[X\] items or \[X\] % of the codebase from optimization. Classes: \[X\], Fields: \[X\], Methods: \[X\] are prevented from optimization due to this keep rule -- **Kept items**: \[Class1\], \[Class2\] -- **Action** : **Remove** (Library bundles rules) OR **Refine** (Too broad, use \[Surgical Rule\]). - -## 5. Subsumed keep rules - -*(Optional section for the report, omit if no relevant findings are present.)* - -### \[Redundant rules\] - -- **Subsumed By**: \[Broader Rule\] -- **Action** : **Remove**. - -## 6. Historical analysis summary - -*(Only include this section if a previous report existed. Summarize the changes -in optimization scores here to track progress. For example:)* The previous app -had scores: Optimization (XX%), Obfuscation (XX%), and Shrinking (XX%). The -current app has scores: Optimization (YY%), Obfuscation (YY%), and Shrinking -(YY%). -**Change**: Optimization improved by ZZ%, Obfuscation improved by ZZ%, and -Shrinking improved by ZZ%. \ No newline at end of file diff --git a/performance/r8-analyzer/references/android/topic/performance/app-optimization/enable-app-optimization.md b/performance/r8-analyzer/references/android/topic/performance/app-optimization/enable-app-optimization.md index 560e2ec..d9f6ac1 100644 --- a/performance/r8-analyzer/references/android/topic/performance/app-optimization/enable-app-optimization.md +++ b/performance/r8-analyzer/references/android/topic/performance/app-optimization/enable-app-optimization.md @@ -1,15 +1,3 @@ -## Android skills - -[View on GitHub](https://github.com/android/skills/tree/main/performance/r8-analyzer) - -### R8 Analyzer - -Use the R8 Analyzer [Android skill](https://developer.android.com/tools/agents/android-skills) to analyze build files and identify redundant, broad, or subsumed [keep rules](https://developer.android.com/topic/performance/app-optimization/keep-rules-overview). To install the skill from the [Android CLI](https://developer.android.com/tools/agents/android-cli), run: - - android skills add r8-analyzer - -
- For the best user experience, you should optimize your app to make it as small and fast as possible. Our app optimizer, called R8, streamlines your app by removing unused code and resources, rewriting code to optimize runtime @@ -39,8 +27,8 @@ operations include the following: efficiency and reduce overhead. Key techniques include: - **Method inlining**: R8 replaces a method call site with the actual body - of the called method. This eliminates the overhead of a function call - and lets R8 conduct further optimizations. + of the called method. This eliminates the overhead of a function call and + lets R8 conduct further optimizations. - **Class merging**: R8 combines sets of classes and interfaces into a single class. This reduces the number of classes in the app, lowering @@ -56,124 +44,69 @@ resource shrinking](https://developer.android.com/topic/performance/app-optimiza ## Enable optimization -To enable app optimization for your app use the appropriate DSL based on your -project's AGP version. We recommend enabling app optimization only in the final -version of your app that you test before publishing---usually your release -build---because the optimizations increase the build time of your project and can -make debugging harder due to the way it modifies code. - -### For AGP versions 9.3 and higher - -To enable app optimization for AGP 9.3 and higher: - -- Set `enable = true` in the `optimization` block in your release build's app-level build script. -- Add your [keep rules](https://developer.android.com/topic/performance/app-optimization/add-keep-rules) to a file with the suffix `.keep` in the `src//keepRules` source set. For example, `src/main/keepRules/custom-rules.keep`. - -> [!NOTE] -> **Note:** The updated DSL enables both code and resource optimization. It also includes a set of default keep rules for the Android platform, equivalent to "proguard-android-optimize.txt". To omit the default rules, see [Omit default -> rules](https://developer.android.com/topic/performance/app-optimization/keep-rules-overview#omit-default). +To enable app optimization, set `isMinifyEnabled = true` (for code optimization) +and `isShrinkResources = true` (for resource optimization) in your [release +build's](https://developer.android.com/studio/publish/preparing#turn-off-debugging) app-level build script as shown in the following code. We recommend +that you always enable both settings. We also recommend enabling app +optimization only in the final version of your app that you test before +publishing---usually your release build---because the optimizations increase the +build time of your project and can make debugging harder due to the way it +modifies code. ### Kotlin - android { - buildTypes { - release { - optimization { - enable = true // Enables code and resource optimizations. - } - } +```kotlin +android { + buildTypes { + release { + + // Enables code-related app optimization. + isMinifyEnabled = true + + // Enables resource shrinking. + isShrinkResources = true + + proguardFiles( + // Default file with automatically generated optimization rules. + getDefaultProguardFile("proguard-android-optimize.txt"), + + ... + ) + ... } } + ... +} +``` ### Groovy - android { - buildTypes { - release { - optimization { - enable = true // Enables code and resource optimizations. - } - } +```groovy +android { + buildTypes { + release { + + // Enables code-related app optimization. + minifyEnabled = true + + // Enables resource shrinking. + shrinkResources = true + + // Default file with automatically generated optimization rules. + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt') + + ... } } - -### Legacy DSL for AGP versions lower than 9.3 - -To enable app optimization, set `isMinifyEnabled = true` (for code optimization) -and `isShrinkResources = true` (for resource optimization) in your [release -build's](https://developer.android.com/studio/publish/preparing#turn-off-debugging) app-level build script. We recommend that you always enable both -settings. - -### Legacy DSL (Kotlin) - - android { - buildTypes { - release { - - // Enables code optimizations. - isMinifyEnabled = true - - // Enables resource shrinking. - isShrinkResources = true - - proguardFiles( - // Default file with automatically generated optimization rules. - getDefaultProguardFile("proguard-android-optimize.txt"), - - ... - ) - ... - } - } - ... - } - -### Legacy DSL (Groovy) - - android { - buildTypes { - release { - - // Enables code optimizations. - minifyEnabled = true - - // Enables resource shrinking. - shrinkResources = true - - // Default file with automatically generated optimization rules. - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt') - - ... - } - } - } - -## Improve R8 optimization - -The performance benefits of R8 are directly correlated to how much of your -codebase R8 is able to optimize. To get the maximum benefits out of R8, follow -best practices: - -- Enable R8 in [full mode](https://developer.android.com/topic/performance/app-optimization/full-mode) -- Enable [obfuscation, optimization, and shrinking](https://developer.android.com/topic/performance/app-optimization/adopt-optimizations-incrementally) -- Enable resource shrinking and [optimized resource shrinking](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization#optimize-resource-shrinking) -- [Refine keep rules](https://developer.android.com/topic/performance/app-optimization/keep-rules-best-practices) to allow maximum optimization of classes, fields and methods. - -To help you refine keep rules, use the [R8 Configuration Analyzer](https://developer.android.com/topic/performance/app-optimization/r8-configuration-analyzer). - -The R8 Configuration Analyzer lets you do the following: - -- Track and improve the overall R8 configuration quality by monitoring the metrics provided by the R8 Configuration Analyzer report. -- Find the broadest keep rules - those which prevent the most optimization -- and understand what optimization they prevent to refine them. - -The R8 Configuration Analyzer is available in AGP version 9.3.0-alpha05 or from -R8 version 9.3.7-dev. For more information, see [Analyze R8 configuration](https://developer.android.com/topic/performance/app-optimization/r8-configuration-analyzer). +} +``` ## Optimize resource shrinking for even smaller apps -The 8.12.0 version of AGP introduces optimized resource shrinking, which aims to -integrate resource and code optimization to create even smaller and faster apps. +The 8.12.0 version of Android Gradle Plugin (AGP) introduces optimized resource +shrinking, which aims to integrate resource and code optimization to create even +smaller and faster apps. + Before optimized resource shrinking, Android Asset Packaging Tool (AAPT2) generated keep rules that effectively treating resource shrinking separately from code, often retaining inaccessible code or resources that referenced each @@ -186,8 +119,8 @@ removed. ### Enable optimized resource shrinking -To enable the new optimized resource shrinking pipeline for AGP 8.12 or 8.13, -add the following to your project's `gradle.properties` file: +To turn on the new optimized resource shrinking pipeline for a version of AGP +before 9.0.0, add the following to your project's `gradle.properties` file: android.r8.optimizedResourceShrinking=true @@ -201,7 +134,7 @@ configuration. To enable R8 to use its [full optimization capabilities](https://developer.android.com/topic/performance/app-optimization/full-mode), remove the following line from your project's `gradle.properties` file, if it exists: - android.enableR8.fullMode=false + android.enableR8.fullMode=false # Remove this line from your codebase. Note that enabling app optimization makes stack traces difficult to understand, especially if R8 renames class or method names. To get stack traces that @@ -232,21 +165,9 @@ the Android Gradle Plugin (AGP) and the R8 compiler. | AGP version | Features introduced | |---|---| -| 9.3 | **Simplified DSL:** New \`optimization {}\` block enables requires less configuration- optimized resource shrinking is always enabled, default Android keep rules are opt-out.
**\`keepRules\` source set:** If you use the new DSL, you must place your keep rules in the `src//keepRules` source set, in files with the suffix `.keep`. The source sets are also compatible with the legacy DSL.
**KMP (Kotlin MultiPlatform) consumer rules:** KMP no longer requires setting `publish = true`, and supports the `src//keepRules` source set with the `.keep` extension.
Note that the legacy DSL is still supported. | | 9.1 | **Classes repackaged by default:** R8 repackages classes (moving them to the unnamed package, at the top level) to compact DEX further, eliminating the need to specify `-repackageclasses` option. For information about how this works and how to opt out, see [global options](https://developer.android.com/topic/performance/app-optimization/global-options#global-options). | | 9.0 | **Optimized resource shrinking:** Enabled by default (controlled using `android.r8.optimizedResourceShrinking`). [Optimized resource shrinking](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization#optimize-resource-shrinking) helps integrate resource shrinking with the code optimization pipeline, leading to smaller, faster apps. By optimizing both code and resource references simultaneously, it identifies and removes resources referenced exclusively from unused code. This is a significant improvement over the previous separate optimization processes. This is especially useful for apps that share substantial resources and code across different form factor verticals, with measured improvements of over 50% in app size. The resulting size reduction leads to smaller downloads, faster installations, and a better user experience with faster startup, improved rendering, and fewer ANRs. **Library rule filtering:** Support for global options (for example, `-dontobfuscate`) in library consumer rules has been dropped, and apps will filter them out. For more information, see [Add global options](https://developer.android.com/topic/performance/app-optimization/global-options). **Kotlin null checks:** Optimized by default (controlled using `-processkotlinnullchecks`). This version also introduced significant improvements in build speed. For more information, see [Global options for additional optimization](https://developer.android.com/topic/performance/app-optimization/global-options#global-options). **Optimize specific packages:** You can use `packageScope` to optimize specific packages. This is in experimental support. For more information, see [Optimize specified packages with `packageScope`](https://developer.android.com/topic/performance/app-optimization/optimize-specified-packages). **Optimized by default:** Support for `getDefaultProguardFile("proguard-android.txt")` has been dropped, because it includes `-dontoptimize`, which should be avoided. Instead, use `"proguard-android-optimize.txt"`. If you need to globally disable optimization in your app, [add the flag manually to a proguard file](https://developer.android.com/topic/performance/app-optimization/global-options#global-options-2). | -| 8.12 | **Optimized resource shrinking:** Initial support added (controlled using `android.r8.optimizedResourceShrinking`). [Optimized resource shrinking](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization#optimize-resource-shrinking) helps integrate resource shrinking with the code optimization pipeline. You must manually enable it in this version of AGP. **Logcat retracing:** Support for automatic retracing in the Android Studio [Logcat window](https://developer.android.com/studio/debug/logcat). | +| 8.12 | **Resource shrinking:** Initial support added (Off by default. Enable using `isShrinkResources`). Resource shrinking works in tandem with R8 to identify and remove unused resources effectively. **Logcat retracing:** Support for automatic retracing in the Android Studio [Logcat window](https://developer.android.com/studio/debug/logcat). | | 8.6 | **Improved retracing:** Includes filename and line number retracing by default for all `minSdk` levels (previously required `minSdk` 26+ in version 8.2). Updating R8 helps ensure that stack traces from obfuscated builds are readily and clearly readable. This version improves how line numbers and source files are mapped, making it easier for tools like the Android Studio Logcat to automatically retrace crashes to the original source code. | | 8.0 | **Full mode by default:** [R8 full mode](https://developer.android.com/topic/performance/app-optimization/full-mode) provides significantly more powerful optimization. It is enabled by default. You can opt out using `android.enableR8.fullMode=false`. | -| 7.0 | **Full mode available:** Introduced as an opt-in feature using `android.enableR8.fullMode=true`. Full mode applies more powerful optimizations by making stricter assumptions about how your code uses reflection and other dynamic features. While it reduces app size and improves performance, it might require additional keep rules to prevent necessary code from being stripped. | - -## Use R8 with other build systems - -While AGP is the recommended and officially supported build system for Android -apps, you might use an alternative build system like [Bazel](https://bazel.build/). If -you're using Bazel, you can integrate R8 into your build pipeline to shrink, -obfuscate, and optimize your app. - -For information about optimizing an Android app using Bazel, see Bazel's -[`rules_android` documentation](https://github.com/bazelbuild/rules_android/tree/main/docs/r8-optimization.md). Note that Bazel isn't -[officially supported](https://developer.android.com/build#other-build-systems) for Android app development. \ No newline at end of file +| 7.0 | **Full mode available:** Introduced as an opt-in feature using `android.enableR8.fullMode=true`. Full mode applies more powerful optimizations by making stricter assumptions about how your code uses reflection and other dynamic features. While it reduces app size and improves performance, it might require additional keep rules to prevent necessary code from being stripped. | \ No newline at end of file diff --git a/performance/r8-analyzer/references/android/training/testing/other-components/ui-automator.md b/performance/r8-analyzer/references/android/training/testing/other-components/ui-automator.md deleted file mode 100644 index 0d2d7f6..0000000 --- a/performance/r8-analyzer/references/android/training/testing/other-components/ui-automator.md +++ /dev/null @@ -1,312 +0,0 @@ -The UI Automator testing framework provides a set of APIs to build UI tests that -interact with user apps and system apps. - -> [!NOTE] -> **Note:** This documentation covers the modern approach to writing UI Automator tests, introduced with [UI Automator 2.4](https://developer.android.com/jetpack/androidx/releases/test-uiautomator#2.4.0). This approach makes your tests more concise, readable, and robust. The API is under development, and we strongly recommend using it for any new development with UI Automator. The [legacy API guidance](https://developer.android.com/training/testing/other-components/ui-automator-legacy) is also available. - -## Introduction to modern UI Automator testing - -UI Automator 2.4 introduces a streamlined, Kotlin-friendly Domain Specific -Language (DSL) that simplifies writing UI tests for Android. This new API -surface focuses on predicate-based element finding and explicit control over app -states. Use it to create more maintainable and reliable automated tests. - -UI Automator lets you test an app from outside of the app's process. This -lets you test release versions with minification applied. UI Automator also -helps when writing macrobenchmark tests. - -Key features of the modern approach include: - -- A dedicated `uiAutomator` test scope for cleaner and more expressive test code. -- Methods like `onElement`, `onElements`, and `onElementOrNull` for finding UI elements with clear predicates. -- Built-in waiting mechanism for conditional elements `onElement*(timeoutMs: - Long = 10000)` -- Explicit app state management such as `waitForStable` and `waitForAppToBeVisible`. -- Direct interaction with accessibility window nodes for multi-window testing scenarios. -- Built-in screenshot capabilities and a `ResultsReporter` for visual testing and debugging. - -## Set up your project - -To begin using the modern UI Automator APIs, update your project's -`build.gradle.kts` file to include the [latest dependency](https://developer.android.com/jetpack/androidx/releases/test-uiautomator#2.4.0): - -### Kotlin - - dependencies { - ... - androidTestImplementation("androidx.test.uiautomator:uiautomator:2.4.0-alpha05") - } - -### Groovy - - dependencies { - ... - androidTestImplementation "androidx.test.uiautomator:uiautomator:2.4.0-alpha05" - } - -## Core API concepts - -The following sections describe core concepts of the modern UI Automator API. - -### The uiAutomator test scope - -Access all new UI Automator APIs within the **`uiAutomator { ... }`** -block. This function creates a `UiAutomatorTestScope` that provides a concise -and type-safe environment for your test operations. - - uiAutomator { - // All your UI Automator actions go here - startApp("com.example.targetapp") - onElement { textAsString() == "Hello, World!" }.click() - } - -### Find UI elements - -Use UI Automator APIs with predicates to locate UI elements. These predicates -let you define conditions for properties such as text, selected or focused -state, and content description. - -- `onElement { predicate }`: Returns the first UI element that matches the - predicate within a default timeout. The function throws an exception if it - doesn't locate a matching element. - - // Find a button with the text "Submit" and click it - onElement { textAsString() == "Submit" }.click() - - // Find a UI element by its resource ID - onElement { viewIdResourceName == "my_button_id" }.click() - - // Allow a permission request - watchFor(PermissionDialog) { - clickAllow() - } - -- `onElementOrNull { predicate }`: Similar to `onElement`, but returns - `null` if the function finds no matching element within the timeout. It - doesn't throw an exception. Use this method for optional elements. - - val optionalButton = onElementOrNull { textAsString() == "Skip" } - optionalButton?.click() // Click only if the button exists - -- `onElements { predicate }`: Waits until at least one UI element matches - the given predicate, then returns a list of all matching UI elements. - - // Get all items in a list Ui element - val listItems = onElements { className == "android.widget.TextView" && isClickable } - listItems.forEach { it.click() } - -Here are some tips for using `onElement` calls: - -- Chain `onElement` calls for nested elements: You can chain `onElement` - calls to find elements within other elements, following a parent-child - hierarchy. - - // Find a parent Ui element with ID "first", then its child with ID "second", - // then its grandchild with ID "third", and click it. - onElement { viewIdResourceName == "first" } - .onElement { viewIdResourceName == "second" } - .onElement { viewIdResourceName == "third" } - .click() - -- Specify a timeout for `onElement*` functions by passing a value representing - milliseconds. - - // Find a Ui element with a zero timeout (instant check) - onElement(0) { viewIdResourceName == "something" }.click() - - // Find a Ui element with a custom timeout of 10 seconds - onElement(10_000) { textAsString() == "Long loading text" }.click() - -### Interact with UI elements - -Interact with UI elements by simulating clicks or setting text in editable -fields. - - // Click a Ui element - onElement { textAsString() == "Tap Me" }.click() - - // Set text in an editable field - onElement { className == "android.widget.EditText" }.setText("My input text") - - // Perform a long click - onElement { contentDescription == "Context Menu" }.longClick() - -## Handle app states and watchers - -Manage the lifecycle of your app and handle unexpected UI elements that might -appear during your tests. - -### App lifecycle management - -The APIs provide ways to control the state of the app under test: - - // Start a specific app by package name. Used for benchmarking and other - // self-instrumenting tests. - startApp("com.example.targetapp") - - // Start a specific activity within the target app - startActivity(SomeActivity::class.java) - - // Start an intent - startIntent(myIntent) - - // Clear the app's data (resets it to a fresh state) - clearAppData("com.example.targetapp") - -### Handle unexpected UI - -The `watchFor` API lets you define handlers for unexpected UI elements, -such as permission dialogs, that might appear during your test flow. This -uses the internal watcher mechanism but offers more flexibility. - - import androidx.test.uiautomator.PermissionDialog - - @Test - fun myTestWithPermissionHandling() = uiAutomator { - startActivity(MainActivity::class.java) - - // Register a watcher to click "Allow" if a permission dialog appears - watchFor(PermissionDialog) { clickAllow() } - - // Your test steps that might trigger a permission dialog - onElement { textAsString() == "Request Permissions" }.click() - - // Example: You can register a different watcher later if needed - clearAppData("com.example.targetapp") - - // Now deny permissions - startApp("com.example.targetapp") - watchFor(PermissionDialog) { clickDeny() } - onElement { textAsString() == "Request Permissions" }.click() - } - -`PermissionDialog` is an example of a `ScopedWatcher`, where `T` is the -object passed as a scope to the block in `watchFor`. You can create custom -watchers based on this pattern. - -### Wait for app visibility and stability - -Sometimes tests need to wait for elements to become visible or stable. -UI Automator offers several APIs to help with this. - -The `waitForAppToBeVisible("com.example.targetapp")` waits for a UI element with -the given package name to appear on the screen within a customizable timeout. - - // Wait for the app to be visible after launching it - startApp("com.example.targetapp") - waitForAppToBeVisible("com.example.targetapp") - -Use the `waitForStable()` API to verify that the app's UI is considered stable -before interacting with it. - - // Wait for the entire active window to become stable - activeWindow().waitForStable() - - // Wait for a specific Ui element to become stable (e.g., after a loading animation) - onElement { viewIdResourceName == "my_loading_indicator" }.waitForStable() - -> [!NOTE] -> **Note:** In most cases, `waitForStable()` isn't strictly necessary when using `onElement { ... }` because `onElement` already includes a timeout. Use `waitForStable()` primarily in combination with `onElements { ... }` to verify that all UI elements are visible, when you know that the UI is in an unstable state, or for specific screenshot testing scenarios where you need the UI to completely settle before capturing. `waitForStable()` works by waiting until no changes are detected in the accessibility tree for a set period. Note that this UI stability check doesn't guarantee that the app is fully idle, as background tasks might still be running. - -## Use UI Automator for Macrobenchmarks and Baseline Profiles - -Use UI Automator for performance testing with [Jetpack Macrobenchmark](https://developer.android.com/topic/performance/benchmarking/macrobenchmark-overview) -and for generating [Baseline Profiles](https://developer.android.com/topic/performance/baselineprofiles/overview), as it provides a reliable way to -interact with your app and measure performance from an end-user perspective. - -Macrobenchmark uses UI Automator APIs to drive the UI and measure interactions. -For example, in startup benchmarks, you can use `onElement` to detect when UI -content is fully loaded, enabling you to measure [Time to Full Display -(TTFD)](https://developer.android.com/topic/performance/issues/launch-time#time-full). In jank benchmarks, UI Automator APIs are used to scroll lists or -run animations to measure frame timings. Functions like `startActivity()` or -`startIntent()` are useful for getting the app into the correct state before -measurement begins. - -When [generating Baseline Profiles](https://developer.android.com/topic/performance/baselineprofiles/create-baselineprofile), you automate your app's critical user -journeys (CUJs) to record which classes and methods require pre-compilation. UI -Automator is an ideal tool for writing these automation scripts. The modern -DSL's predicate-based element finding and built-in wait mechanisms (`onElement`) -lead to more robust and deterministic test execution compared to other methods. -This stability reduces flakiness and ensures that the generated Baseline Profile -accurately reflects the code paths executed during your most important user -flows. - -## Advanced features - -The following features are useful for more complex testing scenarios. - -### Interact with multiple windows - -The UI Automator APIs let you directly interact with and inspect UI -elements. This is particularly useful for scenarios involving multiple windows, -such as Picture-in-Picture (PiP) mode or split-screen layouts. - - // Find the first window that is in Picture-in-Picture mode - val pipWindow = windows() - .first { it.isInPictureInPictureMode == true } - - // Now you can interact with elements within that specific window - pipWindow.onElement { textAsString() == "Play" }.click() - -### Screenshots and visual assertions - -Capture screenshots of the entire screen, specific windows, or -individual UI elements directly within your tests. This is helpful for visual -regression testing and debugging. - - uiautomator { - // Take a screenshot of the entire active window - val fullScreenBitmap: Bitmap = activeWindow().takeScreenshot() - fullScreenBitmap.saveToFile(File("/sdcard/Download/full_screen.png")) - - // Take a screenshot of a specific UI element (e.g., a button) - val buttonBitmap: Bitmap = onElement { viewIdResourceName == "my_button" }.takeScreenshot() - buttonBitmap.saveToFile(File("/sdcard/Download/my_button_screenshot.png")) - - // Example: Take a screenshot of a PiP window - val pipWindowScreenshot = windows() - .first { it.isInPictureInPictureMode == true } - .takeScreenshot() - pipWindowScreenshot.saveToFile(File("/sdcard/Download/pip_screenshot.png")) - } - -The `saveToFile` extension function for Bitmap simplifies saving the captured -image to a specified path. - -### Use ResultsReporter for debugging - -The `ResultsReporter` helps you associate test artifacts, like screenshots, -directly with your test results in Android Studio for easier inspection and -debugging. - - uiAutomator { - startApp("com.example.targetapp") - - val reporter = ResultsReporter("MyTestArtifacts") // Name for this set of results - val file = reporter.addNewFile( - filename = "my_screenshot", - title = "Accessible button image" // Title that appears in Android Studio test results - ) - - // Take a screenshot of an element and save it using the reporter - onElement { textAsString() == "Accessible button" } - .takeScreenshot() - .saveToFile(file) - - // Report the artifacts to instrumentation, making them visible in Android Studio - reporter.reportToInstrumentation() - } - -## Migrate from older UI Automator versions - -If you have existing UI Automator tests written with older API surfaces, use the -following table as a reference to migrate to the modern approach: - -| Action type | Old UI Automator method | New UI Automator method | -|---|---|---| -| Entry point | `UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())` | Wrap test logic in the `uiAutomator { ... }` scope. | -| Find UI elements | `device.findObject(By.res("com.example.app:id/my_button"))` | `onElement { viewIdResourceName == "my\_button" }` | -| Find UI elements | `device.findObject(By.text("Click Me"))` | `onElement { textAsString() == "Click Me" }` | -| Wait for idle UI | `device.waitForIdle()` | Prefer `onElement`'s built-in timeout mechanism; otherwise, `activeWindow().waitForStable()` | -| Find child elements | Manually nested `findObject` calls | `onElement().onElement()` chaining | -| Handle permission dialogs | `UiAutomator.registerWatcher()` | `watchFor(PermissionDialog)` | \ No newline at end of file diff --git a/play/engage-sdk-integration/SKILL.md b/play/engage-sdk-integration/SKILL.md deleted file mode 100644 index a04a605..0000000 --- a/play/engage-sdk-integration/SKILL.md +++ /dev/null @@ -1,127 +0,0 @@ ---- -name: engage-sdk-integration -description: Helps developers integrate, debug, and resolve Play Engage SDK implementation - issues. Use when adding Engage SDK support, generating publishing code, mapping - data classes to entities, or fixing SDK-related errors. -license: Complete terms in LICENSE.txt -metadata: - author: Google LLC - last-updated: '2026-10-03' - keywords: - - android - - engage - - engage sdk - - play engage library - - google play ---- - -This skill guides you through integrating the Play Engage SDK into an Android -app. It ensures that the code follows the mandatory structure and uses the -required Engage entities for each vertical. - -## Workflow - -Follow these steps to assist the developer: - -1. **Identify vertical and cluster:** - - - Ask the developer which vertical their app belongs to based on **[references/schemas/](references/schemas)**. - - Check if the integration is for TV or mobile. If the integration is for TV, read the TV-specific sections in [patterns.md](references/patterns.md) as well. - - Use `{VERTICAL}.md` in the **[references/schemas/](references/schemas)** directory to identify the corresponding Engage entities and the `client` class name. The `client` field in the JSON provides the full class name. For example, `com.google.android.engage.food.service.AppEngageFoodClient`. - - **Note:** Initializing the client class requires a `Context` parameter. For example, `AppEngageFoodClient(context)`. - - Always refer to [common.md](references/common.md) for common entities. - - Ask which cluster type they want to publish from the supported cluster types for that vertical. - - Find the method to call from `{VERTICAL}.md` in the **[references/schemas/](references/schemas)** directory for the specified cluster. Each method will specify the request it expects. - - Get the request structure from [requests.md](references/requests.md) and clusters from [clusters.md](references/clusters.md). Then suggest and use sources to fill the fields in the request structure correctly, along with the required entities and clusters. -2. **Generate structured boilerplate code:** - - - Create a new directory for all Engage-related code. Name the directory to match the naming convention of the existing codebase. - - Generate the following classes using templates in [patterns.md](references/patterns.md): - - `Constants`: holds constant values such as attempt counts and publish types. - - `ItemToEntityConverter`: converts the app's local models to Engage's Entity models. - - `ClusterRequestFactory`: constructs the publish requests. - - `EngageWorker`: handles the actual publishing and publish errors using WorkManager. - - `EngagePublisher`: orchestrates periodic and one-time jobs. - - `EngageBroadcastReceiver`: listens for AppEngageService intents and starts a one-time publish job from `EngagePublisher`. **Important** : Implement both **static registration** and **dynamic registration** patterns, including the companion object `register` method inside the `EngageBroadcastReceiver` class. -3. **Suggest entity mapping:** - - - Ask the developer to provide their local model schema (for example, a data class or a JSON snippet). - - If they haven't provided one, share entities from `{VERTICAL}.md` in the **[references/schemas/](references/schemas)** directory as a guide. - - Once the local model is identified, suggest a mapping to the corresponding Engage entity. - - Generate the conversion logic using the `ItemToEntityConverter` pattern in [patterns.md](references/patterns.md) and add it to the generated `{ENGAGE_CODE_DIR}/ItemToEntityConverter`. -4. **Suggest data source:** - - - Ask the developer to provide the source of actual data you'll publish. - - Once you identify the data source, use it to fetch the data in the app's local model schema. - - Use `{ENGAGE_CODE_DIR}/ItemToEntityConverter` to convert this data to an Engage entity. - - Use obtained Engage entity model data with `{ENGAGE_CODE_DIR}/ - ClusterRequestFactory` to get cluster requests. - - Call corresponding cluster publishing method obtained from `{VERTICAL}.md` in the **[references/schemas/](references/schemas)** directory with the obtained request in previous step in `{ENGAGE_CODE_DIR}/EngageWorker`. -5. **Gradle and manifest updates:** - - - Suggest updates to `build.gradle` and `AndroidManifest.xml`. - - For mobile apps, use [patterns.md](references/patterns.md). - - For TV apps, use the TV-specific sections in [patterns.md](references/patterns.md). - - Provide the necessary `implementation` dependencies for `build.gradle` or `build.gradle.kts` from [patterns.md](references/patterns.md). - - Provide the `` and `` declarations for `AndroidManifest.xml`. - - Note: Except for TV, there aren't any vertical-specific imports. For all other verticals, `com.google.android.engage:engage-core:1.6.0` is sufficient. -6. **Debugging and verification**: - - - **Self-verification checklist** : Before considering your work complete, you must verify that you've implemented all of the following: - - \[ \] Registered `EngageBroadcastReceiver` **statically** in `AndroidManifest.xml` (inside the `` tag). - - \[ \] Registered `EngageBroadcastReceiver` **dynamically** by calling `EngageBroadcastReceiver.register(context)` in the `Application` class or main `Activity` class. - - \[ \] Implemented the `register` method in `EngageBroadcastReceiver`'s `companion object` to handle dynamic registration. - - \[ \] Handled empty data lists in `EngageWorker` (for example, by deleting the cluster instead of publishing empty data). - - \[ \] Used `--no-daemon` for all Gradle compilations. - - Perform a Gradle sync. - - If errors occur (such as import failures, namespace conflicts, or compile errors), read **[references/troubleshooting.md](references/troubleshooting.md)** to resolve them. - - Execute a Gradle compilation. You must run `./gradlew compileDebugUnitTestSources --no-daemon` or `./gradlew assembleDebug --no-daemon`. See **[references/troubleshooting.md](references/troubleshooting.md)** for compile rules and warnings about fast-compilation shortcuts. Repeat this step until compilation is successful. -7. **User checklist:** At the end of code generation, notify the user to go - through this checklist to verify that the integration is complete and as - intended: - - - \[ \] Verify that all the Engage-related files are created in `{ENGAGE_CODE_DIR}/`: - - `Constants` - - `ItemToEntityConverter` - - `ClusterRequestFactory` - - `EngageWorker` - - `{cluster_type}Publisher` - - `EngageBroadcastReceiver` - - \[ \] Verify that app's local model is converted to Engage entity by populating the fields correctly in the model in `{ENGAGE_CODE_DIR}/ItemToEntityConverter`. - - \[ \] Verify that all image URIs in `ItemToEntityConverter` point to images matching the strict aspect ratio requirements of the vertical (for example, 16:9, 1:1, 2:3). - - \[ \] Verify that `{ENGAGE_CODE_DIR}/EngageWorker` uses the data source identified in Step 4. - - \[ \] Verify that `EngageBroadcastReceiver.register(context)` is called within the `Application` class or `MainActivity` to register the receiver dynamically. - - \[ \] Verify that `AndroidManifest.xml` contains the static `` declaration for `EngageBroadcastReceiver` with the necessary intent actions. - - **Important** : Explicitly instruct the developer to call `EngageBroadcastReceiver.register(context)` inside their custom `Application` class `onCreate()` (or their main activity `onCreate()`) to dynamically register the receiver. Stress that **both** static and dynamic registrations are required for the integration to function. - -## Reference materials - -- **FAQ:** [Engage FAQ](references/android/guide/playcore/engage/faq.md) - Refer to this document for answers to frequently - asked questions from developers. - -- **Vertical-specific guides:** - - - [Food Vertical](references/android/guide/playcore/engage/food.md) - - [Watch Vertical](references/android/guide/playcore/engage/watch.md) - - [Listen Vertical](references/android/guide/playcore/engage/listen.md) - - [Read Vertical](references/android/guide/playcore/engage/read.md) - - [Shopping Vertical](references/android/guide/playcore/engage/shopping.md) - - [Social Vertical](references/android/guide/playcore/engage/social.md) - - [Travel Vertical](references/android/guide/playcore/engage/travel.md) - - [Health and Fitness Vertical](references/android/guide/playcore/engage/healthandfitness.md) - - [Other Verticals](references/android/guide/playcore/engage/otherverticals.md) - - [TV Getting Started](references/android/guide/playcore/engage/tv/getting-started.md) - - [TV Recommendations](references/android/guide/playcore/engage/tv/recommendations.md) - - [TV Continue Watching](references/android/guide/playcore/engage/tv/continue-watching/index.md) - - [TV Entitlements](references/android/guide/playcore/engage/tv/entitlements.md) -- **Vertical-specific schemas:** - - - [Food Schema](references/schemas/food.md) - - [Watch Schema](references/schemas/watch.md) - - [Listen Schema](references/schemas/listen.md) - - [Read Schema](references/schemas/read.md) - - [Shopping Schema](references/schemas/shopping.md) - - [Social Schema](references/schemas/social.md) - - [Travel Schema](references/schemas/travel.md) - - [TV Schema](references/schemas/tv.md) - - [Other Schema](references/schemas/other.md) diff --git a/play/engage-sdk-integration/references/android/guide/playcore/engage/faq.md b/play/engage-sdk-integration/references/android/guide/playcore/engage/faq.md deleted file mode 100644 index 3ed6401..0000000 --- a/play/engage-sdk-integration/references/android/guide/playcore/engage/faq.md +++ /dev/null @@ -1,439 +0,0 @@ -## Publish FAQs - -#### Who manages the content publishing job? - -The app developer manages the content publishing job and sends requests to the -Engage Service. In this way, developer partners have more control over when and -how to publish content to the users. This avoids waking up the partner app too -frequently to publish content. - -#### Does a developer need to publish all cluster types? - -While technically developers are free to publish just one cluster, we **strongly -advise** including more. Otherwise, developers miss the opportunity to drive -better engagement with their content. We **highly recommend** publishing all -cluster types for each vertical. - -#### How often should the developer partner be publishing data using the work manager while the app is running? - -This is to be decided by the developer partner. Google recommends publishing -once or twice per day for general recommendation content, and to use an -event-driven methodology for shopping cart, reorder, and other continuation -content (for example, start the worker as a callback of the user adding items -to the cart or the user stopping a movie halfway). -For **social** apps, it's critical to publish updated recommendation clusters -**after each app usage**. Social app users are more interested in the most -recent recommendations and ideally would like to see a post at most once. - -#### When should the developer call delete APIs? - -Delete APIs should only be called when there is no content to publish. **Don't** -call delete and publish APIs subsequently to replace content; the publish -APIs remove the earlier content automatically. - -## Broadcast Intent FAQs - -#### Why do Android app developers need to register for broadcast intents? - -In order to serve fresh content to the user, you should use broadcast intents to -trigger a data sync in cases where users might not use the app frequently. - -#### Unable to test broadcast intent - -The verification app doesn't support testing broadcast intent with -permission. You have to remove permissions while testing and -add them back before switching the SDK to prod version in [Step 6](https://developer.android.com/guide/playcore/engage/workflow#switch-to-prod). - -#### Background execution not allowed - -While registering the broadcast intent, you may come across the following error: - - Background execution not allowed: receiving Intent - { act=com.google.android.engage.action.PUBLISH_RECOMMENDATION .. } - -You need to register the broadcast receivers dynamically. - - class AppEngageBroadcastReceiver extends BroadcastReceiver { - // Trigger recommendation cluster publish when PUBLISH_RECOMMENDATION broadcast - // is received - } - - public static void registerBroadcastReceivers(Context context) { - - context = context.getApplicationContext(); - - // Register Recommendation Cluster Publish Intent - context.registerReceiver(new AppEngageBroadcastReceiver(), - new IntentFilter(com.google.android.engage.service.Intents.ACTION_PUBLISH_RECOMMENDATION, - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null)); - ... - - } - -## Workflow FAQs - -While integrating with the SDK, you may come across the following errors: - -#### Validation errors at the app, cluster, entity level - -Summaries at the app, cluster, and entity levels display a count of -validation errors. These errors correspond to missing required fields or -invalid values provided. Error messages show up in red beneath each relevant -field. Fix all validation errors and check for correctness before sharing -the APK. - -#### Testing Deep links - -The deep links are associated with the package name. A good way to test -deep links is using the adb tool. - - adb shell am start -W -a android.intent.action.VIEW -d - -#### How can I calculate the impact of the integration? - -The deep links are a great way to track the attribution. The deep link URLs -that take users to your app can be included with additional tracking params. -For example - "http://xx/deeplink?source_tag=engage". - -Developers can add their own tracking params and provide attribution to -calculate impact. - -## Engage for TV 2.0 FAQs - -### General Questions - -#### What is Engage? - -Engage takes the "pick up where you left off" experience to the next level! It's -a significant upgrade that allows viewers to seamlessly resume their content -across a wider range of devices. Imagine starting a movie on your Google TV and -then effortlessly continuing it on your phone during your commute -- that's the -power of Engage. - -This new system is designed to boost viewer engagement and retention by -providing a smooth, frictionless experience across the entire Google ecosystem. - -#### Is Video Discovery API the same as Engage? - -Yes, they are the same. The Engage SDK is a library that includes support for -the continue watching row. Engage supports more content entity types than video, -which is why the integration is no longer named "Video Discovery". - -#### What are the benefits of using Engage? - -Answer: Engage makes it easier than ever for viewers to pick up where they left -off on your content, no matter what device they're using. Here's how it works: - -- Seamless experience across Google: Start watching on your Google TV, and continue seamlessly on your Android phone, iPhone, or Android tablet. It even works on devices where you haven't installed the app yet! -- Increased engagement and retention: Engage helps bring users back to your app, even on new devices. By letting users resume their favorite shows, you increase the chances they'll keep watching. -- Wider reach: Beyond Google TV, Engage works across other Android media experiences, like Play Collections and other Google media apps. -- Backward compatible: If you're already using the older "[Watch Next](https://developer.android.com/training/tv/discovery/watch-next-add-programs)" feature, no problem! Engage is backward compatible, so your existing integration will still work. - -Important Note: All new Continue Watching integrations must use Engage. The -older "Cross Device Play Next" system is being phased out. - -#### What surfaces support Engage? - -1. Google TV -2. Android TV (on-device only but supports Engage SDK) -3. Google TV Android mobile app -4. Google TV iOS mobile app -5. Play Collections -6. Google entertainment space -7. iOS devices (with REST API integration). - -#### Is Engage SDK for Continue Watching? - -Yes, the Engage SDK supports content for the continue watching row. It is -required to integrate with Engage. - -#### Is Engage available for everyone? - -Engage is being rolled out in phases. - -- Early Access: We're initially granting access to a select group of partners through an Early Access Program (EAP). -- Expanding Access: We're working hard to make Engage available to all developers soon. - -For a smooth and successful launch, we have safeguards in place to manage the -rollout. This involves both an allowlist on the Engage side and a separate check -within the Engage SDK. Whether you are an EAP partner or wants to be onboard -soon, please contact us so that we can set up the access permissions before you -begin with Engage SDK integration. - -#### Is there a recommended image size we should provide? - -Image requirements have been updated in the [Create Entities](https://developer.android.com/guide/playcore/engage/watch#image-specs) -section. - -#### With this new API documentation, will the Continue Watching data pulled by the Google server from the client and will it be reflected in all the devices? - -The new API offers significant improvements for content on the continue watching -row, including: - -- **Seamless experience across Google TVs:** Users can start watching on one - Google TV and resume on any other Google TV logged in with the same account. - This feature also works with older Android TV versions. - -- **Mobile app integration:** Content from Engage is surfaced on the Google TV - mobile app for Android and iOS, allowing users to seamlessly switch between - their TV and mobile devices. - -- **Enhanced user retention:** Even on devices without the app installed or - where the user isn't logged in, content in the continue watching row - encourages users to re-engage with your app, boosting retention. - -- **Expansion to other platforms:** Engage extends to other Google media - platforms like Android, Play Collections, tablets, and other Google media - apps \& surfaces on Android, maximizing user engagement across devices. - -#### What is the limit on the number of entities I can publish to the Continuation cluster? - -Each developer partner is limited to a maximum of 5 entities in the Continuation -cluster. This limit is for fair distribution of content on the "continue -watching" row on Google TV, which is a shared space for multiple media -providers. - -#### What happens if I try to publish more than 5 entities? - -Engage SDK will reject your publish request if it exceeds the 5-entity limit. -You'll need to reduce the number of entities in your request to successfully -publish. You should only include the entities where the users have left off -watching, so in most cases, there will be only a few such entities. When there -are more than 5 such entities, you could choose the more recent ones to publish. - -#### Why is there a limit on the number of entities? - -The continue watching row on Google TV displays content from various media -providers. Limiting the number of entities per provider so that users see a -diverse selection of content from all their favorite sources, promoting a fair -and balanced user experience. - -### Verification App Questions - -#### Is it mandatory to test my app with the verification app before submission? - -Yes, testing your app with the verification app is essential before submitting -your APK. - -While we understand you might be confident in your implementation, the Engage -integration has many intricate components. The verification app acts as a safety -net, catching potential issues early on and saving you valuable time and effort -in the long run. - -Think of it as a quick checkup that helps guarantee a smooth launch and a great -user experience. - -By identifying and addressing any problems beforehand, you can avoid the -frustration of rejections and resubmissions. - -To submit your APK, you'll need to include a screenshot showing that your app -has passed the verification process. - -#### What are some common mistakes to watch out for during integration? - -The verification app is designed to catch potential issues with your Engage -integration. Here are some common mistakes that developers often encounter: - -For all content types (movies, TV episodes, live streams, video clips): - -- Missing Links: Make sure you provide valid platform-specific URIs (links) for your content. These links tell the system where to find your content on each platform. -- Missing Titles: Don't forget to include titles for all your content. This helps users identify what they were watching. -- Image Aspect Ratios: Verify all images associated with your content have an aspect ratio close to 16:9. This makes sure your images display correctly on different screens. - -For TV Episodes: - -- Complete Episode Information: Make sure to include the show title, episode number, and season number. This helps organize episodes and allows users to navigate within a series. -- Accurate Playback Position: Double-check that the last playback position is less than or equal to the total duration of the episode. This makes sure users resume from the correct spot. - -For Movies: - -- Accurate Playback Position: Similar to TV episodes, verify the last playback position is accurate. - -For Live Streaming Videos: - -- Broadcaster Information: Include the broadcaster's name for live streams. - -For Video Clips: - -- Creator Information: Specify the creator of the video clip. - -Remember: The verification app will flag these issues, allowing you to fix them -before submitting your app. This saves you time and ensures a smoother -experience for your users. - -### Account and Profile Questions - -#### My app uses anonymous user logins. Is AccountProfile still required for Engage? - -`AccountProfile` is designed for apps that use individual user accounts. -However, we understand that some apps, like yours, may rely on anonymous logins. -Here's how Engage works in this scenario: - -- `AccountProfile` is technically required, but you can still integrate Engage even if your app doesn't have a user account system. -- Limited to on-device use: The cross-device capabilities of Engage relies on identifying users across different devices. Since anonymous logins don't provide this, the feature will be limited to the user's current device. -- How to configure: To set this up, you'll need to disable cross-device syncing. This makes sure that continuation entries only appear on the specific device where the content was started. - -In summary: While you can integrate Engage with anonymous logins, users will -only be able to resume content on the same device. - -#### Can I use AccountProfile with only `accountId` and no `profileId`, even when my app supports both accountId and `profileId`? - -AccountProfile requires both `accountId` and `profileId` to function correctly. -Here's why: - -- Consistent identification: `accountId` identifies the user, while `profileId` distinguishes between different profiles within that user's account (if applicable). Providing both makes sure that Engage accurately tracks and displays content for each individual profile. -- Preventing errors: Using `accountId` and `profileId` inconsistently across different API calls can lead to unexpected behavior and errors. For example, if you include both when adding content to Engage but only use `accountId` when deleting content, the system may not be able to correctly identify and remove the intended items. - -#### Is `profileId` required for Engage? - -- `accountId` is required. This identifies the user across devices. -- `profileId` is crucial for a good user experience. While technically optional, `profileId` is strongly recommended if your service supports multiple profiles (like many streaming services do). Why is it so important? Because without `profileId`, the continue watching row may show content from other profiles on the same account. This can lead to a confusing and frustrating experience for your users. -- In short: Providing `profileId` makes sure that content provided to the continue watching row accurately reflects each individual's viewing history. Unless your app doesn't support the concept of profile within an account, you should provide it. - -#### How does Google use the `profileId` on their side? - -If the service offers different profiles to watch content, `accountId` and -`profileId` would be used to associate the content watched on the device to the -signed-in Google Account on the device. Google would record the ContinueWatching -data against the combination of `accountId` and `profileId`. Any Google device -that has that same Google Account logged in, would get the latest updated data -from the same associated `accountId` and `profileId`, in its ContinueWatching -row. - -#### Is account linking required to implement Engage? - -Account Linking is not needed. It is being deprioritized and all related use -cases will be covered by the new Device Entitlements API. - -### Sync Across Devices Questions - -#### What does "sync across devices" mean when users give consent? - -With the user's "sync across device" consent, the content they're watching will -be saved to Google TV servers, letting them seamlessly pick up where they left -off on any signed-in device. Without consent, their watch history remains local -to the current device. - -#### Can we set "sync across devices" to false? - -The `setUserConsentToSyncAcrossDevices` flag controls whether a user's -ContinuationCluster data is synchronized across their devices (TV, phone, -tablet, etc.). If this flag is set to false, then continue watching content is -only surfaced on same device. - -To get the most out of our cross-device feature, we strongly advise your app to -obtain user consent and set SyncAcrossDevices to true. - -#### How is user consent for sharing watch history obtained on non-Android - -devices? What data points are shared to 3P servers from non-Android devices? - -The consent is collected at the user level (profile or account level). Once -consent is obtained, the continue watching payloads based on engagement can be -sent anywhere so Google can reflect the users' ubiquity resumption state across -all entities that they have partial or next engagement with, on any device -(without having to re-ask consent on every device or platform). Partners will -send the users latest continue watching state (as per spec) associated with -profile ID (that was deposited on android). - -### REST API Questions - -#### Is there documentation on the REST API? - -The ETA for REST API is March 2025, this is documented in Engage Developer Docs. - -### Legacy Watch Next Questions - -#### Is Engage replacing the Watch Next API? - -Engage will be backward compatible on all Android TV devices that support the -Watch Next API. To integrate on Google TV and other surfaces that support -Engage, developers should use the Engage SDK. - -### Testing and Integration Questions - -#### What is the difference between LastPlayBackPositionTimeMillis and duration? - -LastPlayBackPositionTimeMillis should reflect the playback duration in -milliseconds where the user stopped watching (e.g., 605000 ms for 10 minutes and -5 seconds). It should never be greater than the entity's total duration. - -Whereas, **LastEngagementTime** is the timestamp when the user last engaged with -the content. - -#### What are the test cases we should perform? - -The following are test cases for Google TV that our QA performs. Similar test -cases can be performed on other surfaces as well. - -1. Watch a video, which is longer than 20 minutes for about 5 minutes. Exit app. The video card should be displayed in the continue watching row. Note: We only display 5 cards per 3p app in CW -2. Selecting the newly appeared card in the continue watching row should continue playing the video from the right point in the video.Note: Any New or old content should resume playback from where it was left off last -3. Changing accounts on the GTV device should change the cards on the continue watching row. Only videos from the current account should show up. Sorted in recent order. 3p app profile CW will be intermixed. Note: CW for GoogleAccount2 will show 3P contents that GoogleAccount2 was engaged watching -4. Exit the app with BACK button \> Verify card is displayed in the "Continue watching" row -5. Hide the video in the continue watching row, it shouldn't show again Test if hidden content stays hidden beyond 24 hours and even after the app opens after 24 hrs. Confirm hiding one item does not hide multiple items. -6. Content availability in the continue watching row with full metadata: Card image, App name, title, season episode # for TV contents -7. Check Progress displays in the progress bar -8. User watched the content until ending credits - content does not display in the continue watching row -9. Confirm no unwatched items show up in the continue watching row -10. Confirm that the CW items are arranged chronologically based on when watch activity happened and not when the app was last opened or last day -11. Confirm that episode and season details on CW card match what was watched on episodic content -12. Confirm completed (items at credits or beyond) items don't show up in the continue watching row -13. Turn off the device halfway through watching the episode/movie/show. "Turn off the device halfway through watching the episode/movie/show. Verify on turning on the device and on other TV, CW displays the right card , at the right position and progress bar" -14. Turn off the device after completely watching episode 1, verify -15. episode 1 drops and doesn't reappear in continue watching row \[on second device and on turning on the test device\] - 1. episode 2 (if available), should appear in continue watching row \[on second device and on turning on the test device\] -16. First scenario: TV1: GoogleAccount: mom, 3p account / profile: account 1 / profile_1. Watch content and verify CW data displays contents watched by 3P account_1/profile_1 -17. TV2: GoogleAccount: mom. Verify CW data from the first scenario. Now login - to the 3p app as a different account. 3p account / profile: account_2 / - profile_2. Watch content and verify CW data displays contents watched by 3p - account_2/profile_2 - -18. GoogleAccount: mom. New device case /3P app not installed. On a new - device(FDR the device), Verify CW displays data from the last used 3P app - that was used by the GoogleAccount. Note: CW row shouldn't show 3P contents - if the GAIA is not yet associated with a 3P profile on other device - - 1. GoogleAccount: mom. New device case /3P app installed but not logged in. On a new device(FDR the device), Verify CW displays data from the last used 3P app that was used by the GoogleAccount. -19. - - > [!NOTE] - > **Note:** When the app is installed and logged in, the CW state would reflect the active 3P user logged into the 3P app. - - 1. Note: the continue watching row shouldn't show 3P contents if the GoogleAccount is not yet associated with a 3P profile - -#### We are not seeing continuation content showing up on Google TV iOS app. What happened? - -You will need to send iOS deeplinks for content to appear in the continue -watching row on iOS devices. - -#### How often should I update content information for the continue watching row? Should I update it frequently, like every 15 seconds? - -No, frequent updates are not recommended. Here's why: - -- Performance Impact: Continuously sending updates puts unnecessary strain on our servers, potentially slowing down the system for everyone. -- Unnecessary Data: While a user is actively watching, their playback position changes constantly. Sending updates every few seconds creates a lot of redundant data that isn't helpful for resuming playback. - -When to update content information for the continue watching row: - -Focus on capturing meaningful changes in the user's viewing progress. Here are -the key scenarios: - -- Playback Paused or Stopped: When a user pauses or stops watching, send an update to store their current position. -- App Closed or Backgrounded: If a user exits the app or switches to another app while watching a video, send an update to save their progress. -- When user removes an item from their continue watching row within app - -How to efficiently update: - -Instead of timed updates, utilize events within your video player or app -lifecycle to trigger updates. For example: - -- `onPause`, `onStop`: When the video playback pauses or stops. -- `onAppClose`, `onAppBackgrounded`: When the app closes or moves to the background. - -By following these guidelines, you'll ensure efficient use of resources while -still providing a seamless experience in the continue watching row for your -users. - -> [!NOTE] -> **Note:** Please contact [`engage-developers@google.com`](mailto:engage-developers@google.com) if you have any questions that are not covered here. \ No newline at end of file diff --git a/play/engage-sdk-integration/references/android/guide/playcore/engage/food.md b/play/engage-sdk-integration/references/android/guide/playcore/engage/food.md deleted file mode 100644 index 6c86ff9..0000000 --- a/play/engage-sdk-integration/references/android/guide/playcore/engage/food.md +++ /dev/null @@ -1,1172 +0,0 @@ -Boost app engagement by reaching your users where they are. Integrate Engage SDK -to deliver personalized recommendations and continuation content directly to -users across multiple on-device surfaces, like -**[Collections](https://android-developers.googleblog.com/2024/07/introducing-collections-powered-by-engage-sdk.html)** , **[Entertainment -Space](https://blog.google/products/android/entertainment-space/)** , and the Play Store. The integration adds -less than 50 KB (compressed) to the average APK and takes most apps about a -week of developer time. Learn more at our **[business -site](http://play.google.com/console/about/programs/EngageSDK)**. - -This guide contains instructions for developer partners to deliver food content -(food ordering, food or restaurant reviews \& discovery, meal subscriptions, -recipes) to Engage content surfaces. - -## Category support and surfaces - -Engage SDK surface eligibility is based on your app's content category. Use the -following table to determine your eligibility for specific surfaces: - -| Status | Content category or use case | Supported surfaces | -|---|---|---| -| **Supported** (Eligible on all surfaces) | - Food / coffee / drink ordering or delivery - Restaurant / Food discovery, reviews, \& reservations - Meal subscriptions - Recipes \& Cooking | - Collections - Play Store Apps tab - Play Store You tab - Play Store store listing page | -| **Not supported** | - Driver / deliverer app - Cooking tools / controls | | - - -## Android skills - -[View on GitHub](https://github.com/android/skills/tree/main/play/engage-sdk-integration) - -### Integrate Engage SDK - -Use the Engage SDK [Android skill](https://developer.android.com/tools/agents/android-skills) to integrate, debug, and resolve implementation issues. To install the skill from the [Android CLI](https://developer.android.com/tools/agents/android-cli), run: - - android skills add engage-sdk-integration - -
- -## Integration detail - -### Terminology - -This integration includes the following five cluster types: **Recommendation** , -**Featured** , **Food Shopping Cart** , **Food Shopping List** , and **Reorder**. - -- **Recommendation** clusters show personalized food-related suggestions from an - individual developer partner. These recommendations can be personalized to the - user or generalized (for example, new on sale). Use them to surface recipes, - stores, dishes, groceries, and so on as you see fit. - - - A Recommendation cluster can be made of `ProductEntity`, `StoreEntity`, or `RecipeEntity` listings, but not a mix of different entity types. - - ![](https://developer.android.com/static/images/guide/playcore/engage/food-entities.png) **Figure :**\`ProductEntity\`, \`StoreEntity\`, and \`RecipeEntity\`. (\*UI for illustrative purposes only) -- The **Featured** cluster showcases a selection of entities from multiple - developer partners in one UI grouping. There will be a single Featured - cluster, which is surfaced near the top of the UI with a priority placement - above all Recommendation clusters. Each developer partner will be allowed to - broadcast up to 10 entities in the Featured cluster. - - ![](https://developer.android.com/static/images/guide/playcore/engage/food-featured.png) **Figure :** Featured cluster with the \`RecipeEntity\`. (\*UI for illustrative purposes only) -- The **Food Shopping Cart** cluster shows a sneak peek of grocery shopping - carts from multiple developer partners in one UI grouping, prompting users to - complete their outstanding carts. There is a single Food Shopping Cart - cluster. - - - Food Shopping Cart Cluster must show the total count of items in the - cart and may also include images for X items in the user's cart. - - ![](https://developer.android.com/static/images/guide/playcore/engage/food-shopping-cart.png) **Figure:** Food Shopping Cart cluster from a single partner. (\*UI for illustrative purposes only) -- The **Food Shopping List** cluster shows a sneak peek of the grocery shopping - lists from multiple developer partners in one UI grouping, prompting users to - return to the corresponding app to update and complete their lists. There is a - single Food Shopping List cluster. - - ![](https://developer.android.com/static/images/guide/playcore/engage/food-shopping-list.png) **Figure:** Food Shopping List cluster from a single partner. (\*UI for illustrative purposes only) -- The **Reorder** cluster shows a sneak peek of the previous orders from - multiple developer partners in one UI grouping, prompting users to reorder. - There is a single Reorder cluster. - - - Reorder cluster must show the total count of items in the - user's previous order and must also include one of the following: - - - Images for X items in the user's previous order. - - Labels for X items in the user's previous order. - - ![](https://developer.android.com/static/images/guide/playcore/engage/food-reorder-cluster.png) **Figure:** Food Reorder cluster from a single partner. (\*UI for illustrative purposes only) - -### Pre-work - -Minimum API level: 19 - -Add the `com.google.android.engage:engage-core` library to your app: - - dependencies { - // Make sure you also include that repository in your project's build.gradle file. - implementation 'com.google.android.engage:engage-core:1.6.0' - } - -### Summary - -The design is based on an implementation of a [bound -service](https://developer.android.com/guide/components/bound-services). - -The data a client can publish is subject to the following limits for different -cluster types: - -| Cluster type | Cluster limits | Maximum entity limits in a cluster | -|---|---|---| -| Recommendation Cluster(s) | At most 7 | At most 50 (`ProductEntity`, `RecipeEntity`, or `StoreEntity`) | -| Featured Cluster | At most 1 | At most 20 (`ProductEntity`, `RecipeEntity`, or `StoreEntity`) | -| Food Shopping Cart Cluster | At most 1 | At most 3 `FoodShoppingCart` | -| Food Shopping List Cluster | At most 1 | At most 3 `FoodShoppingList` | -| Food Reorder Cluster | At most 1 | At most 1 `ReorderEntity` | - -### Step 1: Provide entity data - -The SDK has defined different entities to represent each item type. We support -the following entities for the Food category: - -1. `ProductEntity` -2. `StoreEntity` -3. `RecipeEntity` -4. `FoodShoppingCart` -5. `FoodShoppingList` -6. `FoodReorderCluster` - -The charts below outline available attributes and requirements for each type. - -#### `ProductEntity` - -The `ProductEntity` object represents an individual item (such as a grocery -item, dish from a restaurant, or a promotion) that developer partners want to -publish. - -
- -![](https://developer.android.com/static/images/guide/playcore/engage/food-product-entity.png) **Figure :** Attributes of `ProductEntity` - -
- -| Attribute | Requirement | Description | Format | -|---|---|---|---| -| Poster images | **Required** | At least one image must be provided. | See [Image Specifications](https://developer.android.com/guide/playcore/engage/food#image-specs) for guidance. | -| Action Uri | **Required** | The deep link to the page in the app displaying details about the product. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | Uri | -| Title | Optional | The name of the product. | Free text **Recommended text size: under 90 chars** (Text that is too long may show ellipses) | -| Price - current | Conditionally required | The current price of the product. **Must be provided if strikethrough price is provided.** | Free text | -| Price - strikethrough | Optional | The original price of the entity, which is struck-through in the UI. | Free text | -| Callout | Optional | Callout to feature a promo, event, or update for the product, if available. | Free text **Recommended text size: under 45 chars** (Text that is too long may show ellipses) | -| Callout fine print | Optional | Fine print text for the callout. | Free text **Recommended text size: under 45 chars** (Text that is too long may show ellipses) | -| **Rating (Optional) - Note: All ratings are displayed using our standard star rating system.** |||| -| Rating - Max value | Optional | The maximum value of the rating scale. **Must be provided if current value of rating is also provided.** | Number \>= 0.0 | -| Rating - Current value | Optional | The current value of the rating scale. **Must be provided if maximum value of rating is also provided.** | Number \>= 0.0 | -| Rating - Count | Optional | The count of the ratings for the product. **Note:** Provide this field if your app controls how the count is displayed to the users. Use a concise string. For example, if the count is 1,000,000, consider using an abbreviation like 1M so that the count isn't truncated on smaller display sizes. | String | -| Rating - Count Value | Optional | The count of the ratings for the product. **Note:** Provide this field if you don't handle the display abbreviation logic yourself. If both Count and Count Value are present, Count is displayed to users. | Long | -| **DisplayTimeWindow (Optional) - Set a time window for a content to be shown on the surface** |||| -| Start Timestamp | Optional | The epoch timestamp after which the content should be shown on the surface. If not set, content is eligible to be shown on the surface. | Epoch timestamp in milliseconds | -| End Timestamp | Optional | The epoch timestamp after which the content is no longer shown on the surface. If not set, content is eligible to be shown on the surface. | Epoch timestamp in milliseconds | - -#### `StoreEntity` - -The `StoreEntity` object represents an individual store that developer partners -want to publish, such as a restaurant or a grocery store. - -
- -![](https://developer.android.com/static/images/guide/playcore/engage/food-store-entity.png) **Figure :** Attributes of `StoreEntity` - -
- -| Attribute | Requirement | Description | Format | -|---|---|---|---| -| Poster images | **Required** | At least one image must be provided. | See [Image Specifications](https://developer.android.com/guide/playcore/engage/food#image-specs) for guidance. | -| Action Uri | **Required** | The deep link to the page in the app displaying details about the store. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | Uri | -| Title | Optional | The name of the store. | Free text **Recommended text size: under 45 chars** (Text that is too long may show ellipses) | -| Location | Optional | The location of the store. | Free text **Recommended text size: under 45 chars** (Text that is too long may show ellipses) | -| Callout | Optional | Callout to feature a promo, event, or update for the store, if available. | Free text **Recommended text size: under 45 chars** (Text that is too long may show ellipses) | -| Callout fine print | Optional | Fine print text for the callout. | Free text **Recommended text size: under 45 chars** (Text that is too long may show ellipses) | -| Description | Optional | A description of the store. | Free text **Recommended text size: under 90 chars** (Text that is too long may show ellipses) | -| Category | Optional | Category of a store, in the context of dining places, it can be cuisine like "french", "new american", "ramen", "fine dining". | Free text **Recommended text size: under 45 chars** (Text that is too long may show ellipses) | -| **Note: All ratings is displayed using our standard star rating system.** |||| -| Rating - Max value | Optional | The maximum value of the rating scale. **Must be provided if current value of rating is also provided.** | Number \>= 0.0 | -| Rating - Current value | Optional | The current value of the rating scale. **Must be provided if maximum value of rating is also provided.** | Number \>= 0.0 | -| Rating - Count | Optional | The count of the ratings for the store. **Note:** Provide this field if your app wants to control how this is displayed to the users. Provide the concise string that can be displayed to the user. For example, if the count is 1,000,000, consider using abbreviations like 1M, so that it won't be truncated on smaller display sizes. | String | -| Rating - Count Value | Optional | The count of the ratings for the store. **Note:** Provide this field if you don't want to handle the display abbreviation logic yourself. If both Count and Count Value are present, we will use the Count to display to users | Long | - -#### `RecipeEntity` - -The `RecipeEntity` object represents a recipe item that developer partners want -to publish. - -
- -![](https://developer.android.com/static/images/guide/playcore/engage/food-recipe-entity.png) **Figure :** Attributes of `RecipeEntity` - -
- -| Attribute | Requirement | Description | Format | -|---|---|---|---| -| Poster images | **Required** | At least one image must be provided. | See [Image Specifications](https://developer.android.com/guide/playcore/engage/food#image-specs) for guidance. | -| Action Uri | **Required** | The deep link to the page in the app displaying details about the recipe. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | Uri | -| Title | Optional | The name of the recipe. | Free text **Recommended text size: under 45 chars** (Text that is too long may show ellipses) | -| Author | Optional | The author of the recipe. | Free text **Recommended text size: under 45 chars** (Text that is too long may show ellipses) | -| Cook/Preparation time | Optional | The cooking time of the recipe. | Free text **Recommended text size: under 45 chars** (Text that is too long may show ellipses) | -| Callout | Optional | Callout to feature a promo, event, or update for the recipe, if available. | Free text **Recommended text size: under 45 chars** (Text that is too long may show ellipses) | -| Category | Optional | The category of the recipe. | Free text **Recommended text size: under 45 chars** (Text that is too long may show ellipses) | -| Description | Optional | A description of the recipe. | Free text **Recommended text size: under 90 chars** (Text that is too long may show ellipses) | -| **Note: All ratings are displayed using our standard star rating system.** |||| -| Rating - Max value | Optional | The maximum value of the rating scale. **Must be provided if current value of rating is also provided.** | Number \>= 0.0 | -| Rating - Current value | Optional | The current value of the rating scale. **Must be provided if maximum value of rating is also provided.** | Number \>= 0.0 | -| Rating - Count | Optional | The count of the ratings for the recipe. **Note:** Provide this field if your app wants to control how this is displayed to the users. Provide the concise string that can be displayed to the user. For example, if the count is 1,000,000, consider using abbreviations like 1M, so that it won't be truncated on smaller display sizes. | String | -| Rating - Count Value | Optional | The count of the ratings for the recipe. **Note:** Provide this field if you don't want to handle the display abbreviation logic yourself. If both Count and Count Value are present, we will use the Count to display to users | Long | - -#### `FoodShoppingCart` - -
- -![](https://developer.android.com/static/images/guide/playcore/engage/food-shopping-cart-attributes.png) **Figure:** Food Shopping Cart cluster attributes. - -
- -| Attribute | Requirement | Description | Format | -|---|---|---|---| -| Action Uri | **Required** | The deep link to the shopping cart in the partner's app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | Uri | -| Number of items | **Required** | The number of items (not just number of products) in the shopping cart. **For example: If there are 3 oranges and 1 apple in the cart, this number should be 4.** | Integer \>= 1 | -| Last user interaction timestamp | **Required** | Number of milliseconds elapsed from the epoch, identifying the last time when user interacted with the shopping cart. | Epoch timestamp in milliseconds | -| Title | Optional | The title of the cart (for example, *Your cart*). **If no title is provided by the developer, *Your cart* is the default.** | Free text **Recommended text size: under 25 chars** (Text that is too long may show ellipses) | -| Action Text | Optional | The call to action text of the button on the Shopping Cart (for example, *Your Shopping Bag*). **If no action text is provided by the developer, *View Cart* is the default.** This attribute is supported in version 1.1.0 onwards. | String | -| Cart images | Optional | Images of each product in the cart. **Up to 10 images can be provided in order of priority; the actual number of images displayed depends on the device form factor.** | See [Image Specifications](https://developer.android.com/guide/playcore/engage/food#image-specs) for guidance. | -| Item labels | Optional | The list of labels for items on the shopping list. **The actual number of labels displayed depends on the device form factor.** | List of free text labels **Recommended text size: under 20 chars** (Text that is too long may show ellipses) | -| **DisplayTimeWindow (Optional) - Set a time window for a content to be shown on the surface** |||| -| Start Timestamp | Optional | The epoch timestamp after which the content should be shown on the surface. If not set, content is eligible to be shown on the surface. | Epoch timestamp in milliseconds | -| End Timestamp | Optional | The epoch timestamp after which the content is no longer shown on the surface. If not set, content is eligible to be shown on the surface. | Epoch timestamp in milliseconds | - -#### `FoodShoppingList` - -
- -![](https://developer.android.com/static/images/guide/playcore/engage/food-shopping-list.png) **Figure:** Food Shopping List cluster. - -
- -| Attribute | Requirement | Description | Format | -|---|---|---|---| -| Action Uri | **Required** | The deep link to the shopping list in the partner's app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | Uri | -| Number of items | **Required** | The number of items in the shopping list. | Integer \>= 1 | -| Last user interaction timestamp | **Required** | Number of milliseconds elapsed from the epoch, identifying the last time when user interacted with the shopping list. | Epoch timestamp in milliseconds | -| Title | Optional | The title of the list (for example, *Your Grocery List*). **If no title is provided by the developer, *Shopping list* is the default.** | Free text **Recommended text size: under 25 chars** (Text that is too long may show ellipses) | -| Item labels | **Required** | The list of labels for items on the shopping list. **At least 1 label must be provided and up to 10 labels can be provided in order of priority; the actual number of labels displayed depends on the device form factor.** | List of free text labels **Recommended text size: under 20 chars** (Text that is too long may show ellipses) | - -#### `FoodReorderCluster` - -
- -![](https://developer.android.com/static/images/guide/playcore/engage/food-reorder-cluster.png) **Figure:** Food Reorder cluster. - -
- -| Attribute | Requirement | Description | Format | -|---|---|---|---| -| Action Uri | **Required** | The deep link to reorder in the partner's app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | Uri | -| Action Text | Optional | The call to action text of the button on the Reorder (for example, *Order again*). **If no action text is provided by the developer, *Reorder* is the default.** This attribute is supported in version 1.1.0 onwards. | String | -| Number of items | **Required** | The number of items (not just number of products) in the previous order. **For example: If there were 3 small coffees and 1 croissant in the previous order, this number should be 4.** | Integer \>= 1 | -| Title | **Required** | The title of the reorder item. | Free text **Recommended text size: under 40 chars** (Text that is too long may show ellipses) | -| Item labels | Optional (If not provided, poster images should be provided) | The list of item labels for the previous order. **Up to 10 labels can be provided in order of priority; the actual number of labels displayed depends on the device form factor.** | List of free text **Recommended text size per label: under 20 chars** (Text that is too long may show ellipses) | -| Poster images | Optional (If not provided, item labels should be provided) | Images of the items in the previous order. **Up to 10 images can be provided in order of priority; the actual number of images displayed depends on the device form factor.** | See [Image Specifications](https://developer.android.com/guide/playcore/engage/food#image-specs) for guidance. | - -#### Image specifications - -Required specifications for image assets are listed below: - -| Aspect ratio | Minimum pixels | Recommended pixels | -|---|---|---| -| Square (1x1) **Preferred** | 300x300 | 1200x1200 | -| Landscape (1.91x1) | 600x314 | 1200x628 | -| Portrait (4x5) | 480x600 | 960x1200 | - -*File formats* - -PNG, JPG, static GIF, WebP - -*Maximum file size* - -5120 KB - -*Additional recommendations* - -- **Image safe area:** Put your important content in the center 80% of the image. -- Use a transparent background so that the image can be properly displayed in Dark and Light theme settings. - -### Step 2: Provide Cluster data - -It is recommended to have the content publish job executed in the background -(for example, using [WorkManager](https://developer.android.com/topic/libraries/architecture/workmanager)) -and scheduled on a regular basis or on an event basis (for example, every time -the user opens the app or when the user just added something to their cart). - -`AppEngageFoodClient` is responsible for publishing food clusters. - -There are following APIs to publish clusters in the client: - -- `isServiceAvailable` -- `publishRecommendationClusters` -- `publishFeaturedCluster` -- `publishFoodShoppingCarts` -- `publishFoodShoppingLists` -- `publishReorderCluster` -- `publishUserAccountManagementRequest` -- `updatePublishStatus` -- `deleteRecommendationsClusters` -- `deleteFeaturedCluster` -- `deleteFoodShoppingCartCluster` -- `deleteFoodShoppingListCluster` -- `deleteReorderCluster` -- `deleteUserManagementCluster` -- `deleteClusters` - -#### `isServiceAvailable` - -This API is used to check if the service is available for integration and -whether the content can be presented on the device. - -##### For Engage SDK v1.6.0 and higher (Recommended) - - -## Android skills - -[View on GitHub](https://github.com/android/skills/tree/main/play/engage-sdk-integration) - -### Engage SDK integration - -To install the skill from the [Android CLI](https://developer.android.com/tools/agents/android-cli), run: - - android skills add engage-sdk-integration - -If your team uses AI coding tools (such as Gemini in Android Studio), you can automate this migration by prompting your AI assistant: - - Use the engage-sdk-integration skill to use Engage SDK 1.6.0 and refactor isServiceAvailable to pass ServiceAvailabilityRequest for publishing all cluster types. - -
- -You can check the service availability for every cluster type that you intend to -publish. The `isServiceAvailable` API accepts a request object, -`ServiceAvailabilityRequest`, which contains the cluster types for which service -availability needs to be checked. You can find the `ClusterType` enum values -required for `ServiceAvailabilityRequest` from the following table. - -| Cluster Type | Cluster Type Constant | Integer Value | -|---|---|---| -| Unknown | `TYPE_UNKNOWN` | 0 | -| Recommendation Cluster | `TYPE_RECOMMENDATION` | 1 | -| Featured Cluster | `TYPE_FEATURED` | 2 | -| Continuation Cluster | `TYPE_CONTINUATION` | 3 | -| Food Reorder Cluster | `TYPE_FOOD_REORDER` | 5 | -| Food Shopping Cart Cluster | `TYPE_FOOD_SHOPPING_CART` | 6 | -| Food Shopping List Cluster | `TYPE_FOOD_SHOPPING_LIST` | 7 | -| User Management Cluster | `TYPE_ENGAGEMENT` | 8 | -| Subscription Cluster | `TYPE_SUBSCRIPTION` | 12 | - -### Kotlin - - val request = ServiceAvailabilityRequest.Builder() - .addIntendedClusterType(ClusterType.TYPE_CONTINUATION) - .addIntendedClusterType(ClusterType.TYPE_RECOMMENDATION) - .build() - - client.isServiceAvailable(request).addOnCompleteListener { task -> - if (task.isSuccessful) { - val availabilityMap = task.result - if (availabilityMap[ClusterType.TYPE_CONTINUATION] == true) { - // Proceed with publishing continuation content - } - if (availabilityMap[ClusterType.TYPE_RECOMMENDATION] == true) { - // Proceed with publishing recommendation content - } - } else { - // The IPC call itself fails, proceed with error handling logic here, - // such as retry. - } - } - -### Java - - ServiceAvailabilityRequest request = - new ServiceAvailabilityRequest.Builder() - .addIntendedClusterType(ClusterType.TYPE_CONTINUATION) - .addIntendedClusterType(ClusterType.TYPE_RECOMMENDATION) - .build(); - - client.isServiceAvailable(request).addOnCompleteListener(task -> { - if (task.isSuccessful()) { - Map availabilityMap = task.getResult(); - if (Boolean.TRUE.equals(availabilityMap.get(ClusterType.TYPE_CONTINUATION))) { - // Proceed with publishing continuation content - } - if (Boolean.TRUE.equals(availabilityMap.get(ClusterType.TYPE_RECOMMENDATION))) { - // Proceed with publishing recommendation content - } - } else { - // The IPC call itself fails, proceed with error handling logic here, - // such as retry. - } - }); - -###### Conditional Service Availability Feature - -Some integrated apps request a special configuration that enables and disables -the Engage service intermittently in order to reduce their serving cost. This -intermittent content ingestion strategy, although possible, negatively affects -the user and the product -- stale content will not be presented and some surfaces -will not be served at all. - -Starting with v1.6.0, the Engage SDK allows checking availability for specific -cluster types. This provides more flexibility so that if the intermittent -content strategy was adopted by a given application, some cluster types can -follow that intermittent strategy while other cluster types are always enabled -(i.e. continuation clusters). - -If the Engage service should not be 'continuously' enabled on all supported -devices for whatever reason, and is configured for intermittent ingestion for -any set of devices, all continuation cluster publications (e.g. Food Shopping -Cart, Food Shopping List, and Reorder) will be still enabled by default -configuration, and the rest of the cluster types will be enabled and disabled -intermittently. If intermittent ingestion applies to you but this default -configuration is not suitable for your needs, please contact -engage-developers@google.com. - -##### For SDK versions prior to v1.6.0 (Deprecated) - -### Kotlin - - client.isServiceAvailable.addOnCompleteListener { task -> - if (task.isSuccessful) { - // Handle IPC call success - if(task.result) { - // Service is available on the device, proceed with content publish - // calls. - } else { - // Service is not available, no further action is needed. - } - } else { - // The IPC call itself fails, proceed with error handling logic here, - // such as retry. - } - } - -### Java - - client.isServiceAvailable().addOnCompleteListener(task - > { - if (task.isSuccessful()) { - // Handle success - if(task.getResult()) { - // Service is available on the device, proceed with content publish - // calls. - } else { - // Service is not available, no further action is needed. - } - } else { - // The IPC call itself fails, proceed with error handling logic here, - // such as retry. - } - }); - -> [!NOTE] -> **Note:** We highly recommend keeping a periodic job running to check if the service becomes available at a later point in time. The availability of the service may change with Android version upgrades, app upgrades, installs, and uninstalls. By ensuring periodic job checks at a certain time interval, data can be published once the service becomes available. - -#### `publishRecommendationClusters` - -This API is used to publish a list of `RecommendationCluster` objects. - -A `RecommendationCluster` object can have the following attributes: - -| Attribute | Requirement | Description | -|---|---|---| -| List of ProductEntity, StoreEntity, or RecipeEntity | **Required** | A list of entities that make up the recommendations for this Recommendation Cluster. Entities in a single cluster must be of the same type. | -| Title | **Required** | The title for the Recommendation Cluster (for example, *Big savings on Thanksgiving menu*). **Recommended text size: under 25 chars** (Text that is too long may show ellipses) | -| Subtitle | Optional | The subtitle for the Recommendation Cluster. | -| Action Uri | Optional | The deep link to the page in the partner app where users can see the complete list of recommendations. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | - -> [!IMPORTANT] -> **Important:** The publish APIs are upsert APIs; it replaces the existing content. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. - -### Kotlin - - client.publishRecommendationClusters( - PublishRecommendationClustersRequest.Builder() - .addRecommendationCluster( - RecommendationCluster.Builder() - .addEntity(entity1) - .addEntity(entity2) - .setTitle("Big savings on Thanksgiving menu") - .build()) - .build()) - -### Java - - client.publishRecommendationClusters( - new PublishRecommendationClustersRequest.Builder() - .addRecommendationCluster( - new RecommendationCluster.Builder() - .addEntity(entity1) - .addEntity(entity2) - .setTitle("Big savings on Thanksgiving menu") - .build()) - .build()); - -When the service receives the request, the following actions take place within -one transaction: - -- All existing Recommendation Cluster data is removed. -- Data from the request is parsed and stored in new Recommendation Clusters. - -In case of an error, the entire request is rejected and the existing state is -maintained. - -#### `publishFeaturedCluster` - -This API is used to publish a `FeaturedCluster` object. - -> [!IMPORTANT] -> **Important:** The publish APIs are upsert APIs; it replaces the existing content. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. - -### Kotlin - - client.publishFeaturedCluster( - PublishFeaturedClusterRequest.Builder() - .setFeaturedCluster( - FeaturedCluster.Builder() - ... - .build()) - .build()) - -### Java - - client.publishFeaturedCluster( - new PublishFeaturedClusterRequest.Builder() - .setFeaturedCluster( - new FeaturedCluster.Builder() - ... - .build()) - .build()); - -When the service receives the request, the following actions take place within -one transaction: - -- Existing `FeaturedCluster` data from the developer partner is removed. -- Data from the request is parsed and stored in the updated Featured Cluster. - -In case of an error, the entire request is rejected and the existing state is -maintained. - -#### `publishFoodShoppingCarts` - -This API is used to publish a list of `FoodShoppingCart` objects. - -> [!IMPORTANT] -> **Important:** The publish APIs are upsert APIs; it replaces the existing content. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. - -### Kotlin - - client.publishFoodShoppingCarts( - PublishFoodShoppingCartsRequest.Builder() - .addFoodShoppingCart( - FoodShoppingCart.Builder() - ... - .build()) - .build()) - -### Java - - client.publishFoodShoppingCarts( - new PublishFoodShoppingCartsRequest.Builder() - .addFoodShoppingCart( - new FoodShoppingCart.Builder() - ... - .build()) - .build()); - -When the service receives the request, the following actions take place within -one transaction: - -- Existing `FoodShoppingCart` data from the developer partner is removed. -- Data from the request is parsed and stored in the updated Shopping Cart Cluster. - -In case of an error, the entire request is rejected and the existing state is -maintained. - -#### `publishFoodShoppingLists` - -This API is used to publish a list of `FoodShoppingList` objects. - -> [!IMPORTANT] -> **Important:** The publish APIs are upsert APIs; it replaces the existing content. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. - -### Kotlin - - client.publishFoodShoppingLists( - PublishFoodShoppingListsRequest.Builder() - .addFoodShoppingList( - FoodShoppingListEntity.Builder() - ... - .build()) - .build()) - -### Java - - client.publishFoodShoppingLists( - new PublishFoodShoppingListsRequest.Builder() - .addFoodShoppingList( - new FoodShoppingListEntity.Builder() - ... - .build()) - .build()); - -When the service receives the request, the following actions take place within -one transaction: - -- Existing `FoodShoppingList` data from the developer partner is removed. -- Data from the request is parsed and stored in the updated Shopping List Cluster. - -In case of an error, the entire request is rejected and the existing state is -maintained. - -#### `publishReorderCluster` - -This API is used to publish a `FoodReorderCluster` object. - -> [!IMPORTANT] -> **Important:** The publish APIs are upsert APIs; it replaces the existing content. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. - -### Kotlin - - client.publishReorderCluster( - PublishReorderClusterRequest.Builder() - .setReorderCluster( - FoodReorderCluster.Builder() - ... - .build()) - .build()) - -### Java - - client.publishReorderCluster( - new PublishReorderClusterRequest.Builder() - .setReorderCluster( - new FoodReorderCluster.Builder() - ... - .build()) - .build()); - -When the service receives the request, the following actions take place within -one transaction: - -- Existing `FoodReorderCluster` data from the developer partner is removed. -- Data from the request is parsed and stored in the updated Reorder Cluster. - -In case of an error, the entire request is rejected and the existing state is -maintained. - -#### `publishUserAccountManagementRequest` - -This API is used to publish a Sign In card . The signin action directs users to -the app's sign in page so that the app can publish content (or provide more -personalized content) - -The following metadata is part of the Sign In Card - - -| Attribute | Requirement | Description | -|---|---|---| -| Action Uri | Required | Deeplink to Action (i.e. navigates to app sign in page) | -| Image | Optional - If not provided, Title must be provided | Image Shown on the Card 16x9 aspect ratio images with a resolution of 1264x712 | -| Title | Optional - If not provided, Image must be provided | Title on the Card | -| Action Text | Optional | Text Shown on the CTA (i.e. Sign in) | -| Subtitle | Optional | Optional Subtitle on the Card | - -> [!IMPORTANT] -> **Important:** The publish APIs are upsert APIs; it replaces the existing content. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. - -### Kotlin - - var SIGN_IN_CARD_ENTITY = - SignInCardEntity.Builder() - .addPosterImage( - Image.Builder() - .setImageUri(Uri.parse("http://www.x.com/image.png")) - .setImageHeightInPixel(500) - .setImageWidthInPixel(500) - .build()) - .setActionText("Sign In") - .setActionUri(Uri.parse("http://xx.com/signin")) - .build() - - client.publishUserAccountManagementRequest( - PublishUserAccountManagementRequest.Builder() - .setSignInCardEntity(SIGN_IN_CARD_ENTITY) - .build()); - -### Java - - SignInCardEntity SIGN_IN_CARD_ENTITY = - new SignInCardEntity.Builder() - .addPosterImage( - new Image.Builder() - .setImageUri(Uri.parse("http://www.x.com/image.png")) - .setImageHeightInPixel(500) - .setImageWidthInPixel(500) - .build()) - .setActionText("Sign In") - .setActionUri(Uri.parse("http://xx.com/signin")) - .build(); - - client.publishUserAccountManagementRequest( - new PublishUserAccountManagementRequest.Builder() - .setSignInCardEntity(SIGN_IN_CARD_ENTITY) - .build()); - -When the service receives the request, the following actions take place within -one transaction: - -- Existing `UserAccountManagementCluster` data from the developer partner is removed. -- Data from the request is parsed and stored in the updated UserAccountManagementCluster Cluster. - -In case of an error, the entire request is rejected and the existing state is -maintained. - -#### `updatePublishStatus` - -If for any internal business reason, none of the clusters is published, -we **strongly recommend** updating the publish status using the -**updatePublishStatus** API. -This is important because : - -- Providing the status in all scenarios, even when the content is published (STATUS == PUBLISHED), is critical to populate dashboards that use this explicit status to convey the health and other metrics of your integration. -- If no content is published but the integration status isn't broken (STATUS == NOT_PUBLISHED), Google can avoid triggering alerts in the app health dashboards. It confirms that content is not published due to an **expected** situation from the provider's standpoint. -- It helps developers provide insights into when the data is published versus not. -- Google may use the status codes to nudge the user to do certain actions in the app so they can see the app content or overcome it. - -The list of eligible publish status codes are : - - // Content is published - AppEngagePublishStatusCode.PUBLISHED, - - // Content is not published as user is not signed in - AppEngagePublishStatusCode.NOT_PUBLISHED_REQUIRES_SIGN_IN, - - // Content is not published as user is not subscribed - AppEngagePublishStatusCode.NOT_PUBLISHED_REQUIRES_SUBSCRIPTION, - - // Content is not published as user location is ineligible - AppEngagePublishStatusCode.NOT_PUBLISHED_INELIGIBLE_LOCATION, - - // Content is not published as there is no eligible content - AppEngagePublishStatusCode.NOT_PUBLISHED_NO_ELIGIBLE_CONTENT, - - // Content is not published as the feature is disabled by the client - // Available in v1.3.1 - AppEngagePublishStatusCode.NOT_PUBLISHED_FEATURE_DISABLED_BY_CLIENT, - - // Content is not published as the feature due to a client error - // Available in v1.3.1 - AppEngagePublishStatusCode.NOT_PUBLISHED_CLIENT_ERROR, - - // Content is not published as the feature due to a service error - // Available in v1.3.1 - AppEngagePublishStatusCode.NOT_PUBLISHED_SERVICE_ERROR, - - // Content is not published due to some other reason - // Reach out to engage-developers@ before using this enum. - AppEngagePublishStatusCode.NOT_PUBLISHED_OTHER - -If the content is not published due to a user not logged in, -Google would recommend publishing the Sign In Card. -If for any reason providers are not able to publish the Sign In Card -then we recommend calling the **updatePublishStatus** API -with the status code **NOT_PUBLISHED_REQUIRES_SIGN_IN** - -### Kotlin - - client.updatePublishStatus( - PublishStatusRequest.Builder() - .setStatusCode(AppEngagePublishStatusCode.NOT_PUBLISHED_REQUIRES_SIGN_IN) - .build()) - -### Java - - client.updatePublishStatus( - new PublishStatusRequest.Builder() - .setStatusCode(AppEngagePublishStatusCode.NOT_PUBLISHED_REQUIRES_SIGN_IN) - .build()); - -#### `deleteRecommendationClusters` - -This API is used to delete the content of Recommendation Clusters. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteRecommendationClusters() - -### Java - - client.deleteRecommendationClusters(); - -When the service receives the request, it removes the existing data from the -Recommendation Clusters. In case of an error, the entire request is rejected -and the existing state is maintained. - -> [!NOTE] -> **Note:** This api is available from version 1.1.0 onwards. - -#### `deleteFeaturedCluster` - -This API is used to delete the content of Featured Cluster. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteFeaturedCluster() - -### Java - - client.deleteFeaturedCluster(); - -When the service receives the request, it removes the existing data from the -Featured Cluster. In case of an error, the entire request is rejected -and the existing state is maintained. - -> [!NOTE] -> **Note:** This api is available from version 1.1.0 onwards. - -#### `deleteFoodShoppingCartCluster` - -This API is used to delete the content of Food Shopping Cart Cluster. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteFoodShoppingCartCluster() - -### Java - - client.deleteFoodShoppingCartCluster(); - -When the service receives the request, it removes the existing data from the -Food Shopping Cart Cluster. In case of an error, the entire request is rejected -and the existing state is maintained. - -> [!NOTE] -> **Note:** This api is availaile from version 1.1.0 onwards. - -#### `deleteFoodShoppingListCluster` - -This API is used to delete the content of Food Shopping List Cluster. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteFoodShoppingListCluster() - -### Java - - client.deleteFoodShoppingListCluster(); - -When the service receives the request, it removes the existing data from the -Food Shopping List Cluster. In case of an error, the entire request is rejected -and the existing state is maintained. - -> [!NOTE] -> **Note:** This api is available from version 1.1.0 onwards. - -#### `deleteReorderCluster` - -This API is used to delete the content of FoodReorderCluster. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteReorderCluster() - -### Java - - client.deleteReorderCluster(); - -When the service receives the request, it removes the existing data from the -Reorder Cluster. In case of an error, the entire request is rejected -and the existing state is maintained. - -> [!NOTE] -> **Note:** This api is available from version 1.1.0 onwards. - -#### `deleteUserManagementCluster` - -This API is used to delete the content of UserAccountManagement Cluster. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteUserManagementCluster() - -### Java - - client.deleteUserManagementCluster(); - -When the service receives the request, it removes the existing data from the -UserAccountManagement Cluster. In case of an error, the entire request is -rejected and the existing state is maintained. - -> [!NOTE] -> **Note:** This api is available from version 1.1.0 onwards. - -#### `deleteClusters` - -This API is used to delete the content of a given cluster type. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteClusters( - DeleteClustersRequest.Builder() - .addClusterType(ClusterType.TYPE_FEATURED) - .addClusterType(ClusterType.TYPE_RECOMMENDATION) - ... - .build()) - -### Java - - client.deleteClusters( - new DeleteClustersRequest.Builder() - .addClusterType(ClusterType.TYPE_FEATURED) - .addClusterType(ClusterType.TYPE_RECOMMENDATION) - ... - .build()); - -When the service receives the request, it removes the existing data from all -clusters matching the specified cluster types. Clients can choose to pass one or -many cluster types. In case of an error, the entire request is rejected and the -existing state is maintained. - -#### Error handling - -It is highly recommended to listen to the task result from the publish APIs such -that a follow-up action can be taken to recover and resubmit an successful task. - - client.publishRecommendationClusters( - new PublishRecommendationClustersRequest.Builder() - .addRecommendationCluster(...) - .build()) - .addOnCompleteListener( - task -> { - if (task.isSuccessful()) { - // do something - } else { - Exception exception = task.getException(); - if (exception instanceof AppEngageException) { - @AppEngageErrorCode - int errorCode = ((AppEngageException) exception).getErrorCode(); - if (errorCode == AppEngageErrorCode.SERVICE_NOT_FOUND) { - // do something - } - } - } - }); - -The error is returned as an `AppEngageException` with the cause included as an -error code. - -| Error code | Error name | Note | -|---|---|---| -| `1` | `SERVICE_NOT_FOUND` | The service is not available on the given device. | -| `2` | `SERVICE_NOT_AVAILABLE` | The service is available on the given device, but it is not available at the time of the call (for example, it is explicitly disabled). | -| `3` | `SERVICE_CALL_EXECUTION_FAILURE` | The task execution failed due to threading issues. In this case, it can be retried. | -| `4` | `SERVICE_CALL_PERMISSION_DENIED` | The caller is not allowed to make the service call. | -| `5` | `SERVICE_CALL_INVALID_ARGUMENT` | The request contains invalid data (for example, more than the allowed number of clusters). | -| `6` | `SERVICE_CALL_INTERNAL` | There is an error on the service side. | -| `7` | `SERVICE_CALL_RESOURCE_EXHAUSTED` | The service call is made too frequently. | - -### Step 3: Handle broadcast intents - -In addition to making publish content API calls through a job, it is also -required to set up a -[`BroadcastReceiver`](https://developer.android.com/reference/android/content/BroadcastReceiver) to receive -the request for a content publish. - -The goal of broadcast intents is mainly for app reactivation and forcing data -sync. Broadcast intents are not designed to be sent very frequently. It is only -triggered when the Engage Service determines the content might be stale (for -example, a week old). That way, there is more confidence that the user can have -a fresh content experience, even if the application has not been executed for a -long period of time. - -The `BroadcastReceiver` must be set up in the following two ways: - -- Dynamically register an instance of the `BroadcastReceiver` class using - `Context.registerReceiver()`. This enables communication from applications - that are still live in memory. - -### Kotlin - - class AppEngageBroadcastReceiver : BroadcastReceiver(){ - // Trigger recommendation cluster publish when PUBLISH_RECOMMENDATION - // broadcast is received - // Trigger featured cluster publish when PUBLISH_FEATURED broadcast is - // received - // Trigger food shopping cart cluster publish when PUBLISH_FOOD_SHOPPING_CART broadcast - // is received - // Trigger food shopping list cluster publish when PUBLISH_FOOD_SHOPPING_LIST broadcast - // is received - // Trigger reorder cluster publish when PUBLISH_REORDER_CLUSTER broadcast is - // received - } - - fun registerBroadcastReceivers(context: Context){ - var context = context - context = context.applicationContext - - // Register Recommendation Cluster Publish Intent - context.registerReceiver(AppEngageBroadcastReceiver(), - IntentFilter(Intents.ACTION_PUBLISH_RECOMMENDATION), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null) - - // Register Featured Cluster Publish Intent - context.registerReceiver(AppEngageBroadcastReceiver(), - IntentFilter(Intents.ACTION_PUBLISH_FEATURED), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null) - - // Register food Shopping Cart Cluster Publish Intent - context.registerReceiver(AppEngageBroadcastReceiver(), - IntentFilter(Intents.ACTION_PUBLISH_FOOD_SHOPPING_CART), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null) - - // Register food Shopping List Cluster Publish Intent - context.registerReceiver(AppEngageBroadcastReceiver(), - IntentFilter(Intents.ACTION_PUBLISH_FOOD_SHOPPING_LIST), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null) - - // Register Reorder Cluster Publish Intent - context.registerReceiver(AppEngageBroadcastReceiver(), - IntentFilter(Intents.ACTION_PUBLISH_REORDER_CLUSTER), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null) - } - -### Java - - class AppEngageBroadcastReceiver extends BroadcastReceiver { - // Trigger recommendation cluster publish when PUBLISH_RECOMMENDATION broadcast - // is received - - // Trigger featured cluster publish when PUBLISH_FEATURED broadcast is received - - // Trigger food shopping cart cluster publish when PUBLISH_FOOD_SHOPPING_CART broadcast is - // received - - // Trigger food shopping list cluster publish when PUBLISH_FOOD_SHOPPING_LIST broadcast is - // received - - // Trigger reorder cluster publish when PUBLISH_REORDER_CLUSTER broadcast is - // received - } - - public static void registerBroadcastReceivers(Context context) { - - context = context.getApplicationContext(); - - // Register Recommendation Cluster Publish Intent - context.registerReceiver(new AppEngageBroadcastReceiver(), - new IntentFilter(com.google.android.engage.service.Intents.ACTION_PUBLISH_RECOMMENDATION), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null); - - // Register Featured Cluster Publish Intent - context.registerReceiver(new AppEngageBroadcastReceiver(), - new IntentFilter(com.google.android.engage.service.Intents.ACTION_PUBLISH_FEATURED), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null); - - // Register food Shopping Cart Cluster Publish Intent - context.registerReceiver(new AppEngageBroadcastReceiver(), - new IntentFilter(com.google.android.engage.shopping.service.Intents.ACTION_PUBLISH_FOOD_SHOPPING_CART), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null); - - // Register food Shopping List Cluster Publish Intent - context.registerReceiver(new AppEngageBroadcastReceiver(), - new IntentFilter(com.google.android.engage.shopping.service.Intents.ACTION_PUBLISH_FOOD_SHOPPING_LIST), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null); - - // Register Reorder Cluster Publish Intent - context.registerReceiver(new AppEngageBroadcastReceiver(), - new IntentFilter(com.google.android.engage.shopping.service.Intents.ACTION_PUBLISH_REORDER_CLUSTER), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null); - - } - -- Statically declare an implementation with the `` tag in your - `AndroidManifest.xml` file. This allows the application to receive broadcast - intents when it is not running, and also allows the application to publish - the content. - - - - - - - - - - - - - - - - - - - - - -The following [intents](https://developer.android.com/reference/android/content/Intent) will be sent by the -service: - -- `com.google.android.engage.action.PUBLISH_RECOMMENDATION` It is recommended to start a `publishRecommendationClusters` call when receiving this intent. -- `com.google.android.engage.action.PUBLISH_FEATURED` It is recommended to start a `publishFeaturedCluster` call when receiving this intent. -- `com.google.android.engage.action.food.PUBLISH_FOOD_SHOPPING_CART` It is recommended to start a `publishFoodShoppingCarts` call when receiving this intent. -- `com.google.android.engage.action.food.PUBLISH_FOOD_SHOPPING_LIST` It is recommended to start a `publishFoodShoppingLists` call when receiving this intent. -- `com.google.android.engage.action.food.PUBLISH_REORDER_CLUSTER` It is recommended to start a `publishReorderCluster` call when receiving this intent. - -## Integration workflow - -For a step-by-step guide on verifying your integration after it is complete, see -[Engage developer integration workflow](https://developer.android.com/guide/playcore/engage/workflow). - -## FAQs - -See [Engage SDK Frequently Asked Questions](https://developer.android.com/guide/playcore/engage/faq) for -FAQs. - -## Contact - -Contact [`engage-developers@google.com`](mailto:engage-developers@google.com) if there are any questions during -the integration process. Our team will reply as soon as possible. - -## Next steps - -After completing this integration, your next steps are as follows: - -- Send an email to [`engage-developers@google.com`](mailto:engage-developers@google.com) and attach your integrated APK that is ready for testing by Google. -- Google will perform a verification and review internally to make sure the integration works as expected. If changes are needed, Google will contact you with any necessary details. -- When testing is complete and no changes are needed, Google will contact you to notify you that you can start publishing the updated and integrated APK to the Play Store. -- After Google has confirmed that your updated APK has been published to the Play Store, your **Recommendation** , **Featured** , **Shopping Cart** , **Shopping List** , and **Reorder** clusters will be published and visible to users. \ No newline at end of file diff --git a/play/engage-sdk-integration/references/android/guide/playcore/engage/healthandfitness.md b/play/engage-sdk-integration/references/android/guide/playcore/engage/healthandfitness.md deleted file mode 100644 index 2b0bf02..0000000 --- a/play/engage-sdk-integration/references/android/guide/playcore/engage/healthandfitness.md +++ /dev/null @@ -1,1078 +0,0 @@ -Boost app engagement by reaching your users where they are. Integrate Engage SDK -to deliver personalized recommendations and continuation content directly to -users across multiple on-device surfaces, like -**[Collections](https://android-developers.googleblog.com/2024/07/introducing-collections-powered-by-engage-sdk.html)** , **[Entertainment -Space](https://blog.google/products/android/entertainment-space/)** , and the Play Store. The integration adds -less than 50 KB (compressed) to the average APK and takes most apps about a -week of developer time. Learn more at our **[business -site](http://play.google.com/console/about/programs/EngageSDK)**. - -This guide contains instructions for developer partners to deliver health and -fitness content to Engage content surfaces. - -## Category support and surfaces - -Engage SDK surface eligibility is based on your app's content category. Use the -following table to determine your eligibility for specific surfaces: - -| Status | Content category or use case | Supported surfaces | -|---|---|---| -| **Supported** (Eligible on all surfaces) | - Fitness classes - Fitness routines - Relaxation \& meditation - Routes / trails discovery - Meal planning | - Collections - Play Store Apps tab - Play Store You tab - Play Store store listing page | -| **Not supported** | - BMI calculator - Physical activity tracker / Step counter - Calorie counter - Sleep tracker - Tools / Remotes - Medical apps | | - - -## Android skills - -[View on GitHub](https://github.com/android/skills/tree/main/play/engage-sdk-integration) - -### Integrate Engage SDK - -Use the Engage SDK [Android skill](https://developer.android.com/tools/agents/android-skills) to integrate, debug, and resolve implementation issues. To install the skill from the [Android CLI](https://developer.android.com/tools/agents/android-cli), run: - - android skills add engage-sdk-integration - -
- -## Integration detail - -### Terminology - -This integration includes the following three cluster types: **Recommendation** , -**Featured** , and **Continuation**. - -- **Recommendation** clusters show personalized health and fitness suggestions - from an individual developer partner. These recommendations can be - personalized to the user or generalized (for example, trending fitness \& - health). Use these to surface articles or people related to health and - fitness. - - - A Recommendation cluster can be made of `ArticleEntity`, `PersonEntity`, or `EventEntity` but not a mix of different entity types. - - Your recommendations take the following structure: - - **Recommendation Cluster:** A UI view that contains a group of - recommendations from the same developer partner. - - - **Entity:** An object representing a single item in a cluster. This - integration offers some entities that would be surfaced using the - Recommendation Cluster: - - - **ArticleEntity**: ArticleEntity represents a recommendation for - text-based content related to health \& fitness. It can be used for - articles, blogposts, marketing content, news snippets, etc. - - ![](https://developer.android.com/static/images/guide/playcore/engage/article-entity-health-and-fitness.png) **Figure 1:** UI showing a single ArticleEntity within Recommendations cluster. - - **PersonEntity**: PersonEntity represents a person. The - recommendations could be to highlight a coach or any person related - to health and fitness, etc. - - ![](https://developer.android.com/static/images/guide/playcore/engage/person-entity-health-and-fitness.png) **Figure 2:** UI showing a single PersonEntity within Recommendations cluster. - - **EventEntity**: EventEntity represents an event happening in the - future. Event start time is a critical piece of information that - needs to be conveyed to the users This entity could be used for - surfacing events like blood donation camp, training sessions, gym or - yoga classes etc. related to health and fitness. - - ![](https://developer.android.com/static/images/guide/playcore/engage/event-entity-health-and-fitness.png) **Figure 3:** UI showing a single EventEntity within Recommendations cluster. -- The **Continuation** cluster shows content recently engaged by users from - multiple developer partners in a single UI grouping. Each developer partner - will be allowed to broadcast a maximum of 10 entities in the Continuation - cluster. - - Your continuation content can take the following structure: - - **ArticleEntity**: ArticleEntity represents a recommendation for - text-based content that is related to health \& fitness. This entity can - be used to represent unfinished news articles or other content that the - user would like to continue consuming from where they left it. Ex: News - snippet, blogpost snippet about health or fitness related topics. - - ![](https://developer.android.com/static/images/guide/playcore/engage/article-entity-continuation-health-and-fitness.png) **Figure 6.** UI showing a single ArticleEntity within a Continuation cluster. - - **EventReservationEntity**: EventReservationEntity represents - reservation for an event and helps users track upcoming or ongoing - fitness and health events reservations. Ex: Training sessions - - ![](https://developer.android.com/static/images/guide/playcore/engage/event-reservation-entity-health-and-fitness.png) **Figure 8.** UI showing a single EventReservationEntity within a Continuation cluster. -- The **Featured** cluster showcases a selection of entities from multiple - developer partners in one UI grouping. There will be a single Featured - cluster, which is surfaced near the top of the UI with a priority placement - above all Recommendation clusters. Each developer partner will be allowed to - broadcast up to 10 entities in the Featured cluster. - - - **GenericFeaturedEntity**: GenericFeaturedEntity differs from - Recommendation item in that Featured item should be used for a single - top content from developers and should represent the single most - important content that will be interesting and relevant to users. - - ![](https://developer.android.com/static/images/guide/playcore/engage/featured-item-health-and-fitness.png) **Figure 12:** UI showing a single hero GenericFeaturedEntity card within a Featured cluster - -### Pre-work - -Minimum API level: 19 - -Add the `com.google.android.engage:engage-core` library to your app: - - dependencies { - // Make sure you also include that repository in your project's build.gradle file. - implementation 'com.google.android.engage:engage-core:1.6.0' - } - -### Summary - -The design is based on an implementation of a -[bound service](https://developer.android.com/guide/components/bound-services). - -The data a client can publish is subject to the following limits for different -cluster types: - -| Cluster type | Cluster limits | Minimum entity limits in a cluster | Maximum entity limits in a cluster | -|---|---|---|---| -| Recommendation Cluster(s) | At most 7 | At least 1 | At most 50 (`ArticleEntity`, `PersonEntity`, or `EventEntity`) | -| Continuation Cluster | At most 1 | At least 1 | At most 20 (`ArticleEntity`, or `EventReservationEntity`) | -| Featured Cluster | At most 1 | At least 1 | At most 20 (`GenericFeaturedEntity`) | - -### Step 1: Provide entity data - -The SDK has defined different entities to represent each item type. We support -the following entities for the Health \& Fitness category: - -1. `GenericFeaturedEntity` -2. `ArticleEntity` -3. `PersonEntity` -4. `EventEntity` -5. `EventReservationEntity` - -The charts below outline available attributes and requirements for each type. - -#### `GenericFeaturedEntity` - -| Attribute | Requirement | Description | Format | -|---|---|---|---| -| Action Uri | **Required** | Deep Link to the entity in the provider app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | Uri | -| Poster images | **Required** | We will show only 1 image when multiple images are provided. Recommended aspect ratio is 16:9 **Note:** If a badge is provided, please ensure safe space of 24 dps at both the top and bottom of the image | See [Image Specifications](https://developer.android.com/guide/playcore/engage/healthandfitness#image-specs) for guidance. | -| Title | Optional | Title of the entity. | Free text **Recommended text size: 50 chars** | -| Description | Optional | A single paragraph of text to describe the entity. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size: 180 chars** | -| Subtitle list | Optional | Up to 3 subtitles, with each subtitle a single line of text. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size for each subtitle: max 50 chars** | -| Badges | Optional | Each badge is either free text (max 15 chars) or small image. Special UX treatment on top of image/video, for example as badge overlay on the image - "Live update" - Article read duration | | -| Badge - Text | Optional | Title for the badge **Note:** Either text or image is required for the badge | Free text **Recommended text size: max 15 chars** | -| Badge - Image | Optional | Small image Special UX treatment, for example as badge overlay on the image/video thumbnail. **Note:** Either text or image is required for the badge | See [Image Specifications](https://developer.android.com/guide/playcore/engage/healthandfitness#image-specs) for guidance. | -| Content Categories | Optional | Describe the category of the content in the entity. | List of Enums See the [Content Category section](https://developer.android.com/guide/playcore/engage/healthandfitness#content-category) for guidance. | - -#### `ArticleEntity` - -| Attribute | Requirement | Description | Format | -|---|---|---|---| -| Action Uri | **Required** | Deep Link to the entity in the provider app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | Uri | -| Title | **Required** | Title of the entity. | Free text **Recommended text size: Max 50 chars** | -| Poster images | Optional | We will show only 1 image when multiple images are provided. Recommended aspect ratio is 16:9 **Note:** Image is highly recommended. If a badge is provided, please ensure safe space of 24 dps at both the top and bottom of the image | See [Image Specifications](https://developer.android.com/guide/playcore/engage/healthandfitness#image-specs) for guidance. | -| Source - Title | Optional | The name of the author, organization, or reporter | Free text **Recommended text size: Under 25 chars** | -| Source - Image | Optional | An image of the source like the author, the organization, reporter | See [Image Specifications](https://developer.android.com/guide/playcore/engage/healthandfitness#image-specs) for guidance. | -| Description | Optional | A single paragraph of text to describe the entity. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size: 180 chars** | -| Subtitle list | Optional | Up to 3 subtitles, with each subtitle a single line of text. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size for each subtitle: max 50 chars** | -| Badges | Optional | Each badge is either free text (max 15 chars) or small image. Special UX treatment on top of image/video, for example as badge overlay on the image - "Live update" - Article read duration | | -| Badge - Text | Optional | Title for the badge **Note:** Either text or image is required for the badge | Free text **Recommended text size: max 15 chars** | -| Badge - Image | Optional | Small image Special UX treatment, for example as badge overlay on the image/video thumbnail. **Note:** Either text or image is required for the badge | See [Image Specifications](https://developer.android.com/guide/playcore/engage/healthandfitness#image-specs) for guidance. | -| Content Publish Time | Optional | This is the epoch timestamp in milliseconds on when the content was published / updated in the app. | Epoch timestamp in milliseconds | -| Last Engagement Time | Conditionally Required | The epoch timestamp in milliseconds when the user interacted with this entity last time. **Note:** This field is required if this entity is part of the continuation cluster. | Epoch timestamp in milliseconds | -| Progress Percentage | Conditionally Required | The percentage of the full content consumed by the user to date. **Note:** This field is required if this entity is part of the continuation cluster. | An int value between 0\~100 inclusive. | -| Content Categories | Optional | Describe the category of the content in the entity. | List of Enums See the [Content Category section](https://developer.android.com/guide/playcore/engage/healthandfitness#content-category) for guidance. | - -#### `PersonEntity` - -| Attribute | Requirement | Description | Format | -|---|---|---|---| -| Action Uri | **Required** | Deep Link to the entity in the provider app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | Uri | -| Profile - Name | **Required** | Profile name or id or handle, eg "John Doe", "@TeamPixel", etc. | String **Recommended text size: Max 50 chars** | -| Profile - Avatar | **Required** | Profile picture or avatar image of the user. **Note:**Must be Square 1:1 image. | See [Image Specifications](https://developer.android.com/guide/playcore/engage/healthandfitness#image-specs) for guidance. | -| Profile - Additional Text | Optional | Free text like the profile handle. | Free text **Recommended text size: Max 15 chars** | -| Profile - Additional Image | Optional | Small image like a verified badge. | See [Image Specifications](https://developer.android.com/guide/playcore/engage/healthandfitness#image-specs) for guidance. | -| Header image | Optional | We will show only 1 image when multiple images are provided. Recommended aspect ratio is 16:9 **Note:** Image is highly recommended. If a badge is provided, please ensure safe space of 24 dps at both the top and bottom of the image | See [Image Specifications](https://developer.android.com/guide/playcore/engage/healthandfitness#image-specs) for guidance. | -| Popularity - Count | Optional | Indicate the number of followers or popularity value, for example - "3.7 M". **Note:** If both Count and Count Value are provided, Count will be used | String **Recommended text size: max 20 chars for count + label combined** | -| Popularity - Count Value | Optional | The number of followers or popularity value. **Note:** Provide Count Value if your app doesn't want to handle logic on how a large number should be optimized for different display sizes. If both Count and Count Value are provided, Count will be used. | Long | -| Popularity - Label | Optional | Indicate what the popularity label is. For example - "Likes". | String **Recommended text size: Max 20 chars for count + label combined** | -| Popularity - Visual | Optional | Indicate what the interaction is for. For example - Image showing Likes icon, Emojis. Can provide more than 1 image, though not all may not be shown on all form factors. **Note:** Must be Square 1:1 image | See [Image Specifications](https://developer.android.com/guide/playcore/engage/healthandfitness#image-specs) for guidance. | -| Rating - Max value | Required | The maximum value of the rating scale. **Must be provided if current value of rating is also provided.** | Number \>= 0.0 | -| Rating - Current value | Required | The current value of the rating scale. **Must be provided if maximum value of rating is also provided.** | Number \>= 0.0 | -| Rating - Count | Optional | The count of the ratings for the entity. **Note:** Provide this field if your app controls how the count is displayed to the users. Use a concise string. For example, if the count is 1,000,000, consider using an abbreviation like 1M so that the count isn't truncated on smaller display sizes. | String | -| Rating - Count Value | Optional | The count of the ratings for the entity. **Note:** Provide this field if you don't handle the display abbreviation logic yourself. If both Count and Count Value are present, Count is displayed to users. | Long | -| Location - Country | Optional | The country where the person is located or serving. | Free text **Recommended text size: max \~20 chars** | -| Location - City | Optional | The city where the person is located or serving. | Free text **Recommended text size: max \~20 chars** | -| Location - Display Address | Optional | The address where the person is located or serving will be displayed to the user. | Free text **Recommended text size: max \~20 chars** | -| Location - Street Address | Optional | The street address (if applicable) where the person is located or serving. | Free text **Recommended text size: max \~20 chars** | -| Location - State | Optional | The state (if applicable) where the person is located or serving. | Free text **Recommended text size: max \~20 chars** | -| Location - Zip code | Optional | The zip code (if applicable) where the person is located or serving. | Free text **Recommended text size: max \~20 chars** | -| Location - Neighborhood | Optional | The neighborhood (if applicable) where the person is located or serving. | Free text **Recommended text size: max \~20 chars** | -| Badges | Optional | Each badge is either free text (max 15 chars) or small image. | | -| Badge - Text | Optional | Title for the badge **Note:** Either text or image is required for the badge | Free text **Recommended text size: max 15 chars** | -| Badge - Image | Optional | Small image Special UX treatment, for example as badge overlay on the image/video thumbnail. **Note:** Either text or image is required for the badge | See [Image Specifications](https://developer.android.com/guide/playcore/engage/healthandfitness#image-specs) for guidance. | -| Description | Optional | A single paragraph of text to describe the entity. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size: 180 chars** | -| Subtitle list | Optional | Up to 3 subtitles, with each subtitle a single line of text. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size for each subtitle: max 50 chars** | -| Content Categories | Optional | Describe the category of the content in the entity. | List of Eligible Enums - TYPE_HEALTH_AND_FITENESS (Example - Yoga/fitness trainer) - TYPE_HOME_AND_AUTO (Example - Plumber) - TYPE_SPORTS (Example - Player) - TYPE_DATING See the [Content Category section](https://developer.android.com/guide/playcore/engage/healthandfitness#content-category) for guidance. | - -#### `EventEntity` - -| Attribute | Requirement | Description | Format | -|---|---|---|---| -| Action Uri | **Required** | Deep Link to the entity in the provider app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | Uri | -| Title | **Required** | Title of the entity. | String **Recommended text size: Max 50 chars** | -| Start time | **Required** | The epoch timestamp when the event is expected to start. **Note:**This will be represented in milliseconds. | Epoch timestamp in milliseconds | -| Event mode | **Required** | A field to indicate whether the event will be virtual, in-person or both. | Enum: VIRTUAL, IN_PERSON, or HYBRID | -| Poster images | **Required** | We will show only 1 image when multiple images are provided. Recommended aspect ratio is 16:9 **Note:** Image is highly recommended. If a badge is provided, please ensure safe space of 24 dps at both the top and bottom of the image | See [Image Specifications](https://developer.android.com/guide/playcore/engage/healthandfitness#image-specs) for guidance. | -| Location - Country | Conditionally Required | The country in which the event is happening. **Note:** This is required for events which are IN_PERSON or HYBRID | Free text **Recommended text size: max \~20 chars** | -| Location - City | Conditionally Required | The city in which the event is happening. **Note:** This is required for events which are IN_PERSON or HYBRID | Free text **Recommended text size: max \~20 chars** | -| Location - Display Address | Conditionally Required | The address or venue name where the event will take place that should be displayed to the user. **Note:** This is required for events which are IN_PERSON or HYBRID | Free text **Recommended text size: max \~20 chars** | -| Location - Street Address | Optional | The street address (if applicable) of the location at which event is being hosted. | Free text **Recommended text size: max \~20 chars** | -| Location - State | Optional | The state or province (if applicable) in which the event is being hosted. | Free text **Recommended text size: max \~20 chars** | -| Location - Zip code | Optional | The zip code (if applicable) of the location in which the event is being hosted. | Free text **Recommended text size: max \~20 chars** | -| Location - Neighborhood | Optional | The neighborhood (if applicable) in which the event is being hosted. | Free text **Recommended text size: max \~20 chars** | -| End time | Optional | The epoch timestamp when the event is expected to end. **Note:**This will be represented in milliseconds. | Epoch timestamp in milliseconds | -| Description | Optional | A single paragraph of text to describe the entity. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size: 180 chars** | -| Subtitle list | Optional | Up to 3 subtitles, with each subtitle a single line of text. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size for each subtitle: max 50 chars** | -| Badges | Optional | Each badge is either free text (max 15 chars) or small image. | | -| Badge - Text | Optional | Title for the badge **Note:** Either text or image is required for the badge | Free text **Recommended text size: max 15 chars** | -| Badge - Image | Optional | Small image Special UX treatment, for example as badge overlay on the image/video thumbnail. **Note:** Either text or image is required for the badge | See [Image Specifications](https://developer.android.com/guide/playcore/engage/healthandfitness#image-specs) for guidance. | -| Price - CurrentPrice | Conditionally required | The current price of the ticket/pass for the event. **Must be provided if strikethrough price is provided.** | Free text | -| Price - StrikethroughPrice | Optional | The original price of the ticket/pass for the event. | Free text | -| Price Callout | Optional | Price callout to feature a promo, event, member discount, if available. | Free text **Recommended text size: under 45 chars (Text that is too long may show ellipses)** | -| Content Categories | Optional | Describe the category of the content in the entity. | List of Eligible Enums - TYPE_MOVIES_AND_TV_SHOWS (Example - Cinema) - TYPE_DIGITAL_GAMES (Example - eSports) - TYPE_MUSIC (Example - Concert) - TYPE_TRAVEL_AND_LOCAL (Example - Tour, festival) - TYPE_HEALTH_AND_FITENESS (Example - Yoga class) - TYPE_EDUCATION (Example - Class) - TYPE_SPORTS (Example - Football game) - TYPE_DATING (Example - meetup) See the [Content Category section](https://developer.android.com/guide/playcore/engage/healthandfitness#content-category) for guidance. | - -#### `EventReservationEntity` - -| Attribute | Requirement | Description | Format | -|---|---|---|---| -| Action Uri | **Required** | Deep Link to the entity in the provider app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | Uri | -| Title | **Required** | Title of the entity. | String **Recommended text size: Max 50 chars** | -| Start time | **Required** | The epoch timestamp when the event is expected to start. **Note:**This will be represented in milliseconds. | Epoch timestamp in milliseconds | -| Event mode | **Required** | A field to indicate whether the event will be virtual, in-person or both. | Enum: VIRTUAL, IN_PERSON, or HYBRID | -| Location - Country | Conditionally Required | The country in which the event is happening. **Note:** This is required for events which are IN_PERSON or HYBRID | Free text **Recommended text size: max \~20 chars** | -| Location - City | Conditionally Required | The city in which the event is happening. **Note:** This is required for events which are IN_PERSON or HYBRID | Free text **Recommended text size: max \~20 chars** | -| Location - Display Address | Conditionally Required | The address or venue name where the event will take place that should be displayed to the user. **Note:** This is required for events which are IN_PERSON or HYBRID | Free text **Recommended text size: max \~20 chars** | -| Location - Street Address | Optional | The street address (if applicable) of the location at which event is being hosted. | Free text **Recommended text size: max \~20 chars** | -| Location - State | Optional | The state or province (if applicable) in which the event is being hosted. | Free text **Recommended text size: max \~20 chars** | -| Location - Zip code | Optional | The zip code (if applicable) of the location in which the event is being hosted. | Free text **Recommended text size: max \~20 chars** | -| Location - Neighborhood | Optional | The neighborhood (if applicable) in which the event is being hosted. | Free text **Recommended text size: max \~20 chars** | -| Poster images | Optional | We will show only 1 image when multiple images are provided. Recommended aspect ratio is 16:9 **Note:** Image is highly recommended. If a badge is provided, please ensure safe space of 24 dps at both the top and bottom of the image | See [Image Specifications](https://developer.android.com/guide/playcore/engage/healthandfitness#image-specs) for guidance. | -| End time | Optional | The epoch timestamp when the event is expected to end. **Note:**This will be represented in milliseconds. | Epoch timestamp in milliseconds | -| Service Provider - Name | Optional | The name of the service provider. **Note:**Either text or image is required for the service provider. | Free text. For example, name of the event organizer/tour | -| Service Provider - Image | Optional | The logo/image of the service provider. **Note:**Either text or image is required for the service provider. | See [Image Specifications](https://developer.android.com/guide/playcore/engage/healthandfitness#image-specs) for guidance. | -| Description | Optional | A single paragraph of text to describe the entity. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size: 180 chars** | -| Subtitle list | Optional | Up to 3 subtitles, with each subtitle a single line of text. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size for each subtitle: max 50 chars** | -| Badges | Optional | Each badge is either free text (max 15 chars) or small image. | | -| Badge - Text | Optional | Title for the badge **Note:** Either text or image is required for the badge | Free text **Recommended text size: max 15 chars** | -| Badge - Image | Optional | Small image Special UX treatment, for example as badge overlay on the image/video thumbnail. **Note:** Either text or image is required for the badge | See [Image Specifications](https://developer.android.com/guide/playcore/engage/healthandfitness#image-specs) for guidance. | -| Reservation ID | Optional | The reservation ID for the event reservation. | Free text | -| Price - CurrentPrice | Conditionally required | The current price of the ticket/pass for the event. **Must be provided if strikethrough price is provided.** | Free text | -| Price - StrikethroughPrice | Optional | The original price of the ticket/pass for the event. | Free text | -| Price Callout | Optional | Price callout to feature a promo, event, member discount, if available. | Free text **Recommended text size: under 45 chars (Text that is too long may show ellipses)** | -| Rating - Max value | Optional | The maximum value of the rating scale. **Must be provided if current value of rating is also provided.** | Number \>= 0.0 | -| Rating - Current value | Optional | The current value of the rating scale. **Must be provided if maximum value of rating is also provided.** | Number \>= 0.0 | -| Rating - Count | Optional | The count of the ratings for the event. **Note:** Provide this field if your app wants to control how this is displayed to the users. Please provide the concise string that can be displayed to the user. For example, if the count is 1,000,000, consider using abbreviations like 1M, so that it won't be truncated on smaller display sizes. | String | -| Rating - Count Value | Optional | The count of the ratings for the event. **Note:** Provide this field if you don't want to handle the display abbreviation logic yourself. If both Count and Count Value are present, we will use the Count to display to users | Long | -| Content Categories | Optional | Describe the category of the content in the entity. | List of Eligible Enums - TYPE_MOVIES_AND_TV_SHOWS (Example - Cinema) - TYPE_DIGITAL_GAMES (Example - eSports) - TYPE_MUSIC (Example - Concert) - TYPE_TRAVEL_AND_LOCAL (Example - Tour, festival) - TYPE_HEALTH_AND_FITENESS (Example - Yoga class) - TYPE_EDUCATION (Example - Class) - TYPE_SPORTS (Example - Football game) - TYPE_DATING (Example - meetup) See the [Content Category section](https://developer.android.com/guide/playcore/engage/healthandfitness#content-category) for guidance. | - -#### Image specifications - -Required specifications for image assets are listed in this table: - -| Aspect ratio | Minimum pixels | Recommended pixels | -|---|---|---| -| Square (1x1) **Preferred** | 300x300 | 1200x1200 | -| Landscape (1.91x1) | 600x314 | 1200x628 | -| Portrait (4x5) | 480x600 | 960x1200 | - -The images are required to be hosted on public CDNs so that Google can access -them. - -*File formats* - -PNG, JPG, static GIF, WebP - -*Maximum file size* - -5120 KB - -*Additional recommendations* - -- **Image safe area:** Put your important content in the center 80% of the image. -- Use a transparent background so that the image can be properly displayed in Dark and Light theme settings. - -#### Content Category - -The content category allows apps to publish content belonging to multiple -categories. This maps the content with some of the predefined categories namely: - -- `TYPE_EDUCATION` -- `TYPE_SPORTS` -- `TYPE_MOVIES_AND_TV_SHOWS` -- `TYPE_BOOKS` -- `TYPE_AUDIOBOOKS` -- `TYPE_MUSIC` -- `TYPE_DIGITAL_GAMES` -- `TYPE_TRAVEL_AND_LOCAL` -- `TYPE_HOME_AND_AUTO` -- `TYPE_BUSINESS` -- `TYPE_NEWS` -- `TYPE_FOOD_AND_DRINK` -- `TYPE_SHOPPING` -- `TYPE_HEALTH_AND_FITENESS` -- `TYPE_MEDICAL` -- `TYPE_PARENTING` -- `TYPE_DATING` - -The images are required to be hosted on public CDNs so that Google can access -them. - -*Guidelines to use the content categories* - -1. Some entities like **ArticleEntity** and **GenericFeaturedEntity** are eligible to use any of the content categories. For other entities like **EventEntity** , **EventReservationEntity** , **PersonEntity**, only a subset of these categories are eligible. Check the list of categories eligible for an entity type before populating the list. -2. Use the specific entity type for some content categories over a combination - of the Generic entities and the ContentCategory: - - - TYPE_MOVIES_AND_TV_SHOWS - Check out the entities from [Watch integration guide](https://developer.android.com/guide/playcore/engage/watch) before using the generic entities. - - TYPE_BOOKS - Check out the [EbookEntity](https://developer.android.com/guide/playcore/engage/read#ebookentity) before using the generic entities. - - TYPE_AUDIOBOOKS - Check out [AudiobookEntity](https://developer.android.com/guide/playcore/engage/read#audiobookentity) before using the generic entities. - - TYPE_SHOPPING - Check out [ShoppingEntity](https://developer.android.com/guide/playcore/engage/shopping#shoppingEntity) before using the generic entities. - - TYPE_FOOD_AND_DRINK - Check out entities from [Food Integration guide](https://developer.android.com/guide/playcore/engage/food) before using the generic entities. -3. The ContentCategory field is optional and should be left blank if the - content doesn't belong to any of the categories mentioned earlier. - -4. In case multiple content categories are provided, provide them in the order - of relevance to the content with the most relevant content category placed - first in the list. - -### Step 2: Provide Cluster data - -It is recommended to have the content publish job executed in the background -(for example, using [WorkManager](https://developer.android.com/topic/libraries/architecture/workmanager)) -and scheduled on a regular basis or on an event basis (for example, every time -the user opens the app or when the user just added something to their cart). - -`AppEngagePublishClient` is responsible for publishing clusters. - -There are following APIs to publish clusters in the client: - -- `isServiceAvailable` -- `publishRecommendationClusters` -- `publishFeaturedCluster` -- `publishContinuationCluster` -- `publishUserAccountManagementRequest` -- `updatePublishStatus` -- `deleteRecommendationsClusters` -- `deleteFeaturedCluster` -- `deleteContinuationCluster` -- `deleteUserManagementCluster` -- `deleteClusters` - -#### `isServiceAvailable` - -This API is used to check if the service is available for integration and -whether the content can be presented on the device. - -##### For Engage SDK v1.6.0 and higher (Recommended) - - -## Android skills - -[View on GitHub](https://github.com/android/skills/tree/main/play/engage-sdk-integration) - -### Engage SDK integration - -To install the skill from the [Android CLI](https://developer.android.com/tools/agents/android-cli), run: - - android skills add engage-sdk-integration - -If your team uses AI coding tools (such as Gemini in Android Studio), you can automate this migration by prompting your AI assistant: - - Use the engage-sdk-integration skill to use Engage SDK 1.6.0 and refactor isServiceAvailable to pass ServiceAvailabilityRequest for publishing all cluster types. - -
- -You can check the service availability for every cluster type that you intend to -publish. The `isServiceAvailable` API accepts a request object, -`ServiceAvailabilityRequest`, which contains the cluster types for which service -availability needs to be checked. You can find the `ClusterType` enum values -required for `ServiceAvailabilityRequest` from the following table. - -| Cluster Type | Cluster Type Constant | Integer Value | -|---|---|---| -| Unknown | `TYPE_UNKNOWN` | 0 | -| Recommendation Cluster | `TYPE_RECOMMENDATION` | 1 | -| Featured Cluster | `TYPE_FEATURED` | 2 | -| Continuation Cluster | `TYPE_CONTINUATION` | 3 | -| User Management Cluster | `TYPE_ENGAGEMENT` | 8 | -| Subscription Cluster | `TYPE_SUBSCRIPTION` | 12 | - -### Kotlin - - val request = ServiceAvailabilityRequest.Builder() - .addIntendedClusterType(ClusterType.TYPE_CONTINUATION) - .addIntendedClusterType(ClusterType.TYPE_RECOMMENDATION) - .build() - - client.isServiceAvailable(request).addOnCompleteListener { task -> - if (task.isSuccessful) { - val availabilityMap = task.result - if (availabilityMap[ClusterType.TYPE_CONTINUATION] == true) { - // Proceed with publishing continuation content - } - if (availabilityMap[ClusterType.TYPE_RECOMMENDATION] == true) { - // Proceed with publishing recommendation content - } - } else { - // The IPC call itself fails, proceed with error handling logic here, - // such as retry. - } - } - -### Java - - ServiceAvailabilityRequest request = - new ServiceAvailabilityRequest.Builder() - .addIntendedClusterType(ClusterType.TYPE_CONTINUATION) - .addIntendedClusterType(ClusterType.TYPE_RECOMMENDATION) - .build(); - - client.isServiceAvailable(request).addOnCompleteListener(task -> { - if (task.isSuccessful()) { - Map availabilityMap = task.getResult(); - if (Boolean.TRUE.equals(availabilityMap.get(ClusterType.TYPE_CONTINUATION))) { - // Proceed with publishing continuation content - } - if (Boolean.TRUE.equals(availabilityMap.get(ClusterType.TYPE_RECOMMENDATION))) { - // Proceed with publishing recommendation content - } - } else { - // The IPC call itself fails, proceed with error handling logic here, - // such as retry. - } - }); - -###### Conditional Service Availability Feature - -Some integrated apps request a special configuration that enables and disables -the Engage service intermittently in order to reduce their serving cost. This -intermittent content ingestion strategy, although possible, negatively affects -the user and the product -- stale content will not be presented and some surfaces -will not be served at all. - -Starting with v1.6.0, the Engage SDK allows checking availability for specific -cluster types. This provides more flexibility so that if the intermittent -content strategy was adopted by a given application, some cluster types can -follow that intermittent strategy while other cluster types are always enabled -(i.e. continuation clusters). - -If the Engage service should not be 'continuously' enabled on all supported -devices for whatever reason, and is configured for intermittent ingestion for -any set of devices, all continuation cluster publications (e.g. Continue -Reading and Event Reservations) will be still enabled by default -configuration, and the rest of the cluster types will be enabled and disabled -intermittently. If intermittent ingestion applies to you but this default -configuration is not suitable for your needs, please contact -engage-developers@google.com. - -##### For SDK versions prior to v1.6.0 (Deprecated) - -### Kotlin - - client.isServiceAvailable.addOnCompleteListener { task -> - if (task.isSuccessful) { - // Handle IPC call success - if(task.result) { - // Service is available on the device, proceed with content publish - // calls. - } else { - // Service is not available, no further action is needed. - } - } else { - // The IPC call itself fails, proceed with error handling logic here, - // such as retry. - } - } - -### Java - - client.isServiceAvailable().addOnCompleteListener(task - > { - if (task.isSuccessful()) { - // Handle success - if(task.getResult()) { - // Service is available on the device, proceed with content publish - // calls. - } else { - // Service is not available, no further action is needed. - } - } else { - // The IPC call itself fails, proceed with error handling logic here, - // such as retry. - } - }); - -> [!NOTE] -> **Note:** We highly recommend keeping a periodic job running to check if the service becomes available at a later point in time. The availability of the service may change with Android version upgrades, app upgrades, installs, and uninstalls. By ensuring periodic job checks at a certain time interval, data can be published once the service becomes available. - -#### `publishRecommendationClusters` - -This API is used to publish a list of `RecommendationCluster` objects. - -> [!IMPORTANT] -> **Important:** The publish APIs are upsert APIs; it replaces the existing content. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. - -### Kotlin - - client.publishRecommendationClusters( - PublishRecommendationClustersRequest.Builder() - .addRecommendationCluster( - RecommendationCluster.Builder() - .addEntity(entity1) - .addEntity(entity2) - .setTitle("Top Picks For You") - .build() - ) - .build() - ) - -### Java - - client.publishRecommendationClusters( - new PublishRecommendationClustersRequest.Builder() - .addRecommendationCluster( - new RecommendationCluster.Builder() - .addEntity(entity1) - .addEntity(entity2) - .setTitle("Top Picks For You") - .build()) - .build()); - -When the service receives the request, the following actions take place within -one transaction: - -- Existing `RecommendationCluster` data from the developer partner is removed. -- Data from the request is parsed and stored in the updated Recommendation Cluster. - -In case of an error, the entire request is rejected and the existing state is -maintained. - -#### `publishFeaturedCluster` - -This API is used to publish a list of `FeaturedCluster` objects. - -> [!IMPORTANT] -> **Important:** The publish APIs are upsert APIs; it replaces the existing content. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. - -### Kotlin - - client.publishFeaturedCluster( - PublishFeaturedClusterRequest.Builder() - .setFeaturedCluster( - FeaturedCluster.Builder() - .addEntity(entity1) - .addEntity(entity2) - .build()) - .build()) - -### Java - - client.publishFeaturedCluster( - new PublishFeaturedClustersRequest.Builder() - .addFeaturedCluster( - new FeaturedCluster.Builder() - .addEntity(entity1) - .addEntity(entity2) - .build()) - .build()); - -When the service receives the request, the following actions take place within -one transaction: - -- Existing `FeaturedCluster` data from the developer partner is removed. -- Data from the request is parsed and stored in the updated Featured Cluster. - -In case of an error, the entire request is rejected and the existing state is -maintained. - -#### `publishContinuationCluster` - -This API is used to publish a `ContinuationCluster` object. - -> [!IMPORTANT] -> **Important:** The publish APIs are upsert APIs; it replaces the existing content. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. - -### Kotlin - - client.publishContinuationCluster( - PublishContinuationClusterRequest.Builder() - .setContinuationCluster( - ContinuationCluster.Builder() - .addEntity(entity1) - .addEntity(entity2) - .build()) - .build()) - -### Java - - client.publishContinuationCluster( - new PublishContinuationClusterRequest.Builder() - .setContinuationCluster( - new ContinuationCluster.Builder() - .addEntity(entity1) - .addEntity(entity2) - .build()) - .build()); - -When the service receives the request, the following actions take place within -one transaction: - -- Existing `ContinuationCluster` data from the developer partner is removed. -- Data from the request is parsed and stored in the updated Continuation Cluster. - -In case of an error, the entire request is rejected and the existing state is -maintained. - -#### `publishUserAccountManagementRequest` - -This API is used to publish a Sign In card . The signin action directs users to -the app's sign in page so that the app can publish content (or provide more -personalized content) - -The following metadata is part of the Sign In Card - - -| Attribute | Requirement | Description | -|---|---|---| -| Action Uri | Required | Deeplink to Action (i.e. navigates to app sign in page) | -| Image | Optional - If not provided, Title must be provided | Image Shown on the Card 16x9 aspect ratio images with a resolution of 1264x712 | -| Title | Optional - If not provided, Image must be provided | Title on the Card | -| Action Text | Optional | Text Shown on the CTA (i.e. Sign in) | -| Subtitle | Optional | Optional Subtitle on the Card | - -> [!IMPORTANT] -> **Important:** The publish APIs are upsert APIs; it replaces the existing content. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. - -### Kotlin - - var SIGN_IN_CARD_ENTITY = - SignInCardEntity.Builder() - .addPosterImage( - Image.Builder() - .setImageUri(Uri.parse("http://www.x.com/image.png")) - .setImageHeightInPixel(500) - .setImageWidthInPixel(500) - .build()) - .setActionText("Sign In") - .setActionUri(Uri.parse("http://xx.com/signin")) - .build() - - client.publishUserAccountManagementRequest( - PublishUserAccountManagementRequest.Builder() - .setSignInCardEntity(SIGN_IN_CARD_ENTITY) - .build()); - -### Java - - SignInCardEntity SIGN_IN_CARD_ENTITY = - new SignInCardEntity.Builder() - .addPosterImage( - new Image.Builder() - .setImageUri(Uri.parse("http://www.x.com/image.png")) - .setImageHeightInPixel(500) - .setImageWidthInPixel(500) - .build()) - .setActionText("Sign In") - .setActionUri(Uri.parse("http://xx.com/signin")) - .build(); - - client.publishUserAccountManagementRequest( - new PublishUserAccountManagementRequest.Builder() - .setSignInCardEntity(SIGN_IN_CARD_ENTITY) - .build()); - -When the service receives the request, the following actions take place within -one transaction: - -- Existing `UserAccountManagementCluster` data from the developer partner is removed. -- Data from the request is parsed and stored in the updated UserAccountManagementCluster Cluster. - -In case of an error, the entire request is rejected and the existing state is -maintained. - -#### `updatePublishStatus` - -If for any internal business reason, none of the clusters is published, we -**strongly recommend** updating the publish status using the -**updatePublishStatus** API. This is important because : - -- Providing the status in all scenarios, even when the content is published (STATUS == PUBLISHED), is critical to populate dashboards that use this explicit status to convey the health and other metrics of your integration. -- If no content is published but the integration status isn't broken (STATUS == NOT_PUBLISHED), Google can avoid triggering alerts in the app health dashboards. It confirms that content is not published due to an **expected** situation from the provider's standpoint. -- It helps developers provide insights into when the data is published versus not. -- Google may use the status codes to nudge the user to do certain actions in the app so they can see the app content or overcome it. - -The list of eligible publish status codes are : - - // Content is published - AppEngagePublishStatusCode.PUBLISHED, - - // Content is not published as user is not signed in - AppEngagePublishStatusCode.NOT_PUBLISHED_REQUIRES_SIGN_IN, - - // Content is not published as user is not subscribed - AppEngagePublishStatusCode.NOT_PUBLISHED_REQUIRES_SUBSCRIPTION, - - // Content is not published as user location is ineligible - AppEngagePublishStatusCode.NOT_PUBLISHED_INELIGIBLE_LOCATION, - - // Content is not published as there is no eligible content - AppEngagePublishStatusCode.NOT_PUBLISHED_NO_ELIGIBLE_CONTENT, - - // Content is not published as the feature is disabled by the client - // Available in v1.3.1 - AppEngagePublishStatusCode.NOT_PUBLISHED_FEATURE_DISABLED_BY_CLIENT, - - // Content is not published as the feature due to a client error - // Available in v1.3.1 - AppEngagePublishStatusCode.NOT_PUBLISHED_CLIENT_ERROR, - - // Content is not published as the feature due to a service error - // Available in v1.3.1 - AppEngagePublishStatusCode.NOT_PUBLISHED_SERVICE_ERROR, - - // Content is not published due to some other reason - // Reach out to engage-developers@ before using this enum. - AppEngagePublishStatusCode.NOT_PUBLISHED_OTHER - -If the content is not published due to a user not logged in, Google would -recommend publishing the Sign In Card. If for any reason providers are not able -to publish the Sign In Card then we recommend calling the -**updatePublishStatus** API with the status code -**NOT_PUBLISHED_REQUIRES_SIGN_IN** - -### Kotlin - - client.updatePublishStatus( - PublishStatusRequest.Builder() - .setStatusCode(AppEngagePublishStatusCode.NOT_PUBLISHED_REQUIRES_SIGN_IN) - .build()) - -### Java - - client.updatePublishStatus( - new PublishStatusRequest.Builder() - .setStatusCode(AppEngagePublishStatusCode.NOT_PUBLISHED_REQUIRES_SIGN_IN) - .build()); - -#### `deleteRecommendationClusters` - -This API is used to delete the content of Recommendation Clusters. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteRecommendationClusters() - -### Java - - client.deleteRecommendationClusters(); - -When the service receives the request, it removes the existing data from the -Recommendation Clusters. In case of an error, the entire request is rejected and -the existing state is maintained. - -> [!NOTE] -> **Note:** This api is available from version 1.1.0 onwards. - -#### `deleteFeaturedCluster` - -This API is used to delete the content of Featured Cluster. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteFeaturedCluster() - -### Java - - client.deleteFeaturedCluster(); - -When the service receives the request, it removes the existing data from the -Featured Cluster. In case of an error, the entire request is rejected and the -existing state is maintained. - -> [!NOTE] -> **Note:** This api is available from version 1.1.0 onwards. - -#### `deleteContinuationCluster` - -This API is used to delete the content of Continuation Cluster. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteContinuationCluster() - -### Java - - client.deleteContinuationCluster(); - -When the service receives the request, it removes the existing data from the -Continuation Cluster. In case of an error, the entire request is rejected and -the existing state is maintained. - -> [!NOTE] -> **Note:** This api is available from version 1.1.0 onwards. - -#### `deleteUserManagementCluster` - -This API is used to delete the content of UserAccountManagement Cluster. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteUserManagementCluster() - -### Java - - client.deleteUserManagementCluster(); - -When the service receives the request, it removes the existing data from the -UserAccountManagement Cluster. In case of an error, the entire request is -rejected and the existing state is maintained. - -> [!NOTE] -> **Note:** This api is available from version 1.1.0 onwards. - -#### `deleteClusters` - -This API is used to delete the content of a given cluster type. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteClusters( - DeleteClustersRequest.Builder() - .addClusterType(ClusterType.TYPE_CONTINUATION) - .addClusterType(ClusterType.TYPE_FEATURED) - .addClusterType(ClusterType.TYPE_RECOMMENDATION) - .build()) - -### Java - - client.deleteClusters( - new DeleteClustersRequest.Builder() - .addClusterType(ClusterType.TYPE_CONTINUATION) - .addClusterType(ClusterType.TYPE_FEATURED) - .addClusterType(ClusterType.TYPE_RECOMMENDATION) - .build()); - -When the service receives the request, it removes the existing data from all -clusters matching the specified cluster types. Clients can choose to pass one or -many cluster types. In case of an error, the entire request is rejected and the -existing state is maintained. - -#### Error handling - -It is highly recommended to listen to the task result from the publish APIs such -that a follow-up action can be taken to recover and resubmit an successful task. - -### Kotlin - - client.publishRecommendationClusters( - PublishRecommendationClustersRequest.Builder() - .addRecommendationCluster(..) - .build()) - .addOnCompleteListener { task -> - if (task.isSuccessful) { - // do something - } else { - val exception = task.exception - if (exception is AppEngageException) { - @AppEngageErrorCode val errorCode = exception.errorCode - if (errorCode == AppEngageErrorCode.SERVICE_NOT_FOUND) { - // do something - } - } - } - } - -### Java - - client.publishRecommendationClusters( - new PublishRecommendationClustersRequest.Builder() - .addRecommendationCluster(...) - .build()) - .addOnCompleteListener( - task -> { - if (task.isSuccessful()) { - // do something - } else { - Exception exception = task.getException(); - if (exception instanceof AppEngageException) { - @AppEngageErrorCode - int errorCode = ((AppEngageException) exception).getErrorCode(); - if (errorCode == AppEngageErrorCode.SERVICE_NOT_FOUND) { - // do something - } - } - } - }); - -The error is returned as an `AppEngageException` with the cause included as an -error code. - -| Error code | Error name | Note | -|---|---|---| -| `1` | `SERVICE_NOT_FOUND` | The service is not available on the given device. | -| `2` | `SERVICE_NOT_AVAILABLE` | The service is available on the given device, but it is not available at the time of the call (for example, it is explicitly disabled). | -| `3` | `SERVICE_CALL_EXECUTION_FAILURE` | The task execution failed due to threading issues. In this case, it can be retried. | -| `4` | `SERVICE_CALL_PERMISSION_DENIED` | The caller is not allowed to make the service call. | -| `5` | `SERVICE_CALL_INVALID_ARGUMENT` | The request contains invalid data (for example, more than the allowed number of clusters). | -| `6` | `SERVICE_CALL_INTERNAL` | There is an error on the service side. | -| `7` | `SERVICE_CALL_RESOURCE_EXHAUSTED` | The service call is made too frequently. | - -### Step 3: Handle broadcast intents - -In addition to making publish content API calls through a job, it is also -required to set up a -[`BroadcastReceiver`](https://developer.android.com/reference/android/content/BroadcastReceiver) to receive -the request for a content publish. - -The goal of broadcast intents is mainly for app reactivation and forcing data -sync. Broadcast intents are not designed to be sent very frequently. It is only -triggered when the Engage Service determines the content might be stale (for -example, a week old). That way, there is more confidence that the user can have -a fresh content experience, even if the application has not been executed for a -long period of time. - -The `BroadcastReceiver` must be set up in the following two ways: - -- Dynamically register an instance of the `BroadcastReceiver` class using - `Context.registerReceiver()`. This enables communication from applications - that are still live in memory. - -### Kotlin - - class AppEngageBroadcastReceiver : BroadcastReceiver(){ - // Trigger recommendation cluster publish when PUBLISH_RECOMMENDATION broadcast - // is received - // Trigger featured cluster publish when PUBLISH_FEATURED broadcast is received - // Trigger continuation cluster publish when PUBLISH_CONTINUATION broadcast is - // received - } - - fun registerBroadcastReceivers(context: Context){ - var context = context - context = context.applicationContext - - // Register Recommendation Cluster Publish Intent - context.registerReceiver(AppEngageBroadcastReceiver(), - IntentFilter(Intents.ACTION_PUBLISH_RECOMMENDATION), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null) - - // Register Featured Cluster Publish Intent - context.registerReceiver(AppEngageBroadcastReceiver(), - IntentFilter(Intents.ACTION_PUBLISH_FEATURED), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null) - - // Register Continuation Cluster Publish Intent - context.registerReceiver(AppEngageBroadcastReceiver(), - IntentFilter(Intents.ACTION_PUBLISH_CONTINUATION), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null) - } - -### Java - - class AppEngageBroadcastReceiver extends BroadcastReceiver { - // Trigger recommendation cluster publish when PUBLISH_RECOMMENDATION broadcast - // is received - - // Trigger featured cluster publish when PUBLISH_FEATURED broadcast is received - - // Trigger continuation cluster publish when PUBLISH_CONTINUATION broadcast is - // received - } - - public static void registerBroadcastReceivers(Context context) { - - context = context.getApplicationContext(); - - // Register Recommendation Cluster Publish Intent - context.registerReceiver(new AppEngageBroadcastReceiver(), - new IntentFilter(com.google.android.engage.service.Intents.ACTION_PUBLISH_RECOMMENDATION), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null); - - // Register Featured Cluster Publish Intent - context.registerReceiver(new AppEngageBroadcastReceiver(), - new IntentFilter(com.google.android.engage.service.Intents.ACTION_PUBLISH_FEATURED), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null); - - // Register Continuation Cluster Publish Intent - context.registerReceiver(new AppEngageBroadcastReceiver(), - new IntentFilter(com.google.android.engage.service.Intents.ACTION_PUBLISH_CONTINUATION), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null); - - } - -- Statically declare an implementation with the `` tag in your - `AndroidManifest.xml` file. This allows the application to receive broadcast - intents when it is not running, and also allows the application to publish - the content. - - - - - - - - - - - - - - - -The following [intents](https://developer.android.com/reference/android/content/Intent) is sent by the -service: - -- `com.google.android.engage.action.PUBLISH_RECOMMENDATION` It is recommended to start a `publishRecommendationClusters` call when receiving this intent. -- `com.google.android.engage.action.PUBLISH_FEATURED` It is recommended to start a `publishFeaturedCluster` call when receiving this intent. -- `com.google.android.engage.action.PUBLISH_CONTINUATION` It is recommended to start a `publishContinuationCluster` call when receiving this intent. - -## Integration workflow - -For a step-by-step guide on verifying your integration after it is complete, see -[Engage developer integration workflow](https://developer.android.com/guide/playcore/engage/workflow). - -## FAQs - -See [Engage SDK Frequently Asked Questions](https://developer.android.com/guide/playcore/engage/faq) for -FAQs. - -## Contact - -Contact -[`engage-developers@google.com`](mailto:engage-developers@google.com) if there are -any questions during the integration process. - -## Next steps - -After completing this integration, your next steps are as follows: - -- Send an email to [`engage-developers@google.com`](mailto:engage-developers@google.com) and attach your integrated APK that is ready for testing by Google. -- Google performs a verification and reviews internally to make sure the integration works as expected. If changes are needed, Google contacts you with any necessary details. -- When testing is complete and no changes are needed, Google contacts you to notify you that you can start publishing the updated and integrated APK to the Play Store. -- After Google has confirmed that your updated APK has been published to the Play Store, your **Recommendation** , **Featured** , and **Continuation** clusters may be published and visible to users. \ No newline at end of file diff --git a/play/engage-sdk-integration/references/android/guide/playcore/engage/listen.md b/play/engage-sdk-integration/references/android/guide/playcore/engage/listen.md deleted file mode 100644 index 4a1240f..0000000 --- a/play/engage-sdk-integration/references/android/guide/playcore/engage/listen.md +++ /dev/null @@ -1,1074 +0,0 @@ -Boost app engagement by reaching your users where they are. Integrate Engage SDK -to deliver personalized recommendations and continuation content directly to -users across multiple on-device surfaces, like -**[Collections](https://android-developers.googleblog.com/2024/07/introducing-collections-powered-by-engage-sdk.html)** , **[Entertainment -Space](https://blog.google/products/android/entertainment-space/)** , and the Play Store. The integration adds -less than 50 KB (compressed) to the average APK and takes most apps about a -week of developer time. Learn more at our **[business -site](http://play.google.com/console/about/programs/EngageSDK)**. - -This guide contains instructions for developer partners to deliver audio content -(music, podcasts, audiobooks, live radio) to Engage content surfaces. - -## Category support and surfaces - -Engage SDK surface eligibility is based on your app's content category. Use the -following table to determine your eligibility for specific surfaces: - -| Status | Content category or use case | Supported surfaces | -|---|---|---| -| **Supported** (Eligible on all surfaces) | - Music streaming - Audiobooks - Podcasts - Live radio | - Collections - Entertainment Space - Play Store Apps tab - Play Store You tab - Play Store store listing page | -| **Partially supported** (Eligible on some surfaces) | - Live audio streams | - Collections - Entertainment Space | -| **Not supported** | - MP3 player / library - Music maker - Audio recorder / converter / tool - Music recognition - Music education (supported in education) | | - - -## Android skills - -[View on GitHub](https://github.com/android/skills/tree/main/play/engage-sdk-integration) - -### Integrate Engage SDK - -Use the Engage SDK [Android skill](https://developer.android.com/tools/agents/android-skills) to integrate, debug, and resolve implementation issues. To install the skill from the [Android CLI](https://developer.android.com/tools/agents/android-cli), run: - - android skills add engage-sdk-integration - -
- -## Integration detail - -### Terminology - -This integration includes the following three cluster types: **Recommendation** , -**Continuation** , and **Featured**. - -- **Recommendation** clusters show personalized suggestions for content to read - from an individual developer partner. - - Your recommendations take the following structure: - - **Recommendation Cluster:** A UI view that contains a group of - recommendations from the same developer partner. - - ![](https://developer.android.com/static/images/guide/playcore/engage/listen-term-1.png) **Figure 1.** Entertainment Space UI showing a Recommendation Cluster from a single partner. - - **Entity:** An object representing a single item in a cluster. An entity - can be a playlist, an audiobook, a podcast, and more. See the [Provide - entity data](https://developer.android.com/guide/playcore/engage/listen#provide-entity-data) section for a list of supported entity - types. - - ![](https://developer.android.com/static/images/guide/playcore/engage/listen-term-2.png) **Figure 2.** Entertainment Space UI showing a single Entity within a single partner's Recommendation Cluster. -- The **Continuation** cluster shows audio content recently engaged by users - from multiple developer partners in a single UI grouping. Each developer - partner will be allowed to broadcast a maximum of 10 entities in the - Continuation cluster. - - ![](https://developer.android.com/static/images/guide/playcore/engage/listen-term-3.png) **Figure 3.** Entertainment Space UI showing a Continuation cluster with unfinished recommendations from multiple partners (only one recommendation is currently visible). -- The **Featured** cluster showcases a selection of items from multiple - developer partners in a single UI grouping. There will be a single Featured - cluster, which will be surfaced near the top of the UI with a priority - placement above all Recommendation clusters. Each developer partner will be - allowed to broadcast up to 10 entities in the Featured cluster. - - ![](https://developer.android.com/static/images/guide/playcore/engage/listen-term-4.png) **Figure 4.** Entertainment Space UI showing a Featured cluster with recommendations from multiple partners (only one recommendation is currently visible). - -### Pre-work - -Minimum API level: 19 - -Add the `com.google.android.engage:engage-core` library to your app: - - dependencies { - // Make sure you also include that repository in your project's build.gradle file. - implementation 'com.google.android.engage:engage-core:1.6.0' - } - -### Summary - -The design is based on an implementation of a [bound -service](https://developer.android.com/guide/components/bound-services). - -The data a client can publish is subject to the following limits for different -cluster types: - -| Cluster type | Cluster limits | Maximum entity limits in a cluster | -|---|---|---| -| Recommendation Cluster(s) | At most 7 | At most 50 | -| Continuation Cluster | At most 1 | At most 20 | -| Featured Cluster | At most 1 | At most 20 | - -### Step 1: Provide entity data - -The SDK has defined different entities to represent each item type. We support -the following entities for the Listen category: - -1. `MusicAlbumEntity` -2. `MusicArtistEntity` -3. `MusicTrackEntity` -4. `MusicVideoEntity` -5. `PlaylistEntity` -6. `PodcastSeriesEntity` -7. `PodcastEpisodeEntity` -8. `LiveRadioStationEntity` -9. `AudiobookEntity` - -The charts below outline available attributes and requirements for each type. - -#### `MusicAlbumEntity` - -The `MusicAlbumEntity` object represents a music album (for example, *Midnights* -by Taylor Swift). - -| Attribute | Requirement | Notes | -|---|---|---| -| Name | **Required** | The title of the music album. | -| Poster images | **Required** | At least one image must be provided. See [Image Specifications](https://developer.android.com/guide/playcore/engage/listen#image-specs) for guidance. | -| Info page uri | **Required** | The deep link to the provider app for details about the music album. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | -| Artists | **Required** | List of artists in the music album. | -| Playback uri | Optional | A deep link that starts playing the album in the provider app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | -| Description | Optional | Must be within 200 characters if provided. | -| Songs count | Optional | The number of songs in the music album. | -| Genres | Optional | List of genres in the music album. | -| Album Format | Optional | ALBUM (includes LP and double LP) EP SINGLE Mixtape | -| Music labels | Optional | List of music labels associated with the album. | -| Downloaded on Device | Optional | Boolean indicating if the music album is downloaded on device. | -| Explicit | Optional | A boolean indicating if the content is explicit or not Items that contain explicit material or have a parental advisory warning should be set to TRUE. Explicit items appears with an "E" tag. | -| Release date | Optional | The release date of the album in epoch milliseconds. | -| Duration | Optional | The duration of the album in milliseconds. | -| Last engagement time | Optional | Recommended for items in the Continuation Cluster. May be used for ranking. In epoch milliseconds | -| Progress percentage complete | Optional | Recommended for items in the Continuation Cluster. Integer between 0 and 100 | - -#### `MusicArtistEntity` - -The `MusicArtistEntity` object represents a music arist (for example, Adele). - -| Attribute | Requirement | Notes | -|---|---|---| -| Name | **Required** | Name of the music artist. | -| Poster images | **Required** | At least one image must be provided. See [Image Specifications](https://developer.android.com/guide/playcore/engage/listen#image-specs) for guidance. | -| Info page uri | **Required** | The deep link to the provider app for details about the music artist. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | -| Playback uri | Optional | The deep link which starts playing the artist's songs in the provider app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | -| Description | Optional | Must be within 200 characters if provided. | -| Last engagement time | Optional | Recommended for items in the Continuation Cluster. May be used for ranking. In epoch milliseconds | - -#### `MusicTrackEntity` - -The `MusicTrackEntity` object represents a music track (for example, *Yellow* by -Coldplay). - -| Attribute | Requirement | Notes | -|---|---|---| -| Name | **Required** | Title of the music track. | -| Poster images | **Required** | At least one image must be provided. See [Image Specifications](https://developer.android.com/guide/playcore/engage/listen#image-specs) for guidance. | -| Playback uri | **Required** | A deep link that starts playing the music track in the provider app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | -| Artists | **Required** | List of artists for the music track. | -| Info page uri | Optional | A deep link to the provider app for details about the music track. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | -| Description | Optional | Must be within 200 characters if provided. | -| Duration | Optional | The duration of the track in milliseconds. | -| Album | Optional | The name of the album to which the song belongs. | -| Downloaded on Device | Optional | Boolean indicating if the music track is downloaded on device. | -| Explicit | Optional | A boolean indicating if the content is explicit or not Items that contain explicit material or have a parental advisory warning should be set to TRUE. Explicit items appears with an "E" tag. | -| Last engagement time | Optional | Recommended for items in the Continuation Cluster. May be used for ranking. In epoch milliseconds | -| Progress percentage complete | Optional | Recommended for items in the Continuation Cluster. Integer between 0 and 100 | - -#### `MusicVideoEntity` - -The `MusicVideoEntity` object represents a music video (for example, -*The Weeknd - Take My Breath (Official Music Video)*). - -| Attribute | Requirement | Notes | -|---|---|---| -| Name | **Required** | Title of the music video. | -| Poster images | **Required** | At least one image must be provided. See [Image Specifications](https://developer.android.com/guide/playcore/engage/listen#image-specs) for guidance. | -| Playback uri | **Required** | A deep link that starts playing the music video in the provider app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | -| Info page uri | Optional | A deep link to the provider app for details about the music video. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | -| Duration | Optional | The duration of the video in milliseconds. | -| View count | Optional | The number of views on the video in free text format. | -| Artists | Optional | List of artists of the music video. | -| Content rating | Optional | List of content ratings of the track. | -| Description | Optional | Must be within 200 characters if provided. | -| Downloaded on Device | Optional | Boolean indicating if the music video is downloaded on device. | -| Explicit | Optional | A boolean indicating if the content is explicit or not Items that contain explicit material or have a parental advisory warning should be set to TRUE. Explicit items appears with an "E" tag. | -| Last engagement time | Optional | Recommended for items in the Continuation Cluster. May be used for ranking. In epoch milliseconds | -| Progress percentage complete | Optional | Recommended for items in the Continuation Cluster. Integer between 0 and 100 | - -#### `PlaylistEntity` - -The `PlaylistEntity` object represents a music playlist (for example, the US Top -10 Playlist). - -| Attribute | Requirement | Notes | -|---|---|---| -| Name | **Required** | Title of the playlist. | -| Poster images | **Required** | At least one image must be provided. See [Image Specifications](https://developer.android.com/guide/playcore/engage/listen#image-specs) for guidance. | -| Playback uri | **Required** | A deep link that starts playing the music playlist in the provider app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | -| Info page uri | Optional | A deep link to the provider app for details about the music playlist. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | -| Duration | Optional | The duration of the playlist in milliseconds. | -| Songs count | Optional | The number of songs in the music playlist. | -| Description | Optional | Must be within 200 characters if provided. | -| Downloaded on Device | Optional | Boolean indicating if the playlist is downloaded on device. | -| Explicit | Optional | A boolean indicating if the content is explicit or not Items that contain explicit material or have a parental advisory warning should be set to TRUE. Explicit items appears with an "E" tag. | -| Last engagement time | Optional | Recommended for items in the Continuation Cluster. May be used for ranking. In epoch milliseconds | -| Progress percentage complete | Optional | Recommended for items in the Continuation Cluster. Integer between 0 and 100 | - -#### `PodcastSeriesEntity` - -The `PodcastSeriesEntity` object represents a podcast series (for example, *This -American Life*). - -| Attribute | Requirement | Notes | -|---|---|---| -| Name | **Required** | Title of the podcast series. | -| Poster images | **Required** | At least one image must be provided. See [Image Specifications](https://developer.android.com/guide/playcore/engage/listen#image-specs) for guidance. | -| Info page uri | **Required** | A deep link to the provider app for details about the podcast series. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | -| Playback uri | Optional | A deep link that starts playing the podcast series in the provider app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | -| Episode count | Optional | The number of episodes in the podcast series. | -| Production name | Optional | The name of the production of the podcast series. | -| Hosts | Optional | List of hosts of the podcast series. | -| Genres | Optional | List of genres of the podcast series. | -| Downloaded on device | Optional | Boolean indicating if the podcast is downloaded on the device. | -| Description | Optional | Must be within 200 characters if provided. | -| Explicit | Optional | A boolean indicating if the content is explicit or not Items that contain explicit material or have a parental advisory warning should be set to TRUE. Explicit items appears with an "E" tag. | -| Last engagement time | Optional | Recommended for items in the Continuation Cluster. May be used for ranking. In epoch milliseconds | - -#### `PodcastEpisodeEntity` - -The `PodcastEpisodeEntity` object represents a podcast series (for example, -*Spark Bird, Episode 754: This American Life*). - -| Attribute | Requirement | Notes | -|---|---|---| -| Name | **Required** | Title of the podcast episode. | -| Poster images | **Required** | At least one image must be provided. See [Image Specifications](https://developer.android.com/guide/playcore/engage/listen#image-specs) for guidance. | -| Playback uri | **Required** | A deep link that starts playing the podcast episode in the provider app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | -| Podcast series title | **Required** | The name of the podcast series to which the episode belongs. | -| Duration | **Required** | The duration of the podcast episode in milliseconds. | -| Publish Date | **Required** | Publish date of the podcast (in epoch milliseconds) | -| Info page uri | Optional | A deep link to the provider app for details about the podcast episode. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | -| Production name | Optional | The name of the production of the podcast series. | -| Episode index | Optional | The index of the episode in the series (first index is 1). | -| Hosts | Optional | List of hosts of the podcast episode. | -| Genres | Optional | List of genres of the podcast episode. | -| Downloaded on device | Optional | Boolean indicating if the podcast episode is downloaded on the device. | -| Description | Optional | Must be within 200 characters if provided. | -| Video Podcast | Optional | Boolean indicating if the podcast episode has video content | -| Explicit | Optional | A boolean indicating if the content is explicit or not Items that contain explicit material or have a parental advisory warning should be set to TRUE. Explicit items appears with an "E" tag. | -| Listen Next Type | Optional | Recommended for Items in the Continuation Cluster TYPE_CONTINUE - Resume on a unfinished audio item. TYPE_NEXT - Continue on a new one of a series. TYPE_NEW - Newly released. | -| Last engagement time | Optional | Recommended for items in the Continuation Cluster. May be used for ranking. In epoch milliseconds | -| Progress percentage complete | Optional | Recommended for items in the Continuation Cluster. Integer between 0 and 100 | - -#### `LiveRadioStationEntity` - -The `LiveRadioStationEntity` object represents a live radio station (for -example, 98.1 The Breeze). - -| Attribute | Requirement | Notes | -|---|---|---| -| Name | **Required** | Title of the live radio station. | -| Poster images | **Required** | At least one image must be provided. See [Image Specifications](https://developer.android.com/guide/playcore/engage/listen#image-specs) for guidance. | -| Playback uri | **Required** | A deep link that starts playing the radio station in the provider app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | -| Info page uri | Optional | A deep link to the provider app for details about the radio station. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | -| Frequency | Optional | The frequency at which the radio station is broadcasted (for example, "98.1 FM"). | -| Show title | Optional | The current show that is playing on the radio station. | -| Hosts | Optional | List of hosts of the radio station. | -| Description | Optional | Must be within 200 characters if provided. | -| Last engagement time | Optional | Recommended for items in the Continuation Cluster. May be used for ranking. In epoch milliseconds | - -#### `AudiobookEntity` - -The `AudiobookEntity` object represents an audiobook (for example, the audiobook -of *Becoming* by Michelle Obama). - -| Attribute | Requirement | Notes | -|---|---|---| -| Name | **Required** | | -| Poster images | **Required** | At least one image must be provided. See [Image Specifications](https://developer.android.com/guide/playcore/engage/listen#image-specs) for guidance. | -| Author | **Required** | At least one author name must be provided. | -| Action link uri | **Required** | The deep link to the provider app for the audiobook. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | -| Narrator | Optional | At least one narrator's name must be provided. | -| Publish date | Optional | In epoch milliseconds if provided. | -| Description | Optional | Must be within 200 characters if provided. | -| Price | Optional | Free text | -| Duration | Optional | Must be a positive value if provided. | -| Genre | Optional | List of genres associated with the book. | -| Series name | Optional | Name of the series that the audiobook belongs to (for example, *Harry Potter*. | -| Series unit index | Optional | The index of the audiobook in the series, where 1 is the first audiobook in the series. For example, if *Harry Potter and the Prisoner of Azkaban* is the 3rd book in the series, this should be set to 3. | -| Continue book type | Optional | TYPE_CONTINUE - Resume on a unfinished book. TYPE_NEXT - Continue on a new one of a series. TYPE_NEW - Newly released. | -| Last Engagement Time | Conditionally required | Must be provided when the item is in the Continuation cluster. In epoch milliseconds. | -| Progress Percentage Complete | Conditionally required | Must be provided when the item is in the Continuation cluster. \*Newly\* acquired audiobooks can be a part of the continue reading cluster. Value must be greater than 0 and less than 100. | -| **DisplayTimeWindow - Set a time window for a content to be shown on the surface** ||| -| Start Timestamp | Optional | The epoch timestamp after which the content should be shown on the surface. If not set, content is eligible to be shown on the surface. In epoch milliseconds. | -| End Timestamp | Optional | The epoch timestamp after which the content is no longer shown on the surface. If not set, content is eligible to be shown on the surface. In epoch milliseconds. | - -#### Image specifications - -Required specifications for image assets are listed below: - -| Aspect ratio | Requirement | Minimum pixels | Recommended pixels | -|---|---|---|---| -| Square (1x1) | **Required** | 300x300 | 1200x1200 | -| Landscape (1.91x1) | Optional | 600x314 | 1200x628 | -| Portrait (4x5) | Optional | 480x600 | 960x1200 | - -*File formats* - -PNG, JPG, static GIF, WebP - -*Maximum file size* - -5120 KB - -*Additional recommendations* - -- **Image safe area:** Put your important content in the center 80% of the image. - -#### Examples - - MusicAlbumEntity musicAlbumEntity = - new MusicAlbumEntity.Builder() - .setName(NAME) - .addPosterImage(new Image.Builder() - .setImageUri(Uri.parse("http://www.x.com/image.png")) - .setImageHeightInPixel(960) - .setImageWidthInPixel(408) - .build()) - .setPlayBackUri("https://play.google/album/play") - .setInfoPageUri("https://play.google/album/info") - .setDescription("A description of this album.") - .addArtist("Artist") - .addGenre("Genre") - .addMusicLabel("Label") - .addContentRating("Rating") - .setSongsCount(960) - .setReleaseDateEpochMillis(1633032895L) - .setDurationMillis(1633L) - .build(); - - AudiobookEntity audiobookEntity = - new AudiobookEntity.Builder() - .setName("Becoming") - .addPosterImage(new Image.Builder() - .setImageUri(Uri.parse("http://www.x.com/image.png")) - .setImageHeightInPixel(960) - .setImageWidthInPixel(408) - .build()) - .addAuthor("Michelle Obama") - .addNarrator("Michelle Obama") - .setActionLinkUri( - Uri.parse("https://play.google/audiobooks/1")) - .setDurationMillis(16335L) - .setPublishDateEpochMillis(1633032895L) - .setDescription("An intimate, powerful, and inspiring memoir") - .setPrice("$16.95") - .addGenre("biography") - .build(); - -### Step 2: Provide Cluster data - -It's recommended to have the content publish job executed in the background -(for example, using [WorkManager](https://developer.android.com/topic/libraries/architecture/workmanager)) -and scheduled on a regular basis or on an event basis (for example, every time -the user opens the app or when the user just added something to their cart). - -`AppEngagePublishClient` is responsible for publishing clusters. Following -APIs are available in the client: - -- `isServiceAvailable` -- `publishRecommendationClusters` -- `publishFeaturedCluster` -- `publishContinuationCluster` -- `publishUserAccountManagementRequest` -- `updatePublishStatus` -- `deleteRecommendationsClusters` -- `deleteFeaturedCluster` -- `deleteContinuationCluster` -- `deleteUserManagementCluster` -- `deleteClusters` - -#### `isServiceAvailable` - -This API is used to check if the service is available for integration and -whether the content can be presented on the device. - -##### For Engage SDK v1.6.0 and higher (Recommended) - - -## Android skills - -[View on GitHub](https://github.com/android/skills/tree/main/play/engage-sdk-integration) - -### Engage SDK integration - -To install the skill from the [Android CLI](https://developer.android.com/tools/agents/android-cli), run: - - android skills add engage-sdk-integration - -If your team uses AI coding tools (such as Gemini in Android Studio), you can automate this migration by prompting your AI assistant: - - Use the engage-sdk-integration skill to use Engage SDK 1.6.0 and refactor isServiceAvailable to pass ServiceAvailabilityRequest for publishing all cluster types. - -
- -You can check the service availability for every cluster type that you intend to -publish. The `isServiceAvailable` API accepts a request object, -`ServiceAvailabilityRequest`, which contains the cluster types for which service -availability needs to be checked. You can find the `ClusterType` enum values -required for `ServiceAvailabilityRequest` from the following table. - -| Cluster Type | Cluster Type Constant | Integer Value | -|---|---|---| -| Unknown | `TYPE_UNKNOWN` | 0 | -| Recommendation Cluster | `TYPE_RECOMMENDATION` | 1 | -| Featured Cluster | `TYPE_FEATURED` | 2 | -| Continuation Cluster | `TYPE_CONTINUATION` | 3 | -| User Management Cluster | `TYPE_ENGAGEMENT` | 8 | -| Subscription Cluster | `TYPE_SUBSCRIPTION` | 12 | - -### Kotlin - - val request = ServiceAvailabilityRequest.Builder() - .addIntendedClusterType(ClusterType.TYPE_CONTINUATION) - .addIntendedClusterType(ClusterType.TYPE_RECOMMENDATION) - .build() - - client.isServiceAvailable(request).addOnCompleteListener { task -> - if (task.isSuccessful) { - val availabilityMap = task.result - if (availabilityMap[ClusterType.TYPE_CONTINUATION] == true) { - // Proceed with publishing continuation content - } - if (availabilityMap[ClusterType.TYPE_RECOMMENDATION] == true) { - // Proceed with publishing recommendation content - } - } else { - // The IPC call itself fails, proceed with error handling logic here, - // such as retry. - } - } - -### Java - - ServiceAvailabilityRequest request = - new ServiceAvailabilityRequest.Builder() - .addIntendedClusterType(ClusterType.TYPE_CONTINUATION) - .addIntendedClusterType(ClusterType.TYPE_RECOMMENDATION) - .build(); - - client.isServiceAvailable(request).addOnCompleteListener(task -> { - if (task.isSuccessful()) { - Map availabilityMap = task.getResult(); - if (Boolean.TRUE.equals(availabilityMap.get(ClusterType.TYPE_CONTINUATION))) { - // Proceed with publishing continuation content - } - if (Boolean.TRUE.equals(availabilityMap.get(ClusterType.TYPE_RECOMMENDATION))) { - // Proceed with publishing recommendation content - } - } else { - // The IPC call itself fails, proceed with error handling logic here, - // such as retry. - } - }); - -###### Conditional Service Availability Feature - -Some integrated apps request a special configuration that enables and disables -the Engage service intermittently in order to reduce their serving cost. This -intermittent content ingestion strategy, although possible, negatively affects -the user and the product -- stale content will not be presented and some surfaces -will not be served at all. - -Starting with v1.6.0, the Engage SDK allows checking availability for specific -cluster types. This provides more flexibility so that if the intermittent -content strategy was adopted by a given application, some cluster types can -follow that intermittent strategy while other cluster types are always enabled -(i.e. continuation clusters). - -If the Engage service should not be 'continuously' enabled on all supported -devices for whatever reason, and is configured for intermittent ingestion for -any set of devices, all continuation cluster publications (e.g. Continue -Listening) will be still enabled by default configuration, and the rest of -the cluster types will be enabled and disabled intermittently. If -intermittent ingestion applies to you but this default configuration is not -suitable for your needs, please contact engage-developers@google.com. - -##### For SDK versions prior to v1.6.0 (Deprecated) - -### Kotlin - - client.isServiceAvailable.addOnCompleteListener { task -> - if (task.isSuccessful) { - // Handle IPC call success - if(task.result) { - // Service is available on the device, proceed with content publish - // calls. - } else { - // Service is not available, no further action is needed. - } - } else { - // The IPC call itself fails, proceed with error handling logic here, - // such as retry. - } - } - -### Java - - client.isServiceAvailable().addOnCompleteListener(task - > { - if (task.isSuccessful()) { - // Handle success - if(task.getResult()) { - // Service is available on the device, proceed with content publish - // calls. - } else { - // Service is not available, no further action is needed. - } - } else { - // The IPC call itself fails, proceed with error handling logic here, - // such as retry. - } - }); - -> [!NOTE] -> **Note:** We highly recommend keeping a periodic job running to check if the service becomes available at a later point in time. The availability of the service may change with Android version upgrades, app upgrades, installs, and uninstalls. By ensuring periodic job checks at a certain time interval, data can be published once the service becomes available. - -#### `publishRecommendationClusters` - -This API is used to publish a list of `RecommendationCluster` objects. - -> [!IMPORTANT] -> **Important:** The publish APIs are upsert APIs; it replaces the existing content. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. - -### Kotlin - - client.publishRecommendationClusters( - PublishRecommendationClustersRequest.Builder() - .addRecommendationCluster( - RecommendationCluster.Builder() - .addEntity(entity1) - .addEntity(entity2) - .setTitle("Trending music") - .build()) - .build()) - -### Java - - client.publishRecommendationClusters( - new PublishRecommendationClustersRequest.Builder() - .addRecommendationCluster( - new RecommendationCluster.Builder() - .addEntity(entity1) - .addEntity(entity2) - .setTitle("Trending music") - .build()) - .build()); - -When the service receives the request, the following actions take place within -one transaction: - -- Existing `RecommendationCluster` data from the developer partner is removed. -- Data from the request is parsed and stored in the updated Recommendation Cluster. - -In case of an error, the entire request is rejected and the existing state is -maintained. - -#### `publishFeaturedCluster` - -This API is used to publish a list of `FeaturedCluster` objects. - -> [!IMPORTANT] -> **Important:** The publish APIs are upsert APIs; it replaces the existing content. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. - -### Kotlin - - client.publishFeaturedCluster( - PublishFeaturedClusterRequest.Builder() - .setFeaturedCluster( - FeaturedCluster.Builder() - ... - .build()) - .build()) - -### Java - - client.publishFeaturedCluster( - new PublishFeaturedClusterRequest.Builder() - .setFeaturedCluster( - new FeaturedCluster.Builder() - ... - .build()) - .build()); - -When the service receives the request, the following actions take place within -one transaction: - -- Existing `FeaturedCluster` data from the developer partner is removed. -- Data from the request is parsed and stored in the updated Featured Cluster. - -In case of an error, the entire request is rejected and the existing state is -maintained. - -#### `publishContinuationCluster` - -This API is used to publish a `ContinuationCluster` object. - -> [!IMPORTANT] -> **Important:** The publish APIs are upsert APIs; it replaces the existing content. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. - -### Kotlin - - client.publishContinuationCluster( - PublishContinuationClusterRequest.Builder() - .setContinuationCluster( - ContinuationCluster.Builder() - .addEntity(entity1) - .addEntity(entity2) - .build()) - .build()) - -### Java - - client.publishContinuationCluster( - PublishContinuationClusterRequest.Builder() - .setContinuationCluster( - ContinuationCluster.Builder() - .addEntity(entity1) - .addEntity(entity2) - .build()) - .build()) - -When the service receives the request, the following actions take place within -one transaction: - -- Existing `ContinuationCluster` data from the developer partner is removed. -- Data from the request is parsed and stored in the updated Continuation Cluster. - -In case of an error, the entire request is rejected and the existing state is -maintained. - -#### `publishUserAccountManagementRequest` - -This API is used to publish a Sign In card . The signin action directs users to -the app's sign in page so that the app can publish content (or provide more -personalized content) - -The following metadata is part of the Sign In Card - - -| Attribute | Requirement | Description | -|---|---|---| -| Action Uri | Required | Deeplink to Action (i.e. navigates to app sign in page) | -| Image | Optional - If not provided, Title must be provided | Image Shown on the Card 16x9 aspect ratio images with a resolution of 1264x712 | -| Title | Optional - If not provided, Image must be provided | Title on the Card | -| Action Text | Optional | Text Shown on the CTA (i.e. Sign in) | -| Subtitle | Optional | Optional Subtitle on the Card | - -> [!IMPORTANT] -> **Important:** The publish APIs are upsert APIs; it replaces the existing content. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. - -### Kotlin - - var SIGN_IN_CARD_ENTITY = - SignInCardEntity.Builder() - .addPosterImage( - Image.Builder() - .setImageUri(Uri.parse("http://www.x.com/image.png")) - .setImageHeightInPixel(500) - .setImageWidthInPixel(500) - .build()) - .setActionText("Sign In") - .setActionUri(Uri.parse("http://xx.com/signin")) - .build() - - client.publishUserAccountManagementRequest( - PublishUserAccountManagementRequest.Builder() - .setSignInCardEntity(SIGN_IN_CARD_ENTITY) - .build()); - -### Java - - SignInCardEntity SIGN_IN_CARD_ENTITY = - new SignInCardEntity.Builder() - .addPosterImage( - new Image.Builder() - .setImageUri(Uri.parse("http://www.x.com/image.png")) - .setImageHeightInPixel(500) - .setImageWidthInPixel(500) - .build()) - .setActionText("Sign In") - .setActionUri(Uri.parse("http://xx.com/signin")) - .build(); - - client.publishUserAccountManagementRequest( - new PublishUserAccountManagementRequest.Builder() - .setSignInCardEntity(SIGN_IN_CARD_ENTITY) - .build()); - -When the service receives the request, the following actions take place within -one transaction: - -- Existing `UserAccountManagementCluster` data from the developer partner is removed. -- Data from the request is parsed and stored in the updated UserAccountManagementCluster Cluster. - -In case of an error, the entire request is rejected and the existing state is -maintained. - -#### `updatePublishStatus` - -If for any internal business reason, none of the clusters is published, -we **strongly recommend** updating the publish status using the -**updatePublishStatus** API. -This is important because : - -- Providing the status in all scenarios, even when the content is published (STATUS == PUBLISHED), is critical to populate dashboards that use this explicit status to convey the health and other metrics of your integration. -- If no content is published but the integration status isn't broken (STATUS == NOT_PUBLISHED), Google can avoid triggering alerts in the app health dashboards. It confirms that content is not published due to an **expected** situation from the provider's standpoint. -- It helps developers provide insights into when the data is published versus not. -- Google may use the status codes to nudge the user to do certain actions in the app so they can see the app content or overcome it. - -The list of eligible publish status codes are : - - // Content is published - AppEngagePublishStatusCode.PUBLISHED, - - // Content is not published as user is not signed in - AppEngagePublishStatusCode.NOT_PUBLISHED_REQUIRES_SIGN_IN, - - // Content is not published as user is not subscribed - AppEngagePublishStatusCode.NOT_PUBLISHED_REQUIRES_SUBSCRIPTION, - - // Content is not published as user location is ineligible - AppEngagePublishStatusCode.NOT_PUBLISHED_INELIGIBLE_LOCATION, - - // Content is not published as there is no eligible content - AppEngagePublishStatusCode.NOT_PUBLISHED_NO_ELIGIBLE_CONTENT, - - // Content is not published as the feature is disabled by the client - // Available in v1.3.1 - AppEngagePublishStatusCode.NOT_PUBLISHED_FEATURE_DISABLED_BY_CLIENT, - - // Content is not published as the feature due to a client error - // Available in v1.3.1 - AppEngagePublishStatusCode.NOT_PUBLISHED_CLIENT_ERROR, - - // Content is not published as the feature due to a service error - // Available in v1.3.1 - AppEngagePublishStatusCode.NOT_PUBLISHED_SERVICE_ERROR, - - // Content is not published due to some other reason - // Reach out to engage-developers@ before using this enum. - AppEngagePublishStatusCode.NOT_PUBLISHED_OTHER - -If the content is not published due to a user not logged in, -Google would recommend publishing the Sign In Card. -If for any reason providers are not able to publish the Sign In Card -then we recommend calling the **updatePublishStatus** API -with the status code **NOT_PUBLISHED_REQUIRES_SIGN_IN** - -### Kotlin - - client.updatePublishStatus( - PublishStatusRequest.Builder() - .setStatusCode(AppEngagePublishStatusCode.NOT_PUBLISHED_REQUIRES_SIGN_IN) - .build()) - -### Java - - client.updatePublishStatus( - new PublishStatusRequest.Builder() - .setStatusCode(AppEngagePublishStatusCode.NOT_PUBLISHED_REQUIRES_SIGN_IN) - .build()); - -#### `deleteRecommendationClusters` - -This API is used to delete the content of Recommendation Clusters. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteRecommendationClusters() - -### Java - - client.deleteRecommendationClusters(); - -When the service receives the request, it removes the existing data from the -Recommendation Clusters. In case of an error, the entire request is rejected -and the existing state is maintained. - -> [!NOTE] -> **Note:** This api is available from version 1.1.0 onwards. - -#### `deleteFeaturedCluster` - -This API is used to delete the content of Featured Cluster. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteFeaturedCluster() - -### Java - - client.deleteFeaturedCluster(); - -When the service receives the request, it removes the existing data from the -Featured Cluster. In case of an error, the entire request is rejected -and the existing state is maintained. - -> [!NOTE] -> **Note:** This api is available from version 1.1.0 onwards. - -#### `deleteContinuationCluster` - -This API is used to delete the content of Continuation Cluster. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteContinuationCluster() - -### Java - - client.deleteContinuationCluster(); - -When the service receives the request, it removes the existing data from the -Continuation Cluster. In case of an error, the entire request is rejected -and the existing state is maintained. - -> [!NOTE] -> **Note:** This api is available from version 1.1.0 onwards. - -#### `deleteUserManagementCluster` - -This API is used to delete the content of UserAccountManagement Cluster. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteUserManagementCluster() - -### Java - - client.deleteUserManagementCluster(); - -When the service receives the request, it removes the existing data from the -UserAccountManagement Cluster. In case of an error, the entire request is -rejected and the existing state is maintained. - -> [!NOTE] -> **Note:** This api is available from version 1.1.0 onwards. - -#### `deleteClusters` - -This API is used to delete the content of a given cluster type. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteClusters( - DeleteClustersRequest.Builder() - .addClusterType(ClusterType.TYPE_FEATURED) - .addClusterType(ClusterType.TYPE_RECOMMENDATION) - ... - .build()) - -### Java - - client.deleteClusters( - new DeleteClustersRequest.Builder() - .addClusterType(ClusterType.TYPE_FEATURED) - .addClusterType(ClusterType.TYPE_RECOMMENDATION) - ... - .build()); - -When the service receives the request, it removes the existing data from all -clusters matching the specified cluster types. Clients can choose to pass one or -many cluster types. In case of an error, the entire request is rejected and the -existing state is maintained. - -#### Error handling - -It is highly recommended to listen to the task result from the publish APIs such -that a follow-up action can be taken to recover and resubmit an successful task. - - client.publishRecommendationClusters( - new PublishRecommendationClustersRequest.Builder() - .addRecommendationCluster(...) - .build()) - .addOnCompleteListener( - task -> { - if (task.isSuccessful()) { - // do something - } else { - Exception exception = task.getException(); - if (exception instanceof AppEngageException) { - @AppEngageErrorCode - int errorCode = ((AppEngageException) exception).getErrorCode(); - if (errorCode == AppEngageErrorCode.SERVICE_NOT_FOUND) { - // do something - } - } - } - }); - -The error is returned as an `AppEngageException` with the cause included as an -error code. - -| Error code | Error name | Note | -|---|---|---| -| `1` | `SERVICE_NOT_FOUND` | The service is not available on the given device. | -| `2` | `SERVICE_NOT_AVAILABLE` | The service is available on the given device, but it is not available at the time of the call (for example, it is explicitly disabled). | -| `3` | `SERVICE_CALL_EXECUTION_FAILURE` | The task execution failed due to threading issues. In this case, it can be retried. | -| `4` | `SERVICE_CALL_PERMISSION_DENIED` | The caller is not allowed to make the service call. | -| `5` | `SERVICE_CALL_INVALID_ARGUMENT` | The request contains invalid data (for example, more than the allowed number of clusters). | -| `6` | `SERVICE_CALL_INTERNAL` | There is an error on the service side. | -| `7` | `SERVICE_CALL_RESOURCE_EXHAUSTED` | The service call is made too frequently. | - -### Step 3: Handle broadcast intents - -In addition to making publish content API calls through a job, it is also -required to set up a -[`BroadcastReceiver`](https://developer.android.com/reference/android/content/BroadcastReceiver) to receive -the request for a content publish. - -The goal of broadcast intents is mainly for app reactivation and forcing data -sync. Broadcast intents are not designed to be sent very frequently. It is only -triggered when the Engage Service determines the content might be stale (for -example, a week old). That way, there is more confidence that the user can have -a fresh content experience, even if the application has not been executed for a -long period of time. - -The `BroadcastReceiver` must be set up in the following two ways: - -- Dynamically register an instance of the `BroadcastReceiver` class using - `Context.registerReceiver()`. This enables communication from applications - that are still live in memory. - -### Kotlin - - class AppEngageBroadcastReceiver : BroadcastReceiver(){ - // Trigger recommendation cluster publish when PUBLISH_RECOMMENDATION broadcast - // is received - // Trigger featured cluster publish when PUBLISH_FEATURED broadcast is received - // Trigger continuation cluster publish when PUBLISH_CONTINUATION broadcast is - // received - } - - fun registerBroadcastReceivers(context: Context){ - var context = context - context = context.applicationContext - - // Register Recommendation Cluster Publish Intent - context.registerReceiver(AppEngageBroadcastReceiver(), - IntentFilter(Intents.ACTION_PUBLISH_RECOMMENDATION), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null) - - // Register Featured Cluster Publish Intent - context.registerReceiver(AppEngageBroadcastReceiver(), - IntentFilter(Intents.ACTION_PUBLISH_FEATURED), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null) - - // Register Continuation Cluster Publish Intent - context.registerReceiver(AppEngageBroadcastReceiver(), - IntentFilter(Intents.ACTION_PUBLISH_CONTINUATION), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null) - } - -### Java - - class AppEngageBroadcastReceiver extends BroadcastReceiver { - // Trigger recommendation cluster publish when PUBLISH_RECOMMENDATION broadcast - // is received - - // Trigger featured cluster publish when PUBLISH_FEATURED broadcast is received - - // Trigger continuation cluster publish when PUBLISH_CONTINUATION broadcast is - // received - } - - public static void registerBroadcastReceivers(Context context) { - - context = context.getApplicationContext(); - - // Register Recommendation Cluster Publish Intent - context.registerReceiver(new AppEngageBroadcastReceiver(), - new IntentFilter(com.google.android.engage.service.Intents.ACTION_PUBLISH_RECOMMENDATION), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null); - - // Register Featured Cluster Publish Intent - context.registerReceiver(new AppEngageBroadcastReceiver(), - new IntentFilter(com.google.android.engage.service.Intents.ACTION_PUBLISH_FEATURED), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null); - - // Register Continuation Cluster Publish Intent - context.registerReceiver(new AppEngageBroadcastReceiver(), - new IntentFilter(com.google.android.engage.service.Intents.ACTION_PUBLISH_CONTINUATION), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null); - - } - -- Statically declare an implementation with the `` tag in your - `AndroidManifest.xml` file. This allows the application to receive broadcast - intents when it is not running, and also allows the application to publish - the content. - - - - - - - - - - - - - - - -The following [intents](https://developer.android.com/reference/android/content/Intent) will be sent by the -service: - -- `com.google.android.engage.action.PUBLISH_RECOMMENDATION` It is recommended to start a `publishRecommendationClusters` call when receiving this intent. -- `com.google.android.engage.action.PUBLISH_FEATURED` It is recommended to start a `publishFeaturedCluster` call when receiving this intent. -- `com.google.android.engage.action.PUBLISH_CONTINUATION` It is recommended to start a `publishContinuationCluster` call when receiving this intent. - -## Integration workflow - -For a step-by-step guide on verifying your integration after it is complete, see -[Engage developer integration workflow](https://developer.android.com/guide/playcore/engage/workflow). - -## FAQs - -See [Engage SDK Frequently Asked Questions](https://developer.android.com/guide/playcore/engage/faq) for -FAQs. - -## Contact - -Contact -[`engage-developers@google.com`](mailto:engage-developers@google.com) if there are -any questions during the integration process. Our team will reply as soon as -possible. - -## Next steps - -After completing this integration, your next steps are as follows: - -- Send an email to [`engage-developers@google.com`](mailto:engage-developers@google.com) and attach your integrated APK that is ready for testing by Google. -- Google will perform a verification and review internally to make sure the integration works as expected. If changes are needed, Google will contact you with any necessary details. -- When testing is complete and no changes are needed, Google will contact you to notify you that you can start publishing the updated and integrated APK to the Play Store. -- After Google has confirmed that your updated APK has been published to the Play Store, your **Recommendation** , **Featured** , and **Continuation** clusters will be published and visible to users. \ No newline at end of file diff --git a/play/engage-sdk-integration/references/android/guide/playcore/engage/otherverticals.md b/play/engage-sdk-integration/references/android/guide/playcore/engage/otherverticals.md deleted file mode 100644 index 6dec631..0000000 --- a/play/engage-sdk-integration/references/android/guide/playcore/engage/otherverticals.md +++ /dev/null @@ -1,1314 +0,0 @@ -Boost app engagement by reaching your users where they are. Integrate Engage SDK -to deliver personalized recommendations and continuation content directly to -users across multiple on-device surfaces, like -**[Collections](https://android-developers.googleblog.com/2024/07/introducing-collections-powered-by-engage-sdk.html)** , **[Entertainment -Space](https://blog.google/products/android/entertainment-space/)** , and the Play Store. The integration adds -less than 50 KB (compressed) to the average APK and takes most apps about a -week of developer time. Learn more at our **[business -site](http://play.google.com/console/about/programs/EngageSDK)**. - -This document contains instructions for developer partners to integrate new -content like reservations, events, lodging, places of interest, people and other -content that might not belong to any of these categories. - - -## Android skills - -[View on GitHub](https://github.com/android/skills/tree/main/play/engage-sdk-integration) - -### Integrate Engage SDK - -Use the Engage SDK [Android skill](https://developer.android.com/tools/agents/android-skills) to integrate, debug, and resolve implementation issues. To install the skill from the [Android CLI](https://developer.android.com/tools/agents/android-cli), run: - - android skills add engage-sdk-integration - -
- -## Integration detail - -### Terminology - -This integration includes the following three cluster types: **Recommendation** , -**Featured** , and **Continuation**. - -- **Recommendation** clusters show personalized suggestions from an individual - developer partner. It is a UI view that contains a group of recommendations - from the same developer partner. - - - **ArticleEntity**: ArticleEntity that represents a text-based - recommendation for content that is relevant to more than one category - of content. ArticleEntity item allows developers to provide a variety of - text and image content with more metadata to articulate the information - to the users compared to GenericFeaturedEntity. Ex: Marketing content, - News snippet - - ![](https://developer.android.com/static/images/guide/playcore/engage/article-entity-travel.png) **Figure 1:** UI showing a single ArticleEntity within Recommendations cluster. - - **EventEntity**: EventEntity represents an event happening in the - future. Event start time is a critical piece of information that - needs to be conveyed to the users. - - ![](https://developer.android.com/static/images/guide/playcore/engage/event-entity-travel.png) **Figure 2:** UI showing a single EventEntity within Recommendations cluster. - - **LodgingEntity**: LodgingEntity represents an accommodation, such - as a hotel, apartment, vacation home for short term and long term - rental. - - ![](https://developer.android.com/static/images/guide/playcore/engage/lodging-entity-travel.png) **Figure 3:** UI showing a single LodgingEntity within Recommendations cluster. - - **StoreEntity**: StoreEntity represents a store, restaurant, cafe - etc. It highlights content where a dining venue or store is the - critical piece of information that needs to be conveyed to the - users. - - ![](https://developer.android.com/static/images/guide/playcore/engage/store-entity-travel.png) **Figure 4:** UI showing a single StoreEntity within Recommendations cluster. - - **PointOfInterestEntity**: PointOfInterestEntity represents a - place of interest like, a gas station, event venue, theme park, - museum, tourist attraction, hiking trail etc. It highlights content - where location is a critical piece of information that needs to be - conveyed to the users. It shouldn't be used for lodging, a store or - a dining venue. - - ![](https://developer.android.com/static/images/guide/playcore/engage/poi-entity-travel.png) **Figure 5:** UI showing a single PointOfInterestEntity within Recommendations cluster. - - **PersonEntity**: PersonEntity represents a person. The recommendations - could be to highlight a person in categories like health and fitness, - sports, dating, etc. - - ![](https://developer.android.com/static/images/guide/playcore/engage/person-entity-dating.png) **Figure 5:** UI showing a single PersonEntity within Recommendations cluster. -- The **Continuation** cluster shows content recently engaged by users from - multiple developer partners in a single UI grouping. Each developer partner - will be allowed to broadcast a maximum of 10 entities in the Continuation - cluster. - - Your continuation content can take the following structure: - - **ArticleEntity**: ArticleEntity that represents a text-based - recommendation for content that is relevant to more than one category - of content. This entity can be used to represent unfinished news - articles or other content that the user would like to continue consuming - from where they left it. Ex: Marketing content, News snippet - - ![](https://developer.android.com/static/images/guide/playcore/engage/article-entity-continuation-travel.png) **Figure 6.** UI showing a single ArticleEntity within a Continuation cluster. - - **RestaurantReservationEntity**: RestaurantReservationEntity represents - a reservation for a restaurant or cafe and helps users track upcoming or - ongoing restaurant reservations. - - ![](https://developer.android.com/static/images/guide/playcore/engage/restaurant-reservation-entity-travel.png) **Figure 7.** UI showing a single RestaurantReservationEntity within a Continuation cluster. - - **EventReservationEntity**: EventReservationEntity represents a - reservation for an event and helps users track upcoming or ongoing - events reservations. Events could include, but not limited to the - following: - - - Sports events like reservation for a football match - - Gaming events like reservation for eSports - - Entertainment events like reservation for movies in a cinema, concert, theater, book signing - - Travel or point of interest reservations like guided tours, museum tickets - - Social / Seminar / Conferences reservations - - Education / Training sessions reservations - - ![](https://developer.android.com/static/images/guide/playcore/engage/event-reservation-entity-travel.png) **Figure 8.** UI showing a single EventReservationEntity within a Continuation cluster. - - **LodgingReservationEntity**: LodgingEntityReservation represents a - reservation for a travel lodging and helps users track upcoming or - ongoing hotel or vacation rental reservations. - - ![](https://developer.android.com/static/images/guide/playcore/engage/lodging-reservation-entity-travel.png) **Figure 9.** UI showing a single LodgingReservationEntity within a Continuation cluster. - - **TransportationReservationEntity**: TransportationReservationEntity - represents a reservation for transportation by any mode and helps users - track reservations for upcoming or ongoing flight, ferry, train, bus, - ride-hailing, or cruise. - - ![](https://developer.android.com/static/images/guide/playcore/engage/transportation-reservation-entity-travel.png) **Figure 10.** UI showing a single TransportationReservationEntity within a Continuation cluster. - - **VehicleRentalReservationEntity**: VehicleRentalReservationEntity - represents a vehicle rental reservation and helps users track upcoming - or ongoing vehicle rental reservations. - - ![](https://developer.android.com/static/images/guide/playcore/engage/vehicle-rental-reservation-entity-travel.png) **Figure 11.** UI showing a single VehicleRentalReservationEntity within a Continuation cluster. -- The **Featured** cluster is a UI view that showcases the chosen hero - `GenericFeaturedEntity` from many developer partners in one UI grouping. - There is a single Featured cluster, which is surfaced near the top of the - UI, with a priority placement above all Recommendation clusters. Each - developer partner is allowed to broadcast a single entity of a supported - type in Featured, with many entities (potentially of different types) from - multiple app developers in the Featured cluster. - - - **GenericFeaturedEntity**: GenericFeaturedEntity differs from - Recommendation item in that Featured item should be used for a single - top content from developers and should represent the single most - important content that will be interesting and relevant to users. - - ![](https://developer.android.com/static/images/guide/playcore/engage/featured-item-health-and-fitness.png) **Figure 12:** UI showing a single hero GenericFeaturedEntity card within a Featured cluster - -### Pre-work - -Minimum API level: 19 - -Add the `com.google.android.engage:engage-core` library to your app: - - dependencies { - // Make sure you also include that repository in your project's build.gradle file. - implementation 'com.google.android.engage:engage-core:1.6.0' - } - -### Summary - -The design is based on an implementation of a -[bound service](https://developer.android.com/guide/components/bound-services). - -The data a client can publish is subject to the following limits for different -cluster types: - -| Cluster type | Cluster limits | Minimum entity limits in a cluster | Maximum entity limits in a cluster | -|---|---|---|---| -| Recommendation Cluster(s) | At most 7 | At least 1 | At most 50 (`ArticleEntity`, `EventEntity`, `LodgingEntity`, `StoreEntity`, `PointOfInterestEntity`, or `PersonEntity`) | -| Continuation Cluster | At most 1 | At least 1 | At most 20 (`ArticleEntity`, `EventReservationEntity`, `LodgingReservationEntity`, `TransportationReservationEntity`, or `VehicleRentalReservationEntity`) | -| Featured Cluster | At most 1 | At least 1 | At most 20 (`GenericFeaturedEntity`) | - -### Step 1: Provide entity data - -The SDK has defined different entities to represent each item type. We support -the following entities for the others category: - -1. `GenericFeaturedEntity` -2. `ArticleEntity` -3. `EventEntity` -4. `LodgingEntity` -5. `StoreEntity` -6. `PointOfInterestEntity` -7. `PersonEntity` -8. `RestaurantReservationEntity` -9. `EventReservationEntity` -10. `LodgingReservationEntity` -11. `TransportationReservationEntity` -12. `VehicleRentalReservationEntity` - -The charts below outline available attributes and requirements for each type. - -#### `GenericFeaturedEntity` - -| Attribute | Requirement | Description | Format | -|---|---|---|---| -| Action Uri | **Required** | Deep Link to the entity in the provider app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | Uri | -| Poster images | **Required** | We will show only 1 image when multiple images are provided. Recommended aspect ratio is 16:9 **Note:** If a badge is provided, ensure safe space of 24 dps at both the top and bottom of the image | See [Image Specifications](https://developer.android.com/guide/playcore/engage/otherverticals#image-specs) for guidance. | -| Title | Optional | Title of the entity. | Free text **Recommended text size: 50 chars** | -| Description | Optional | A single paragraph of text to describe the entity. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size: 180 chars** | -| Subtitle list | Optional | Up to 3 subtitles, with each subtitle a single line of text. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size for each subtitle: max 50 chars** | -| Badges | Optional | Each badge is either free text (max 15 chars) or small image. Special UX treatment on top of image/video, for example, as badge overlay on the image - "Live update" - Article read duration | | -| Badge - Text | Optional | Title for the badge **Note:** Either text or image is required for the badge | Free text **Recommended text size: max 15 chars** | -| Badge - Image | Optional | Small image Special UX treatment, for example as badge overlay on the image/video thumbnail. **Note:** Either text or image is required for the badge | See [Image Specifications](https://developer.android.com/guide/playcore/engage/otherverticals#image-specs) for guidance. | -| Content Categories | Optional | Describe the category of the content in the entity. | List of Enums See the [Content Category section](https://developer.android.com/guide/playcore/engage/otherverticals#content-category) for guidance. | - -#### `ArticleEntity` - -| Attribute | Requirement | Description | Format | -|---|---|---|---| -| Action Uri | **Required** | Deep Link to the entity in the provider app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | Uri | -| Title | **Required** | Title of the entity. | Free text **Recommended text size: Max 50 chars** | -| Poster images | Optional | We will show only 1 image when multiple images are provided. Recommended aspect ratio is 16:9 **Note:** Image is highly recommended. If a badge is provided, ensure safe space of 24 dps at both the top and bottom of the image | See [Image Specifications](https://developer.android.com/guide/playcore/engage/otherverticals#image-specs) for guidance. | -| Source - Title | Optional | The name of the author, organization, or reporter | Free text **Recommended text size: Under 25 chars** | -| Source - Image | Optional | An image of the source like the author, the organization, reporter | See [Image Specifications](https://developer.android.com/guide/playcore/engage/otherverticals#image-specs) for guidance. | -| Description | Optional | A single paragraph of text to describe the entity. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size: 180 chars** | -| Subtitle list | Optional | Up to 3 subtitles, with each subtitle a single line of text. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size for each subtitle: max 50 chars** | -| Badges | Optional | Each badge is either free text (max 15 chars) or small image. Special UX treatment on top of image/video, for example as badge overlay on the image - "Live update" - Article read duration | | -| Badge - Text | Optional | Title for the badge **Note:** Either text or image is required for the badge | Free text **Recommended text size: max 15 chars** | -| Badge - Image | Optional | Small image Special UX treatment, for example as badge overlay on the image/video thumbnail. **Note:** Either text or image is required for the badge | See [Image Specifications](https://developer.android.com/guide/playcore/engage/otherverticals#image-specs) for guidance. | -| Content Publish Time | Optional | This is the epoch timestamp in milliseconds on when the content was published / updated in the app. | Epoch timestamp in milliseconds | -| Last Engagement Time | Conditionally Required | The epoch timestamp in milliseconds when the user interacted with this entity last time. **Note:** This field is required if this entity is part of the continuation cluster. | Epoch timestamp in milliseconds | -| Progress Percentage | Conditionally Required | The percentage of the full content consumed by the user to date. **Note:** This field is required if this entity is part of the continuation cluster. | An int value between 0\~100 inclusive. | -| Content Categories | Optional | Describe the category of the content in the entity. | List of Enums See the [Content Category section](https://developer.android.com/guide/playcore/engage/otherverticals#content-category) for guidance. | - -#### `EventEntity` - -| Attribute | Requirement | Description | Format | -|---|---|---|---| -| Action Uri | **Required** | Deep Link to the entity in the provider app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | Uri | -| Title | **Required** | Title of the entity. | String **Recommended text size: Max 50 chars** | -| Start time | **Required** | The epoch timestamp when the event is expected to start. **Note:**This will be represented in milliseconds. | Epoch timestamp in milliseconds | -| Event mode | **Required** | A field to indicate whether the event will be virtual, in-person or both. | Enum: VIRTUAL, IN_PERSON, or HYBRID | -| Poster images | **Required** | We will show only 1 image when multiple images are provided. Recommended aspect ratio is 16:9 **Note:** Image is highly recommended. If a badge is provided, ensure safe space of 24 dps at both the top and bottom of the image | See [Image Specifications](https://developer.android.com/guide/playcore/engage/otherverticals#image-specs) for guidance. | -| Location - Country | Conditionally Required | The country in which the event is happening. **Note:** This is required for events which are IN_PERSON or HYBRID | Free text **Recommended text size: max \~20 chars** | -| Location - City | Conditionally Required | The city in which the event is happening. **Note:** This is required for events which are IN_PERSON or HYBRID | Free text **Recommended text size: max \~20 chars** | -| Location - Display Address | Conditionally Required | The address or venue name where the event will take place that should be displayed to the user. **Note:** This is required for events which are IN_PERSON or HYBRID | Free text **Recommended text size: max \~20 chars** | -| Location - Street Address | Optional | The street address (if applicable) of the location at which event is being hosted. | Free text **Recommended text size: max \~20 chars** | -| Location - State | Optional | The state or province (if applicable) in which the event is being hosted. | Free text **Recommended text size: max \~20 chars** | -| Location - Zip code | Optional | The zip code (if applicable) of the location in which the event is being hosted. | Free text **Recommended text size: max \~20 chars** | -| Location - Neighborhood | Optional | The neighborhood (if applicable) in which the event is being hosted. | Free text **Recommended text size: max \~20 chars** | -| End time | Optional | The epoch timestamp when the event is expected to end. **Note:**This will be represented in milliseconds. | Epoch timestamp in milliseconds | -| Description | Optional | A single paragraph of text to describe the entity. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size: 180 chars** | -| Subtitle list | Optional | Up to 3 subtitles, with each subtitle a single line of text. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size for each subtitle: max 50 chars** | -| Badges | Optional | Each badge is either free text (max 15 chars) or small image. | | -| Badge - Text | Optional | Title for the badge **Note:** Either text or image is required for the badge | Free text **Recommended text size: max 15 chars** | -| Badge - Image | Optional | Small image Special UX treatment, for example as badge overlay on the image/video thumbnail. **Note:** Either text or image is required for the badge | See [Image Specifications](https://developer.android.com/guide/playcore/engage/otherverticals#image-specs) for guidance. | -| Price - CurrentPrice | Conditionally required | The current price of the ticket/pass for the event. **Must be provided if strikethrough price is provided.** | Free text | -| Price - StrikethroughPrice | Optional | The original price of the ticket/pass for the event. | Free text | -| Price Callout | Optional | Price callout to feature a promo, event, member discount, if available. | Free text **Recommended text size: under 45 chars (Text that is too long may show ellipses)** | -| Content Categories | Optional | Describe the category of the content in the entity. | List of Eligible Enums - TYPE_MOVIES_AND_TV_SHOWS (Example - Cinema) - TYPE_DIGITAL_GAMES (Example - eSports) - TYPE_MUSIC (Example - Concert) - TYPE_TRAVEL_AND_LOCAL (Example - Tour, festival) - TYPE_HEALTH_AND_FITENESS (Example - Yoga class) - TYPE_EDUCATION (Example - Class) - TYPE_SPORTS (Example - Football game) - TYPE_DATING (Example - meetup) See the [Content Category section](https://developer.android.com/guide/playcore/engage/otherverticals#content-category) for guidance. | - -#### `LodgingEntity` - -| Attribute | Requirement | Description | Format | -|---|---|---|---| -| Action Uri | **Required** | Deep Link to the entity in the provider app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | Uri | -| Title | **Required** | Title of the entity. | String **Recommended text size: Max 50 chars** | -| Poster images | **Required** | We will show only 1 image when multiple images are provided. Recommended aspect ratio is 16:9 **Note:** If a badge is provided, ensure safe space of 24 dps at both the top and bottom of the image | See [Image Specifications](https://developer.android.com/guide/playcore/engage/otherverticals#image-specs) for guidance. | -| Location - Country | **Required** | The country in which the lodging is happening. | Free text **Recommended text size: max \~20 chars** | -| Location - City | **Required** | The city in which the lodging is happening. | Free text **Recommended text size: max \~20 chars** | -| Location - Display Address | **Required** | The address of the lodging that will be displayed to the user. | Free text **Recommended text size: max \~20 chars** | -| Location - Street Address | Optional | The street address (if applicable) of the lodging. | Free text **Recommended text size: max \~20 chars** | -| Location - State | Optional | The state or province (if applicable) in which the lodging is located. | Free text **Recommended text size: max \~20 chars** | -| Location - Zip code | Optional | The zip code (if applicable) of the lodging. | Free text **Recommended text size: max \~20 chars** | -| Location - Neighborhood | Optional | The neighborhood (if applicable) of the lodging. | Free text **Recommended text size: max \~20 chars** | -| Badges | Optional | Each badge is either free text (max 15 chars) or small image. | | -| Badge - Text | Optional | Title for the badge **Note:** Either text or image is required for the badge | Free text **Recommended text size: max 15 chars** | -| Badge - Image | Optional | Small image Special UX treatment, for example as badge overlay on the image/video thumbnail. **Note:** Either text or image is required for the badge | See [Image Specifications](https://developer.android.com/guide/playcore/engage/otherverticals#image-specs) for guidance. | -| Description | Optional | A single paragraph of text to describe the entity. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size: 180 chars** | -| Subtitle list | Optional | Up to 3 subtitles, with each subtitle a single line of text. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size for each subtitle: max 50 chars** | -| AvailabilityTimeWindow - Start Time | Optional | The epoch timestamp in milliseconds when the lodging is expected to be open/available. | Epoch timestamp in milliseconds | -| AvailabilityTimeWindow - End Time | Optional | The epoch timestamp in milliseconds until which the lodging is expected to be open/available. | Epoch timestamp in milliseconds | -| Rating - Max value | Optional | The maximum value of the rating scale. **Must be provided if current value of rating is also provided.** | Number \>= 0.0 | -| Rating - Current value | Optional | The current value of the rating scale. **Must be provided if maximum value of rating is also provided.** | Number \>= 0.0 | -| Rating - Count | Optional | The count of the ratings for the lodging. **Note:** Provide this field if your app controls how the count is displayed to the users. Use a concise string. For example, if the count is 1,000,000, consider using an abbreviation like 1M so that the count isn't truncated on smaller display sizes. | String | -| Rating - Count Value | Optional | The count of the ratings for the lodging. **Note:** Provide this field if you aren't handling the display abbreviation logic yourself. If both Count and Count Value are present, Count is displayed to users. | Long | -| Price - CurrentPrice | Conditionally required | The current price of the lodging. **Must be provided if strikethrough price is provided.** | Free text | -| Price - StrikethroughPrice | Optional | The original price of the lodging, which is be struck-through in the UI. | Free text | -| Price Callout | Optional | Price callout to feature a promo, event, member discount, if available. | Free text **Recommended text size: under 45 chars (Text that is too long may show ellipses)** | - -#### `StoreEntity` - -The `StoreEntity` object represents an individual store that developer partners -want to publish, such as a restaurant or a grocery store. - -| Attribute | Requirement | Description | Format | -|---|---|---|---| -| Poster images | **Required** | At least one image must be provided. | See [Image Specifications](https://developer.android.com/guide/playcore/engage/otherverticals#image-specs) for guidance. | -| Action Uri | **Required** | Deep Link to the entity in the provider app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | Uri | -| Title | Optional | The name of the store. | Free text **Recommended text size: under 45 chars** (Text that is too long may show ellipses) | -| Location | Optional | The location of the store. | Free text **Recommended text size: under 45 chars** (Text that is too long may show ellipses) | -| Callout | Optional | Callout to feature a promo, event, or update for the store, if available. | Free text **Recommended text size: under 45 chars** (Text that is too long may show ellipses) | -| Callout fine print | Optional | Fine print text for the callout. | Free text **Recommended text size: under 45 chars** (Text that is too long may show ellipses) | -| Description | Optional | A description of the store. | Free text **Recommended text size: under 90 chars** (Text that is too long may show ellipses) | -| Rating - Max value | Optional | The maximum value of the rating scale. **Must be provided if current value of rating is also provided.** | Number \>= 0.0 | -| Rating - Current value | Optional | The current value of the rating scale. **Must be provided if maximum value of rating is also provided.** | Number \>= 0.0 | -| Rating - Count | Optional | The count of the ratings for the lodging. **Note:** Provide this field if your app wants to control how this is displayed to the users. Provide the concise string that can be displayed to the user. For example, if the count is 1,000,000, consider using abbreviations like 1M, so that it won't be truncated on smaller display sizes. | String | -| Rating - Count Value | Optional | The count of the ratings for the lodging. **Note:** Provide this field if you don't want to handle the display abbreviation logic yourself. If both Count and Count Value are present, we will use the Count to display to users | Long | - -#### `PointOfInterestEntity` - -| Attribute | Requirement | Description | Format | -|---|---|---|---| -| Action Uri | **Required** | Deep Link to the entity in the provider app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | Uri | -| Title | **Required** | Title of the entity. | String **Recommended text size: Max 50 chars** | -| Poster images | **Required** | We will show only 1 image when multiple images are provided. Recommended aspect ratio is 16:9 **Note:** Image is highly recommended. If a badge is provided, ensure safe space of 24 dps at both the top and bottom of the image | See [Image Specifications](https://developer.android.com/guide/playcore/engage/otherverticals#image-specs) for guidance. | -| Location - Country | **Required** | The country in which the point of interest is happening. | Free text **Recommended text size: max \~20 chars** | -| Location - City | **Required** | The city in which the point of interest is happening. | Free text **Recommended text size: max \~20 chars** | -| Location - Display Address | **Required** | The address of the point of interest that will be displayed to the user. | Free text **Recommended text size: max \~20 chars** | -| Location - Street Address | Optional | The street address (if applicable) of the point of interest. | Free text **Recommended text size: max \~20 chars** | -| Location - State | Optional | The state or province (if applicable) in which the point of interest is located. | Free text **Recommended text size: max \~20 chars** | -| Location - Zip code | Optional | The zip code (if applicable) of the point of interest. | Free text **Recommended text size: max \~20 chars** | -| Location - Neighborhood | Optional | The neighborhood (if applicable) of the point of interest. | Free text **Recommended text size: max \~20 chars** | -| AvailabilityTimeWindow - Start Time | Optional | The epoch timestamp in milliseconds when the point of interest is expected to be open/available. | Epoch timestamp in milliseconds | -| AvailabilityTimeWindow - End Time | Optional | The epoch timestamp in milliseconds until which the point of interest is expected to be open/available. | Epoch timestamp in milliseconds | -| Badges | Optional | Each badge is either free text (max 15 chars) or small image. | | -| Badge - Text | Optional | Title for the badge **Note:** Either text or image is required for the badge | Free text **Recommended text size: max 15 chars** | -| Badge - Image | Optional | Small image Special UX treatment, for example as badge overlay on the image/video thumbnail. **Note:** Either text or image is required for the badge | See [Image Specifications](https://developer.android.com/guide/playcore/engage/otherverticals#image-specs) for guidance. | -| Description | Optional | A single paragraph of text to describe the entity. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size: 180 chars** | -| Subtitle list | Optional | Up to 3 subtitles, with each subtitle a single line of text. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size for each subtitle: max 50 chars** | -| Rating - Max value | Optional | The maximum value of the rating scale. **Must be provided if current value of rating is also provided.** | Number \>= 0.0 | -| Rating - Current value | Optional | The current value of the rating scale. **Must be provided if maximum value of rating is also provided.** | Number \>= 0.0 | -| Rating - Count | Optional | The count of the ratings for the point of interest. **Note:** Provide this field if your app controls how the count is displayed to the users. Use a concise string. For example, if the count is 1,000,000, consider using an abbreviation like 1M so that the count isn't truncated on smaller display sizes. | String | -| Rating - Count Value | Optional | The count of the ratings for the point of interest. **Note:** Provide this field if you aren't handling display abbreviation logic yourself. If both Count and Count Value are present, Count is displayed to users | Long | -| Price - CurrentPrice | Conditionally required | The current price of the tickets/entry pass for the point of interest. **Must be provided if strikethrough price is provided.** | Free text | -| Price - StrikethroughPrice | Optional | The original price of the tickets/entry pass for the point of interest. | Free text | -| Price Callout | Optional | Price callout to feature a promo, event, member discount, if available. | Free text **Recommended text size: under 45 chars (Text that is too long may show ellipses)** | -| Content Categories | Optional | Describe the category of the content in the entity. | List of Eligible Enums - TYPE_TRAVEL_AND_LOCAL - TYPE_MOVIES_AND_TV_SHOWS (Example - theater) - TYPE_MEDICAL (Example - hospital) - TYPE_EDUCATION (Example - school) - TYPE_SPORTS (Example - stadium) See the [Content Category section](https://developer.android.com/guide/playcore/engage/otherverticals#content-category) for guidance. | - -#### `PersonEntity` - -| Attribute | Requirement | Description | Format | -|---|---|---|---| -| Action Uri | **Required** | Deep Link to the entity in the provider app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | Uri | -| Profile - Name | **Required** | Profile name or id or handle, eg "John Doe", "@TeamPixel", etc. | String **Recommended text size: Max 50 chars** | -| Profile - Avatar | **Required** | Profile picture or avatar image of the user. **Note:**Must be Square 1:1 image. | See [Image Specifications](https://developer.android.com/guide/playcore/engage/otherverticals#image-specs) for guidance. | -| Profile - Additional Text | Optional | Free text like the profile handle. | Free text **Recommended text size: Max 15 chars** | -| Profile - Additional Image | Optional | Small image like a verified badge. | See [Image Specifications](https://developer.android.com/guide/playcore/engage/otherverticals#image-specs) for guidance. | -| Header image | Optional | We will show only 1 image when multiple images are provided. Recommended aspect ratio is 16:9 **Note:** Image is highly recommended. If a badge is provided, ensure safe space of 24 dps at both the top and bottom of the image | See [Image Specifications](https://developer.android.com/guide/playcore/engage/otherverticals#image-specs) for guidance. | -| Popularity - Count | Optional | Indicate the number of followers or popularity value, for example - "3.7 M". **Note:** If both Count and Count Value are provided, Count will be used | String **Recommended text size: max 20 chars for count + label combined** | -| Popularity - Count Value | Optional | The number of followers or popularity value. **Note:** Provide Count Value if your app doesn't want to handle logic on how a large number should be optimized for different display sizes. If both Count and Count Value are provided, Count will be used. | Long | -| Popularity - Label | Optional | Indicate what the popularity label is. For example - "Likes". | String **Recommended text size: Max 20 chars for count + label combined** | -| Popularity - Visual | Optional | Indicate what the interaction is for. For example - Image showing Likes icon, Emojis. Can provide more than 1 image, though not all may not be shown on all form factors. **Note:** Must be Square 1:1 image | See [Image Specifications](https://developer.android.com/guide/playcore/engage/otherverticals#image-specs) for guidance. | -| Rating - Max value | Required | The maximum value of the rating scale. **Must be provided if current value of rating is also provided.** | Number \>= 0.0 | -| Rating - Current value | Required | The current value of the rating scale. **Must be provided if maximum value of rating is also provided.** | Number \>= 0.0 | -| Rating - Count | Optional | The count of the ratings for the entity. **Note:** Provide this field if your app wants to control how this is displayed to the users. Provide the concise string that can be displayed to the user. For example, if the count is 1,000,000, consider using abbreviations like 1M, so that it won't be truncated on smaller display sizes. | String | -| Rating - Count Value | Optional | The count of the ratings for the entity. **Note:** Provide this field if you don't want to handle the display abbreviation logic yourself. If both Count and Count Value are present, we will use the Count to display to users | Long | -| Location - Country | Optional | The country where the person is located or serving. | Free text **Recommended text size: max \~20 chars** | -| Location - City | Optional | The city where the person is located or serving. | Free text **Recommended text size: max \~20 chars** | -| Location - Display Address | Optional | The address where the person is located or serving will be displayed to the user. | Free text **Recommended text size: max \~20 chars** | -| Location - Street Address | Optional | The street address (if applicable) where the person is located or serving. | Free text **Recommended text size: max \~20 chars** | -| Location - State | Optional | The state (if applicable) where the person is located or serving. | Free text **Recommended text size: max \~20 chars** | -| Location - Zip code | Optional | The zip code (if applicable) where the person is located or serving. | Free text **Recommended text size: max \~20 chars** | -| Location - Neighborhood | Optional | The neighborhood (if applicable) where the person is located or serving. | Free text **Recommended text size: max \~20 chars** | -| Badges | Optional | Each badge is either free text (max 15 chars) or small image. | | -| Badge - Text | Optional | Title for the badge **Note:** Either text or image is required for the badge | Free text **Recommended text size: max 15 chars** | -| Badge - Image | Optional | Small image Special UX treatment, for example as badge overlay on the image/video thumbnail. **Note:** Either text or image is required for the badge | See [Image Specifications](https://developer.android.com/guide/playcore/engage/otherverticals#image-specs) for guidance. | -| Description | Optional | A single paragraph of text to describe the entity. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size: 180 chars** | -| Subtitle list | Optional | Up to 3 subtitles, with each subtitle a single line of text. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size for each subtitle: max 50 chars** | -| Content Categories | Optional | Describe the category of the content in the entity. | List of Eligible Enums - TYPE_HEALTH_AND_FITENESS (Example - Yoga/fitness trainer) - TYPE_HOME_AND_AUTO (Example - Plumber) - TYPE_SPORTS (Example - Player) - TYPE_DATING See the [Content Category section](https://developer.android.com/guide/playcore/engage/otherverticals#content-category) for guidance. | - -#### `RestaurantReservationEntity` - -| Attribute | Requirement | Description | Format | -|---|---|---|---| -| Action Uri | **Required** | Deep Link to the entity in the provider app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | Uri | -| Title | **Required** | Title of the entity. | String **Recommended text size: Max 50 chars** | -| Reservation Start Time | **Required** | The epoch timestamp in milliseconds when the reservation is expected to start. | Epoch timestamp in milliseconds | -| Location - Country | **Required** | The country in which the restaurant is happening. | Free text **Recommended text size: max \~20 chars** | -| Location - City | **Required** | The city in which the restaurant is happening. | Free text **Recommended text size: max \~20 chars** | -| Location - Display Address | **Required** | The address of the prestaurant that will be displayed to the user. | Free text **Recommended text size: max \~20 chars** | -| Location - Street Address | Optional | The street address (if applicable) of the restaurant. | Free text **Recommended text size: max \~20 chars** | -| Location - State | Optional | The state or province (if applicable) in which the restaurant is located. | Free text **Recommended text size: max \~20 chars** | -| Location - Zip code | Optional | The zip code (if applicable) of the restaurant. | Free text **Recommended text size: max \~20 chars** | -| Location - Neighborhood | Optional | The neighborhood (if applicable) of the restaurant. | Free text **Recommended text size: max \~20 chars** | -| Poster images | Optional | We will show only 1 image when multiple images are provided. Recommended aspect ratio is 16:9 | See [Image Specifications](https://developer.android.com/guide/playcore/engage/otherverticals#image-specs) for guidance. | -| Description | Optional | A single paragraph of text to describe the entity. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size: 180 chars** | -| Subtitle list | Optional | Up to 3 subtitles, with each subtitle a single line of text. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size for each subtitle: max 50 chars** | -| Table Size | Optional | The number of people in the reservation group | Integer \> 0 | - -#### `EventReservationEntity` - -| Attribute | Requirement | Description | Format | -|---|---|---|---| -| Action Uri | **Required** | Deep Link to the entity in the provider app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | Uri | -| Title | **Required** | Title of the entity. | String **Recommended text size: Max 50 chars** | -| Start time | **Required** | The epoch timestamp when the event is expected to start. **Note:**This will be represented in milliseconds. | Epoch timestamp in milliseconds | -| Event mode | **Required** | A field to indicate whether the event will be virtual, in-person or both. | Enum: VIRTUAL, IN_PERSON, or HYBRID | -| Location - Country | Conditionally Required | The country in which the event is happening. **Note:** This is required for events which are IN_PERSON or HYBRID | Free text **Recommended text size: max \~20 chars** | -| Location - City | Conditionally Required | The city in which the event is happening. **Note:** This is required for events which are IN_PERSON or HYBRID | Free text **Recommended text size: max \~20 chars** | -| Location - Display Address | Conditionally Required | The address or venue name where the event will take place that should be displayed to the user. **Note:** This is required for events which are IN_PERSON or HYBRID | Free text **Recommended text size: max \~20 chars** | -| Location - Street Address | Optional | The street address (if applicable) of the location at which event is being hosted. | Free text **Recommended text size: max \~20 chars** | -| Location - State | Optional | The state or province (if applicable) in which the event is being hosted. | Free text **Recommended text size: max \~20 chars** | -| Location - Zip code | Optional | The zip code (if applicable) of the location in which the event is being hosted. | Free text **Recommended text size: max \~20 chars** | -| Location - Neighborhood | Optional | The neighborhood (if applicable) in which the event is being hosted. | Free text **Recommended text size: max \~20 chars** | -| Poster images | Optional | We will show only 1 image when multiple images are provided. Recommended aspect ratio is 16:9 **Note:** Image is highly recommended. If a badge is provided, ensure safe space of 24 dps at both the top and bottom of the image | See [Image Specifications](https://developer.android.com/guide/playcore/engage/otherverticals#image-specs) for guidance. | -| End time | Optional | The epoch timestamp when the event is expected to end. **Note:**This will be represented in milliseconds. | Epoch timestamp in milliseconds | -| Service Provider - Name | Optional | The name of the service provider. **Note:**Either text or image is required for the service provider. | Free text. For example, name of the event organizer/tour | -| Service Provider - Image | Optional | The logo/image of the service provider. **Note:**Either text or image is required for the service provider. | See [Image Specifications](https://developer.android.com/guide/playcore/engage/otherverticals#image-specs) for guidance. | -| Description | Optional | A single paragraph of text to describe the entity. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size: 180 chars** | -| Subtitle list | Optional | Up to 3 subtitles, with each subtitle a single line of text. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size for each subtitle: max 50 chars** | -| Badges | Optional | Each badge is either free text (max 15 chars) or small image. | | -| Badge - Text | Optional | Title for the badge **Note:** Either text or image is required for the badge | Free text **Recommended text size: max 15 chars** | -| Badge - Image | Optional | Small image Special UX treatment, for example as badge overlay on the image/video thumbnail. **Note:** Either text or image is required for the badge | See [Image Specifications](https://developer.android.com/guide/playcore/engage/otherverticals#image-specs) for guidance. | -| Reservation ID | Optional | The reservation ID for the event reservation. | Free text | -| Price - CurrentPrice | Conditionally required | The current price of the ticket/pass for the event. **Must be provided if strikethrough price is provided.** | Free text | -| Price - StrikethroughPrice | Optional | The original price of the ticket/pass for the event. | Free text | -| Price Callout | Optional | Price callout to feature a promo, event, member discount, if available. | Free text **Recommended text size: under 45 chars (Text that is too long may show ellipses)** | -| Rating - Max value | Optional | The maximum value of the rating scale. **Must be provided if current value of rating is also provided.** | Number \>= 0.0 | -| Rating - Current value | Optional | The current value of the rating scale. **Must be provided if maximum value of rating is also provided.** | Number \>= 0.0 | -| Rating - Count | Optional | The count of the ratings for the event. **Note:** Provide this field if your app wants to control how this is displayed to the users. Provide the concise string that can be displayed to the user. For example, if the count is 1,000,000, consider using abbreviations like 1M, so that it won't be truncated on smaller display sizes. | String | -| Rating - Count Value | Optional | The count of the ratings for the event. **Note:** Provide this field if you don't want to handle the display abbreviation logic yourself. If both Count and Count Value are present, we will use the Count to display to users | Long | -| Content Categories | Optional | Describe the category of the content in the entity. | List of Eligible Enums - TYPE_MOVIES_AND_TV_SHOWS (Example - Cinema) - TYPE_DIGITAL_GAMES (Example - eSports) - TYPE_MUSIC (Example - Concert) - TYPE_TRAVEL_AND_LOCAL (Example - Tour, festival) - TYPE_HEALTH_AND_FITENESS (Example - Yoga class) - TYPE_EDUCATION (Example - Class) - TYPE_SPORTS (Example - Football game) - TYPE_DATING (Example - meetup) See the [Content Category section](https://developer.android.com/guide/playcore/engage/otherverticals#content-category) for guidance. | - -#### `LodgingReservationEntity` - -| Attribute | Requirement | Description | Format | -|---|---|---|---| -| Action Uri | **Required** | Deep Link to the entity in the provider app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | Uri | -| Title | **Required** | Title of the entity. | Free text. For example, "Your Stay from Dec 12th" **Recommended text size: Max 50 chars** | -| Check-in Time | **Required** | The epoch timestamp in milliseconds that represents the check in time for the reservation. | Epoch timestamp in milliseconds | -| Check-out Time | **Required** | The epoch timestamp in milliseconds that represents the check out time for the reservation. | Epoch timestamp in milliseconds | -| Location - Country | **Required** | The country in which the lodging is located. | Free text **Recommended text size: max \~20 chars** | -| Location - City | **Required** | The city in which the lodging is located. | Free text **Recommended text size: max \~20 chars** | -| Location - Display Address | **Required** | The address of the lodging that will be displayed to the user. | Free text **Recommended text size: max \~20 chars** | -| Location - Street Address | Optional | The street address (if applicable) of the lodging. | Free text **Recommended text size: max \~20 chars** | -| Location - State | Optional | The state or province (if applicable) in which the lodging is located. | Free text **Recommended text size: max \~20 chars** | -| Location - Zip code | Optional | The zip code (if applicable) of the lodging. | Free text **Recommended text size: max \~20 chars** | -| Location - Neighborhood | Optional | The neighborhood (if applicable) of the lodging. | Free text **Recommended text size: max \~20 chars** | -| Poster images | Optional | We will show only 1 image when multiple images are provided. Recommended aspect ratio is 16:9 **Note:** If a badge is provided, ensure safe space of 24 dps at both the top and bottom of the image | See [Image Specifications](https://developer.android.com/guide/playcore/engage/otherverticals#image-specs) for guidance. | -| Description | Optional | A single paragraph of text to describe the entity. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size: 180 chars** | -| Subtitle list | Optional | Up to 3 subtitles, with each subtitle a single line of text. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size for each subtitle: max 50 chars** | -| Reservation ID | Optional | The reservation ID for the lodging reservation. | Free text | -| Rating - Max value | Optional | The maximum value of the rating scale. **Must be provided if current value of rating is also provided.** | Number \>= 0.0 | -| Rating - Current value | Optional | The current value of the rating scale. **Must be provided if maximum value of rating is also provided.** | Number \>= 0.0 | -| Rating - Count | Optional | The count of the ratings for the lodging. **Note:** Provide this field if your app wants to control how this is displayed to the users. Provide the concise string that can be displayed to the user. For example, if the count is 1,000,000, consider using abbreviations like 1M, so that it won't be truncated on smaller display sizes. | String | -| Rating - Count Value | Optional | The count of the ratings for the lodging. **Note:** Provide this field if you don't want to handle the display abbreviation logic yourself. If both Count and Count Value are present, we will use the Count to display to users | Long | -| Price - CurrentPrice | Conditionally required | The current price of the lodging. **Must be provided if strikethrough price is provided.** | Free text | -| Price - StrikethroughPrice | Optional | The original price of the lodging, which is be struck-through in the UI. | Free text | -| Price Callout | Optional | Price callout to feature a promo, event, member discount, if available. | Free text **Recommended text size: under 45 chars (Text that is too long may show ellipses)** | - -#### `TransportationReservationEntity` - -| Attribute | Requirement | Description | Format | -|---|---|---|---| -| Action Uri | **Required** | Deep Link to the entity in the provider app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | Uri | -| Title | **Required** | Title of the entity. | Free text. For example, "SFO to SAN" **Recommended text size: Max 50 chars** | -| Transportation Type | **Required** | The mode/type of transportation for the reservation. | Enum: FLIGHT, TRAIN, BUS, or FERRY | -| Departure Time | **Required** | The epoch timestamp in milliseconds that represents the departure time. | Epoch timestamp in milliseconds | -| Arrival Time | **Required** | The epoch timestamp in milliseconds that represents the arrival time. | Epoch timestamp in milliseconds | -| Departure Location - Country | Optional | The country of departure. | Free text **Recommended text size: max \~20 chars** | -| Departure Location - City | Optional | The city of departure. | Free text **Recommended text size: max \~20 chars** | -| Departure Location - Display Address | Optional | The location of departure that will be displayed to the user. | Free text **Recommended text size: max \~20 chars** | -| Departure Location - Street Address | Optional | The street address (if applicable) of the departure location. | Free text **Recommended text size: max \~20 chars** | -| Departure Location - State | Optional | The state or province (if applicable) of the departure location. | Free text **Recommended text size: max \~20 chars** | -| Departure Location - Zip code | Optional | The zip code (if applicable) of the departure location. | Free text **Recommended text size: max \~20 chars** | -| Departure Location - Neighborhood | Optional | The neighborhood (if applicable) of the departure location. | Free text **Recommended text size: max \~20 chars** | -| Arrival Location - Country | Optional | The country of arrival. | Free text **Recommended text size: max \~20 chars** | -| Arrival Location - City | Optional | The city of arrival. | Free text **Recommended text size: max \~20 chars** | -| Arrival Location - Display Address | Optional | The location of arrival that will be displayed to the user. | Free text **Recommended text size: max \~20 chars** | -| Arrival Location - Street Address | Optional | The street address (if applicable) of the arrival location. | Free text **Recommended text size: max \~20 chars** | -| Arrival Location - State | Optional | The state or province (if applicable) of the arrival location. | Free text **Recommended text size: max \~20 chars** | -| Arrival Location - Zip code | Optional | The zip code (if applicable) of the arrival location. | Free text **Recommended text size: max \~20 chars** | -| Arrival Location - Neighborhood | Optional | The neighborhood (if applicable) of the arrival location. | Free text **Recommended text size: max \~20 chars** | -| Service Provider - Name | Optional | The name of the service provider. **Note:**Either text or image is required for the service provider. | Free text. For example, Airline name | -| Service Provider - Image | Optional | The logo/image of the service provider. **Note:**Either text or image is required for the service provider. | See [Image Specifications](https://developer.android.com/guide/playcore/engage/otherverticals#image-specs) for guidance. | -| Poster images | Optional | We will show only 1 image when multiple images are provided. Recommended aspect ratio is 16:9 | See [Image Specifications](https://developer.android.com/guide/playcore/engage/otherverticals#image-specs) for guidance. | -| Description | Optional | A single paragraph of text to describe the entity. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size: 180 chars** | -| Subtitle list | Optional | Up to 3 subtitles, with each subtitle a single line of text. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size for each subtitle: max 50 chars** | -| Reservation ID | Optional | The reservation ID for the transportation reservation. | Free text | -| Price - CurrentPrice | Conditionally required | The current price of the reservation. **Must be provided if strikethrough price is provided.** | Free text | -| Price - StrikethroughPrice | Optional | The original price of the reservation, which is be struck-through in the UI. | Free text | -| Price Callout | Optional | Price callout to feature a promo, event, member discount, if available. | Free text **Recommended text size: under 45 chars (Text that is too long may show ellipses)** | -| Transportation Number | Required | The flight number, bus number, train number, or ferry/cruise number. | Free text | -| Boarding Time | Required | The epoch timestamp that represents the boarding time for the reservation (if applicable) | Epoch timestamp in milliseconds | - -#### `VehicleRentalReservationEntity` - -| Attribute | Requirement | Description | Format | -|---|---|---|---| -| Action Uri | **Required** | Deep Link to the entity in the provider app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | Uri | -| Title | **Required** | Title of the entity. | Free text. For example, "Avis Union Square SF" **Recommended text size: Max 50 chars** | -| Pickup Time | **Required** | The epoch timestamp that represents the pick up time for the reservation. | Epoch timestamp in milliseconds | -| Return Time | Optional | The epoch timestamp that represents the check out time for the reservation. | Epoch timestamp in milliseconds | -| Pickup Address - Country | Optional | The country of the pickup location. | Free text **Recommended text size: max \~20 chars** | -| Pickup Address - City | Optional | The city of the pickup location. | Free text **Recommended text size: max \~20 chars** | -| Pickup Address - Display Address | Optional | The pickup location that will be displayed to the user. | Free text **Recommended text size: max \~20 chars** | -| Pickup Address - Street Address | Optional | The street address (if applicable) of the pickup location. | Free text **Recommended text size: max \~20 chars** | -| Pickup Address - State | Optional | The state or province (if applicable) of the pickup location. | Free text **Recommended text size: max \~20 chars** | -| Pickup Address - Zip code | Optional | The zip code (if applicable) of the pickup location. | Free text **Recommended text size: max \~20 chars** | -| Pickup Address - Neighborhood | Optional | The neighborhood (if applicable) of the pickup location. | Free text **Recommended text size: max \~20 chars** | -| Return Address - Country | Optional | The country of return location. | Free text **Recommended text size: max \~20 chars** | -| Return Address - City | Optional | The city of return location. | Free text **Recommended text size: max \~20 chars** | -| Return Address - Display Address | Optional | The return location that will be displayed to the user. | Free text **Recommended text size: max \~20 chars** | -| Return Address - Street Address | Optional | The street address (if applicable) of the return location. | Free text **Recommended text size: max \~20 chars** | -| Return Address - State | Optional | The state or province (if applicable) of the return location. | Free text **Recommended text size: max \~20 chars** | -| Return Address - Zip code | Optional | The zip code (if applicable) of the return location. | Free text **Recommended text size: max \~20 chars** | -| Return Address - Neighborhood | Optional | The neighborhood (if applicable) of the return location. | Free text **Recommended text size: max \~20 chars** | -| Service Provider - Name | Optional | The name of the service provider. **Note:**Either text or image is required for the service provider. | Free text. For example, "Avis Car Rental" | -| Service Provider - Image | Optional | The logo/image of the service provider. **Note:**Either text or image is required for the service provider. | See [Image Specifications](https://developer.android.com/guide/playcore/engage/otherverticals#image-specs) for guidance. | -| Poster images | Optional | We will show only 1 image when multiple images are provided. Recommended aspect ratio is 16:9 | See [Image Specifications](https://developer.android.com/guide/playcore/engage/otherverticals#image-specs) for guidance. | -| Description | Optional | A single paragraph of text to describe the entity. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size: 180 chars** | -| Subtitle list | Optional | Up to 3 subtitles, with each subtitle a single line of text. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size for each subtitle: max 50 chars** | -| Confirmation ID | Optional | The confirmation ID for the vehicle rental reservation. | Free text | -| Price - CurrentPrice | Conditionally required | The current price of the reservation. **Must be provided if strikethrough price is provided.** | Free text | -| Price - StrikethroughPrice | Optional | The original price of the reservation, which is be struck-through in the UI. | Free text | -| Price Callout | Optional | Price callout to feature a promo, event, member discount, if available. | Free text **Recommended text size: under 45 chars (Text that is too long may show ellipses)** | - -#### Image specifications - -Required specifications for image assets are listed in this table: - -| Aspect ratio | Minimum pixels | Recommended pixels | -|---|---|---| -| Square (1x1) **Preferred** | 300x300 | 1200x1200 | -| Landscape (1.91x1) | 600x314 | 1200x628 | -| Portrait (4x5) | 480x600 | 960x1200 | - -The images are required to be hosted on public CDNs so that Google can access -them. - -*File formats* - -PNG, JPG, static GIF, WebP - -*Maximum file size* - -5120 KB - -*Additional recommendations* - -- **Image safe area:** Put your important content in the center 80% of the image. -- Use a transparent background so that the image can be properly displayed in Dark and Light theme settings. - -#### Content Category - -The content category allows apps to publish content belonging to multiple -categories. This maps the content with some of the predefined categories namely: - -- `TYPE_EDUCATION` -- `TYPE_SPORTS` -- `TYPE_MOVIES_AND_TV_SHOWS` -- `TYPE_BOOKS` -- `TYPE_AUDIOBOOKS` -- `TYPE_MUSIC` -- `TYPE_DIGITAL_GAMES` -- `TYPE_TRAVEL_AND_LOCAL` -- `TYPE_HOME_AND_AUTO` -- `TYPE_BUSINESS` -- `TYPE_NEWS` -- `TYPE_FOOD_AND_DRINK` -- `TYPE_SHOPPING` -- `TYPE_HEALTH_AND_FITENESS` -- `TYPE_MEDICAL` -- `TYPE_PARENTING` -- `TYPE_DATING` - -The images are required to be hosted on public CDNs so that Google can access -them. - -*Guidelines to use the content categories* - -1. Some entities like **ArticleEntity** and **GenericFeaturedEntity** are eligible to use any of the content categories. For other entities like **EventEntity** , **EventReservationEntity** , **PointOfInterestEntity**, only a subset of these categories are eligible. Check the list of categories eligible for an entity type before populating the list. -2. Use the specific entity type for some content categories over a combination - of the Generic entities and the ContentCategory: - - - TYPE_MOVIES_AND_TV_SHOWS - Check out the entities from [Watch integration guide](https://developer.android.com/guide/playcore/engage/watch) before using the generic entities. - - TYPE_BOOKS - Check out the [EbookEntity](https://developer.android.com/guide/playcore/engage/read#ebookentity) before using the generic entities. - - TYPE_AUDIOBOOKS - Check out [AudiobookEntity](https://developer.android.com/guide/playcore/engage/read#audiobookentity) before using the generic entities. - - TYPE_SHOPPING - Check out [ShoppingEntity](https://developer.android.com/guide/playcore/engage/shopping#shoppingEntity) before using the generic entities. - - TYPE_FOOD_AND_DRINK - Check out entities from [Food Integration guide](https://developer.android.com/guide/playcore/engage/food) before using the generic entities. -3. The ContentCategory field is optional and should be left blank if the - content doesn't belong to any of the categories mentioned earlier. - -4. In case multiple content categories are provided, provide them in the order - of relevance to the content with the most relevant content category placed - first in the list. - -### Step 2: Provide Cluster data - -It is recommended to have the content publish job executed in the background -(for example, using [WorkManager](https://developer.android.com/topic/libraries/architecture/workmanager)) -and scheduled on a regular basis or on an event basis (for example, every time -the user opens the app or when the user just added something to their cart). - -`AppEngagePublishClient` is responsible for publishing clusters. - -There are following APIs to publish clusters in the client: - -- `isServiceAvailable` -- `publishRecommendationClusters` -- `publishFeaturedCluster` -- `publishContinuationCluster` -- `publishUserAccountManagementRequest` -- `updatePublishStatus` -- `deleteRecommendationsClusters` -- `deleteFeaturedCluster` -- `deleteContinuationCluster` -- `deleteUserManagementCluster` -- `deleteClusters` - -#### `isServiceAvailable` - -This API is used to check if the service is available for integration and -whether the content can be presented on the device. - -##### For Engage SDK v1.6.0 and higher (Recommended) - - -## Android skills - -[View on GitHub](https://github.com/android/skills/tree/main/play/engage-sdk-integration) - -### Engage SDK integration - -To install the skill from the [Android CLI](https://developer.android.com/tools/agents/android-cli), run: - - android skills add engage-sdk-integration - -If your team uses AI coding tools (such as Gemini in Android Studio), you can automate this migration by prompting your AI assistant: - - Use the engage-sdk-integration skill to use Engage SDK 1.6.0 and refactor isServiceAvailable to pass ServiceAvailabilityRequest for publishing all cluster types. - -
- -You can check the service availability for every cluster type that you intend to -publish. The `isServiceAvailable` API accepts a request object, -`ServiceAvailabilityRequest`, which contains the cluster types for which service -availability needs to be checked. You can find the `ClusterType` enum values -required for `ServiceAvailabilityRequest` from the following table. - -| Cluster Type | Cluster Type Constant | Integer Value | -|---|---|---| -| Unknown | `TYPE_UNKNOWN` | 0 | -| Recommendation Cluster | `TYPE_RECOMMENDATION` | 1 | -| Featured Cluster | `TYPE_FEATURED` | 2 | -| Continuation Cluster | `TYPE_CONTINUATION` | 3 | -| Shopping Cart Cluster | `TYPE_SHOPPING_CART` | 4 | -| Food Reorder Cluster | `TYPE_FOOD_REORDER` | 5 | -| Food Shopping Cart Cluster | `TYPE_FOOD_SHOPPING_CART` | 6 | -| Food Shopping List Cluster | `TYPE_FOOD_SHOPPING_LIST` | 7 | -| User Management Cluster | `TYPE_ENGAGEMENT` | 8 | -| Shopping List Cluster | `TYPE_SHOPPING_LIST` | 9 | -| Shopping Reorder Cluster | `TYPE_SHOPPING_REORDER` | 10 | -| Shopping Order Tracking Cluster | `TYPE_SHOPPING_ORDER_TRACKING` | 11 | -| Subscription Cluster | `TYPE_SUBSCRIPTION` | 12 | -| Continue Search Cluster | `TYPE_CONTINUE_SEARCH` | 13 | -| Reservation Cluster | `TYPE_RESERVATION` | 14 | - -### Kotlin - - val request = ServiceAvailabilityRequest.Builder() - .addIntendedClusterType(ClusterType.TYPE_CONTINUATION) - .addIntendedClusterType(ClusterType.TYPE_RECOMMENDATION) - .build() - - client.isServiceAvailable(request).addOnCompleteListener { task -> - if (task.isSuccessful) { - val availabilityMap = task.result - if (availabilityMap[ClusterType.TYPE_CONTINUATION] == true) { - // Proceed with publishing continuation content - } - if (availabilityMap[ClusterType.TYPE_RECOMMENDATION] == true) { - // Proceed with publishing recommendation content - } - } else { - // The IPC call itself fails, proceed with error handling logic here, - // such as retry. - } - } - -### Java - - ServiceAvailabilityRequest request = - new ServiceAvailabilityRequest.Builder() - .addIntendedClusterType(ClusterType.TYPE_CONTINUATION) - .addIntendedClusterType(ClusterType.TYPE_RECOMMENDATION) - .build(); - - client.isServiceAvailable(request).addOnCompleteListener(task -> { - if (task.isSuccessful()) { - Map availabilityMap = task.getResult(); - if (Boolean.TRUE.equals(availabilityMap.get(ClusterType.TYPE_CONTINUATION))) { - // Proceed with publishing continuation content - } - if (Boolean.TRUE.equals(availabilityMap.get(ClusterType.TYPE_RECOMMENDATION))) { - // Proceed with publishing recommendation content - } - } else { - // The IPC call itself fails, proceed with error handling logic here, - // such as retry. - } - }); - -###### Conditional Service Availability Feature - -Some integrated apps request a special configuration that enables and disables -the Engage service intermittently in order to reduce their serving cost. This -intermittent content ingestion strategy, although possible, negatively affects -the user and the product -- stale content will not be presented and some surfaces -will not be served at all. - -Starting with v1.6.0, the Engage SDK allows checking availability for specific -cluster types. This provides more flexibility so that if the intermittent -content strategy was adopted by a given application, some cluster types can -follow that intermittent strategy while other cluster types are always enabled -(i.e. continuation clusters). - -If the Engage service should not be 'continuously' enabled on all supported -devices for whatever reason, and is configured for intermittent ingestion for -any set of devices, all continuation cluster publications (e.g. Continue -Reading and Reservations) will be still enabled by default configuration, -and the rest of the cluster types will be enabled and disabled -intermittently. If intermittent ingestion applies to you but this default -configuration is not suitable for your needs, please contact -engage-developers@google.com. - -##### For SDK versions prior to v1.6.0 (Deprecated) - -### Kotlin - - client.isServiceAvailable.addOnCompleteListener { task -> - if (task.isSuccessful) { - // Handle IPC call success - if(task.result) { - // Service is available on the device, proceed with content publish - // calls. - } else { - // Service is not available, no further action is needed. - } - } else { - // The IPC call itself fails, proceed with error handling logic here, - // such as retry. - } - } - -### Java - - client.isServiceAvailable().addOnCompleteListener(task - > { - if (task.isSuccessful()) { - // Handle success - if(task.getResult()) { - // Service is available on the device, proceed with content publish - // calls. - } else { - // Service is not available, no further action is needed. - } - } else { - // The IPC call itself fails, proceed with error handling logic here, - // such as retry. - } - }); - -> [!NOTE] -> **Note:** We highly recommend keeping a periodic job running to check if the service becomes available at a later point in time. The availability of the service may change with Android version upgrades, app upgrades, installs, and uninstalls. By ensuring periodic job checks at a certain time interval, data can be published once the service becomes available. - -#### `publishRecommendationClusters` - -This API is used to publish a list of `RecommendationCluster` objects. - -> [!IMPORTANT] -> **Important:** The publish APIs are upsert APIs; it replaces the existing content. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. - -### Kotlin - - client.publishRecommendationClusters( - PublishRecommendationClustersRequest.Builder() - .addRecommendationCluster( - RecommendationCluster.Builder() - .addEntity(entity1) - .addEntity(entity2) - .setTitle("Top Picks For You") - .build() - ) - .build() - ) - -### Java - - client.publishRecommendationClusters( - new PublishRecommendationClustersRequest.Builder() - .addRecommendationCluster( - new RecommendationCluster.Builder() - .addEntity(entity1) - .addEntity(entity2) - .setTitle("Top Picks For You") - .build()) - .build()); - -When the service receives the request, the following actions take place within -one transaction: - -- Existing `RecommendationCluster` data from the developer partner is removed. -- Data from the request is parsed and stored in the updated Recommendation Cluster. - -In case of an error, the entire request is rejected and the existing state is -maintained. - -#### `publishFeaturedCluster` - -This API is used to publish a list of `FeaturedCluster` objects. - -> [!IMPORTANT] -> **Important:** The publish APIs are upsert APIs; it replaces the existing content. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. - -### Kotlin - - client.publishFeaturedCluster( - PublishFeaturedClusterRequest.Builder() - .setFeaturedCluster( - FeaturedCluster.Builder() - .addEntity(entity1) - .addEntity(entity2) - .build()) - .build()) - -### Java - - client.publishFeaturedCluster( - new PublishFeaturedClustersRequest.Builder() - .addFeaturedCluster( - new FeaturedCluster.Builder() - .addEntity(entity1) - .addEntity(entity2) - .build()) - .build()); - -When the service receives the request, the following actions take place within -one transaction: - -- Existing `FeaturedCluster` data from the developer partner is removed. -- Data from the request is parsed and stored in the updated Featured Cluster. - -In case of an error, the entire request is rejected and the existing state is -maintained. - -#### `publishContinuationCluster` - -This API is used to publish a `ContinuationCluster` object. - -> [!IMPORTANT] -> **Important:** The publish APIs are upsert APIs; it replaces the existing content. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. - -### Kotlin - - client.publishContinuationCluster( - PublishContinuationClusterRequest.Builder() - .setContinuationCluster( - ContinuationCluster.Builder() - .addEntity(entity1) - .addEntity(entity2) - .build()) - .build()) - -### Java - - client.publishContinuationCluster( - new PublishContinuationClusterRequest.Builder() - .setContinuationCluster( - new ContinuationCluster.Builder() - .addEntity(entity1) - .addEntity(entity2) - .build()) - .build()); - -When the service receives the request, the following actions take place within -one transaction: - -- Existing `ContinuationCluster` data from the developer partner is removed. -- Data from the request is parsed and stored in the updated Continuation Cluster. - -In case of an error, the entire request is rejected and the existing state is -maintained. - -#### `publishUserAccountManagementRequest` - -This API is used to publish a Sign In card . The signin action directs users to -the app's sign in page so that the app can publish content (or provide more -personalized content) - -The following metadata is part of the Sign In Card - - -| Attribute | Requirement | Description | -|---|---|---| -| Action Uri | Required | Deeplink to Action (i.e. navigates to app sign in page) | -| Image | Optional - If not provided, Title must be provided | Image Shown on the Card 16x9 aspect ratio images with a resolution of 1264x712 | -| Title | Optional - If not provided, Image must be provided | Title on the Card | -| Action Text | Optional | Text Shown on the CTA (i.e. Sign in) | -| Subtitle | Optional | Optional Subtitle on the Card | - -> [!IMPORTANT] -> **Important:** The publish APIs are upsert APIs; it replaces the existing content. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. - -### Kotlin - - var SIGN_IN_CARD_ENTITY = - SignInCardEntity.Builder() - .addPosterImage( - Image.Builder() - .setImageUri(Uri.parse("http://www.x.com/image.png")) - .setImageHeightInPixel(500) - .setImageWidthInPixel(500) - .build()) - .setActionText("Sign In") - .setActionUri(Uri.parse("http://xx.com/signin")) - .build() - - client.publishUserAccountManagementRequest( - PublishUserAccountManagementRequest.Builder() - .setSignInCardEntity(SIGN_IN_CARD_ENTITY) - .build()); - -### Java - - SignInCardEntity SIGN_IN_CARD_ENTITY = - new SignInCardEntity.Builder() - .addPosterImage( - new Image.Builder() - .setImageUri(Uri.parse("http://www.x.com/image.png")) - .setImageHeightInPixel(500) - .setImageWidthInPixel(500) - .build()) - .setActionText("Sign In") - .setActionUri(Uri.parse("http://xx.com/signin")) - .build(); - - client.publishUserAccountManagementRequest( - new PublishUserAccountManagementRequest.Builder() - .setSignInCardEntity(SIGN_IN_CARD_ENTITY) - .build()); - -When the service receives the request, the following actions take place within -one transaction: - -- Existing `UserAccountManagementCluster` data from the developer partner is removed. -- Data from the request is parsed and stored in the updated UserAccountManagementCluster Cluster. - -In case of an error, the entire request is rejected and the existing state is -maintained. - -#### `updatePublishStatus` - -If for any internal business reason, none of the clusters is published, we -**strongly recommend** updating the publish status using the -**updatePublishStatus** API. This is important because : - -- Providing the status in all scenarios, even when the content is published (STATUS == PUBLISHED), is critical to populate dashboards that use this explicit status to convey the health and other metrics of your integration. -- If no content is published but the integration status isn't broken (STATUS == NOT_PUBLISHED), Google can avoid triggering alerts in the app health dashboards. It confirms that content is not published due to an **expected** situation from the provider's standpoint. -- It helps developers provide insights into when the data is published versus not. -- Google may use the status codes to nudge the user to do certain actions in the app so they can see the app content or overcome it. - -The list of eligible publish status codes are : - - // Content is published - AppEngagePublishStatusCode.PUBLISHED, - - // Content is not published as user is not signed in - AppEngagePublishStatusCode.NOT_PUBLISHED_REQUIRES_SIGN_IN, - - // Content is not published as user is not subscribed - AppEngagePublishStatusCode.NOT_PUBLISHED_REQUIRES_SUBSCRIPTION, - - // Content is not published as user location is ineligible - AppEngagePublishStatusCode.NOT_PUBLISHED_INELIGIBLE_LOCATION, - - // Content is not published as there is no eligible content - AppEngagePublishStatusCode.NOT_PUBLISHED_NO_ELIGIBLE_CONTENT, - - // Content is not published as the feature is disabled by the client - // Available in v1.3.1 - AppEngagePublishStatusCode.NOT_PUBLISHED_FEATURE_DISABLED_BY_CLIENT, - - // Content is not published as the feature due to a client error - // Available in v1.3.1 - AppEngagePublishStatusCode.NOT_PUBLISHED_CLIENT_ERROR, - - // Content is not published as the feature due to a service error - // Available in v1.3.1 - AppEngagePublishStatusCode.NOT_PUBLISHED_SERVICE_ERROR, - - // Content is not published due to some other reason - // Reach out to engage-developers@ before using this enum. - AppEngagePublishStatusCode.NOT_PUBLISHED_OTHER - -If the content is not published due to a user not logged in, Google would -recommend publishing the Sign In Card. If for any reason providers are not able -to publish the Sign In Card then we recommend calling the -**updatePublishStatus** API with the status code -**NOT_PUBLISHED_REQUIRES_SIGN_IN** - -### Kotlin - - client.updatePublishStatus( - PublishStatusRequest.Builder() - .setStatusCode(AppEngagePublishStatusCode.NOT_PUBLISHED_REQUIRES_SIGN_IN) - .build()) - -### Java - - client.updatePublishStatus( - new PublishStatusRequest.Builder() - .setStatusCode(AppEngagePublishStatusCode.NOT_PUBLISHED_REQUIRES_SIGN_IN) - .build()); - -#### `deleteRecommendationClusters` - -This API is used to delete the content of Recommendation Clusters. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteRecommendationClusters() - -### Java - - client.deleteRecommendationClusters(); - -When the service receives the request, it removes the existing data from the -Recommendation Clusters. In case of an error, the entire request is rejected and -the existing state is maintained. - -> [!NOTE] -> **Note:** This api is available from version 1.1.0 onwards. - -#### `deleteFeaturedCluster` - -This API is used to delete the content of Featured Cluster. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteFeaturedCluster() - -### Java - - client.deleteFeaturedCluster(); - -When the service receives the request, it removes the existing data from the -Featured Cluster. In case of an error, the entire request is rejected and the -existing state is maintained. - -> [!NOTE] -> **Note:** This api is available from version 1.1.0 onwards. - -#### `deleteContinuationCluster` - -This API is used to delete the content of Continuation Cluster. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteContinuationCluster() - -### Java - - client.deleteContinuationCluster(); - -When the service receives the request, it removes the existing data from the -Continuation Cluster. In case of an error, the entire request is rejected and -the existing state is maintained. - -> [!NOTE] -> **Note:** This api is available from version 1.1.0 onwards. - -#### `deleteUserManagementCluster` - -This API is used to delete the content of UserAccountManagement Cluster. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteUserManagementCluster() - -### Java - - client.deleteUserManagementCluster(); - -When the service receives the request, it removes the existing data from the -UserAccountManagement Cluster. In case of an error, the entire request is -rejected and the existing state is maintained. - -> [!NOTE] -> **Note:** This api is available from version 1.1.0 onwards. - -#### `deleteClusters` - -This API is used to delete the content of a given cluster type. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteClusters( - DeleteClustersRequest.Builder() - .addClusterType(ClusterType.TYPE_CONTINUATION) - .addClusterType(ClusterType.TYPE_FEATURED) - .addClusterType(ClusterType.TYPE_RECOMMENDATION) - .build()) - -### Java - - client.deleteClusters( - new DeleteClustersRequest.Builder() - .addClusterType(ClusterType.TYPE_CONTINUATION) - .addClusterType(ClusterType.TYPE_FEATURED) - .addClusterType(ClusterType.TYPE_RECOMMENDATION) - .build()); - -When the service receives the request, it removes the existing data from all -clusters matching the specified cluster types. Clients can choose to pass one or -many cluster types. In case of an error, the entire request is rejected and the -existing state is maintained. - -#### Error handling - -It is highly recommended to listen to the task result from the publish APIs such -that a follow-up action can be taken to recover and resubmit an successful task. - -### Kotlin - - client.publishRecommendationClusters( - PublishRecommendationClustersRequest.Builder() - .addRecommendationCluster(..) - .build()) - .addOnCompleteListener { task -> - if (task.isSuccessful) { - // do something - } else { - val exception = task.exception - if (exception is AppEngageException) { - @AppEngageErrorCode val errorCode = exception.errorCode - if (errorCode == AppEngageErrorCode.SERVICE_NOT_FOUND) { - // do something - } - } - } - } - -### Java - - client.publishRecommendationClusters( - new PublishRecommendationClustersRequest.Builder() - .addRecommendationCluster(...) - .build()) - .addOnCompleteListener( - task -> { - if (task.isSuccessful()) { - // do something - } else { - Exception exception = task.getException(); - if (exception instanceof AppEngageException) { - @AppEngageErrorCode - int errorCode = ((AppEngageException) exception).getErrorCode(); - if (errorCode == AppEngageErrorCode.SERVICE_NOT_FOUND) { - // do something - } - } - } - }); - -The error is returned as an `AppEngageException` with the cause included as an -error code. - -| Error code | Error name | Note | -|---|---|---| -| `1` | `SERVICE_NOT_FOUND` | The service is not available on the given device. | -| `2` | `SERVICE_NOT_AVAILABLE` | The service is available on the given device, but it is not available at the time of the call (for example, it is explicitly disabled). | -| `3` | `SERVICE_CALL_EXECUTION_FAILURE` | The task execution failed due to threading issues. In this case, it can be retried. | -| `4` | `SERVICE_CALL_PERMISSION_DENIED` | The caller is not allowed to make the service call. | -| `5` | `SERVICE_CALL_INVALID_ARGUMENT` | The request contains invalid data (for example, more than the allowed number of clusters). | -| `6` | `SERVICE_CALL_INTERNAL` | There is an error on the service side. | -| `7` | `SERVICE_CALL_RESOURCE_EXHAUSTED` | The service call is made too frequently. | - -### Step 3: Handle broadcast intents - -In addition to making publish content API calls through a job, it is also -required to set up a -[`BroadcastReceiver`](https://developer.android.com/reference/android/content/BroadcastReceiver) to receive -the request for a content publish. - -The goal of broadcast intents is mainly for app reactivation and forcing data -sync. Broadcast intents are not designed to be sent very frequently. It is only -triggered when the Engage Service determines the content might be stale (for -example, a week old). That way, there is more confidence that the user can have -a fresh content experience, even if the application has not been executed for a -long period of time. - -The `BroadcastReceiver` must be set up in the following two ways: - -- Dynamically register an instance of the `BroadcastReceiver` class using - `Context.registerReceiver()`. This enables communication from applications - that are still live in memory. - -### Kotlin - - class AppEngageBroadcastReceiver : BroadcastReceiver(){ - // Trigger recommendation cluster publish when PUBLISH_RECOMMENDATION broadcast - // is received - // Trigger featured cluster publish when PUBLISH_FEATURED broadcast is received - // Trigger continuation cluster publish when PUBLISH_CONTINUATION broadcast is - // received - } - - fun registerBroadcastReceivers(context: Context){ - var context = context - context = context.applicationContext - - // Register Recommendation Cluster Publish Intent - context.registerReceiver(AppEngageBroadcastReceiver(), - IntentFilter(Intents.ACTION_PUBLISH_RECOMMENDATION), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null) - - // Register Featured Cluster Publish Intent - context.registerReceiver(AppEngageBroadcastReceiver(), - IntentFilter(Intents.ACTION_PUBLISH_FEATURED), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null) - - // Register Continuation Cluster Publish Intent - context.registerReceiver(AppEngageBroadcastReceiver(), - IntentFilter(Intents.ACTION_PUBLISH_CONTINUATION), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null) - } - -### Java - - class AppEngageBroadcastReceiver extends BroadcastReceiver { - // Trigger recommendation cluster publish when PUBLISH_RECOMMENDATION broadcast - // is received - - // Trigger featured cluster publish when PUBLISH_FEATURED broadcast is received - - // Trigger continuation cluster publish when PUBLISH_CONTINUATION broadcast is - // received - } - - public static void registerBroadcastReceivers(Context context) { - - context = context.getApplicationContext(); - - // Register Recommendation Cluster Publish Intent - context.registerReceiver(new AppEngageBroadcastReceiver(), - new IntentFilter(com.google.android.engage.service.Intents.ACTION_PUBLISH_RECOMMENDATION), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null); - - // Register Featured Cluster Publish Intent - context.registerReceiver(new AppEngageBroadcastReceiver(), - new IntentFilter(com.google.android.engage.service.Intents.ACTION_PUBLISH_FEATURED), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null); - - // Register Continuation Cluster Publish Intent - context.registerReceiver(new AppEngageBroadcastReceiver(), - new IntentFilter(com.google.android.engage.service.Intents.ACTION_PUBLISH_CONTINUATION), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null); - } - -- Statically declare an implementation with the `` tag in your - `AndroidManifest.xml` file. This allows the application to receive broadcast - intents when it is not running, and also allows the application to publish - the content. - - - - - - - - - - - - - - - -The following [intents](https://developer.android.com/reference/android/content/Intent) is sent by the -service: - -- `com.google.android.engage.action.PUBLISH_RECOMMENDATION` It is recommended to start a `publishRecommendationClusters` call when receiving this intent. -- `com.google.android.engage.action.PUBLISH_FEATURED` It is recommended to start a `publishFeaturedCluster` call when receiving this intent. -- `com.google.android.engage.action.PUBLISH_CONTINUATION` It is recommended to start a `publishContinuationCluster` call when receiving this intent. - -## Integration workflow - -For a step-by-step guide on verifying your integration after it is complete, see -[Engage developer integration workflow](https://developer.android.com/guide/playcore/engage/workflow). - -## FAQs - -See [Engage SDK Frequently Asked Questions](https://developer.android.com/guide/playcore/engage/faq) for -FAQs. - -## Contact - -Contact -[`engage-developers@google.com`](mailto:engage-developers@google.com) if there are -any questions during the integration process. - -## Next steps - -After completing this integration, your next steps are as follows: - -- Send an email to [`engage-developers@google.com`](mailto:engage-developers@google.com) and attach your integrated APK that is ready for testing by Google. -- Google performs a verification and reviews internally to make sure the integration works as expected. If changes are needed, Google contacts you with any necessary details. -- When testing is complete and no changes are needed, Google contacts you to notify you that you can start publishing the updated and integrated APK to the Play Store. -- After Google has confirmed that your updated APK has been published to the Play Store, your **Recommendation** , **Featured** , and **Continuation** clusters may be published and visible to users. \ No newline at end of file diff --git a/play/engage-sdk-integration/references/android/guide/playcore/engage/read.md b/play/engage-sdk-integration/references/android/guide/playcore/engage/read.md deleted file mode 100644 index 9c34f6f..0000000 --- a/play/engage-sdk-integration/references/android/guide/playcore/engage/read.md +++ /dev/null @@ -1,954 +0,0 @@ -> [!IMPORTANT] -> **Important:** Engage SDK has superseded Media Home, which is now deprecated. If you have an existing Media Home integration, follow the instructions in these guides to migrate your content to Engage SDK, which allows your content to be published to more devices and form factors. Please contact [`engage-developers@google.com`](mailto:engage-developers@google.com) if you have any questions. - -Boost app engagement by reaching your users where they are. Integrate Engage SDK -to deliver personalized recommendations and continuation content directly to -users across multiple on-device surfaces, like -**[Collections](https://android-developers.googleblog.com/2024/07/introducing-collections-powered-by-engage-sdk.html)** , **[Entertainment -Space](https://blog.google/products/android/entertainment-space/)** , and the Play Store. The integration adds -less than 50 KB (compressed) to the average APK and takes most apps about a -week of developer time. Learn more at our **[business -site](http://play.google.com/console/about/programs/EngageSDK)**. - -This guide contains instructions for developer partners to deliver reading -content (eBooks, Audiobooks, Comics/Manga, Blogs, News and Magazines) to Engage -content surfaces. - -## Category support and surfaces - -Engage SDK surface eligibility is based on your app's content category. Use the -following table to determine your eligibility for specific surfaces: - -| Status | Content category or use case | Supported surfaces | -|---|---|---| -| **Supported** (Eligible on all surfaces) | - eBooks - Comics \& Manga - Audiobooks - UGC long-form reading services - Blogs | - Collections - Entertainment Space - Play Store Apps tab - Play Store You tab (Personalized recommendations \& continuation only) - Play Store store listing page | -| **Partially supported** (Eligible on some surfaces) | - News \& Magazines\* | - Collections - Entertainment Space | -| **Not supported** | - eBook or PDF reader - Book reviews - Encyclopedia / Dictionary - Translator - Religious text | | - -### News apps - -News apps provide diverse content, each subject to unique eligibility rules. -Because Engage SDK content on the Play Store aligns with Play Premium Growth -Tools, news articles and video clips from news apps are ineligible for the Play -Store, though they are eligible for Collections and Entertainment Space. -Conversely, non-traditional news content, like podcasts and recipes, may be -eligible for the Play Store: - -| Entity type | Eligible for Collections | Eligible for Entertainment Space | Eligible for Play Store | -|---|---|---|---| -| News article | *Yes (Read tab)* | *Yes (Read tab)* | No | -| Video clip | *Yes (Watch tab)* | *Yes (Watch tab)* | No | -| Podcast | *Yes (Listen tab)* | *Yes (Listen tab)* | *Yes* | -| Recipe | *Yes (Food tab)* | No | *Yes* | - -> [!NOTE] -> **Note:** Play Store eligibility aligns with the supported categories for [Play Premium Growth Tools](https://play.google.com/console/about/guides/premium-growth-tools/). If your news app has additional forms of content not listed in the preceding table, contact [`engage-developers@google.com`](mailto:engage-developers@google.com) to clarify its eligibility for the Play Store. - - -## Android skills - -[View on GitHub](https://github.com/android/skills/tree/main/play/engage-sdk-integration) - -### Integrate Engage SDK - -Use the Engage SDK [Android skill](https://developer.android.com/tools/agents/android-skills) to integrate, debug, and resolve implementation issues. To install the skill from the [Android CLI](https://developer.android.com/tools/agents/android-cli), run: - - android skills add engage-sdk-integration - -
- -## Integration detail - -### Terminology - -This integration includes the following three cluster types: **Recommendation** , -**Continuation** , and **Featured**. - -- **Recommendation** clusters show personalized suggestions for content to read - from an individual developer partner. - - Your recommendations take the following structure: - - **Recommendation Cluster:** A UI view that contains a group of - recommendations from a single developer partner. - - ![](https://developer.android.com/static/images/guide/playcore/engage/read-term-1.png) **Figure 1.** Entertainment Space UI showing a Recommendation Cluster from a single partner. - - **Entity:** An object representing a single item in a cluster. An entity - can be an ebook, an audio book, a book series, and more. See the [Provide - entity data](https://developer.android.com/guide/playcore/engage/read#provide-entity-data) section for a list of supported entity - types. - - ![](https://developer.android.com/static/images/guide/playcore/engage/read-term-2.png) **Figure 2.** Entertainment Space UI showing a single Entity within a single partner's Recommendation Cluster. -- The **Continuation** cluster shows unfinished books from multiple developer - partners in a single UI grouping. Each developer partner will be allowed to - broadcast a maximum of 10 entities in the Continuation cluster. - - ![](https://developer.android.com/static/images/guide/playcore/engage/read-term-3.png) **Figure 3.** Entertainment Space UI showing a Continuation cluster with unfinished recommendations from multiple partners (only one recommendation is currently visible). -- The **Featured** cluster showcases a selection of items from multiple - developer partners in a single UI grouping. There will be a single Featured - cluster, which is surfaced near the top of the UI with a priority placement - above all Recommendation clusters. Each developer partner will be allowed to - broadcast up to 10 entities in the Featured cluster. - - ![](https://developer.android.com/static/images/guide/playcore/engage/read-term-4.png) **Figure 4.** Entertainment Space UI showing a Featured cluster with recommendations from multiple partners (only one recommendation is currently visible). - -### Pre-work - -Minimum API level: 19 - -Add the `com.google.android.engage:engage-core` library to your app: - - dependencies { - // Make sure you also include that repository in your project's build.gradle file. - implementation 'com.google.android.engage:engage-core:1.6.0' - } - -### Summary - -The design is based on an implementation of a [bound -service](https://developer.android.com/guide/components/bound-services). - -The data a client can publish is subject to the following limits for different -cluster types: - -| Cluster type | Cluster limits | Maximum entity limits in a cluster | -|---|---|---| -| Recommendation Cluster(s) | At most 7 | At most 50 | -| Continuation Cluster | At most 1 | At most 20 | -| Featured Cluster | At most 1 | At most 20 | - -### Step 1: Provide entity data - -The SDK has defined different entities to represent each item type. We support -the following entities for the Read category: - -1. `EbookEntity` -2. `AudiobookEntity` -3. `BookSeriesEntity` - -The charts below outline available attributes and requirements for each type. - -#### `EbookEntity` - -The `EbookEntity` object represents an ebook (for example, the ebook of -*Becoming* by Michelle Obama). - -| Attribute | Requirement | Notes | -|---|---|---| -| Name | **Required** | | -| Poster images | **Required** | At least one image must be provided. See [Image Specifications](https://developer.android.com/guide/playcore/engage/read#image-specs) for guidance. | -| Author | **Required** | At least one author name must be provided. | -| Action link uri | **Required** | The deep link to the provider app for the ebook. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | -| Publish date | Optional | In epoch milliseconds if provided. | -| Description | Optional | Must be within 200 characters if provided. | -| Price | Optional | Free text | -| Page count | Optional | Must be a positive integer if provided. | -| Genre | Optional | List of genres associated with the book. | -| Series name | Optional | Name of the series that the ebook belongs to (for example, *Harry Potter*). | -| Series unit index | Optional | The index of the ebook in the series, where 1 is the first ebook in the series. For example, if *Harry Potter and the Prisoner of Azkaban* is the 3rd book in the series, this should be set to 3. | -| Continue book type | Optional | TYPE_CONTINUE - Resume on a unfinished book. TYPE_NEXT - Continue on a new one of a series. TYPE_NEW - Newly released. | -| Last Engagement Time | Conditionally required | Must be provided when the item is in the Continuation cluster. \*Newly\* acquired ebooks can be a part of the continue reading cluster. In epoch milliseconds. | -| Progress Percentage Complete | Conditionally required | Must be provided when the item is in the Continuation cluster. Value must be greater than 0 and less than 100. | -| **DisplayTimeWindow - Set a time window for a content to be shown on the surface** ||| -| Start Timestamp | Optional | The epoch timestamp after which the content should be shown on the surface. If not set, content is eligible to be shown on the surface. In epoch milliseconds. | -| End Timestamp | Optional | The epoch timestamp after which the content is no longer shown on the surface. If not set, content is eligible to be shown on the surface. In epoch milliseconds. | - -#### `AudiobookEntity` - -The `AudiobookEntity` object represents an audiobook (for example, the audiobook -of *Becoming* by Michelle Obama). - -| Attribute | Requirement | Notes | -|---|---|---| -| Name | **Required** | | -| Poster images | **Required** | At least one image must be provided. See [Image Specifications](https://developer.android.com/guide/playcore/engage/read#image-specs) for guidance. | -| Author | **Required** | At least one author name must be provided. | -| Action link uri | **Required** | The deep link to the provider app for the audiobook. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | -| Narrator | Optional | At least one narrator's name must be provided. | -| Publish date | Optional | In epoch milliseconds if provided. | -| Description | Optional | Must be within 200 characters if provided. | -| Price | Optional | Free text | -| Duration | Optional | Must be a positive value if provided. | -| Genre | Optional | List of genres associated with the book. | -| Series name | Optional | Name of the series that the audiobook belongs to (for example, *Harry Potter*. | -| Series unit index | Optional | The index of the audiobook in the series, where 1 is the first audiobook in the series. For example, if *Harry Potter and the Prisoner of Azkaban* is the 3rd book in the series, this should be set to 3. | -| Continue book type | Optional | TYPE_CONTINUE - Resume on a unfinished book. TYPE_NEXT - Continue on a new one of a series. TYPE_NEW - Newly released. | -| Last Engagement Time | Conditionally required | Must be provided when the item is in the Continuation cluster. In epoch milliseconds. | -| Progress Percentage Complete | Conditionally required | Must be provided when the item is in the Continuation cluster. \*Newly\* acquired audiobooks can be a part of the continue reading cluster. Value must be greater than 0 and less than 100. | -| **DisplayTimeWindow - Set a time window for a content to be shown on the surface** ||| -| Start Timestamp | Optional | The epoch timestamp after which the content should be shown on the surface. If not set, content is eligible to be shown on the surface. In epoch milliseconds. | -| End Timestamp | Optional | The epoch timestamp after which the content is no longer shown on the surface. If not set, content is eligible to be shown on the surface. In epoch milliseconds. | - -#### `BookSeriesEntity` - -The `BookSeriesEntity` object represents a book series (for example, the *Harry -Potter* book series, which has 7 books). - -| Attribute | Requirement | Notes | -|---|---|---| -| Name | **Required** | | -| Poster images | **Required** | At least one image must be provided. See [Image Specifications](https://developer.android.com/guide/playcore/engage/read#image-specs) for guidance. | -| Author | **Required** | At least one author name must be present. | -| Action link uri | **Required** | The deep link to the provider app for the audiobook or ebook. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | -| Book count | **Required** | Number of books in the series. | -| Description | Optional | Must be within 200 characters if provided. | -| Genre | Optional | List of genres associated with the book. | -| Continue book type | Optional | TYPE_CONTINUE - Resume on a unfinished book. TYPE_NEXT - Continue on a new one of a series. TYPE_NEW - Newly released. | -| Last Engagement Time | Conditionally required | Must be provided when the item is in the Continuation cluster. In epoch milliseconds. | -| Progress Percentage Complete | Conditionally required | Must be provided when the item is in the Continuation cluster. \*Newly\* acquired book series can be a part of the continue reading cluster. Value must be greater than 0 and less than 100. | -| **DisplayTimeWindow - Set a time window for a content to be shown on the surface** ||| -| Start Timestamp | Optional | The epoch timestamp after which the content should be shown on the surface. If not set, content is eligible to be shown on the surface. In epoch milliseconds. | -| End Timestamp | Optional | The epoch timestamp after which the content is no longer shown on the surface. If not set, content is eligible to be shown on the surface. In epoch milliseconds. | - -#### Image specifications - -Required specifications for image assets are listed below: - -| Aspect ratio | Supported cluster(s) | Minimum pixels | Recommended pixels | -|---|---|---|---| -| Square (1x1) | All clusters | 300x300 | 1200x1200 | -| Landscape (1.91x1) | Featured and continuation | 600x314 | 1200x628 | -| Portrait (4x5) | Recommendation | 480x600 | 960x1200 | - -*File formats* - -PNG, JPG, static GIF, WebP - -*Maximum file size* - -5120 KB - -*Additional recommendations* - -- **Image safe area:** Put your important content in the center 80% of the image. - -#### Example - - AudiobookEntity audiobookEntity = - new AudiobookEntity.Builder() - .setName("Becoming") - .addPosterImage( - new Image.Builder() - .setImageUri(Uri.parse("http://www.x.com/image.png")) - .setImageHeightInPixel(960) - .setImageWidthInPixel(408) - .build()) - .addAuthor("Michelle Obama") - .addNarrator("Michelle Obama") - .setActionLinkUri(Uri.parse("https://play.google/audiobooks/1")) - .setDurationMillis(16335L) - .setPublishDateEpochMillis(1633032895L) - .setDescription("An intimate, powerful, and inspiring memoir") - .setPrice("$16.95") - .addGenre("biography") - .build(); - -### Step 2: Provide Cluster data - -It's recommended to have the content publish job executed in the background -(for example, using [WorkManager](https://developer.android.com/topic/libraries/architecture/workmanager)) -and scheduled on a regular basis or on an event basis (for example, every time -the user opens the app or when the user just added something to their cart). - -`AppEngagePublishClient` is responsible for publishing clusters. Following -APIs are available in the client: - -- `isServiceAvailable` -- `publishRecommendationClusters` -- `publishFeaturedCluster` -- `publishContinuationCluster` -- `publishUserAccountManagementRequest` -- `updatePublishStatus` -- `deleteRecommendationsClusters` -- `deleteFeaturedCluster` -- `deleteContinuationCluster` -- `deleteUserManagementCluster` -- `deleteClusters` - -#### `isServiceAvailable` - -This API is used to check if the service is available for integration and -whether the content can be presented on the device. - -##### For Engage SDK v1.6.0 and higher (Recommended) - - -## Android skills - -[View on GitHub](https://github.com/android/skills/tree/main/play/engage-sdk-integration) - -### Engage SDK integration - -To install the skill from the [Android CLI](https://developer.android.com/tools/agents/android-cli), run: - - android skills add engage-sdk-integration - -If your team uses AI coding tools (such as Gemini in Android Studio), you can automate this migration by prompting your AI assistant: - - Use the engage-sdk-integration skill to use Engage SDK 1.6.0 and refactor isServiceAvailable to pass ServiceAvailabilityRequest for publishing all cluster types. - -
- -You can check the service availability for every cluster type that you intend to -publish. The `isServiceAvailable` API accepts a request object, -`ServiceAvailabilityRequest`, which contains the cluster types for which service -availability needs to be checked. You can find the `ClusterType` enum values -required for `ServiceAvailabilityRequest` from the following table. - -| Cluster Type | Cluster Type Constant | Integer Value | -|---|---|---| -| Unknown | `TYPE_UNKNOWN` | 0 | -| Recommendation Cluster | `TYPE_RECOMMENDATION` | 1 | -| Featured Cluster | `TYPE_FEATURED` | 2 | -| Continuation Cluster | `TYPE_CONTINUATION` | 3 | -| User Management Cluster | `TYPE_ENGAGEMENT` | 8 | -| Subscription Cluster | `TYPE_SUBSCRIPTION` | 12 | - -### Kotlin - - val request = ServiceAvailabilityRequest.Builder() - .addIntendedClusterType(ClusterType.TYPE_CONTINUATION) - .addIntendedClusterType(ClusterType.TYPE_RECOMMENDATION) - .build() - - client.isServiceAvailable(request).addOnCompleteListener { task -> - if (task.isSuccessful) { - val availabilityMap = task.result - if (availabilityMap[ClusterType.TYPE_CONTINUATION] == true) { - // Proceed with publishing continuation content - } - if (availabilityMap[ClusterType.TYPE_RECOMMENDATION] == true) { - // Proceed with publishing recommendation content - } - } else { - // The IPC call itself fails, proceed with error handling logic here, - // such as retry. - } - } - -### Java - - ServiceAvailabilityRequest request = - new ServiceAvailabilityRequest.Builder() - .addIntendedClusterType(ClusterType.TYPE_CONTINUATION) - .addIntendedClusterType(ClusterType.TYPE_RECOMMENDATION) - .build(); - - client.isServiceAvailable(request).addOnCompleteListener(task -> { - if (task.isSuccessful()) { - Map availabilityMap = task.getResult(); - if (Boolean.TRUE.equals(availabilityMap.get(ClusterType.TYPE_CONTINUATION))) { - // Proceed with publishing continuation content - } - if (Boolean.TRUE.equals(availabilityMap.get(ClusterType.TYPE_RECOMMENDATION))) { - // Proceed with publishing recommendation content - } - } else { - // The IPC call itself fails, proceed with error handling logic here such as retry. - } - }); - -###### Conditional Service Availability Feature - -Some integrated apps request a special configuration that enables and disables -the Engage service intermittently in order to reduce their serving cost. This -intermittent content ingestion strategy, although possible, negatively affects -the user and the product -- stale content will not be presented and some surfaces -will not be served at all. - -Starting with v1.6.0, the Engage SDK allows checking availability for specific -cluster types. This provides more flexibility so that if the intermittent -content strategy was adopted by a given application, some cluster types can -follow that intermittent strategy while other cluster types are always enabled -(i.e. continuation clusters). - -If the Engage service should not be 'continuously' enabled on all supported -devices for whatever reason, and is configured for intermittent ingestion for -any set of devices, all continuation cluster publications (e.g. Continue -Reading) will be still enabled by default -configuration, and the rest of the cluster types will be enabled and disabled -intermittently. If intermittent ingestion applies to you but this default -configuration is not suitable for your needs, please contact -engage-developers@google.com. - -##### For SDK versions prior to v1.6.0 (Deprecated) - -### Kotlin - - client.isServiceAvailable.addOnCompleteListener { task -> - if (task.isSuccessful) { - // Handle IPC call success - if(task.result) { - // Service is available on the device, proceed with content publish - // calls. - } else { - // Service is not available, no further action is needed. - } - } else { - // The IPC call itself fails, proceed with error handling logic here, - // such as retry. - } - } - -### Java - - client.isServiceAvailable().addOnCompleteListener(task - > { - if (task.isSuccessful()) { - // Handle success - if(task.getResult()) { - // Service is available on the device, proceed with content publish - // calls. - } else { - // Service is not available, no further action is needed. - } - } else { - // The IPC call itself fails, proceed with error handling logic here, - // such as retry. - } - }); - -> [!NOTE] -> **Note:** We highly recommend keeping a periodic job running to check if the service becomes available at a later point in time. The availability of the service may change with Android version upgrades, app upgrades, installs, and uninstalls. By ensuring periodic job checks at a certain time interval, data can be published once the service becomes available. - -#### `publishRecommendationClusters` - -This API is used to publish a list of `RecommendationCluster` objects. - -> [!IMPORTANT] -> **Important:** The publish APIs are upsert APIs; it replaces the existing content. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. - -### Kotlin - - client.publishRecommendationClusters( - PublishRecommendationClustersRequest.Builder() - .addRecommendationCluster( - RecommendationCluster.Builder() - .addEntity(entity1) - .addEntity(entity2) - .setTitle("Reconnect with yourself") - .build()) - .build()) - -### Java - - client.publishRecommendationClusters( - new PublishRecommendationClustersRequest.Builder() - .addRecommendationCluster( - new RecommendationCluster.Builder() - .addEntity(entity1) - .addEntity(entity2) - .setTitle("Reconnect with yourself") - .build()) - .build()); - -When the service receives the request, the following actions take place within -one transaction: - -- Existing `RecommendationCluster` data from the developer partner is removed. -- Data from the request is parsed and stored in the updated Recommendation Cluster. - -In case of an error, the entire request is rejected and the existing state is -maintained. - -#### `publishFeaturedCluster` - -This API is used to publish a list of `FeaturedCluster` objects. - -> [!IMPORTANT] -> **Important:** The publish APIs are upsert APIs; it replaces the existing content. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. - -### Kotlin - - client.publishFeaturedCluster( - PublishFeaturedClusterRequest.Builder() - .setFeaturedCluster( - FeaturedCluster.Builder() - ... - .build()) - .build()) - -### Java - - client.publishFeaturedCluster( - new PublishFeaturedClusterRequest.Builder() - .setFeaturedCluster( - new FeaturedCluster.Builder() - ... - .build()) - .build()); - -When the service receives the request, the following actions take place within -one transaction: - -- Existing `FeaturedCluster` data from the developer partner is removed. -- Data from the request is parsed and stored in the updated Featured Cluster. - -In case of an error, the entire request is rejected and the existing state is -maintained. - -#### `publishContinuationCluster` - -This API is used to publish a `ContinuationCluster` object. - -> [!IMPORTANT] -> **Important:** The publish APIs are upsert APIs; it replaces the existing content. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. - -### Kotlin - - client.publishContinuationCluster( - PublishContinuationClusterRequest.Builder() - .setContinuationCluster( - ContinuationCluster.Builder() - .addEntity(book_entity1) - .addEntity(book_entity2) - .build()) - .build()) - -### Java - - client.publishContinuationCluster( - PublishContinuationClusterRequest.Builder() - .setContinuationCluster( - ContinuationCluster.Builder() - .addEntity(book_entity1) - .addEntity(book_entity2) - .build()) - .build()) - -When the service receives the request, the following actions take place within -one transaction: - -- Existing `ContinuationCluster` data from the developer partner is removed. -- Data from the request is parsed and stored in the updated Continuation Cluster. - -In case of an error, the entire request is rejected and the existing state is -maintained. - -#### `publishUserAccountManagementRequest` - -This API is used to publish a Sign In card . The signin action directs users to -the app's sign in page so that the app can publish content (or provide more -personalized content) - -The following metadata is part of the Sign In Card - - -| Attribute | Requirement | Description | -|---|---|---| -| Action Uri | Required | Deeplink to Action (i.e. navigates to app sign in page) | -| Image | Optional - If not provided, Title must be provided | Image Shown on the Card 16x9 aspect ratio images with a resolution of 1264x712 | -| Title | Optional - If not provided, Image must be provided | Title on the Card | -| Action Text | Optional | Text Shown on the CTA (i.e. Sign in) | -| Subtitle | Optional | Optional Subtitle on the Card | - -> [!IMPORTANT] -> **Important:** The publish APIs are upsert APIs; it replaces the existing content. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. - -### Kotlin - - var SIGN_IN_CARD_ENTITY = - SignInCardEntity.Builder() - .addPosterImage( - Image.Builder() - .setImageUri(Uri.parse("http://www.x.com/image.png")) - .setImageHeightInPixel(500) - .setImageWidthInPixel(500) - .build()) - .setActionText("Sign In") - .setActionUri(Uri.parse("http://xx.com/signin")) - .build() - - client.publishUserAccountManagementRequest( - PublishUserAccountManagementRequest.Builder() - .setSignInCardEntity(SIGN_IN_CARD_ENTITY) - .build()); - -### Java - - SignInCardEntity SIGN_IN_CARD_ENTITY = - new SignInCardEntity.Builder() - .addPosterImage( - new Image.Builder() - .setImageUri(Uri.parse("http://www.x.com/image.png")) - .setImageHeightInPixel(500) - .setImageWidthInPixel(500) - .build()) - .setActionText("Sign In") - .setActionUri(Uri.parse("http://xx.com/signin")) - .build(); - - client.publishUserAccountManagementRequest( - new PublishUserAccountManagementRequest.Builder() - .setSignInCardEntity(SIGN_IN_CARD_ENTITY) - .build()); - -When the service receives the request, the following actions take place within -one transaction: - -- Existing `UserAccountManagementCluster` data from the developer partner is removed. -- Data from the request is parsed and stored in the updated UserAccountManagementCluster Cluster. - -In case of an error, the entire request is rejected and the existing state is -maintained. - -#### `updatePublishStatus` - -If for any internal business reason, none of the clusters is published, -we **strongly recommend** updating the publish status using the -**updatePublishStatus** API. -This is important because : - -- Providing the status in all scenarios, even when the content is published (STATUS == PUBLISHED), is critical to populate dashboards that use this explicit status to convey the health and other metrics of your integration. -- If no content is published but the integration status isn't broken (STATUS == NOT_PUBLISHED), Google can avoid triggering alerts in the app health dashboards. It confirms that content is not published due to an **expected** situation from the provider's standpoint. -- It helps developers provide insights into when the data is published versus not. -- Google may use the status codes to nudge the user to do certain actions in the app so they can see the app content or overcome it. - -The list of eligible publish status codes are : - - // Content is published - AppEngagePublishStatusCode.PUBLISHED, - - // Content is not published as user is not signed in - AppEngagePublishStatusCode.NOT_PUBLISHED_REQUIRES_SIGN_IN, - - // Content is not published as user is not subscribed - AppEngagePublishStatusCode.NOT_PUBLISHED_REQUIRES_SUBSCRIPTION, - - // Content is not published as user location is ineligible - AppEngagePublishStatusCode.NOT_PUBLISHED_INELIGIBLE_LOCATION, - - // Content is not published as there is no eligible content - AppEngagePublishStatusCode.NOT_PUBLISHED_NO_ELIGIBLE_CONTENT, - - // Content is not published as the feature is disabled by the client - // Available in v1.3.1 - AppEngagePublishStatusCode.NOT_PUBLISHED_FEATURE_DISABLED_BY_CLIENT, - - // Content is not published as the feature due to a client error - // Available in v1.3.1 - AppEngagePublishStatusCode.NOT_PUBLISHED_CLIENT_ERROR, - - // Content is not published as the feature due to a service error - // Available in v1.3.1 - AppEngagePublishStatusCode.NOT_PUBLISHED_SERVICE_ERROR, - - // Content is not published due to some other reason - // Reach out to engage-developers@ before using this enum. - AppEngagePublishStatusCode.NOT_PUBLISHED_OTHER - -If the content is not published due to a user not logged in, -Google would recommend publishing the Sign In Card. -If for any reason providers are not able to publish the Sign In Card -then we recommend calling the **updatePublishStatus** API -with the status code **NOT_PUBLISHED_REQUIRES_SIGN_IN** - -### Kotlin - - client.updatePublishStatus( - PublishStatusRequest.Builder() - .setStatusCode(AppEngagePublishStatusCode.NOT_PUBLISHED_REQUIRES_SIGN_IN) - .build()) - -### Java - - client.updatePublishStatus( - new PublishStatusRequest.Builder() - .setStatusCode(AppEngagePublishStatusCode.NOT_PUBLISHED_REQUIRES_SIGN_IN) - .build()); - -#### `deleteRecommendationClusters` - -This API is used to delete the content of Recommendation Clusters. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteRecommendationClusters() - -### Java - - client.deleteRecommendationClusters(); - -When the service receives the request, it removes the existing data from the -Recommendation Clusters. In case of an error, the entire request is rejected -and the existing state is maintained. - -> [!NOTE] -> **Note:** This api is available from version 1.1.0 onwards. - -#### `deleteFeaturedCluster` - -This API is used to delete the content of Featured Cluster. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteFeaturedCluster() - -### Java - - client.deleteFeaturedCluster(); - -When the service receives the request, it removes the existing data from the -Featured Cluster. In case of an error, the entire request is rejected -and the existing state is maintained. - -> [!NOTE] -> **Note:** This api is available from version 1.1.0 onwards. - -#### `deleteContinuationCluster` - -This API is used to delete the content of Continuation Cluster. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteContinuationCluster() - -### Java - - client.deleteContinuationCluster(); - -When the service receives the request, it removes the existing data from the -Continuation Cluster. In case of an error, the entire request is rejected -and the existing state is maintained. - -> [!NOTE] -> **Note:** This api is available from version 1.1.0 onwards. - -#### `deleteUserManagementCluster` - -This API is used to delete the content of UserAccountManagement Cluster. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteUserManagementCluster() - -### Java - - client.deleteUserManagementCluster(); - -When the service receives the request, it removes the existing data from the -UserAccountManagement Cluster. In case of an error, the entire request is -rejected and the existing state is maintained. - -> [!NOTE] -> **Note:** This api is available from version 1.1.0 onwards. - -#### `deleteClusters` - -This API is used to delete the content of a given cluster type. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteClusters( - DeleteClustersRequest.Builder() - .addClusterType(ClusterType.TYPE_FEATURED) - .addClusterType(ClusterType.TYPE_RECOMMENDATION) - ... - .build()) - -### Java - - client.deleteClusters( - new DeleteClustersRequest.Builder() - .addClusterType(ClusterType.TYPE_FEATURED) - .addClusterType(ClusterType.TYPE_RECOMMENDATION) - ... - .build()); - -When the service receives the request, it removes the existing data from all -clusters matching the specified cluster types. Clients can choose to pass one or -many cluster types. In case of an error, the entire request is rejected and the -existing state is maintained. - -#### Error handling - -It is highly recommended to listen to the task result from the publish APIs such -that a follow-up action can be taken to recover and resubmit an successful task. - - client.publishRecommendationClusters( - new PublishRecommendationClustersRequest.Builder() - .addRecommendationCluster(...) - .build()) - .addOnCompleteListener( - task -> { - if (task.isSuccessful()) { - // do something - } else { - Exception exception = task.getException(); - if (exception instanceof AppEngageException) { - @AppEngageErrorCode - int errorCode = ((AppEngageException) exception).getErrorCode(); - if (errorCode == AppEngageErrorCode.SERVICE_NOT_FOUND) { - // do something - } - } - } - }); - -The error is returned as an `AppEngageException` with the cause included as an -error code. - -| Error code | Error name | Note | -|---|---|---| -| `1` | `SERVICE_NOT_FOUND` | The service is not available on the given device. | -| `2` | `SERVICE_NOT_AVAILABLE` | The service is available on the given device, but it is not available at the time of the call (for example, it is explicitly disabled). | -| `3` | `SERVICE_CALL_EXECUTION_FAILURE` | The task execution failed due to threading issues. In this case, it can be retried. | -| `4` | `SERVICE_CALL_PERMISSION_DENIED` | The caller is not allowed to make the service call. | -| `5` | `SERVICE_CALL_INVALID_ARGUMENT` | The request contains invalid data (for example, more than the allowed number of clusters). | -| `6` | `SERVICE_CALL_INTERNAL` | There is an error on the service side. | -| `7` | `SERVICE_CALL_RESOURCE_EXHAUSTED` | The service call is made too frequently. | - -### Step 3: Handle broadcast intents - -In addition to making publish content API calls through a job, it is also -required to set up a -[`BroadcastReceiver`](https://developer.android.com/reference/android/content/BroadcastReceiver) to receive -the request for a content publish. - -The goal of broadcast intents is mainly for app reactivation and forcing data -sync. Broadcast intents are not designed to be sent very frequently. It is only -triggered when the Engage Service determines the content might be stale (for -example, a week old). That way, there is more confidence that the user can have -a fresh content experience, even if the application has not been executed for a -long period of time. - -The `BroadcastReceiver` must be set up in the following two ways: - -- Dynamically register an instance of the `BroadcastReceiver` class using - `Context.registerReceiver()`. This enables communication from applications - that are still live in memory. - -### Kotlin - - class AppEngageBroadcastReceiver : BroadcastReceiver(){ - // Trigger recommendation cluster publish when PUBLISH_RECOMMENDATION broadcast - // is received - // Trigger featured cluster publish when PUBLISH_FEATURED broadcast is received - // Trigger continuation cluster publish when PUBLISH_CONTINUATION broadcast is - // received - } - - fun registerBroadcastReceivers(context: Context){ - var context = context - context = context.applicationContext - - // Register Recommendation Cluster Publish Intent - context.registerReceiver(AppEngageBroadcastReceiver(), - IntentFilter(Intents.ACTION_PUBLISH_RECOMMENDATION), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null) - - // Register Featured Cluster Publish Intent - context.registerReceiver(AppEngageBroadcastReceiver(), - IntentFilter(Intents.ACTION_PUBLISH_FEATURED), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null) - - // Register Continuation Cluster Publish Intent - context.registerReceiver(AppEngageBroadcastReceiver(), - IntentFilter(Intents.ACTION_PUBLISH_CONTINUATION), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null) - } - -### Java - - class AppEngageBroadcastReceiver extends BroadcastReceiver { - // Trigger recommendation cluster publish when PUBLISH_RECOMMENDATION broadcast - // is received - - // Trigger featured cluster publish when PUBLISH_FEATURED broadcast is received - - // Trigger continuation cluster publish when PUBLISH_CONTINUATION broadcast is - // received - } - - public static void registerBroadcastReceivers(Context context) { - - context = context.getApplicationContext(); - - // Register Recommendation Cluster Publish Intent - context.registerReceiver(new AppEngageBroadcastReceiver(), - new IntentFilter(com.google.android.engage.service.Intents.ACTION_PUBLISH_RECOMMENDATION), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null); - - // Register Featured Cluster Publish Intent - context.registerReceiver(new AppEngageBroadcastReceiver(), - new IntentFilter(com.google.android.engage.service.Intents.ACTION_PUBLISH_FEATURED), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null); - - // Register Continuation Cluster Publish Intent - context.registerReceiver(new AppEngageBroadcastReceiver(), - new IntentFilter(com.google.android.engage.service.Intents.ACTION_PUBLISH_CONTINUATION), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null); - - } - -- Statically declare an implementation with the `` tag in your - `AndroidManifest.xml` file. This allows the application to receive broadcast - intents when it is not running, and also allows the application to publish - the content. - - - - - - - - - - - - - - - -The following [intents](https://developer.android.com/reference/android/content/Intent) will be sent by the -service: - -- `com.google.android.engage.action.PUBLISH_RECOMMENDATION` It is recommended to start a `publishRecommendationClusters` call when receiving this intent. -- `com.google.android.engage.action.PUBLISH_FEATURED` It is recommended to start a `publishFeaturedCluster` call when receiving this intent. -- com.google.android.engage.action.PUBLISH_CONTINUATION`It is recommended to start a`publishContinuationCluster\` call when receiving this intent. - -## Integration workflow - -For a step-by-step guide on verifying your integration after it is complete, see -[Engage developer integration workflow](https://developer.android.com/guide/playcore/engage/workflow). - -## FAQs - -See [Engage SDK Frequently Asked Questions](https://developer.android.com/guide/playcore/engage/faq) for -FAQs. - -## Contact - -Contact [`engage-developers@google.com`](mailto:engage-developers@google.com) if there are any questions during -the integration process. Our team will reply as soon as possible. - -## Next steps - -After completing this integration, your next steps are as follows: - -- Send an email to [`engage-developers@google.com`](mailto:engage-developers@google.com) and attach your integrated APK that is ready for testing by Google. -- Google will perform a verification and review internally to make sure the integration works as expected. If changes are needed, Google will contact you with any necessary details. -- When testing is complete and no changes are needed, Google will contact you to notify you that you can start publishing the updated and integrated APK to the Play Store. -- After Google has confirmed that your updated APK has been published to the Play Store, your **Recommendation** , **Featured** , and **Continuation** clusters will be published and visible to users. \ No newline at end of file diff --git a/play/engage-sdk-integration/references/android/guide/playcore/engage/shopping.md b/play/engage-sdk-integration/references/android/guide/playcore/engage/shopping.md deleted file mode 100644 index 9dc53d3..0000000 --- a/play/engage-sdk-integration/references/android/guide/playcore/engage/shopping.md +++ /dev/null @@ -1,1247 +0,0 @@ -Boost app engagement by reaching your users where they are. Integrate Engage SDK -to deliver personalized recommendations and continuation content directly to -users across multiple on-device surfaces, like -**[Collections](https://android-developers.googleblog.com/2024/07/introducing-collections-powered-by-engage-sdk.html)** , **[Entertainment -Space](https://blog.google/products/android/entertainment-space/)** , and the Play Store. The integration adds -less than 50 KB (compressed) to the average APK and takes most apps about a -week of developer time. Learn more at our **[business -site](http://play.google.com/console/about/programs/EngageSDK)**. - -This guide contains instructions for developer partners to deliver shopping -content to Engage content surfaces. - -## Category support and surfaces - -Engage SDK surface eligibility is based on your app's content category. Use the -following table to determine your eligibility for specific surfaces: - -| Status | Content category or use case | Supported surfaces | -|---|---|---| -| **Supported** (Eligible on all surfaces) | - Clothes shopping - Online marketplace - Retailer - Food \& drink shopping / grocery - Discounts \& coupons - Pet / supplies - House \& home / Real estate | - Collections - Play Store Apps tab - Play Store You tab - Play Store store listing page | -| **Not supported** | - Shopper app - B2B Shopping - Seller / merchant app - Receipts scanner rewards - Telecom service - Buy now pay later | | - - -## Android skills - -[View on GitHub](https://github.com/android/skills/tree/main/play/engage-sdk-integration) - -### Integrate Engage SDK - -Use the Engage SDK [Android skill](https://developer.android.com/tools/agents/android-skills) to integrate, debug, and resolve implementation issues. To install the skill from the [Android CLI](https://developer.android.com/tools/agents/android-cli), run: - - android skills add engage-sdk-integration - -
- -## Integration detail - -### Terminology - -This integration includes the following five cluster types: **Recommendation** , -**Featured** , **Shopping Cart** , **Shopping List** , **Reorder** and -**Shopping Order Tracking**. - -- **Recommendation** clusters show personalized shopping suggestions from an - individual developer partner. These recommendations can be personalized to the - user or generalized (for example, trending items). Use these to surface - products, events, sales, promos, subscriptions as you see fit. - - Your recommendations take the following structure: - - **Recommendation Cluster:** A UI view that contains a group of - recommendations from the same developer partner. - - - **ShoppingEntity:** An object representing a single item in a cluster. - -- The **Featured** cluster showcases a selection of entities from multiple - developer partners in one UI grouping. There will be a single Featured - cluster, which is surfaced near the top of the UI with a priority placement - above all Recommendation clusters. Each developer partner will be allowed to - broadcast up to 10 entities in the Featured cluster. - -- The **Shopping Cart** cluster shows a sneak peek of shopping carts from many - developer partners in one UI grouping, nudging users to complete their - outstanding carts. There is a single Shopping Cart cluster, which is - surfaced near the top of the UI, with a priority placement above all - Recommendation clusters. Each developer partner is allowed to broadcast up to - 3 `ShoppingCart` instances in the Shopping Cart cluster. - - Your Shopping Cart takes the following structure: - - **Shopping Cart Cluster:** A UI view that contains a group of shopping - cart previews from many developer partners. - - - **ShoppingCart:** An object representing the shopping cart preview - for a single developer partner, to be displayed in the Shopping Cart - cluster. The `ShoppingCart` must show the total count of items in the - cart and may also include images for some items in the user's cart. - -- The **Shopping List** cluster shows a sneak peek of the shopping - lists from multiple developer partners in one UI grouping, prompting users to - return to the corresponding app to update and complete their lists. There is a - single Shopping List cluster. - -- The **Reorder** cluster shows a sneak peek of the previous orders from - multiple developer partners in one UI grouping, prompting users to reorder. - There is a single Reorder cluster. - - - Reorder cluster must show the total count of items in the - user's previous order and must also include one of the following: - - - Images for X items in the user's previous order. - - Labels for X items in the user's previous order. -- The **Shopping Order Tracking** cluster shows a sneak peek of pending - or recently completed shopping orders from many developer partners in one UI - grouping, allowing users to track their orders. - - There is a single ShoppingOrderTracking cluster that is surfaced - near the top of the UI, with a priority placement above all Recommendation - clusters. Each developer partner is allowed to broadcast multiple - ShoppingOrderTrackingEntity items in the Shopping Order Tracking cluster. - - Your ShoppingOrderTrackingCluster takes the following structure: - - - **ShoppingOrderTracking Cluster**: A UI view that contains a group of order tracking previews from many developer partners - - **ShoppingOrderTrackingEntity**: An object representing a shopping order tracking preview for a single developer partner, to be displayed in the Shopping Order Tracking cluster. The ShoppingOrderTrackingEntity must show the status of the order and the order time. We strongly recommend populating the expected delivery time for ShoppingOrderTrackingEntity, as it's displayed to users when provided. - - > [!NOTE] - > **Note:** Provide multiple ShoppingOrderTrackingEntity objects if an order has been split into multiple shipments. - -### Pre-work - -Minimum API level: 19 - -Add the `com.google.android.engage:engage-core` library to your app: - - dependencies { - // Make sure you also include that repository in your project's build.gradle file. - implementation 'com.google.android.engage:engage-core:1.6.0' - } - -For more information, see [Package visibility in Android -11](https://developer.android.com/about/versions/11/privacy/package-visibility). - -### Summary - -The design is based on an implementation of a [bound -service](https://developer.android.com/guide/components/bound-services). - -The data a client can publish is subject to the following limits for different -cluster types: - -| Cluster type | Cluster limits | Maximum entity limits in a cluster | -|---|---|---| -| Recommendation Cluster(s) | At most 7 | At most 50 `ShoppingEntity` | -| Featured Cluster | At most 1 | At most 20 `ShoppingEntity` | -| Shopping Cart Cluster | At most 1 | At most 3 `ShoppingCart` Multiple carts only expected for apps with separate carts per merchant. | -| Shopping List Cluster | At most 1 | At most 3 `ShoppingList` | -| Shopping Reorder Cluster | At most 1 | At most 1 `ReorderEntity` | -| Shopping Order Tracking Cluster | At most 3 | At most 3 `ShoppingOrderTrackingEntity` | - -### Step 1: Provide entity data - -The SDK has defined different entities to represent each item type. The -following entities are supported for the Shopping category: - -1. `ShoppingEntity` -2. `ShoppingCart` -3. `ShoppingList` -4. `Reorder` -5. `ShoppingOrderTracking` - -The charts below outline available attributes and requirements for each type. - -#### `ShoppingEntity` - -The `ShoppingEntity` object represents a product, promotion, deal, subscription, -or event that developer partners want to publish. - -##### `ShoppingEntity` - -| Attribute | Requirement | Description | Format | -|---|---|---|---| -| Poster images | **Required** | At least one image must be provided. | See [Image Specifications](https://developer.android.com/guide/playcore/engage/shopping#image-specs) for guidance. | -| Action Uri | **Required** | The deep link to the page in the app displaying details about the entity. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | Uri | -| Title | Optional | The name of the entity. | Free text **Recommended text size: under 90 chars** (Text that is too long may show ellipses) | -| Price - current | Conditionally required | The current price of the entity. **Must be provided if strikethrough price is provided.** | Free text | -| Price - strikethrough | Optional | The original price of the entity, which is be struck-through in the UI. | Free text | -| Callout | Optional | Callout to feature a promo, event, or update for the entity, if available. | Free text **Recommended text size: under 45 chars** (Text that is too long may show ellipses) | -| Callout fine print | Optional | Fine print text for the callout. | Free text **Recommended text size: under 45 chars** (Text that is too long may show ellipses) | -| **Rating (Optional) - Note: All ratings are displayed using our standard star rating system.** |||| -| Rating - Max value | Optional | The maximum value of the rating scale. **Must be provided if current value of rating is also provided.** | Number \>= 0.0 | -| Rating - Current value | Optional | The current value of the rating scale. **Must be provided if maximum value of rating is also provided.** | Number \>= 0.0 | -| Rating - Count | Optional | The count of the ratings for the entity. **Note:** Provide this field if your app controls how the count is displayed to the users. Use a concise string. For example, if the count is 1,000,000, consider using an abbreviation like 1M so that the count isn't truncated on smaller display sizes. | String | -| Rating - Count Value | Optional | The count of the ratings for the entity. **Note:** Provide this field if you don't handle the display abbreviation logic yourself. If both Count and Count Value are present, Count is displayed to users. | Long | -| **DisplayTimeWindow (Optional) - Set a time window for a content to be shown on the surface** |||| -| Start Timestamp | Optional | The epoch timestamp after which the content should be shown on the surface. If not set, content is eligible to be shown on the surface. | Epoch timestamp in milliseconds | -| End Timestamp | Optional | The epoch timestamp after which the content is no longer shown on the surface. If not set, content is eligible to be shown on the surface. | Epoch timestamp in milliseconds | - -#### `ShoppingCart` - -| Attribute | Requirement | Description | Format | -|---|---|---|---| -| Action Uri | **Required** | The deep link to the shopping cart in the partner's app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | Uri | -| Number of items | **Required** | The number of items (not just number of products) in the shopping cart. **For example: If there are 3 identical shirts and 1 hat in the cart, this number should be 4.** | Integer \>= 1 | -| Action Text | Optional | The call to action text of the button on the Shopping Cart (for example, *Your Shopping Bag*). **If no action text is provided by the developer, *View Cart* is the default.** This attribute is supported in version 1.1.0 onwards. | String | -| Title | Optional | The title of the cart (for example, *Your Shopping Bag*). **If no title is provided by the developer, *Your cart* is the default.** **If developer partner publishes a separate cart per merchant, please include *merchant name* in the title.** | Free text **Recommended text size: under 25 chars** (Text that is too long may show ellipses) | -| Cart images | Optional | Images of each product in the cart. **Up to 10 images can be provided in order of priority; the actual number of images displayed depends on the device form factor.** | See [Image Specifications](https://developer.android.com/guide/playcore/engage/shopping#image-specs) for guidance. | -| Item labels | Optional | The list of labels for items on the shopping list. **The actual number of labels displayed depends on the device form factor.** | List of free text labels **Recommended text size: under 20 chars** (Text that is too long may show ellipses) | -| Last user interaction timestamp | Optional | Number of milliseconds elapsed from the epoch, identifying the last time when user interacted with the cart. **This will be passed as input by the developer partners publishing separate cart per merchant and maybe used for ranking.** | Epoch timestamp in milliseconds | -| **DisplayTimeWindow (Optional) - Set a time window for a content to be shown on the surface** |||| -| Start Timestamp | Optional | The epoch timestamp after which the content should be shown on the surface. If not set, content is eligible to be shown on the surface. | Epoch timestamp in milliseconds | -| End Timestamp | Optional | The epoch timestamp after which the content is no longer shown on the surface. If not set, content is eligible to be shown on the surface. | Epoch timestamp in milliseconds | - -#### `ShoppingList` - -| Attribute | Requirement | Description | Format | -|---|---|---|---| -| Action Uri | **Required** | The deep link to the shopping list in the partner's app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | Uri | -| Number of items | **Required** | The number of items in the shopping list. | Integer \>= 1 | -| Title | Optional | The title of the list (for example, *Your Grocery List*). **If no title is provided by the developer, *Shopping list* is the default.** | Free text **Recommended text size: under 25 chars** (Text that is too long may show ellipses) | -| Item labels | **Required** | The list of labels for items on the shopping list. **At least 1 label must be provided and up to 10 labels can be provided in order of priority; the actual number of labels displayed depends on the device form factor.** | List of free text labels **Recommended text size: under 20 chars** (Text that is too long may show ellipses) | -| Last user interaction timestamp | **Required** | Number of milliseconds elapsed from the epoch, identifying the last time when user interacted with the shopping list. | Epoch timestamp in milliseconds | - -#### `ShoppingReorderCluster` - -| Attribute | Requirement | Description | Format | -|---|---|---|---| -| Action Uri | **Required** | The deep link to reorder in the partner's app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | Uri | -| Action Text | Optional | The call to action text of the button on the Reorder (for example, *Order again*). **If no action text is provided by the developer, *Reorder* is the default.** This attribute is supported in version 1.1.0 onwards. | String | -| Number of items | **Required** | The number of items (not just number of products) in the previous order. **For example: If there were 3 small coffees and 1 croissant in the previous order, this number should be 4.** | Integer \>= 1 | -| Title | **Required** | The title of the reorder item. | Free text **Recommended text size: under 40 chars** (Text that is too long may show ellipses) | -| Item labels | Optional (If not provided, poster images should be provided) | The list of item labels for the previous order. **Up to 10 labels can be provided in order of priority; the actual number of labels displayed depends on the device form factor.** | List of free text **Recommended text size per label: under 20 chars** (Text that is too long may show ellipses) | -| Poster images | Optional (If not provided, item labels should be provided) | Images of the items in the previous order. **Up to 10 images can be provided in order of priority; the actual number of images displayed depends on the device form factor.** | See [Image Specifications](https://developer.android.com/guide/playcore/engage/shopping#image-specs) for guidance. | - -#### `ShoppingOrderTrackingCluster` - -| Attribute | Requirement | Description | Format | -|---|---|---|---| -| Title | **Required** | A short title of the package/items being tracked or the tracking number. | Free text **Recommended text size: 50 chars (Text that is too long will show ellipses)** | -| Order Type | **Required** | A short title of the package/items being tracked or the tracking number. | Enum: IN_STORE_PICKUP, SAME_DAY_DELIVERY, MULTI_DAY_DELIVERY | -| Status | **Required** | The current status of the order. **For example: "Running late", "In transit", "Delayed", "Shipped", "Delivered", "Out of stock", "Order ready"** | Free text **Recommended text size: 25 chars (Text that is too long will show ellipses)** | -| Order Time | **Required** | The epoch timestamp in milliseconds at which the order was placed. **Order time will be displayed if expected delivery time window is not present** | Epoch timestamp in milliseconds | -| Action Uri | **Required** | Deep link to the order tracking in the partner's app. | Uri | -| **OrderDeliveryTimeWindow (Optional) - Set a time window for the order that is being tracked from the time the order was placed to the time of expected/actual delivery.** |||| -| OrderDeliveryTimeWindow - Start Time | Optional | The epoch timestamp in milliseconds on/after which the order will be delivered or be ready for pickup. | Epoch timestamp in milliseconds | -| OrderDeliveryTimeWindow - End Time | Optional | The epoch timestamp in milliseconds on/before which the order will be delivered or be ready for pickup. | Epoch timestamp in milliseconds | -| Poster images | Optional | Image of one item/product that is part of the order. Recommended aspect ratio is 1:1 | See [Image Specifications](https://developer.android.com/guide/playcore/engage/shopping#image-specs) for guidance. | -| Number of items | Optional | The number of items in the order. | Integer \>= 1 | -| Description | Optional | A single paragraph of text to describe the items in the order. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size: 180 chars** | -| Subtitle list | Optional | Up to 3 subtitles, with each subtitle a single line of text. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size for each subtitle: max 50 chars** | -| Order Value - CurrentPrice | Optional | The current value of the order. | Free text | -| Order number | Optional | The order number/ID that can be used to uniquely identify the order. | Free text **Recommended text size: max 25 chars** | -| Tracking number | Optional | The tracking number for the order/parcel delivery in case the order requires a delivery. | Free text **Recommended text size: max 25 chars** | - -#### Image specifications - -Required specifications for image assets are listed below: - -| Aspect ratio | Minimum pixels | Recommended pixels | -|---|---|---| -| Square (1x1) **Preferred for non featured clusters** | 300x300 | 1200x1200 | -| Landscape (1.91x1) **Preferred for featured clusters** | 600x314 | 1200x628 | -| Portrait (4x5) | 480x600 | 960x1200 | - -*File formats* - -PNG, JPG, static GIF, WebP - -*Maximum file size* - -5120 KB - -*Additional recommendations* - -- **Image safe area:** Put your important content in the center 80% of the image. -- Use a transparent background so that the image can be properly displayed in Dark and Light theme settings. - -### Step 2: Provide Cluster data - -It is recommended to have the content publish job executed in the background -(for example, using [WorkManager](https://developer.android.com/topic/libraries/architecture/workmanager)) -and scheduled on a regular basis or on an event basis (for example, every time -the user opens the app or when the user just added something to their cart). - -`AppEngageShoppingClient` is responsible for publishing shopping clusters. - -Following APIs are exposed to publish clusters in the client: - -- `isServiceAvailable` -- `publishRecommendationClusters` -- `publishFeaturedCluster` -- `publishShoppingCarts` -- `publishShoppingLists` -- `publishShoppingReorderCluster` -- `publishShoppingOrderTrackingCluster` -- `publishUserAccountManagementRequest` -- `updatePublishStatus` -- `deleteRecommendationsClusters` -- `deleteFeaturedCluster` -- `deleteShoppingCartCluster` -- `deleteShoppingListCluster` -- `deleteShoppingReorderCluster` -- `deleteShoppingOrderTrackingCluster` -- `deleteUserManagementCluster` -- `deleteClusters` - -#### `isServiceAvailable` - -This API is used to check if the service is available for integration and -whether the content can be presented on the device. - -##### For Engage SDK v1.6.0 and higher (Recommended) - - -## Android skills - -[View on GitHub](https://github.com/android/skills/tree/main/play/engage-sdk-integration) - -### Engage SDK integration - -To install the skill from the [Android CLI](https://developer.android.com/tools/agents/android-cli), run: - - android skills add engage-sdk-integration - -If your team uses AI coding tools (such as Gemini in Android Studio), you can automate this migration by prompting your AI assistant: - - Use the engage-sdk-integration skill to use Engage SDK 1.6.0 and refactor isServiceAvailable to pass ServiceAvailabilityRequest for publishing all cluster types. - -
- -You can check the service availability for every cluster type that you intend to -publish. The `isServiceAvailable` API accepts a request object, -`ServiceAvailabilityRequest`, which contains the cluster types for which service -availability needs to be checked. You can find the `ClusterType` enum values -required for `ServiceAvailabilityRequest` from the following table. - -| Cluster Type | Cluster Type Constant | Integer Value | -|---|---|---| -| Unknown | `TYPE_UNKNOWN` | 0 | -| Recommendation Cluster | `TYPE_RECOMMENDATION` | 1 | -| Featured Cluster | `TYPE_FEATURED` | 2 | -| Continuation Cluster | `TYPE_CONTINUATION` | 3 | -| Shopping Cart Cluster | `TYPE_SHOPPING_CART` | 4 | -| User Management Cluster | `TYPE_ENGAGEMENT` | 8 | -| Shopping List Cluster | `TYPE_SHOPPING_LIST` | 9 | -| Shopping Reorder Cluster | `TYPE_SHOPPING_REORDER` | 10 | -| Shopping Order Tracking Cluster | `TYPE_SHOPPING_ORDER_TRACKING` | 11 | -| Subscription Cluster | `TYPE_SUBSCRIPTION` | 12 | - -### Kotlin - - val request = ServiceAvailabilityRequest.Builder() - .addIntendedClusterType(ClusterType.TYPE_CONTINUATION) - .addIntendedClusterType(ClusterType.TYPE_RECOMMENDATION) - .build() - - client.isServiceAvailable(request).addOnCompleteListener { task -> - if (task.isSuccessful) { - val availabilityMap = task.result - if (availabilityMap[ClusterType.TYPE_CONTINUATION] == true) { - // Proceed with publishing continuation content - } - if (availabilityMap[ClusterType.TYPE_RECOMMENDATION] == true) { - // Proceed with publishing recommendation content - } - } else { - // The IPC call itself fails, proceed with error handling logic here, - // such as retry. - } - } - -### Java - - ServiceAvailabilityRequest request = - new ServiceAvailabilityRequest.Builder() - .addIntendedClusterType(ClusterType.TYPE_CONTINUATION) - .addIntendedClusterType(ClusterType.TYPE_RECOMMENDATION) - .build(); - - client.isServiceAvailable(request).addOnCompleteListener(task -> { - if (task.isSuccessful()) { - Map availabilityMap = task.getResult(); - if (Boolean.TRUE.equals(availabilityMap.get(ClusterType.TYPE_CONTINUATION))) { - // Proceed with publishing continuation content - } - if (Boolean.TRUE.equals(availabilityMap.get(ClusterType.TYPE_RECOMMENDATION))) { - // Proceed with publishing recommendation content - } - } else { - // The IPC call itself fails, proceed with error handling logic here, - // such as retry. - } - }); - -###### Conditional Service Availability Feature - -Some integrated apps request a special configuration that enables and disables -the Engage service intermittently in order to reduce their serving cost. This -intermittent content ingestion strategy, although possible, negatively affects -the user and the product -- stale content will not be presented and some surfaces -will not be served at all. - -Starting with v1.6.0, the Engage SDK allows checking availability for specific -cluster types. This provides more flexibility so that if the intermittent -content strategy was adopted by a given application, some cluster types can -follow that intermittent strategy while other cluster types are always enabled -(i.e. continuation clusters). - -If the Engage service should not be 'continuously' enabled on all supported -devices for whatever reason, and is configured for intermittent ingestion for -any set of devices, all continuation cluster publications (e.g. Shopping -Cart, Shopping List, Reorder, and Shopping Order Tracking) will be still -enabled by default configuration, and the rest of the cluster types will be -enabled and disabled intermittently. If intermittent ingestion applies to -you but this default configuration is not suitable for your needs, please -contact engage-developers@google.com. - -##### For SDK versions prior to v1.6.0 (Deprecated) - -### Kotlin - - client.isServiceAvailable.addOnCompleteListener { task -> - if (task.isSuccessful) { - // Handle IPC call success - if(task.result) { - // Service is available on the device, proceed with content publish - // calls. - } else { - // Service is not available, no further action is needed. - } - } else { - // The IPC call itself fails, proceed with error handling logic here, - // such as retry. - } - } - -### Java - - client.isServiceAvailable().addOnCompleteListener(task - > { - if (task.isSuccessful()) { - // Handle success - if(task.getResult()) { - // Service is available on the device, proceed with content publish - // calls. - } else { - // Service is not available, no further action is needed. - } - } else { - // The IPC call itself fails, proceed with error handling logic here, - // such as retry. - } - }); - -> [!NOTE] -> **Note:** We highly recommend keeping a periodic job running to check if the service becomes available at a later point in time. The availability of the service may change with Android version upgrades, app upgrades, installs, and uninstalls. By ensuring periodic job checks at a certain time interval, data can be published once the service becomes available. - -#### `publishRecommendationClusters` - -This API is used to publish a list of `RecommendationCluster` objects. - -A `RecommendationCluster` object can have the following attributes: - -| Attribute | Requirement | Description | -|---|---|---| -| List of ShoppingEntity | **Required** | A list of ShoppingEntity objects that make up the recommendations for this Recommendation Cluster. | -| Title | **Required** | The title for the Recommendation Cluster. **Recommended text size: under 25 chars** (Text that is too long may show ellipses) | -| Subtitle | Optional | The subtitle for the Recommendation Cluster. | -| Action Uri | Optional | The deep link to the page in the partner app where users can see the complete list of recommendations. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | - -> [!IMPORTANT] -> **Important:** The publish APIs are upsert APIs; it replaces the existing content. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. - -### Kotlin - - client.publishRecommendationClusters( - PublishRecommendationClustersRequest.Builder() - .addRecommendationCluster( - RecommendationCluster.Builder() - .addEntity(entity1) - .addEntity(entity2) - .setTitle("Black Friday Deals") - .build()) - .build()) - -### Java - - client.publishRecommendationClusters( - new PublishRecommendationClustersRequest.Builder() - .addRecommendationCluster( - new RecommendationCluster.Builder() - .addEntity(entity1) - .addEntity(entity2) - .setTitle("Black Friday Deals") - .build()) - .build()); - -When the service receives the request, the following actions take place within -one transaction: - -- All existing Recommendation Cluster data is removed. -- Data from the request is parsed and stored in new Recommendation Clusters. - -In case of an error, the entire request is rejected and the existing state is -maintained. - -#### `publishFeaturedCluster` - -This API is used to publish a `FeaturedCluster` object. - -> [!IMPORTANT] -> **Important:** The publish APIs are upsert APIs; it replaces the existing content. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. - -### Kotlin - - client.publishFeaturedCluster( - PublishFeaturedClusterRequest.Builder() - .setFeaturedCluster( - FeaturedCluster.Builder() - ... - .build()) - .build()) - -### Java - - client.publishFeaturedCluster( - new PublishFeaturedClusterRequest.Builder() - .setFeaturedCluster( - new FeaturedCluster.Builder() - ... - .build()) - .build()); - -When the service receives the request, the following actions take place within -one transaction: - -- Existing `FeaturedCluster` data from the developer partner is removed. -- Data from the request is parsed and stored in the updated Featured Cluster. - -In case of an error, the entire request is rejected and the existing state is -maintained. - -#### `publishShoppingCarts` - -This API is used to publish a list of `ShoppingCart` objects. This is -applicable to developer partner publishing separate carts per merchant. Include -merchant name in the title when using this API. - -> [!IMPORTANT] -> **Important:** The publish APIs are upsert APIs; it replaces the existing content. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. - -### Kotlin - - client.publishShoppingCarts( - PublishShoppingCartClustersRequest.Builder() - .addShoppingCart( - ShoppingCart.Builder() - ... - .build()) - .build()) - -### Java - - client.publishShoppingCarts( - new PublishShoppingCartClustersRequest.Builder() - .addShoppingCart( - new ShoppingCart.Builder() - ... - .build()) - .build()) - -When the service receives the request, the following actions take place within -one transaction: - -- Existing `ShoppingCart` data from the developer partner is removed. -- Data from the request is parsed and stored in the updated Shopping Cart Cluster. - -In case of an error, the entire request is rejected and the existing state is -maintained. - -#### `publishShoppingLists` - -This API is used to publish a list of `ShoppingList` objects. - -> [!IMPORTANT] -> **Important:** The publish APIs are upsert APIs; it replaces the existing content. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. - -### Kotlin - - client.publishShoppingLists( - PublishShoppingListsRequest.Builder() - .addShoppingList( - ShoppingList.Builder() - ... - .build()) - .build()) - -### Java - - client.publishShoppingLists( - new PublishShoppingListsRequest.Builder() - .addShoppingList( - new ShoppingListEntity.Builder() - ... - .build()) - .build()); - -When the service receives the request, the following actions take place within -one transaction: - -- Existing `ShoppingList` data from the developer partner is removed. -- Data from the request is parsed and stored in the updated Shopping List Cluster. - -In case of an error, the entire request is rejected and the existing state is -maintained. - -#### `publishShoppingReorderCluster` - -This API is used to publish a `ShoppingReorderCluster` object. - -> [!IMPORTANT] -> **Important:** The publish APIs are upsert APIs; it replaces the existing content. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. - -### Kotlin - - client.publishShoppingReorderCluster( - PublishShoppingReorderClusterRequest.Builder() - .setReorderCluster( - ShoppingReorderCluster.Builder() - ... - .build()) - .build()) - -### Java - - client.publishShoppingReorderCluster( - new PublishShoppingReorderClusterRequest.Builder() - .setReorderCluster( - new ShoppingReorderCluster.Builder() - ... - .build()) - .build()); - -When the service receives the request, the following actions take place within -one transaction: - -- Existing `ShoppingReorderCluster` data from the developer partner is removed. -- Data from the request is parsed and stored in the updated Reorder Cluster. - -In case of an error, the entire request is rejected and the existing state is -maintained. - -#### `publishShoppingOrderTrackingCluster` - -This API is used to publish a `ShoppingOrderTrackingCluster` object. - -> [!IMPORTANT] -> **Important:** The publish APIs are upsert APIs; it replaces the existing content. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. - -### Kotlin - - client.publishShoppingOrderTrackingCluster( - PublishShoppingOrderTrackingClusterRequest.Builder() - .setShoppingOrderTrackingCluster( - ShoppingOrderTrackingCluster.Builder() - ... - .build()) - .build()) - -### Java - - client.publishShoppingOrderTrackingCluster( - new PublishShoppingOrderTrackingClusterRequest.Builder() - .setShoppingOrderTrackingCluster( - new ShoppingOrderTrackingCluster.Builder() - ... - .build()) - .build()); - -When the service receives the request, the following actions take place within -one transaction: - -- Existing `ShoppingOrderTrackingCluster` data from the developer partner is removed. -- Data from the request is parsed and stored in the updated Shopping Order Tracking Cluster. - -In case of an error, the entire request is rejected and the existing state is -maintained. - -#### `publishUserAccountManagementRequest` - -This API is used to publish a Sign In card . The signin action directs users to -the app's sign in page so that the app can publish content (or provide more -personalized content) - -The following metadata is part of the Sign In Card - - -| Attribute | Requirement | Description | -|---|---|---| -| Action Uri | Required | Deeplink to Action (i.e. navigates to app sign in page) | -| Image | Optional - If not provided, Title must be provided | Image Shown on the Card 16x9 aspect ratio images with a resolution of 1264x712 | -| Title | Optional - If not provided, Image must be provided | Title on the Card | -| Action Text | Optional | Text Shown on the CTA (i.e. Sign in) | -| Subtitle | Optional | Optional Subtitle on the Card | - -> [!IMPORTANT] -> **Important:** The publish APIs are upsert APIs; it replaces the existing content. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. - -### Kotlin - - var SIGN_IN_CARD_ENTITY = - SignInCardEntity.Builder() - .addPosterImage( - Image.Builder() - .setImageUri(Uri.parse("http://www.x.com/image.png")) - .setImageHeightInPixel(500) - .setImageWidthInPixel(500) - .build()) - .setActionText("Sign In") - .setActionUri(Uri.parse("http://xx.com/signin")) - .build() - - client.publishUserAccountManagementRequest( - PublishUserAccountManagementRequest.Builder() - .setSignInCardEntity(SIGN_IN_CARD_ENTITY) - .build()); - -### Java - - SignInCardEntity SIGN_IN_CARD_ENTITY = - new SignInCardEntity.Builder() - .addPosterImage( - new Image.Builder() - .setImageUri(Uri.parse("http://www.x.com/image.png")) - .setImageHeightInPixel(500) - .setImageWidthInPixel(500) - .build()) - .setActionText("Sign In") - .setActionUri(Uri.parse("http://xx.com/signin")) - .build(); - - client.publishUserAccountManagementRequest( - new PublishUserAccountManagementRequest.Builder() - .setSignInCardEntity(SIGN_IN_CARD_ENTITY) - .build()); - -When the service receives the request, the following actions take place within -one transaction: - -- Existing `UserAccountManagementCluster` data from the developer partner is removed. -- Data from the request is parsed and stored in the updated UserAccountManagementCluster Cluster. - -In case of an error, the entire request is rejected and the existing state is -maintained. - -#### `updatePublishStatus` - -If for any internal business reason, none of the clusters is published, -we **strongly recommend** updating the publish status using the -**updatePublishStatus** API. -This is important because : - -- Providing the status in all scenarios, even when the content is published (STATUS == PUBLISHED), is critical to populate dashboards that use this explicit status to convey the health and other metrics of your integration. -- If no content is published but the integration status isn't broken (STATUS == NOT_PUBLISHED), Google can avoid triggering alerts in the app health dashboards. It confirms that content is not published due to an **expected** situation from the provider's standpoint. -- It helps developers provide insights into when the data is published versus not. -- Google may use the status codes to nudge the user to do certain actions in the app so they can see the app content or overcome it. - -The list of eligible publish status codes are : - - // Content is published - AppEngagePublishStatusCode.PUBLISHED, - - // Content is not published as user is not signed in - AppEngagePublishStatusCode.NOT_PUBLISHED_REQUIRES_SIGN_IN, - - // Content is not published as user is not subscribed - AppEngagePublishStatusCode.NOT_PUBLISHED_REQUIRES_SUBSCRIPTION, - - // Content is not published as user location is ineligible - AppEngagePublishStatusCode.NOT_PUBLISHED_INELIGIBLE_LOCATION, - - // Content is not published as there is no eligible content - AppEngagePublishStatusCode.NOT_PUBLISHED_NO_ELIGIBLE_CONTENT, - - // Content is not published as the feature is disabled by the client - // Available in v1.3.1 - AppEngagePublishStatusCode.NOT_PUBLISHED_FEATURE_DISABLED_BY_CLIENT, - - // Content is not published as the feature due to a client error - // Available in v1.3.1 - AppEngagePublishStatusCode.NOT_PUBLISHED_CLIENT_ERROR, - - // Content is not published as the feature due to a service error - // Available in v1.3.1 - AppEngagePublishStatusCode.NOT_PUBLISHED_SERVICE_ERROR, - - // Content is not published due to some other reason - // Reach out to engage-developers@ before using this enum. - AppEngagePublishStatusCode.NOT_PUBLISHED_OTHER - -If the content is not published due to a user not logged in, -Google would recommend publishing the Sign In Card. -If for any reason providers are not able to publish the Sign In Card -then we recommend calling the **updatePublishStatus** API -with the status code **NOT_PUBLISHED_REQUIRES_SIGN_IN** - -### Kotlin - - client.updatePublishStatus( - PublishStatusRequest.Builder() - .setStatusCode(AppEngagePublishStatusCode.NOT_PUBLISHED_REQUIRES_SIGN_IN) - .build()) - -### Java - - client.updatePublishStatus( - new PublishStatusRequest.Builder() - .setStatusCode(AppEngagePublishStatusCode.NOT_PUBLISHED_REQUIRES_SIGN_IN) - .build()); - -#### `deleteRecommendationClusters` - -This API is used to delete the content of Recommendation Clusters. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteRecommendationClusters() - -### Java - - client.deleteRecommendationClusters(); - -When the service receives the request, it removes the existing data from the -Recommendation Clusters. In case of an error, the entire request is rejected -and the existing state is maintained. - -> [!NOTE] -> **Note:** This api is available from version 1.1.0 onwards. - -#### `deleteFeaturedCluster` - -This API is used to delete the content of Featured Cluster. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteFeaturedCluster() - -### Java - - client.deleteFeaturedCluster(); - -When the service receives the request, it removes the existing data from the -Featured Cluster. In case of an error, the entire request is rejected -and the existing state is maintained. - -> [!NOTE] -> **Note:** This api is available from version 1.1.0 onwards. - -#### `deleteShoppingCartCluster` - -This API is used to delete the content of Shopping Cart Cluster. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteShoppingCartCluster() - -### Java - - client.deleteShoppingCartCluster(); - -When the service receives the request, it removes the existing data from the -Shopping Cart Cluster. In case of an error, the entire request is rejected -and the existing state is maintained. - -> [!NOTE] -> **Note:** This api is available from version 1.1.0 onwards. - -#### `deleteShoppingListCluster` - -This API is used to delete the content of Shopping List Cluster. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteShoppingListCluster() - -### Java - - client.deleteShoppingListCluster(); - -When the service receives the request, it removes the existing data from the -Shopping List Cluster. In case of an error, the entire request is rejected -and the existing state is maintained. - -> [!NOTE] -> **Note:** This api is available from version 1.1.0 onwards. - -#### `deleteShoppingReorderCluster` - -This API is used to delete the content of Shopping Reorder Cluster. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteShoppingReorderCluster() - -### Java - - client.deleteShoppingReorderCluster(); - -When the service receives the request, it removes the existing data from the -Shopping Reorder Cluster. In case of an error, the entire request is rejected -and the existing state is maintained. - -> [!NOTE] -> **Note:** This api is available from version 1.1.0 onwards. - -#### `deleteShoppingOrderTrackingCluster` - -This API is used to delete the content of Shopping Order Tracking Cluster. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteShoppingOrderTrackingCluster() - -### Java - - client.deleteShoppingOrderTrackingCluster(); - -When the service receives the request, it removes the existing data from the -Shopping Order Tracking Cluster. In case of an error, the entire request is -rejected and the existing state is maintained. - -> [!NOTE] -> **Note:** This API is available in versions 1.4.0 and higher. - -#### `deleteUserManagementCluster` - -This API is used to delete the content of UserAccountManagement Cluster. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteUserManagementCluster() - -### Java - - client.deleteUserManagementCluster(); - -When the service receives the request, it removes the existing data from the -UserAccountManagement Cluster. In case of an error, the entire request is -rejected and the existing state is maintained. - -> [!NOTE] -> **Note:** This api is available from version 1.1.0 onwards. - -#### `deleteClusters` - -This API is used to delete the content of a given cluster type. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteClusters( - DeleteClustersRequest.Builder() - .addClusterType(ClusterType.TYPE_FEATURED) - .addClusterType(ClusterType.TYPE_RECOMMENDATION) - ... - .build()) - -### Java - - client.deleteClusters( - new DeleteClustersRequest.Builder() - .addClusterType(ClusterType.TYPE_FEATURED) - .addClusterType(ClusterType.TYPE_RECOMMENDATION) - ... - .build()); - -When the service receives the request, it removes the existing data from all -clusters matching the specified cluster types. Clients can choose to pass one or -many cluster types. In case of an error, the entire request is rejected and the -existing state is maintained. - -#### Error handling - -It is highly recommended to listen to the task result from the publish APIs such -that a follow-up action can be taken to recover and resubmit an successful task. - -### Kotlin - - client.publishRecommendationClusters( - PublishRecommendationClustersRequest.Builder() - .addRecommendationCluster(..) - .build()) - .addOnCompleteListener { task -> - if (task.isSuccessful) { - // do something - } else { - val exception = task.exception - if (exception is AppEngageException) { - @AppEngageErrorCode val errorCode = exception.errorCode - if (errorCode == AppEngageErrorCode.SERVICE_NOT_FOUND) { - // do something - } - } - } - } - -### Java - - client.publishRecommendationClusters( - new PublishRecommendationClustersRequest.Builder() - .addRecommendationCluster(...) - .build()) - .addOnCompleteListener( - task -> { - if (task.isSuccessful()) { - // do something - } else { - Exception exception = task.getException(); - if (exception instanceof AppEngageException) { - @AppEngageErrorCode - int errorCode = ((AppEngageException) exception).getErrorCode(); - if (errorCode == AppEngageErrorCode.SERVICE_NOT_FOUND) { - // do something - } - } - } - }); - -The error is returned as an `AppEngageException` with the cause included as an -error code. - -| Error code | Error name | Note | -|---|---|---| -| `1` | `SERVICE_NOT_FOUND` | The service is not available on the given device. | -| `2` | `SERVICE_NOT_AVAILABLE` | The service is available on the given device, but it is not available at the time of the call (for example, it is explicitly disabled). | -| `3` | `SERVICE_CALL_EXECUTION_FAILURE` | The task execution failed due to threading issues. In this case, it can be retried. | -| `4` | `SERVICE_CALL_PERMISSION_DENIED` | The caller is not allowed to make the service call. | -| `5` | `SERVICE_CALL_INVALID_ARGUMENT` | The request contains invalid data (for example, more than the allowed number of clusters). | -| `6` | `SERVICE_CALL_INTERNAL` | There is an error on the service side. | -| `7` | `SERVICE_CALL_RESOURCE_EXHAUSTED` | The service call is made too frequently. | - -### Step 3: Handle broadcast intents - -In addition to making publish content API calls through a job, it is also -required to set up a -[`BroadcastReceiver`](https://developer.android.com/reference/android/content/BroadcastReceiver) to receive -the request for a content publish. - -The goal of broadcast intents is mainly for app reactivation and forcing data -sync. Broadcast intents are not designed to be sent very frequently. It is only -triggered when the Engage Service determines the content might be stale (for -example, a week old). That way, there is more confidence that the user can have -a fresh content experience, even if the application has not been executed for a -long period of time. - -The `BroadcastReceiver` must be set up in the following two ways: - -- Dynamically register an instance of the `BroadcastReceiver` class using - `Context.registerReceiver()`. This enables communication from applications - that are still live in memory. - -### Kotlin - - class AppEngageBroadcastReceiver : BroadcastReceiver(){ - // Trigger recommendation cluster publish when PUBLISH_RECOMMENDATION - // broadcast is received - // Trigger featured cluster publish when PUBLISH_FEATURED broadcast is - // received - // Trigger shopping cart cluster publish when PUBLISH_SHOPPING_CART broadcast - // is received - // Trigger shopping list cluster publish when PUBLISH_SHOPPING_LIST broadcast - // is received - // Trigger reorder cluster publish when PUBLISH_REORDER_CLUSTER broadcast is - // received - // Trigger shopping order tracking cluster publish when - // PUBLISH_SHOPPING_ORDER_TRACKING_CLUSTER broadcast is received - } - - fun registerBroadcastReceivers(context: Context){ - var context = context - context = context.applicationContext - - // Register Recommendation Cluster Publish Intent - context.registerReceiver(AppEngageBroadcastReceiver(), - IntentFilter(Intents.ACTION_PUBLISH_RECOMMENDATION), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null) - - // Register Featured Cluster Publish Intent - context.registerReceiver(AppEngageBroadcastReceiver(), - IntentFilter(Intents.ACTION_PUBLISH_FEATURED), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null) - - // Register Shopping Cart Cluster Publish Intent - context.registerReceiver(AppEngageBroadcastReceiver(), - IntentFilter(Intents.ACTION_PUBLISH_SHOPPING_CART), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null) - - // Register Shopping List Cluster Publish Intent - context.registerReceiver(AppEngageBroadcastReceiver(), - IntentFilter(Intents.ACTION_PUBLISH_SHOPPING_LIST), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null) - - // Register Reorder Cluster Publish Intent - context.registerReceiver(AppEngageBroadcastReceiver(), - IntentFilter(Intents.ACTION_PUBLISH_REORDER_CLUSTER), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null) - - // Register Shopping Order Tracking Cluster Publish Intent - context.registerReceiver(AppEngageBroadcastReceiver(), - IntentFilter(Intents.ACTION_PUBLISH_SHOPPING_ORDER_TRACKING_CLUSTER), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null) - } - -### Java - - class AppEngageBroadcastReceiver extends BroadcastReceiver { - // Trigger recommendation cluster publish when PUBLISH_RECOMMENDATION broadcast - // is received - - // Trigger featured cluster publish when PUBLISH_FEATURED broadcast is received - - // Trigger shopping cart cluster publish when PUBLISH_SHOPPING_CART broadcast is - // received - - // Trigger shopping list cluster publish when PUBLISH_SHOPPING_LIST broadcast is - // received - - // Trigger reorder cluster publish when PUBLISH_REORDER_CLUSTER broadcast is - // received - - // Trigger reorder cluster publish when PUBLISH_SHOPPING_ORDER_TRACKING_CLUSTER - // broadcast is received - } - - public static void registerBroadcastReceivers(Context context) { - - context = context.getApplicationContext(); - - // Register Recommendation Cluster Publish Intent - context.registerReceiver(new AppEngageBroadcastReceiver(), - new IntentFilter(com.google.android.engage.service.Intents.ACTION_PUBLISH_RECOMMENDATION), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null); - - // Register Featured Cluster Publish Intent - context.registerReceiver(new AppEngageBroadcastReceiver(), - new IntentFilter(com.google.android.engage.service.Intents.ACTION_PUBLISH_FEATURED), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null); - - // Register Shopping Cart Cluster Publish Intent - context.registerReceiver(new AppEngageBroadcastReceiver(), - new IntentFilter(com.google.android.engage.shopping.service.Intents.ACTION_PUBLISH_SHOPPING_CART), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null); - - // Register Shopping List Cluster Publish Intent - context.registerReceiver(new AppEngageBroadcastReceiver(), - new IntentFilter(com.google.android.engage.shopping.service.Intents.ACTION_PUBLISH_SHOPPING_LIST), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null); - - // Register Reorder Cluster Publish Intent - context.registerReceiver(new AppEngageBroadcastReceiver(), - new IntentFilter(com.google.android.engage.shopping.service.Intents.ACTION_PUBLISH_REORDER_CLUSTER), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null); - - // Register Shopping Order Tracking Cluster Publish Intent - context.registerReceiver(new AppEngageBroadcastReceiver(), - new IntentFilter(com.google.android.engage.shopping.service.Intents.ACTION_PUBLISH_SHOPPING_ORDER_TRACKING_CLUSTER), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null); - - } - -- Statically declare an implementation with the `` tag in your - `AndroidManifest.xml` file. This allows the application to receive broadcast - intents when it is not running, and also allows the application to publish - the content. - - - - - - - - - - - - - - - - - - - - - - - - -The following [intents](https://developer.android.com/reference/android/content/Intent) are sent by the -service: - -- `com.google.android.engage.action.PUBLISH_RECOMMENDATION` It is recommended to start a `publishRecommendationClusters` call when this intent is received. -- `com.google.android.engage.action.PUBLISH_FEATURED` It is recommended to start a `publishFeaturedCluster` call when this intent is received. -- `com.google.android.engage.action.shopping.PUBLISH_SHOPPING_CART` It is recommended to start a `publishShoppingCarts` call when this intent is received. -- `com.google.android.engage.action.shopping.PUBLISH_SHOPPING_LIST` It is recommended to start a `publishShoppingLists` call when this intent is received. -- `com.google.android.engage.action.shopping.PUBLISH_REORDER_CLUSTER` It is recommended to start a `publishReorderCluster` call when this intent is received. -- `com.google.android.engage.action.shopping.PUBLISH_SHOPPING_ORDER_TRACKING_CLUSTER` It is recommended to start a `publishShoppingOrderTrackingCluster` call when this intent is received. - -## Integration workflow - -For a step-by-step guide on verifying your integration after it is complete, see -[Engage developer integration workflow](https://developer.android.com/guide/playcore/engage/workflow). - -## FAQs - -See [Engage SDK Frequently Asked Questions](https://developer.android.com/guide/playcore/engage/faq) for -FAQs. - -## Contact - -Contact -[`engage-developers@google.com`](mailto:engage-developers@google.com) if there are -any questions during the integration process. Our team replies as soon as -possible. - -## Next steps - -After completing this integration, your next steps are as follows: - -- Send an email to [`engage-developers@google.com`](mailto:engage-developers@google.com) and attach your integrated APK that is ready for testing by Google. -- Google performs a verification and reviews internally to make sure the integration works as expected. If changes are needed, Google contacts you with any necessary details. -- When testing is complete and no changes are needed, Google contacts you to notify you that you can start publishing the updated and integrated APK to the Play Store. -- After Google has confirmed that your updated APK has been published to the Play Store, your **Recommendation** , **Featured** , **Shopping Cart** , **Shopping List** , **Reorder Cluster** and **Shopping Order Tracking Cluster** clusters may be published and visible to users. \ No newline at end of file diff --git a/play/engage-sdk-integration/references/android/guide/playcore/engage/social.md b/play/engage-sdk-integration/references/android/guide/playcore/engage/social.md deleted file mode 100644 index 778b040..0000000 --- a/play/engage-sdk-integration/references/android/guide/playcore/engage/social.md +++ /dev/null @@ -1,742 +0,0 @@ -Boost app engagement by reaching your users where they are. Integrate Engage SDK -to deliver personalized recommendations and continuation content directly to -users across multiple on-device surfaces, like -**[Collections](https://android-developers.googleblog.com/2024/07/introducing-collections-powered-by-engage-sdk.html)** , **[Entertainment -Space](https://blog.google/products/android/entertainment-space/)** , and the Play Store. The integration adds -less than 50 KB (compressed) to the average APK and takes most apps about a -week of developer time. Learn more at our **[business -site](http://play.google.com/console/about/programs/EngageSDK)**. - -This guide contains instructions for developer partners to deliver social media -content to Engage content surfaces. - -## Category support and surfaces - -Engage SDK surface eligibility is based on your app's content category. Use the -following table to determine your eligibility for specific surfaces: - -| Status | Content category or use case | Supported surfaces | -|---|---|---| -| **Supported** (Eligible on all surfaces) | - Social networks - Image / memes - Video clips / short-form video - IRL meetups - Cloud photo storage | - Collections - Play Store Apps tab - Play Store You tab - Play Store store listing page | -| **Not supported** | - Live Video Chat - Instant messaging, sharing online presence, or live data - File-sharing \& downloaders - Dating - Avatar generators | | - - -## Android skills - -[View on GitHub](https://github.com/android/skills/tree/main/play/engage-sdk-integration) - -### Integrate Engage SDK - -Use the Engage SDK [Android skill](https://developer.android.com/tools/agents/android-skills) to integrate, debug, and resolve implementation issues. To install the skill from the [Android CLI](https://developer.android.com/tools/agents/android-cli), run: - - android skills add engage-sdk-integration - -
- -## Integration detail - -The following section captures the integration detail. - -### Terminology - -***Recommendation*** clusters show personalized suggestions from an individual -developer partner. - -Your recommendations take the following structure: - -**Recommendation Cluster**: UI view that contains a group of recommendations -from the same developer partner. - -Each Recommendation Cluster consists of one of the following two types of -entities : - -- PortraitMediaEntity -- SocialPostEntity - -**PortraitMediaEntity** must contain 1 portrait image for the post. Profile and -Interaction related metadata are optional. - -- Post - - - Image in portrait mode and Timestamp, or - - Image in portrait mode + text content and Timestamp -- Profile - - - Avatar, Name or Handle, Additional image -- Interactions - - - Count and label only, or - - Count and visual (icon) - -**SocialPostEntity** contains profile, post and interaction related metadata. - -- Profile - - - Avatar, Name or Handle, additional text, additional image -- Post - - - Text and Timestamp, or - - Rich media (image or rich URL) and Timestamp, or - - Text and rich media (image or rich URL) and Timestamp, or - - Video preview (thumbnail and duration) and Timestamp -- Interactions - - - Count \& label only, or - - Count \& visual (icon) - -### Pre-work - -Minimum API level: 19 - -Add the `com.google.android.engage:engage-core` library to your app: - - dependencies { - // Make sure you also include that repository in your project's build.gradle file. - implementation 'com.google.android.engage:engage-core:1.6.0' - } - -### Summary - -The design is based on an implementation of a [bound -service](https://developer.android.com/guide/components/bound-services). - -The data a client can publish is subject to the following limits for different -cluster types: - -| Cluster type | Cluster limits | Minimum entity limits in a cluster | Maximum entity limits in a cluster | -|---|---|---|---| -| Recommendation Cluster(s) | At most 7 | At least 1 (`PortraitMediaEntity`, or `SocialPostEntity`) | At most 50 (`PortraitMediaEntity`, or `SocialPostEntity`) | - -### Step 1: Provide entity data - -The SDK has defined different entities to represent each item type. The SDK -supports the following entities for the Social category: - -1. `PortraitMediaEntity` -2. `SocialPostEntity` - -The charts below outline available attributes and requirements for each type. - -#### `PortraitMediaEntity` - -| Attribute | Requirement | Description | Format | -|---|---|---|---| -| Action URI | **Required** for all surfaces other than Google TV | Deep Link to the entity in the provider app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | URI | -| PlatformSpecificPlayback | **Required** for Google TV surface | Deep Link to the entity in the provider app for platforms like Google TV and Mobile. | List of PlatformSpecificPlayback objects | -| Recommendation Reason | Optional | The justification for recommending the content to the user. | RecommendationReason object | -| Comments Summary | Optional | Summary of comments for the post. | String | -| **Post related metadata (Required)** |||| -| Image(s) | Required | Image(s) should be in **portrait aspect ratio.** The UI may show only 1 image when multiple images are provided. However, the UI may provide visual indication that there are more images in the app. *If the post is a video, the provider should provide a thumbnail of the video to be shown as an image.* | See [Image Specifications](https://developer.android.com/guide/playcore/engage/social#image-specs) for guidance. | -| Text content | Optional | The main text of a post, update, etc. | String (recommended max 140 chars) | -| Timestamp | Optional | Time when the post was published. | Epoch timestamp in milliseconds | -| Is video content | Optional | Is the post a video? | boolean | -| Video duration | Optional | The duration of the video in milliseconds. | Long | -| **Profile related metadata (Optional)** |||| -| Name | Required | Profile name or id or handle, eg "John Doe", "@TeamPixel" | String(recommended max 25 chars) | -| Avatar | Required | Profile picture or avatar image of the user. **Square 1:1 image** | See [Image Specifications](https://developer.android.com/guide/playcore/engage/social#image-specs) for guidance. | -| Additional Image | Optional | Profile badge. for example - verified badge **Square 1:1 image** | See [Image Specifications](https://developer.android.com/guide/playcore/engage/social#image-specs) for guidance. | -| **Interactions related metadata (Optional)** |||| -| Count | Optional | Indicate the number of interactions, for example - "3.7 M.". **Note:** If both Count and Count Value are provided, Count will be used. **Note:** Partners should use either **Count** or **CountWithOptionalLabel**. | String | -| CountWithOptionalLabel | Optional | Indicate the number of interactions with an optional label, for example - "3.7 M Likes.". **Note:** If both CountWithOptionalLabel and Count Value are provided, one of them will be used. **Note:** Partners should use either **Count** or **CountWithOptionalLabel**. | String | -| Count Value | Optional | The number of interactions as a value. **Note:** Provide Count Value instead of Count if your app doesn't handle logic on how a large number should be optimized for different display sizes. If both Count and Count Value are provided, Count is used. | Long | -| Label | Optional | Indicate what the interaction label is for. For example - "Likes". | String | -| Visual | Optional | Indicate what the interaction is for. For example - Image showing Likes icon, emoji. Can provide more than 1 image, though not all may not be shown on all form factors. **Note:** Must be Square 1:1 image | See [Image Specifications](https://developer.android.com/guide/playcore/engage/social#image-specs) for guidance. | -| **DisplayTimeWindow (Optional) - Set a time window for a content to be shown on the surface** |||| -| Start Timestamp | Optional | The epoch timestamp after which the content should be shown on the surface. If not set, content is eligible to be shown on the surface. | Epoch timestamp in milliseconds | -| End Timestamp | Optional | The epoch timestamp after which the content is no longer shown on the surface. If not set, content is eligible to be shown on the surface. | Epoch timestamp in milliseconds | - -#### `SocialPostEntity` - -| Attribute | Requirement | Description | Format | -|---|---|---|---| -| Action URI | **Required** | Deep Link to the entity in the provider app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | URI | -| PlatformSpecificPlayback URIs | **Required** for Google TV surface | Deep Link to the entity in the provider app for platforms like Google TV and Mobile. | List of PlatformSpecificPlayback objects | -| Recommendation Reason | Optional | The justification for recommending the content to the user. | RecommendationReason object | -| Comments Summary | Optional | Summary of comments for the post. | String | -| **Post related metadata (Required)** At least one of TextContent, Image or WebContent is required |||| -| Image(s) | Optional | Image(s) should be in **portrait aspect ratio.** The UI may show only 1 image when multiple images are provided. However, the UI may provide visual indication that there are more images in the app. *If the post is a video, the provider should provide a thumbnail of the video to be shown as an image.* | See [Image Specifications](https://developer.android.com/guide/playcore/engage/social#image-specs) for guidance. | -| Text content | Optional | The main text of a post, update, etc. | String (recommended max 140 chars) | -| **Video Content (Optional)** |||| -| Duration | Required | The duration of the video in milliseconds. | Long | -| Image | Required | Preview image of the video content. | See [Image Specifications](https://developer.android.com/guide/playcore/engage/social#image-specs) for guidance. | -| **Link Preview (Optional)** |||| -| Link Preview - Title | Required | Text to indicate the title of the web page content | String | -| Link Preview - Hostname | Required | Text to indicate the web page owner, eg "INSIDER" | String | -| Link Preview - Image | Optional | Hero image for the web content | See [Image Specifications](https://developer.android.com/guide/playcore/engage/social#image-specs) for guidance. | -| Timestamp | Optional | Time when the post was published. | Epoch timestamp in milliseconds | -| **Profile related metadata (Optional)** |||| -| Name | Required | Profile name or id or handle, eg "John Doe", "@TeamPixel." | String(recommended max 25 chars) | -| Additional Text | Optional | Could be used as profile id or handle or additional metadata For example "@John-Doe", "5M followers", "You might like", "Trending", "5 new posts" | String(recommended max 40 chars) | -| Avatar | Required | Profile picture or avatar image of the user. **Square 1:1 image** | See [Image Specifications](https://developer.android.com/guide/playcore/engage/social#image-specs) for guidance. | -| Additional Image | Optional | Profile badge, for example - verified badge **Square 1:1 image** | See [Image Specifications](https://developer.android.com/guide/playcore/engage/social#image-specs) for guidance. | -| **Interactions related metadata (Optional)** |||| -| Count | Required | Indicate the number of interactions, for example - "3.7 M." **Note:** Partners should use either **Count** or **CountWithOptionalLabel**. | String | -| CountWithOptionalLabel | Required | Indicate the number of interactions with an optional label, for example - "3.7 M Likes." **Note:** Partners should use either **Count** or **CountWithOptionalLabel**. | String | -| Label | Optional If not provided, **Visual** must be provided. | Indicate what the interaction is for. For example - "Likes." | String (recommended max 20 chars for count + label combined) | -| Visual | Optional If not provided, **Label** must be provided. | Indicate what the interaction is for. For example - Image showing Likes icon, emoji. Can provide more than 1 image, though not all may not be shown on all form factors. **Square 1:1 image** | See [Image Specifications](https://developer.android.com/guide/playcore/engage/social#image-specs) for guidance. | -| **DisplayTimeWindow (Optional) - Set a time window for a content to be shown on the surface** |||| -| Start Timestamp | Optional | The epoch timestamp after which the content should be shown on the surface. If not set, content is eligible to be shown on the surface. | Epoch timestamp in milliseconds | -| End Timestamp | Optional | The epoch timestamp after which the content is no longer shown on the surface. If not set, content is eligible to be shown on the surface. | Epoch timestamp in milliseconds | - -#### Image specifications - -The images are required to be hosted on public CDNs so that Google can access -them. - -*File formats* - -PNG, JPG, static GIF, WebP - -*Maximum file size* - -5120 KB - -*Additional recommendations* - -- **Image safe area:** Put your important content in the center 80% of the image. -- Use a transparent background so that the image can be properly displayed in Dark and Light theme settings. - -### Step 2: Provide Cluster data - -It is recommended to have the content publish job executed in the background -(for example, using [WorkManager](https://developer.android.com/topic/libraries/architecture/workmanager)) -and scheduled on a regular basis or on an event basis (for example, every time -the user opens the app or when the user just followed a new account) - -`AppEngageSocialClient` is responsible for publishing social clusters. - -There are following APIs to publish clusters in the client: - -- `isServiceAvailable` -- `publishRecommendationClusters` -- `publishUserAccountManagementRequest` -- `updatePublishStatus` -- `deleteRecommendationsClusters` -- `deleteUserManagementCluster` -- `deleteClusters` - -#### `isServiceAvailable` - -This API is used to check if the service is available for integration and -whether the content can be presented on the device. - -##### For Engage SDK v1.6.0 and higher (Recommended) - - -## Android skills - -[View on GitHub](https://github.com/android/skills/tree/main/play/engage-sdk-integration) - -### Engage SDK integration - -To install the skill from the [Android CLI](https://developer.android.com/tools/agents/android-cli), run: - - android skills add engage-sdk-integration - -If your team uses AI coding tools (such as Gemini in Android Studio), you can automate this migration by prompting your AI assistant: - - Use the engage-sdk-integration skill to use Engage SDK 1.6.0 and refactor isServiceAvailable to pass ServiceAvailabilityRequest for publishing all cluster types. - -
- -You can check the service availability for every cluster type that you intend to -publish. The `isServiceAvailable` API accepts a request object, -`ServiceAvailabilityRequest`, which contains the cluster types for which service -availability needs to be checked. You can find the `ClusterType` enum values -required for `ServiceAvailabilityRequest` from the following table. - -| Cluster Type | Cluster Type Constant | Integer Value | -|---|---|---| -| Unknown | `TYPE_UNKNOWN` | 0 | -| Recommendation Cluster | `TYPE_RECOMMENDATION` | 1 | -| Featured Cluster | `TYPE_FEATURED` | 2 | -| Continuation Cluster | `TYPE_CONTINUATION` | 3 | -| User Management Cluster | `TYPE_ENGAGEMENT` | 8 | -| Subscription Cluster | `TYPE_SUBSCRIPTION` | 12 | - -### Kotlin - - val request = ServiceAvailabilityRequest.Builder() - .addIntendedClusterType(ClusterType.TYPE_CONTINUATION) - .addIntendedClusterType(ClusterType.TYPE_RECOMMENDATION) - .build() - - client.isServiceAvailable(request).addOnCompleteListener { task -> - if (task.isSuccessful) { - val availabilityMap = task.result - if (availabilityMap[ClusterType.TYPE_CONTINUATION] == true) { - // Proceed with publishing continuation content - } - if (availabilityMap[ClusterType.TYPE_RECOMMENDATION] == true) { - // Proceed with publishing recommendation content - } - } else { - // The IPC call itself fails, proceed with error handling logic here, - // such as retry. - } - } - -### Java - - ServiceAvailabilityRequest request = - new ServiceAvailabilityRequest.Builder() - .addIntendedClusterType(ClusterType.TYPE_CONTINUATION) - .addIntendedClusterType(ClusterType.TYPE_RECOMMENDATION) - .build(); - - client.isServiceAvailable(request).addOnCompleteListener(task -> { - if (task.isSuccessful()) { - Map availabilityMap = task.getResult(); - if (Boolean.TRUE.equals(availabilityMap.get(ClusterType.TYPE_CONTINUATION))) { - // Proceed with publishing continuation content - } - if (Boolean.TRUE.equals(availabilityMap.get(ClusterType.TYPE_RECOMMENDATION))) { - // Proceed with publishing recommendation content - } - } else { - // The IPC call itself fails, proceed with error handling logic here, - // such as retry. - } - }); - -###### Conditional Service Availability Feature - -Some integrated apps request a special configuration that enables and disables -the Engage service intermittently in order to reduce their serving cost. This -intermittent content ingestion strategy, although possible, negatively affects -the user and the product -- stale content will not be presented and some surfaces -will not be served at all. - -Starting with v1.6.0, the Engage SDK allows checking availability for specific -cluster types. If you are interested in opting into this feature for any cluster type, -please contact engage-developers@google.com. - -##### For SDK versions prior to v1.6.0 (Deprecated) - -### Kotlin - - client.isServiceAvailable.addOnCompleteListener { task -> - if (task.isSuccessful) { - // Handle IPC call success - if(task.result) { - // Service is available on the device, proceed with content publish - // calls. - } else { - // Service is not available, no further action is needed. - } - } else { - // The IPC call itself fails, proceed with error handling logic here, - // such as retry. - } - } - -### Java - - client.isServiceAvailable().addOnCompleteListener(task - > { - if (task.isSuccessful()) { - // Handle success - if(task.getResult()) { - // Service is available on the device, proceed with content publish - // calls. - } else { - // Service is not available, no further action is needed. - } - } else { - // The IPC call itself fails, proceed with error handling logic here, - // such as retry. - } - }); - -> [!NOTE] -> **Note:** We highly recommend keeping a periodic job running to check if the service becomes available at a later point in time. The availability of the service may change with Android version upgrades, app upgrades, installs, and uninstalls. By ensuring periodic job checks at a certain time interval, data can be published once the service becomes available. - -#### `publishRecommendationClusters` - -This API is used to publish a list `RecommendationCluster` objects. - -A `RecommendationCluster` object can have the following attributes: - -| Attribute | Requirement | Description | -|---|---|---| -| List of SocialPostEntity, or PortraitMediaEntity | **Required** | A list of entities that make up the recommendations for this Recommendation Cluster. Entities in a single cluster must be of the same type. | -| Title | **Required** | The title for the Recommendation Cluster (for example, *Latest from your friends*). **Recommended text size: under 25 chars** (Text that is too long may show ellipses) | -| Subtitle | Optional | The subtitle for the Recommendation Cluster. | -| Action Uri | Optional | The deep link to the page in the partner app where users can see the complete list of recommendations. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | - -> [!IMPORTANT] -> **Important:** The publish APIs are upsert APIs; it replaces the existing content. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. - -> [!IMPORTANT] -> **Important:** For social apps, it's critical to update recommendations after each app usage. Social app users are more interested in the most recent recommendations and ideally would like to see a post at most once. - -### Kotlin - - client.publishRecommendationClusters( - PublishRecommendationClustersRequest.Builder() - .addRecommendationCluster( - RecommendationCluster.Builder() - .addEntity(entity1) - .addEntity(entity2) - .setTitle("Latest from your friends") - .build()) - .build()) - -### Java - - client.publishRecommendationClusters( - new PublishRecommendationClustersRequest.Builder() - .addRecommendationCluster( - new RecommendationCluster.Builder() - .addEntity(entity1) - .addEntity(entity2) - .setTitle("Latest from your friends") - .build()) - .build()); - -When the service receives the request, the following actions take place within -one transaction: - -- All existing Recommendation Cluster data is removed. -- Data from the request is parsed and stored in new Recommendation Clusters. - -In case of an error, the entire request is rejected and the existing state is -maintained. - -#### `publishUserAccountManagementRequest` - -This API is used to publish a Sign In card . The signin action directs users to -the app's sign in page so that the app can publish content (or provide more -personalized content) - -The following metadata is part of the Sign In Card - - -| Attribute | Requirement | Description | -|---|---|---| -| Action Uri | Required | Deeplink to Action (i.e. navigates to app sign in page) | -| Image | Optional - If not provided, Title must be provided | Image Shown on the Card 16x9 aspect ratio images with a resolution of 1264x712 | -| Title | Optional - If not provided, Image must be provided | Title on the Card | -| Action Text | Optional | Text Shown on the CTA (i.e. Sign in) | -| Subtitle | Optional | Optional Subtitle on the Card | - -> [!IMPORTANT] -> **Important:** The publish APIs are upsert APIs; it replaces the existing content. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. - -### Kotlin - - var SIGN_IN_CARD_ENTITY = - SignInCardEntity.Builder() - .addPosterImage( - Image.Builder() - .setImageUri(Uri.parse("http://www.x.com/image.png")) - .setImageHeightInPixel(500) - .setImageWidthInPixel(500) - .build()) - .setActionText("Sign In") - .setActionUri(Uri.parse("http://xx.com/signin")) - .build() - - client.publishUserAccountManagementRequest( - PublishUserAccountManagementRequest.Builder() - .setSignInCardEntity(SIGN_IN_CARD_ENTITY) - .build()); - -### Java - - SignInCardEntity SIGN_IN_CARD_ENTITY = - new SignInCardEntity.Builder() - .addPosterImage( - new Image.Builder() - .setImageUri(Uri.parse("http://www.x.com/image.png")) - .setImageHeightInPixel(500) - .setImageWidthInPixel(500) - .build()) - .setActionText("Sign In") - .setActionUri(Uri.parse("http://xx.com/signin")) - .build(); - - client.publishUserAccountManagementRequest( - new PublishUserAccountManagementRequest.Builder() - .setSignInCardEntity(SIGN_IN_CARD_ENTITY) - .build()); - -When the service receives the request, the following actions take place within -one transaction: - -- Existing `UserAccountManagementCluster` data from the developer partner is removed. -- Data from the request is parsed and stored in the updated UserAccountManagementCluster Cluster. - -In case of an error, the entire request is rejected and the existing state is -maintained. - -#### `updatePublishStatus` - -If for any internal business reason, none of the clusters is published, -we **strongly recommend** updating the publish status using the -**updatePublishStatus** API. -This is important because : - -- Providing the status in all scenarios, even when the content is published (STATUS == PUBLISHED), is critical to populate dashboards that use this explicit status to convey the health and other metrics of your integration. -- If no content is published but the integration status isn't broken (STATUS == NOT_PUBLISHED), Google can avoid triggering alerts in the app health dashboards. It confirms that content is not published due to an **expected** situation from the provider's standpoint. -- It helps developers provide insights into when the data is published versus not. -- Google may use the status codes to nudge the user to do certain actions in the app so they can see the app content or overcome it. - -The list of eligible publish status codes are : - - // Content is published - AppEngagePublishStatusCode.PUBLISHED, - - // Content is not published as user is not signed in - AppEngagePublishStatusCode.NOT_PUBLISHED_REQUIRES_SIGN_IN, - - // Content is not published as user is not subscribed - AppEngagePublishStatusCode.NOT_PUBLISHED_REQUIRES_SUBSCRIPTION, - - // Content is not published as user location is ineligible - AppEngagePublishStatusCode.NOT_PUBLISHED_INELIGIBLE_LOCATION, - - // Content is not published as there is no eligible content - AppEngagePublishStatusCode.NOT_PUBLISHED_NO_ELIGIBLE_CONTENT, - - // Content is not published as the feature is disabled by the client - // Available in v1.3.1 - AppEngagePublishStatusCode.NOT_PUBLISHED_FEATURE_DISABLED_BY_CLIENT, - - // Content is not published as the feature due to a client error - // Available in v1.3.1 - AppEngagePublishStatusCode.NOT_PUBLISHED_CLIENT_ERROR, - - // Content is not published as the feature due to a service error - // Available in v1.3.1 - AppEngagePublishStatusCode.NOT_PUBLISHED_SERVICE_ERROR, - - // Content is not published due to some other reason - // Reach out to engage-developers@ before using this enum. - AppEngagePublishStatusCode.NOT_PUBLISHED_OTHER - -If the content is not published due to a user not logged in, -Google would recommend publishing the Sign In Card. -If for any reason providers are not able to publish the Sign In Card -then we recommend calling the **updatePublishStatus** API -with the status code **NOT_PUBLISHED_REQUIRES_SIGN_IN** - -### Kotlin - - client.updatePublishStatus( - PublishStatusRequest.Builder() - .setStatusCode(AppEngagePublishStatusCode.NOT_PUBLISHED_REQUIRES_SIGN_IN) - .build()) - -### Java - - client.updatePublishStatus( - new PublishStatusRequest.Builder() - .setStatusCode(AppEngagePublishStatusCode.NOT_PUBLISHED_REQUIRES_SIGN_IN) - .build()); - -#### `deleteRecommendationClusters` - -This API is used to delete the content of Recommendation Clusters. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteRecommendationClusters() - -### Java - - client.deleteRecommendationClusters(); - -When the service receives the request, it removes the existing data from the -Recommendation Clusters. In case of an error, the entire request is rejected -and the existing state is maintained. - -#### `deleteUserManagementCluster` - -This API is used to delete the content of UserAccountManagement Cluster. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteUserManagementCluster() - -### Java - - client.deleteUserManagementCluster(); - -When the service receives the request, it removes the existing data from the -UserAccountManagement Cluster. In case of an error, the entire request is -rejected and the existing state is maintained. - -#### `deleteClusters` - -This API is used to delete the content of a given cluster type. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteClusters( - DeleteClustersRequest.Builder() - .addClusterType(ClusterType.TYPE_RECOMMENDATION) - ... - .build()) - -### Java - - client.deleteClusters( - new DeleteClustersRequest.Builder() - .addClusterType(ClusterType.TYPE_RECOMMENDATION) - ... - .build()); - -When the service receives the request, it removes the existing data from all -clusters matching the specified cluster types. Clients can choose to pass one or -many cluster types. In case of an error, the entire request is rejected and the -existing state is maintained. - -#### Error handling - -It is highly recommended to listen to the task result from the publish APIs such -that a follow-up action can be taken to recover and resubmit an successful task. - - client.publishRecommendationClusters( - new PublishRecommendationClustersRequest.Builder() - .addRecommendationCluster(...) - .build()) - .addOnCompleteListener( - task -> { - if (task.isSuccessful()) { - // do something - } else { - Exception exception = task.getException(); - if (exception instanceof AppEngageException) { - @AppEngageErrorCode - int errorCode = ((AppEngageException) exception).getErrorCode(); - if (errorCode == AppEngageErrorCode.SERVICE_NOT_FOUND) { - // do something - } - } - } - }); - -The error is returned as an `AppEngageException` with the cause included as an -error code. - -| Error code | Error name | Note | -|---|---|---| -| `1` | `SERVICE_NOT_FOUND` | The service is not available on the given device. | -| `2` | `SERVICE_NOT_AVAILABLE` | The service is available on the given device, but it is not available at the time of the call (for example, it is explicitly disabled). | -| `3` | `SERVICE_CALL_EXECUTION_FAILURE` | The task execution failed due to threading issues. In this case, it can be retried. | -| `4` | `SERVICE_CALL_PERMISSION_DENIED` | The caller is not allowed to make the service call. | -| `5` | `SERVICE_CALL_INVALID_ARGUMENT` | The request contains invalid data (for example, more than the allowed number of clusters). | -| `6` | `SERVICE_CALL_INTERNAL` | There is an error on the service side. | -| `7` | `SERVICE_CALL_RESOURCE_EXHAUSTED` | The service call is made too frequently. | - -### Step 3: Handle broadcast intents - -In addition to making publish content API calls through a job, it is also -required to set up a -[`BroadcastReceiver`](https://developer.android.com/reference/android/content/BroadcastReceiver) to receive -the request for a content publish. - -The goal of broadcast intents is mainly for app reactivation and forcing data -sync. Broadcast intents are not designed to be sent very frequently. It is only -triggered when the Engage Service determines the content might be stale (for -example, a week old). That way, there is more confidence that the user can have -a fresh content experience, even if the application has not been executed for a -long period of time. - -The `BroadcastReceiver` must be set up in the following two ways: - -- Dynamically register an instance of the `BroadcastReceiver` class using - `Context.registerReceiver()`. This enables communication from applications - that are still live in memory. - -### Kotlin - - class AppEngageBroadcastReceiver : BroadcastReceiver(){ - // Trigger recommendation cluster publish when PUBLISH_RECOMMENDATION - // broadcast is received - } - - fun registerBroadcastReceivers(context: Context){ - var context = context - context = context.applicationContext - - // Register Recommendation Cluster Publish Intent - context.registerReceiver(AppEngageBroadcastReceiver(), - IntentFilter(Intents.ACTION_PUBLISH_RECOMMENDATION), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null) - } - -### Java - - class AppEngageBroadcastReceiver extends BroadcastReceiver { - // Trigger recommendation cluster publish when PUBLISH_RECOMMENDATION broadcast - // is received - } - - public static void registerBroadcastReceivers(Context context) { - - context = context.getApplicationContext(); - - // Register Recommendation Cluster Publish Intent - context.registerReceiver(new AppEngageBroadcastReceiver(), - new IntentFilter(com.google.android.engage.service.Intents.ACTION_PUBLISH_RECOMMENDATION), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null); - } - -- Statically declare an implementation with the `` tag in your - `AndroidManifest.xml` file. This allows the application to receive broadcast - intents when it is not running, and also allows the application to publish - the content. - - - - - - - - - -The following [intents](https://developer.android.com/reference/android/content/Intent) will be sent by the -service: - -- `com.google.android.engage.action.PUBLISH_RECOMMENDATION` It is recommended to start a `publishRecommendationClusters` call when receiving this intent. - -## Integration workflow - -For a step-by-step guide on verifying your integration after it is complete, see -[Engage developer integration workflow](https://developer.android.com/guide/playcore/engage/workflow). - -## FAQs - -See [Engage SDK Frequently Asked Questions](https://developer.android.com/guide/playcore/engage/faq) for -FAQs. - -## Contact - -Contact -[`engage-developers@google.com`](mailto:engage-developers@google.com) if there are -any questions during the integration process. Our team will reply as soon as -possible. - -## Next steps - -After completing this integration, your next steps are as follows: - -- Send an email to [`engage-developers@google.com`](mailto:engage-developers@google.com) and attach your integrated APK that is ready for testing by Google. -- Google performs a verification and reviews internally to make sure the integration works as expected. If changes are needed, Google contacts you with any necessary details. -- When testing is complete and no changes are needed, Google contacts you to notify you that you can start publishing the updated and integrated APK to the Play Store. -- After Google has confirmed that your updated APK has been published to the Play Store, your **Recommendation**, clusters will be published and visible to users. \ No newline at end of file diff --git a/play/engage-sdk-integration/references/android/guide/playcore/engage/travel.md b/play/engage-sdk-integration/references/android/guide/playcore/engage/travel.md deleted file mode 100644 index 84baa26..0000000 --- a/play/engage-sdk-integration/references/android/guide/playcore/engage/travel.md +++ /dev/null @@ -1,1436 +0,0 @@ -Boost app engagement by reaching your users where they are. Integrate Engage SDK -to deliver personalized recommendations and continuation content directly to -users across multiple on-device surfaces, like -**[Collections](https://android-developers.googleblog.com/2024/07/introducing-collections-powered-by-engage-sdk.html)** , **[Entertainment -Space](https://blog.google/products/android/entertainment-space/)** , and the Play Store. The integration adds -less than 50 KB (compressed) to the average APK and takes most apps about a -week of developer time. Learn more at our **[business -site](http://play.google.com/console/about/programs/EngageSDK)**. - -This guide contains instructions for developer partners to deliver travel and -events content to Engage content surfaces. - -## Category support and surfaces - -Engage SDK surface eligibility is based on your app's content category. Use the -following table to determine your eligibility for specific surfaces: - -| Status | Content category or use case | Supported surfaces | -|---|---|---| -| **Supported** (Eligible on all surfaces) | - All Travel \& local (except unsupported subcategories listed in this table) - Events (all subcategories) - Pet activity, monitoring, sitting, walking | - Collections - Play Store Apps tab - Play Store You tab - Play Store store listing page | -| **Not supported** | - Flight trackers | | - - -## Android skills - -[View on GitHub](https://github.com/android/skills/tree/main/play/engage-sdk-integration) - -### Integrate Engage SDK - -Use the Engage SDK [Android skill](https://developer.android.com/tools/agents/android-skills) to integrate, debug, and resolve implementation issues. To install the skill from the [Android CLI](https://developer.android.com/tools/agents/android-cli), run: - - android skills add engage-sdk-integration - -
- -## Integration detail - -### Terminology - -This integration includes the following cluster types: **Recommendation** , -**Featured** , **Reservation** and **Continue Search**. - -- **Recommendation** clusters show personalized travel \& event suggestions - from an individual developer partner. These recommendations can be - personalized to the user or generalized (for example, trending items). Use - these to surface articles, events, lodging, or places of interest - recommendations. - - - A Recommendation cluster can be made of `ArticleEntity`, `EventEntity`, `LodgingEntity`, `PointOfInterestEntity`, or `StoreEntity` listings, but not a mix of different entity types. - - Your recommendations take the following structure: - - **Recommendation Cluster:** A UI view that contains a group of - recommendations from the same developer partner. - - - **Entity:** An object representing a single item in a cluster. This - integration offers some entities that would be surfaced using the - Recommendation Cluster: - - - **ArticleEntity**: ArticleEntity represents a recommendation for - text-based content related to travel \& events. It can be used for - articles, blogposts, marketing content, news snippets, etc. - - ![](https://developer.android.com/static/images/guide/playcore/engage/article-entity-travel.png) **Figure 1:** UI showing a single ArticleEntity within Recommendations cluster. - - **EventEntity**: EventEntity represents an event happening in the - future. Event start time is a critical piece of information that - needs to be conveyed to the users. - - ![](https://developer.android.com/static/images/guide/playcore/engage/event-entity-travel.png) **Figure 2:** UI showing a single EventEntity within Recommendations cluster. - - **LodgingEntity**: LodgingEntity represents an accommodation, such - as a hotel, apartment, vacation home for short term and long term - rental. - - ![](https://developer.android.com/static/images/guide/playcore/engage/lodging-entity-travel.png) **Figure 3:** UI showing a single LodgingEntity within Recommendations cluster. - - **StoreEntity**: StoreEntity represents a store, restaurant, cafe - etc. It highlights content where a dining venue or store is the - critical piece of information that needs to be conveyed to the - users. - - ![](https://developer.android.com/static/images/guide/playcore/engage/store-entity-travel.png) **Figure 4:** UI showing a single StoreEntity within Recommendations cluster. - - **PointOfInterestEntity**: PointOfInterestEntity represents a - place of interest like, a gas station, event venue, theme park, - museum, tourist attraction, hiking trail etc. It highlights content - where location is a critical piece of information that needs to be - conveyed to the users. It shouldn't be used for lodging, a store or - a dining venue. - - ![](https://developer.android.com/static/images/guide/playcore/engage/poi-entity-travel.png) **Figure 5:** UI showing a single PointOfInterestEntity within Recommendations cluster. -- The **Reservation** cluster shows content recently engaged by users from - multiple developer partners in a single UI grouping. Each developer partner - will be allowed to broadcast a maximum of 10 entities in the Reservation - cluster. - - Your reservation content can take the following structure: - - **RestaurantReservationEntity**: RestaurantReservationEntity represents - a reservation for a restaurant or cafe and helps users track upcoming or - ongoing restaurant reservations. - - ![](https://developer.android.com/static/images/guide/playcore/engage/restaurant-reservation-entity-travel.png) **Figure 6.** UI showing a single RestaurantReservationEntity within a Reservation cluster. - - **EventReservationEntity**: EventReservationEntity represents a - reservation for an event and helps users track upcoming or ongoing - events reservations. Events could include, but not limited to the - following: - - - Sports events like reservation for a football match - - Gaming events like reservation for eSports - - Entertainment events like reservation for movies in a cinema, concert, theater, book signing - - Travel or point of interest reservations like guided tours, museum tickets - - Social / Seminar / Conferences reservations - - Education / Training sessions reservations - - ![](https://developer.android.com/static/images/guide/playcore/engage/event-reservation-entity-travel.png) **Figure 7.** UI showing a single EventReservationEntity within a Reservation cluster. - - **LodgingReservationEntity**: LodgingEntityReservation represents a - reservation for travel lodging and helps users track upcoming or - ongoing hotel or vacation rental reservations. - - ![](https://developer.android.com/static/images/guide/playcore/engage/lodging-reservation-entity-travel.png) **Figure 8.** UI showing a single LodgingReservationEntity within a Reservation cluster. - - **TransportationReservationEntity**: TransportationReservationEntity - represents reservation for transportation by any mode and helps users - track reservations for upcoming or ongoing flight, ferry, train, bus, - ride-hailing, or cruise. - - ![](https://developer.android.com/static/images/guide/playcore/engage/transportation-reservation-entity-travel.png) **Figure 9.** UI showing a single TransportationReservationEntity within a Reservation cluster. - - **VehicleRentalReservationEntity**: VehicleRentalReservationEntity - represents vehicle rental reservation and helps users track upcoming - or ongoing vehicle rental reservations. - - ![](https://developer.android.com/static/images/guide/playcore/engage/vehicle-rental-reservation-entity-travel.png) **Figure 10.** UI showing a single VehicleRentalReservationEntity within a Reservation cluster. -- The **Featured** cluster showcases a selection of entities from multiple - developer partners in one UI grouping. There will be a single Featured - cluster, which is surfaced near the top of the UI with a priority placement - above all Recommendation clusters. Each developer partner will be allowed to - broadcast up to 10 entities in the Featured cluster. - - - **GenericFeaturedEntity**: GenericFeaturedEntity differs from - Recommendation item in that Featured item should be used for a single - top content from developers and should represent the single most - important content that will be interesting and relevant to users. - - ![](https://developer.android.com/static/images/guide/playcore/engage/featured-cluster-travel.png) **Figure 11:** UI showing a FeaturedCluster with a list of GenericFeaturedEntity -- The **Continue Search** cluster helps users resume their previous travel - search journey by showcasing a list of search queries that the user has - recently searched for across all their travel apps. The cluster will be - pinned in second position, after reservations and before featured and - recommendation clusters. Each developer partner will be allowed to broadcast - up to 3 entities in the Continue Search cluster. - - - **PointOfInterestEntity:** PointOfInterestEntity represents a place of interest like, a gas station, event venue, theme park, museum, tourist attraction, hiking trail etc. It highlights content the user has previously searched for. - -### Pre-work - -Minimum API level: 19 - -Add the `com.google.android.engage:engage-core` library to your app: - - dependencies { - // Make sure you also include that repository in your project's build.gradle file. - implementation 'com.google.android.engage:engage-core:1.6.0' - } - -### Summary - -The design is based on an implementation of a -[bound service](https://developer.android.com/guide/components/bound-services). - -The data a client can publish is subject to the following limits for different -cluster types: - -| Cluster type | Cluster limits | Minimum entity limits in a cluster | Maximum entity limits in a cluster | -|---|---|---|---| -| Recommendation Cluster(s) | At most 7 | At least 1 | At most 50 (`ArticleEntity`, `EventEntity`, `LodgingEntity`, `StoreEntity`, or `PointOfInterestEntity`) | -| Reservation Cluster | At most 1 | At least 1 | At most 20 (`RestaurantReservationEntity`, `EventReservationEntity`, `LodgingReservationEntity`, `TransportationReservationEntity`, or `VehicleRentalReservationEntity`) | -| Featured Cluster | At most 1 | At least 1 | At most 20 (`GenericFeaturedEntity`) | -| Continue Search Cluster | At most 1 | At least 1 | At most 3 (`PointOfInterestEntity`) | - -### Step 1: Provide entity data - -The SDK has defined different entities to represent each item type. We support -the following entities for the Travel \& Events category: - -1. `GenericFeaturedEntity` -2. `ArticleEntity` -3. `EventEntity` -4. `LodgingEntity` -5. `StoreEntity` -6. `PointOfInterestEntity` -7. `RestaurantReservationEntity` -8. `EventReservationEntity` -9. `LodgingReservationEntity` -10. `TransportationReservationEntity` -11. `VehicleRentalReservationEntity` - -The charts below outline available attributes and requirements for each type. - -#### `GenericFeaturedEntity` - -| Attribute | Requirement | Description | Format | -|---|---|---|---| -| Action Uri | **Required** | Deep Link to the entity in the provider app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | Uri | -| Poster images | **Required** | We will show only 1 image when multiple images are provided. Recommended aspect ratio is 16:9 **Note:** If a badge is provided, ensure safe space of 24 dps at both the top and bottom of the image | See [Image Specifications](https://developer.android.com/guide/playcore/engage/travel#image-specs) for guidance. | -| Title | Optional | Title of the entity. | Free text **Recommended text size: 50 chars** | -| Description | Optional | A single paragraph of text to describe the entity. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size: 180 chars** | -| Subtitle list | Optional | Up to 3 subtitles, with each subtitle a single line of text. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size for each subtitle: max 50 chars** | -| Badges | Optional | Each badge is either free text (max 15 chars) or small image. Special UX treatment on top of image/video, For example, as badge overlay on the image - "Live update" - Article read duration | | -| Badge - Text | Optional | Title for the badge **Note:** Either text or image is required for the badge | Free text **Recommended text size: max 15 chars** | -| Badge - Image | Optional | Small image Special UX treatment, for example as badge overlay on the image/video thumbnail. **Note:** Either text or image is required for the badge | See [Image Specifications](https://developer.android.com/guide/playcore/engage/travel#image-specs) for guidance. | -| Content Categories | Optional | Describe the category of the content in the entity. | List of Enums See the [Content Category section](https://developer.android.com/guide/playcore/engage/travel#content-category) for guidance. | -| **DisplayTimeWindow (Optional) - Set a time window for a content to be shown on the surface** |||| -| Start Timestamp | Optional | The epoch timestamp after which the content should be shown on the surface. If not set, content is eligible to be shown on the surface. | Epoch timestamp in milliseconds | -| End Timestamp | Optional | The epoch timestamp after which the content is no longer shown on the surface. If not set, content is eligible to be shown on the surface. | Epoch timestamp in milliseconds | - -#### `ArticleEntity` - -| Attribute | Requirement | Description | Format | -|---|---|---|---| -| Action Uri | **Required** | Deep Link to the entity in the provider app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | Uri | -| Title | **Required** | Title of the entity. | Free text **Recommended text size: Max 50 chars** | -| Poster images | Optional | We will show only 1 image when multiple images are provided. Recommended aspect ratio is 16:9 **Note:** Image is highly recommended. If a badge is provided, ensure safe space of 24 dps at both the top and bottom of the image | See [Image Specifications](https://developer.android.com/guide/playcore/engage/travel#image-specs) for guidance. | -| Source - Title | Optional | The name of the author, organization, or reporter | Free text **Recommended text size: Under 25 chars** | -| Source - Image | Optional | An image of the source like the author, the organization, reporter | See [Image Specifications](https://developer.android.com/guide/playcore/engage/travel#image-specs) for guidance. | -| Description | Optional | A single paragraph of text to describe the entity. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size: 180 chars** | -| Subtitle list | Optional | Up to 3 subtitles, with each subtitle a single line of text. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size for each subtitle: max 50 chars** | -| Badges | Optional | Each badge is either free text (max 15 chars) or small image. Special UX treatment on top of image/video, for example, as badge overlay on the image - "Live update" - Article read duration | | -| Badge - Text | Optional | Title for the badge **Note:** Either text or image is required for the badge | Free text **Recommended text size: max 15 chars** | -| Badge - Image | Optional | Small image Special UX treatment, for example as badge overlay on the image/video thumbnail. **Note:** Either text or image is required for the badge | See [Image Specifications](https://developer.android.com/guide/playcore/engage/travel#image-specs) for guidance. | -| Content Publish Time | Optional | This is the epoch timestamp in milliseconds on when the content was published / updated in the app. | Epoch timestamp in milliseconds | -| Last Engagement Time | Optional | The epoch timestamp in milliseconds when the user interacted with this entity last time. | Epoch timestamp in milliseconds | -| Progress Percentage | Optional | The percentage of the full content consumed by the user to date. | An int value between 0\~100 inclusive. | -| Content Categories | Optional | Describe the category of the content in the entity. | List of Enums See the [Content Category section](https://developer.android.com/guide/playcore/engage/travel#content-category) for guidance. | -| **DisplayTimeWindow (Optional) - Set a time window for a content to be shown on the surface** |||| -| Start Timestamp | Optional | The epoch timestamp after which the content should be shown on the surface. If not set, content is eligible to be shown on the surface. | Epoch timestamp in milliseconds | -| End Timestamp | Optional | The epoch timestamp after which the content is no longer shown on the surface. If not set, content is eligible to be shown on the surface. | Epoch timestamp in milliseconds | - -#### `EventEntity` - -| Attribute | Requirement | Description | Format | -|---|---|---|---| -| Action Uri | **Required** | Deep Link to the entity in the provider app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | Uri | -| Title | **Required** | Title of the entity. | String **Recommended text size: Max 50 chars** | -| Localized Start time - Timestamp | **Required** | The epoch timestamp when the event is expected to start. | Joda-Time Instant | -| Localized Start time - Timezone | **Required** | The timezone in which the event is expected to start. | Joda-Time DateTimeZone See [Timezone Specifications](https://developer.android.com/guide/playcore/engage/travel#tz-specs) for guidance. | -| Event mode | **Required** | A field to indicate whether the event will be virtual, in-person or both. | Enum: VIRTUAL, IN_PERSON, or HYBRID | -| Poster images | **Required** | We will show only 1 image when multiple images are provided. Recommended aspect ratio is 16:9 **Note:** Image is highly recommended. If a badge is provided, ensure safe space of 24 dps at both the top and bottom of the image | See [Image Specifications](https://developer.android.com/guide/playcore/engage/travel#image-specs) for guidance. | -| Location - Country | Conditionally required | The country in which the event is happening. **Note:** This is required for events which are IN_PERSON or HYBRID | Free text **Recommended text size: max \~20 chars** | -| Location - City | Conditionally required | The city in which the event is happening. **Note:** This is required for events which are IN_PERSON or HYBRID | Free text **Recommended text size: max \~20 chars** | -| Location - Display Address | Conditionally required | The address or venue name where the event will take place that should be displayed to the user. **Note:** This is required for events which are IN_PERSON or HYBRID | Free text **Recommended text size: max \~20 chars** | -| Location - Street Address | Optional | The street address (if applicable) of the location at which event is being hosted. | Free text **Recommended text size: max \~20 chars** | -| Location - State | Optional | The state or province (if applicable) in which the event is being hosted. | Free text **Recommended text size: max \~20 chars** | -| Location - Zip code | Optional | The zip code (if applicable) of the location in which the event is being hosted. | Free text **Recommended text size: max \~20 chars** | -| Location - Neighborhood | Optional | The neighborhood (if applicable) in which the event is being hosted. | Free text **Recommended text size: max \~20 chars** | -| End time | Optional | The epoch timestamp when the event is expected to end. **Note:**This will be represented in milliseconds. | Epoch timestamp in milliseconds | -| Description | Optional | A single paragraph of text to describe the entity. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size: 180 chars** | -| Subtitle list | Optional | Up to 3 subtitles, with each subtitle a single line of text. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size for each subtitle: max 50 chars** | -| Badges | Optional | Each badge is either free text (max 15 chars) or small image. | | -| Badge - Text | Optional | Title for the badge **Note:** Either text or image is required for the badge | Free text **Recommended text size: max 15 chars** | -| Badge - Image | Optional | Small image Special UX treatment, for example as badge overlay on the image/video thumbnail. **Note:** Either text or image is required for the badge | See [Image Specifications](https://developer.android.com/guide/playcore/engage/travel#image-specs) for guidance. | -| Price - CurrentPrice | Conditionally required | The current price of the ticket/pass for the event. **Must be provided if strikethrough price is provided.** | Free text | -| Price - StrikethroughPrice | Optional | The original price of the ticket/pass for the event. | Free text | -| Price Callout | Optional | Price callout to feature a promo, event, member discount, if available. | Free text **Recommended text size: under 45 chars (Text that is too long may show ellipses)** | -| Content Categories | Optional | Describe the category of the content in the entity. | List of Eligible Enums - TYPE_MOVIES_AND_TV_SHOWS (Example - Cinema) - TYPE_DIGITAL_GAMES (Example - eSports) - TYPE_MUSIC (Example - Concert) - TYPE_TRAVEL_AND_LOCAL (Example - Tour, festival) - TYPE_HEALTH_AND_FITENESS (Example - Yoga class) - TYPE_EDUCATION (Example - Class) - TYPE_SPORTS (Example - Football game) - TYPE_DATING (Example - meetup) See the [Content Category section](https://developer.android.com/guide/playcore/engage/travel#content-category) for guidance. | -| **DisplayTimeWindow (Optional) - Set a time window for a content to be shown on the surface** |||| -| Start Timestamp | Optional | The epoch timestamp after which the content should be shown on the surface. If not set, content is eligible to be shown on the surface. | Epoch timestamp in milliseconds | -| End Timestamp | Optional | The epoch timestamp after which the content is no longer shown on the surface. If not set, content is eligible to be shown on the surface. | Epoch timestamp in milliseconds | - -#### `LodgingEntity` - -| Attribute | Requirement | Description | Format | -|---|---|---|---| -| Action Uri | **Required** | Deep Link to the entity in the provider app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | Uri | -| Title | **Required** | Title of the entity. | String **Recommended text size: Max 50 chars** | -| Poster images | **Required** | We will show only 1 image when multiple images are provided. Recommended aspect ratio is 16:9 **Note:** If a badge is provided, ensure safe space of 24 dps at both the top and bottom of the image | See [Image Specifications](https://developer.android.com/guide/playcore/engage/travel#image-specs) for guidance. | -| Location - Country | **Required** | The country in which the lodging is happening. | Free text **Recommended text size: max \~20 chars** | -| Location - City | **Required** | The city in which the lodging is happening. | Free text **Recommended text size: max \~20 chars** | -| Location - Display Address | **Required** | The address that will be displayed to the user. We recommend including the city name and possibly state or country for most use cases. Only include the street address or neighborhood if the user is near the location, the user is familiar with the location, or the city is included in the cluster title. If you include the street address, provide a succinct address, using abbreviations where possible (for example, "St" for "Street", "Ave" for "Avenue"). | Free text **Recommended text size: max \~20 chars** | -| Location - Street Address | Optional | The street address (if applicable) of the lodging. | Free text **Recommended text size: max \~20 chars** | -| Location - State | Optional | The state or province (if applicable) in which the lodging is located. | Free text **Recommended text size: max \~20 chars** | -| Location - Zip code | Optional | The zip code (if applicable) of the lodging. | Free text **Recommended text size: max \~20 chars** | -| Location - Neighborhood | Optional | The neighborhood (if applicable) of the lodging. | Free text **Recommended text size: max \~20 chars** | -| Badges | Optional | Each badge is either free text (max 15 chars) or small image. | | -| Badge - Text | Optional | Title for the badge **Note:** Either text or image is required for the badge | Free text **Recommended text size: max 15 chars** | -| Badge - Image | Optional | Small image Special UX treatment, for example as badge overlay on the image/video thumbnail. **Note:** Either text or image is required for the badge | See [Image Specifications](https://developer.android.com/guide/playcore/engage/travel#image-specs) for guidance. | -| Description | Optional | A single paragraph of text to describe the entity. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size: 180 chars** | -| Subtitle list | Optional | Up to 3 subtitles, with each subtitle a single line of text. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size for each subtitle: max 50 chars** | -| AvailabilityTimeWindow - Localized Start Time - Timestamp | Optional | The epoch timestamp when the lodging is expected to be open/available. | Joda-Time Instant | -| AvailabilityTimeWindow - Localized Start Time - Timezone | Optional | The timezone in which the lodging is expected to be open/available. | Joda-Time DateTimeZone See [Timezone Specifications](https://developer.android.com/guide/playcore/engage/travel#tz-specs) for guidance. | -| AvailabilityTimeWindow - Localized End Time - Timestamp | Optional | The epoch timestamp until which the lodging is expected to be open/available. | Joda-Time Instant | -| AvailabilityTimeWindow - Localized End Time - Timezone | Optional | The timezone in which the lodging is expected to be open/available. | Joda-Time DateTimeZone See [Timezone Specifications](https://developer.android.com/guide/playcore/engage/travel#tz-specs) for guidance. | -| Rating - Max value | Optional | The maximum value of the rating scale. **Must be provided if current value of rating is also provided.** | Number \>= 0.0 | -| Rating - Current value | Optional | The current value of the rating scale. **Must be provided if maximum value of rating is also provided.** | Number \>= 0.0 | -| Rating - Count | Optional | The count of the ratings for the lodging. **Note:** Provide this field if your app wants to control how this is displayed to the users. Provide the concise string that can be displayed to the user. For example, if the count is 1,000,000, consider using abbreviations like 1M, so that it won't be truncated on smaller display sizes. | String | -| Rating - Count Value | Optional | The count of the ratings for the lodging. **Note:** Provide this field if you don't want to handle the display abbreviation logic yourself. If both Count and Count Value are present, we will use the Count to display to users | Long | -| Price - CurrentPrice | Conditionally required | The current price of the lodging. **Must be provided if strikethrough price is provided.** | Free text | -| Price - StrikethroughPrice | Optional | The original price of the lodging, which is be struck-through in the UI. | Free text | -| Price Callout | Optional | Price callout to feature a promo, event, member discount, if available. | Free text **Recommended text size: under 45 chars (Text that is too long may show ellipses)** | -| **DisplayTimeWindow (Optional) - Set a time window for a content to be shown on the surface** |||| -| Start Timestamp | Optional | The epoch timestamp after which the content should be shown on the surface. If not set, content is eligible to be shown on the surface. | Epoch timestamp in milliseconds | -| End Timestamp | Optional | The epoch timestamp after which the content is no longer shown on the surface. If not set, content is eligible to be shown on the surface. | Epoch timestamp in milliseconds | - -#### `StoreEntity` - -The `StoreEntity` object represents an individual store that developer partners -want to publish, such as a popular dining place or eatery that are relevant to -one's travel experience. - -| Attribute | Requirement | Description | Format | -|---|---|---|---| -| Poster images | **Required** | At least one image must be provided. | See [Image Specifications](https://developer.android.com/guide/playcore/engage/travel#image-specs) for guidance. | -| Action Uri | **Required** | Deep Link to the entity in the provider app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | Uri | -| Title | Optional | The name of the store. | Free text **Recommended text size: under 45 chars** (Text that is too long may show ellipses) | -| Location | Optional | The location of the store. | Free text **Recommended text size: under 45 chars** (Text that is too long may show ellipses) | -| Callout | Optional | Callout to feature a promo, event, or update for the store, if available. | Free text **Recommended text size: under 45 chars** (Text that is too long may show ellipses) | -| Callout fine print | Optional | Fine print text for the callout. | Free text **Recommended text size: under 45 chars** (Text that is too long may show ellipses) | -| Description | Optional | A description of the store. | Free text **Recommended text size: under 90 chars** (Text that is too long may show ellipses) | -| Category | Optional | Category of a store, in the context of dining places, it can be cuisine like "french", "new american", "ramen", "fine dining". | Free text **Recommended text size: under 45 chars** (Text that is too long may show ellipses) | -| Rating - Max value | Optional | The maximum value of the rating scale. **Must be provided if current value of rating is also provided.** | Number \>= 0.0 | -| Rating - Current value | Optional | The current value of the rating scale. **Must be provided if maximum value of rating is also provided.** | Number \>= 0.0 | -| Rating - Count | Optional | The count of the ratings for the lodging. **Note:** Provide this field if your app wants to control how this is displayed to the users. Provide the concise string that can be displayed to the user. For example, if the count is 1,000,000, consider using abbreviations like 1M, so that it won't be truncated on smaller display sizes. | String | -| Rating - Count Value | Optional | The count of the ratings for the lodging. **Note:** Provide this field if you don't want to handle the display abbreviation logic yourself. If both Count and Count Value are present, we will use the Count to display to users | Long | -| **DisplayTimeWindow (Optional) - Set a time window for a content to be shown on the surface** |||| -| Start Timestamp | Optional | The epoch timestamp after which the content should be shown on the surface. If not set, content is eligible to be shown on the surface. | Epoch timestamp in milliseconds | -| End Timestamp | Optional | The epoch timestamp after which the content is no longer shown on the surface. If not set, content is eligible to be shown on the surface. | Epoch timestamp in milliseconds | - -#### `PointOfInterestEntity` - -| Attribute | Requirement | Description | Format | -|---|---|---|---| -| Action Uri | **Required** | Deep Link to the entity in the provider app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | Uri | -| Title | **Required** | Title of the entity. | String **Recommended text size: Max 50 chars** | -| Poster images | Conditionally required | We will show only 1 image when multiple images are provided. Recommended aspect ratio is 16:9 **Note:** Image is required if the entity is part of recommendation cluster. If a badge is provided, ensure safe space of 24 dps at both the top and bottom of the image | See [Image Specifications](https://developer.android.com/guide/playcore/engage/travel#image-specs) for guidance. | -| Last Engagement Time | Conditionally required | The epoch timestamp when the user last interacted with this entity. **Note:** This field is required if the entity is part of continue search cluster. | Joda-Time Instant | -| Location - Country | Conditionally required | The country in which the point of interest is happening. **Note:** This field is required if the entity is part of recommendation cluster. | Free text **Recommended text size: max \~20 chars** | -| Location - City | Conditionally required | The city in which the point of interest is happening. **Note:** This field is required if the entity is part of recommendation cluster. | Free text **Recommended text size: max \~20 chars** | -| Location - Display Address | Conditionally required | The address that will be displayed to the user. Provide a succinct address, using abbreviations where possible (for example, "St" for "Street", "Ave" for "Avenue"). This string may be truncated depending on the user's device and settings. Include the city name for clear identification. **Note:** This field is required if the entity is part of recommendation cluster. | Free text **Recommended text size: max \~35 chars** | -| Location - Street Address | Optional | The street address (if applicable) of the point of interest. | Free text **Recommended text size: max \~20 chars** | -| Location - State | Optional | The state or province (if applicable) in which the point of interest is located. | Free text **Recommended text size: max \~20 chars** | -| Location - Zip code | Optional | The zip code (if applicable) of the point of interest. | Free text **Recommended text size: max \~20 chars** | -| Location - Neighborhood | Optional | The neighborhood (if applicable) of the point of interest. | Free text **Recommended text size: max \~20 chars** | -| AvailabilityTimeWindow - Localized Start Time - Timestamp | Optional | The epoch timestamp when the point of interest is expected to be open/available. | Joda-Time Instant | -| AvailabilityTimeWindow - Localized Start Time - Timezone | Optional | The timezone in which the point of interest is expected to be open/available. | Joda-Time DateTimeZone See [Timezone Specifications](https://developer.android.com/guide/playcore/engage/travel#tz-specs) for guidance. | -| AvailabilityTimeWindow - Localized End Time - Timestamp | Optional | The epoch timestamp until which the point of interest is expected to be open/available. | Joda-Time Instant | -| AvailabilityTimeWindow - Localized End Time - Timezone | Optional | The timezone in which the point of interest is expected to be open/available. | Joda-Time DateTimeZone See [Timezone Specifications](https://developer.android.com/guide/playcore/engage/travel#tz-specs) for guidance. | -| Badges | Optional | Each badge is either free text (max 15 chars) or small image. | | -| Badge - Text | Optional | Title for the badge **Note:** Either text or image is required for the badge | Free text **Recommended text size: max 15 chars** | -| Badge - Image | Optional | Small image Special UX treatment, for example as badge overlay on the image/video thumbnail. **Note:** Either text or image is required for the badge | See [Image Specifications](https://developer.android.com/guide/playcore/engage/travel#image-specs) for guidance. | -| Description | Optional | A single paragraph of text to describe the entity. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size: 180 chars** | -| Subtitle list | Optional | Up to 3 subtitles, with each subtitle a single line of text. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size for each subtitle: max 50 chars** | -| Rating - Max value | Optional | The maximum value of the rating scale. **Must be provided if current value of rating is also provided.** | Number \>= 0.0 | -| Rating - Current value | Optional | The current value of the rating scale. **Must be provided if maximum value of rating is also provided.** | Number \>= 0.0 | -| Rating - Count | Optional | The count of the ratings for the point of interest. **Note:** Provide this field if your app wants to control how this is displayed to the users. Provide the concise string that can be displayed to the user. For example, if the count is 1,000,000, consider using abbreviations like 1M, so that it won't be truncated on smaller display sizes. | String | -| Rating - Count Value | Optional | The count of the ratings for the point of interest. **Note:** Provide this field if you don't want to handle the display abbreviation logic yourself. If both Count and Count Value are present, we will use the Count to display to users | Long | -| Price - CurrentPrice | Conditionally required | The current price of the tickets/entry pass for the point of interest. **Must be provided if strikethrough price is provided.** | Free text | -| Price - StrikethroughPrice | Optional | The original price of the tickets/entry pass for the point of interest. | Free text | -| Price Callout | Optional | Price callout to feature a promo, event, member discount, if available. | Free text **Recommended text size: under 45 chars (Text that is too long may show ellipses)** | -| Content Categories | Optional | Describe the category of the content in the entity. | List of Eligible Enums - TYPE_TRAVEL_AND_LOCAL - TYPE_MOVIES_AND_TV_SHOWS (Example - theater) - TYPE_MEDICAL (Example - hospital) - TYPE_EDUCATION (Example - school) - TYPE_SPORTS (Example - stadium) See the [Content Category section](https://developer.android.com/guide/playcore/engage/travel#content-category) for guidance. | -| **DisplayTimeWindow (Optional) - Set a time window for a content to be shown on the surface** |||| -| Start Timestamp | Optional | The epoch timestamp after which the content should be shown on the surface. If not set, content is eligible to be shown on the surface. | Epoch timestamp in milliseconds | -| End Timestamp | Optional | The epoch timestamp after which the content is no longer shown on the surface. If not set, content is eligible to be shown on the surface. | Epoch timestamp in milliseconds | - -#### `RestaurantReservationEntity` - -| Attribute | Requirement | Description | Format | -|---|---|---|---| -| Action Uri | **Required** | Deep Link to the entity in the provider app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | Uri | -| Title | **Required** | Title of the entity. | String **Recommended text size: Max 50 chars** | -| Localized Reservation Start Time - Timestamp | **Required** | The epoch timestamp when the reservation is expected to start. | Joda-Time Instant | -| Localized Reservation Start Time - Timezone | **Required** | The timezone in which the reservation is expected to start. | Joda-Time DateTimeZone See [Timezone Specifications](https://developer.android.com/guide/playcore/engage/travel#tz-specs) for guidance. | -| Location - Country | **Required** | The country in which the restaurant is happening. | Free text **Recommended text size: max \~20 chars** | -| Location - City | **Required** | The city in which the restaurant is happening. | Free text **Recommended text size: max \~20 chars** | -| Location - Display Address | **Required** | The address of the prestaurant that will be displayed to the user. | Free text **Recommended text size: max \~20 chars** | -| Location - Street Address | Optional | The street address (if applicable) of the restaurant. | Free text **Recommended text size: max \~20 chars** | -| Location - State | Optional | The state or province (if applicable) in which the restaurant is located. | Free text **Recommended text size: max \~20 chars** | -| Location - Zip code | Optional | The zip code (if applicable) of the restaurant. | Free text **Recommended text size: max \~20 chars** | -| Location - Neighborhood | Optional | The neighborhood (if applicable) of the restaurant. | Free text **Recommended text size: max \~20 chars** | -| Poster images | Optional | We will show only 1 image when multiple images are provided. Recommended aspect ratio is 16:9 | See [Image Specifications](https://developer.android.com/guide/playcore/engage/travel#image-specs) for guidance. | -| Description | Optional | A single paragraph of text to describe the entity. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size: 180 chars** | -| Subtitle list | Optional | Up to 3 subtitles, with each subtitle a single line of text. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size for each subtitle: max 50 chars** | -| Table Size | Optional | The number of people in the reservation group | Integer \> 0 | -| **DisplayTimeWindow (Optional) - Set a time window for a content to be shown on the surface** |||| -| Start Timestamp | Optional | The epoch timestamp after which the content should be shown on the surface. If not set, content is eligible to be shown on the surface. | Epoch timestamp in milliseconds | -| End Timestamp | Optional | The epoch timestamp after which the content is no longer shown on the surface. If not set, content is eligible to be shown on the surface. | Epoch timestamp in milliseconds | - -#### `EventReservationEntity` - -| Attribute | Requirement | Description | Format | -|---|---|---|---| -| Action Uri | **Required** | Deep Link to the entity in the provider app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | Uri | -| Title | **Required** | Title of the entity. | String **Recommended text size: Max 50 chars** | -| Localized Start time - Timestamp | **Required** | The epoch timestamp when the event is expected to start. | Joda-Time Instant | -| Localized Start time - Timezone | **Required** | The timezone in which the event is expected to start. | Joda-Time DateTimeZone See [Timezone Specifications](https://developer.android.com/guide/playcore/engage/travel#tz-specs) for guidance. | -| Event mode | **Required** | A field to indicate whether the event will be virtual, in-person or both. | Enum: VIRTUAL, IN_PERSON, or HYBRID | -| Location - Country | Conditionally required | The country in which the event is happening. **Note:** This is required for events which are IN_PERSON or HYBRID | Free text **Recommended text size: max \~20 chars** | -| Location - City | Conditionally required | The city in which the event is happening. **Note:** This is required for events which are IN_PERSON or HYBRID | Free text **Recommended text size: max \~20 chars** | -| Location - Display Address | Conditionally required | The address or venue name where the event will take place that should be displayed to the user. **Note:** This is required for events which are IN_PERSON or HYBRID | Free text **Recommended text size: max \~20 chars** | -| Location - Street Address | Optional | The street address (if applicable) of the location at which event is being hosted. | Free text **Recommended text size: max \~20 chars** | -| Location - State | Optional | The state or province (if applicable) in which the event is being hosted. | Free text **Recommended text size: max \~20 chars** | -| Location - Zip code | Optional | The zip code (if applicable) of the location in which the event is being hosted. | Free text **Recommended text size: max \~20 chars** | -| Location - Neighborhood | Optional | The neighborhood (if applicable) in which the event is being hosted. | Free text **Recommended text size: max \~20 chars** | -| Poster images | Optional | We will show only 1 image when multiple images are provided. Recommended aspect ratio is 16:9 **Note:** Image is highly recommended. If a badge is provided, ensure safe space of 24 dps at both the top and bottom of the image | See [Image Specifications](https://developer.android.com/guide/playcore/engage/travel#image-specs) for guidance. | -| Localized End time - Timestamp | Optional | The epoch timestamp when the event is expected to end. | Joda-Time Instant | -| Localized End time - Timezone | Optional | The timezone in which the event is expected to end. | Joda-Time DateTimeZone See [Timezone Specifications](https://developer.android.com/guide/playcore/engage/travel#tz-specs) for guidance. | -| Service Provider - Name | Optional | The name of the service provider. **Note:**Either text or image is required for the service provider. | Free text. For example, name of the event organizer/tour | -| Service Provider - Image | Optional | The logo/image of the service provider. **Note:**Either text or image is required for the service provider. | See [Image Specifications](https://developer.android.com/guide/playcore/engage/travel#image-specs) for guidance. | -| Description | Optional | A single paragraph of text to describe the entity. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size: 180 chars** | -| Subtitle list | Optional | Up to 3 subtitles, with each subtitle a single line of text. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size for each subtitle: max 50 chars** | -| Badges | Optional | Each badge is either free text (max 15 chars) or small image. | | -| Badge - Text | Optional | Title for the badge **Note:** Either text or image is required for the badge | Free text **Recommended text size: max 15 chars** | -| Badge - Image | Optional | Small image Special UX treatment, for example as badge overlay on the image/video thumbnail. **Note:** Either text or image is required for the badge | See [Image Specifications](https://developer.android.com/guide/playcore/engage/travel#image-specs) for guidance. | -| Reservation ID | Optional | The reservation ID for the event reservation. | Free text | -| Price - CurrentPrice | Conditionally required | The current price of the ticket/pass for the event. **Must be provided if strikethrough price is provided.** | Free text | -| Price - StrikethroughPrice | Optional | The original price of the ticket/pass for the event. | Free text | -| Price Callout | Optional | Price callout to feature a promo, event, member discount, if available. | Free text **Recommended text size: under 45 chars (Text that is too long may show ellipses)** | -| Rating - Max value | Optional | The maximum value of the rating scale. **Must be provided if current value of rating is also provided.** | Number \>= 0.0 | -| Rating - Current value | Optional | The current value of the rating scale. **Must be provided if maximum value of rating is also provided.** | Number \>= 0.0 | -| Rating - Count | Optional | The count of the ratings for the event. **Note:** Provide this field if your app wants to control how this is displayed to the users. Provide the concise string that can be displayed to the user. For example, if the count is 1,000,000, consider using abbreviations like 1M, so that it won't be truncated on smaller display sizes. | String | -| Rating - Count Value | Optional | The count of the ratings for the event. **Note:** Provide this field if you don't want to handle the display abbreviation logic yourself. If both Count and Count Value are present, we will use the Count to display to users | Long | -| Content Categories | Optional | Describe the category of the content in the entity. | List of Eligible Enums - TYPE_MOVIES_AND_TV_SHOWS (Example - Cinema) - TYPE_DIGITAL_GAMES (Example - eSports) - TYPE_MUSIC (Example - Concert) - TYPE_TRAVEL_AND_LOCAL (Example - Tour, festival) - TYPE_HEALTH_AND_FITENESS (Example - Yoga class) - TYPE_EDUCATION (Example - Class) - TYPE_SPORTS (Example - Football game) - TYPE_DATING (Example - meetup) See the [Content Category section](https://developer.android.com/guide/playcore/engage/travel#content-category) for guidance. | -| **DisplayTimeWindow (Optional) - Set a time window for a content to be shown on the surface** |||| -| Start Timestamp | Optional | The epoch timestamp after which the content should be shown on the surface. If not set, content is eligible to be shown on the surface. | Epoch timestamp in milliseconds | -| End Timestamp | Optional | The epoch timestamp after which the content is no longer shown on the surface. If not set, content is eligible to be shown on the surface. | Epoch timestamp in milliseconds | - -#### `LodgingReservationEntity` - -| Attribute | Requirement | Description | Format | -|---|---|---|---| -| Action Uri | **Required** | Deep Link to the entity in the provider app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | Uri | -| Title | **Required** | Title of the entity. | Free text. For example, "Your Stay from Dec 12th" **Recommended text size: Max 50 chars** | -| Localized Check-in Time - Timestamp | **Required** | The epoch timestamp that represents the check in time for the reservation. | Joda-Time Instant | -| Localized Check-in Time - Timezone | **Required** | The timezone in which the check in time exists for the reservation. | Joda-Time DateTimeZone See [Timezone Specifications](https://developer.android.com/guide/playcore/engage/travel#tz-specs) for guidance. | -| Localized Check-out Time - Timestamp | **Required** | The epoch timestamp that represents the check out time for the reservation. | Joda-Time Instant | -| Localized Check-out Time - Timezone | **Required** | The timezone in which the check out time exists for the reservation. | Joda-Time DateTimeZone See [Timezone Specifications](https://developer.android.com/guide/playcore/engage/travel#tz-specs) for guidance. | -| Location - Country | **Required** | The country in which the lodging is located. | Free text **Recommended text size: max \~20 chars** | -| Location - City | **Required** | The city in which the lodging is located. | Free text **Recommended text size: max \~20 chars** | -| Location - Display Address | **Required** | The address that will be displayed to the user. Provide a succinct address, using abbreviations where possible (for example, "St" for "Street", "Ave" for "Avenue"). This string may be truncated depending on the user's device and settings. Include the city name for clear identification. | Free text **Recommended text size: max \~35 chars** | -| Location - Street Address | Optional | The street address (if applicable) of the lodging. | Free text **Recommended text size: max \~20 chars** | -| Location - State | Optional | The state or province (if applicable) in which the lodging is located. | Free text **Recommended text size: max \~20 chars** | -| Location - Zip code | Optional | The zip code (if applicable) of the lodging. | Free text **Recommended text size: max \~20 chars** | -| Location - Neighborhood | Optional | The neighborhood (if applicable) of the lodging. | Free text **Recommended text size: max \~20 chars** | -| Poster images | Optional | We will show only 1 image when multiple images are provided. Recommended aspect ratio is 16:9 **Note:** If a badge is provided, ensure safe space of 24 dps at both the top and bottom of the image | See [Image Specifications](https://developer.android.com/guide/playcore/engage/travel#image-specs) for guidance. | -| Description | Optional | A single paragraph of text to describe the entity. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size: 180 chars** | -| Subtitle list | Optional | Up to 3 subtitles, with each subtitle a single line of text. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size for each subtitle: max 50 chars** | -| Reservation ID | Optional | The reservation ID for the lodging reservation. | Free text | -| Rating - Max value | Optional | The maximum value of the rating scale. **Must be provided if current value of rating is also provided.** | Number \>= 0.0 | -| Rating - Current value | Optional | The current value of the rating scale. **Must be provided if maximum value of rating is also provided.** | Number \>= 0.0 | -| Rating - Count | Optional | The count of the ratings for the lodging. **Note:** Provide this field if your app wants to control how this is displayed to the users. Provide the concise string that can be displayed to the user. For example, if the count is 1,000,000, consider using abbreviations like 1M, so that it won't be truncated on smaller display sizes. | String | -| Rating - Count Value | Optional | The count of the ratings for the lodging. **Note:** Provide this field if you don't want to handle the display abbreviation logic yourself. If both Count and Count Value are present, we will use the Count to display to users | Long | -| Price - CurrentPrice | Conditionally required | The current price of the lodging. **Must be provided if strikethrough price is provided.** | Free text | -| Price - StrikethroughPrice | Optional | The original price of the lodging, which is be struck-through in the UI. | Free text | -| Price Callout | Optional | Price callout to feature a promo, event, member discount, if available. | Free text **Recommended text size: under 45 chars (Text that is too long may show ellipses)** | -| **DisplayTimeWindow (Optional) - Set a time window for a content to be shown on the surface** |||| -| Start Timestamp | Optional | The epoch timestamp after which the content should be shown on the surface. If not set, content is eligible to be shown on the surface. | Epoch timestamp in milliseconds | -| End Timestamp | Optional | The epoch timestamp after which the content is no longer shown on the surface. If not set, content is eligible to be shown on the surface. | Epoch timestamp in milliseconds | - -#### `TransportationReservationEntity` - -| Attribute | Requirement | Description | Format | -|---|---|---|---| -| Action Uri | **Required** | Deep Link to the entity in the provider app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | Uri | -| Title | **Required** | Title of the entity. | Free text. For example, "SFO to SAN" **Recommended text size: Max 50 chars** | -| Transportation Type | **Required** | The mode/type of transportation for the reservation. | Enum: FLIGHT, TRAIN, BUS, or FERRY | -| Localized Departure Time - Timestamp | **Required** | The epoch timestamp that represents the departure time. | Joda-Time Instant | -| Localized Departure Time - Timezone | **Required** | The timezone of the departure time. | Joda-Time DateTimeZone See [Timezone Specifications](https://developer.android.com/guide/playcore/engage/travel#tz-specs) for guidance. | -| Localized Arrival Time - Timestamp | **Required** | The epoch timestamp that represents the arrival time. | Joda-Time Instant | -| Localized Arrival Time - Timezone | **Required** | The timezone of the arrival time. | Joda-Time DateTimeZone See [Timezone Specifications](https://developer.android.com/guide/playcore/engage/travel#tz-specs) for guidance. | -| Transportation Number | **Required** | The flight number, bus number, train number, or ferry/cruise number. | Free text | -| Localized Boarding Time - Timestamp | **Required** | The epoch timestamp that represents the boarding time for the reservation (if applicable) | Joda-Time Instant | -| Localized Boarding Time - Timezone | **Required** | The timezone of the boarding time for the reservation (if applicable) | Joda-Time DateTimeZone See [Timezone Specifications](https://developer.android.com/guide/playcore/engage/travel#tz-specs) for guidance. | -| Departure Location - Country | Optional | The country of departure. | Free text **Recommended text size: max \~20 chars** | -| Departure Location - City | Optional | The city of departure. | Free text **Recommended text size: max \~20 chars** | -| Departure Location - Display Address | Optional | The location of departure that will be displayed to the user. | Free text **Recommended text size: max \~20 chars** | -| Departure Location - Street Address | Optional | The street address (if applicable) of the departure location. | Free text **Recommended text size: max \~20 chars** | -| Departure Location - State | Optional | The state or province (if applicable) of the departure location. | Free text **Recommended text size: max \~20 chars** | -| Departure Location - Zip code | Optional | The zip code (if applicable) of the departure location. | Free text **Recommended text size: max \~20 chars** | -| Departure Location - Neighborhood | Optional | The neighborhood (if applicable) of the departure location. | Free text **Recommended text size: max \~20 chars** | -| Arrival Location - Country | Optional | The country of arrival. | Free text **Recommended text size: max \~20 chars** | -| Arrival Location - City | Optional | The city of arrival. | Free text **Recommended text size: max \~20 chars** | -| Arrival Location - Display Address | Optional | The location of arrival that will be displayed to the user. | Free text **Recommended text size: max \~20 chars** | -| Arrival Location - Street Address | Optional | The street address (if applicable) of the arrival location. | Free text **Recommended text size: max \~20 chars** | -| Arrival Location - State | Optional | The state or province (if applicable) of the arrival location. | Free text **Recommended text size: max \~20 chars** | -| Arrival Location - Zip code | Optional | The zip code (if applicable) of the arrival location. | Free text **Recommended text size: max \~20 chars** | -| Arrival Location - Neighborhood | Optional | The neighborhood (if applicable) of the arrival location. | Free text **Recommended text size: max \~20 chars** | -| Service Provider - Name | Optional | The name of the service provider. **Note:**Either text or image is required for the service provider. | Free text. For example, Airline name | -| Service Provider - Image | Optional | The logo/image of the service provider. **Note:**Either text or image is required for the service provider. | See [Image Specifications](https://developer.android.com/guide/playcore/engage/travel#image-specs) for guidance. | -| Poster images | Optional | We will show only 1 image when multiple images are provided. Recommended aspect ratio is 16:9 | See [Image Specifications](https://developer.android.com/guide/playcore/engage/travel#image-specs) for guidance. | -| Description | Optional | A single paragraph of text to describe the entity. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size: 180 chars** | -| Subtitle list | Optional | Up to 3 subtitles, with each subtitle a single line of text. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size for each subtitle: max 50 chars** | -| Reservation ID | Optional | The reservation ID for the transportation reservation. | Free text | -| Price - CurrentPrice | Conditionally required | The current price of the reservation. **Must be provided if strikethrough price is provided.** | Free text | -| Price - StrikethroughPrice | Optional | The original price of the reservation, which is be struck-through in the UI. | Free text | -| Price Callout | Optional | Price callout to feature a promo, event, member discount, if available. | Free text **Recommended text size: under 45 chars (Text that is too long may show ellipses)** | -| **DisplayTimeWindow (Optional) - Set a time window for a content to be shown on the surface** |||| -| Start Timestamp | Optional | The epoch timestamp after which the content should be shown on the surface. If not set, content is eligible to be shown on the surface. | Epoch timestamp in milliseconds | -| End Timestamp | Optional | The epoch timestamp after which the content is no longer shown on the surface. If not set, content is eligible to be shown on the surface. | Epoch timestamp in milliseconds | - -#### `VehicleRentalReservationEntity` - -| Attribute | Requirement | Description | Format | -|---|---|---|---| -| Action Uri | **Required** | Deep Link to the entity in the provider app. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | Uri | -| Title | **Required** | Title of the entity. | Free text. For example, "Avis Union Square SF" **Recommended text size: Max 50 chars** | -| Localized Pickup Time - Timestamp | **Required** | The epoch timestamp that represents the pick up time for the reservation. | Joda-Time Instant | -| Localized Pickup Time - Timezone | **Required** | The timezone of the pick up time for the reservation. | Joda-Time DateTimeZone See [Timezone Specifications](https://developer.android.com/guide/playcore/engage/travel#tz-specs) for guidance. | -| Localized Return Time - Timestamp | Optional | The epoch timestamp that represents the check out time for the reservation. | Joda-Time Instant | -| Localized Return Time - Timezone | Optional | The timezone of the check out time for the reservation. | Joda-Time DateTimeZone See [Timezone Specifications](https://developer.android.com/guide/playcore/engage/travel#tz-specs) for guidance. | -| Pickup Address - Country | Optional | The country of the pickup location. | Free text **Recommended text size: max \~20 chars** | -| Pickup Address - City | Optional | The city of the pickup location. | Free text **Recommended text size: max \~20 chars** | -| Pickup Address - Display Address | Optional | The pickup location that will be displayed to the user. | Free text **Recommended text size: max \~20 chars** | -| Pickup Address - Street Address | Optional | The street address (if applicable) of the pickup location. | Free text **Recommended text size: max \~20 chars** | -| Pickup Address - State | Optional | The state or province (if applicable) of the pickup location. | Free text **Recommended text size: max \~20 chars** | -| Pickup Address - Zip code | Optional | The zip code (if applicable) of the pickup location. | Free text **Recommended text size: max \~20 chars** | -| Pickup Address - Neighborhood | Optional | The neighborhood (if applicable) of the pickup location. | Free text **Recommended text size: max \~20 chars** | -| Return Address - Country | Optional | The country of return location. | Free text **Recommended text size: max \~20 chars** | -| Return Address - City | Optional | The city of return location. | Free text **Recommended text size: max \~20 chars** | -| Return Address - Display Address | Optional | The return location that will be displayed to the user. | Free text **Recommended text size: max \~20 chars** | -| Return Address - Street Address | Optional | The street address (if applicable) of the return location. | Free text **Recommended text size: max \~20 chars** | -| Return Address - State | Optional | The state or province (if applicable) of the return location. | Free text **Recommended text size: max \~20 chars** | -| Return Address - Zip code | Optional | The zip code (if applicable) of the return location. | Free text **Recommended text size: max \~20 chars** | -| Return Address - Neighborhood | Optional | The neighborhood (if applicable) of the return location. | Free text **Recommended text size: max \~20 chars** | -| Service Provider - Name | Optional | The name of the service provider. **Note:**Either text or image is required for the service provider. | Free text. For example, "Avis Car Rental" | -| Service Provider - Image | Optional | The logo/image of the service provider. **Note:**Either text or image is required for the service provider. | See [Image Specifications](https://developer.android.com/guide/playcore/engage/travel#image-specs) for guidance. | -| Poster images | Optional | We will show only 1 image when multiple images are provided. Recommended aspect ratio is 16:9 | See [Image Specifications](https://developer.android.com/guide/playcore/engage/travel#image-specs) for guidance. | -| Description | Optional | A single paragraph of text to describe the entity. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size: 180 chars** | -| Subtitle list | Optional | Up to 3 subtitles, with each subtitle a single line of text. **Note:** Either description or subtitle list will be displayed to the user, not both. | Free text **Recommended text size for each subtitle: max 50 chars** | -| Confirmation ID | Optional | The confirmation ID for the vehicle rental reservation. | Free text | -| Price - CurrentPrice | Conditionally required | The current price of the reservation. **Must be provided if strikethrough price is provided.** | Free text | -| Price - StrikethroughPrice | Optional | The original price of the reservation, which is be struck-through in the UI. | Free text | -| Price Callout | Optional | Price callout to feature a promo, event, member discount, if available. | Free text **Recommended text size: under 45 chars (Text that is too long may show ellipses)** | -| **DisplayTimeWindow (Optional) - Set a time window for a content to be shown on the surface** |||| -| Start Timestamp | Optional | The epoch timestamp after which the content should be shown on the surface. If not set, content is eligible to be shown on the surface. | Epoch timestamp in milliseconds | -| End Timestamp | Optional | The epoch timestamp after which the content is no longer shown on the surface. If not set, content is eligible to be shown on the surface. | Epoch timestamp in milliseconds | - -#### Image specifications - -Required specifications for image assets are listed in this table: - -| Aspect ratio | Minimum pixels | Recommended pixels | -|---|---|---| -| Square (1x1) **Preferred** | 300x300 | 1200x1200 | -| Landscape (1.91x1) | 600x314 | 1200x628 | -| Portrait (4x5) | 480x600 | 960x1200 | - -The images are required to be hosted on public CDNs so that Google can access -them. - -*File formats* - -PNG, JPG, static GIF, WebP - -*Maximum file size* - -5120 KB - -*Additional recommendations* - -- **Image safe area:** Put your important content in the center 80% of the image. -- Use a transparent background so that the image can be properly displayed in Dark and Light theme settings. - -#### Timezone specifications - -Prefer ID (for example, "America/Los_Angeles") over offset (for example, -"-07:00"). - -Sample usage: `DateTimeZone.forID("America/Los_Angeles")` - -#### Content Category - -The content category allows apps to publish content belonging to multiple -categories. This maps the content with some of the predefined categories namely: - -- `TYPE_EDUCATION` -- `TYPE_SPORTS` -- `TYPE_MOVIES_AND_TV_SHOWS` -- `TYPE_BOOKS` -- `TYPE_AUDIOBOOKS` -- `TYPE_MUSIC` -- `TYPE_DIGITAL_GAMES` -- `TYPE_TRAVEL_AND_LOCAL` -- `TYPE_HOME_AND_AUTO` -- `TYPE_BUSINESS` -- `TYPE_NEWS` -- `TYPE_FOOD_AND_DRINK` -- `TYPE_SHOPPING` -- `TYPE_HEALTH_AND_FITENESS` -- `TYPE_MEDICAL` -- `TYPE_PARENTING` -- `TYPE_DATING` - -The images are required to be hosted on public CDNs so that Google can access -them. - -*Guidelines to use the content categories* - -1. Some entities like **ArticleEntity** and **GenericFeaturedEntity** are eligible to use any of the content categories. For other entities like **EventEntity** , **EventReservationEntity** , **PointOfInterestEntity**, only a subset of these categories are eligible. Check the list of categories eligible for an entity type before populating the list. -2. Use the specific entity type for some content categories over a combination - of the Generic entities and the ContentCategory: - - - TYPE_MOVIES_AND_TV_SHOWS - Check out the entities from [Watch integration guide](https://developer.android.com/guide/playcore/engage/watch) before using the generic entities. - - TYPE_BOOKS - Check out the [EbookEntity](https://developer.android.com/guide/playcore/engage/read#ebookentity) before using the generic entities. - - TYPE_AUDIOBOOKS - Check out [AudiobookEntity](https://developer.android.com/guide/playcore/engage/read#audiobookentity) before using the generic entities. - - TYPE_SHOPPING - Check out [ShoppingEntity](https://developer.android.com/guide/playcore/engage/shopping#shoppingEntity) before using the generic entities. - - TYPE_FOOD_AND_DRINK - Check out entities from [Food Integration guide](https://developer.android.com/guide/playcore/engage/food) before using the generic entities. -3. The ContentCategory field is optional and should be left blank if the - content doesn't belong to any of the categories mentioned earlier. - -4. In case multiple content categories are provided, provide them in the order - of relevance to the content with the most relevant content category placed - first in the list. - -### Step 2: Provide Cluster data - -It is recommended to have the content publish job executed in the background -(for example, using [WorkManager](https://developer.android.com/topic/libraries/architecture/workmanager)) -and scheduled on a regular basis or on an event basis (for example, every time -the user opens the app or when the user just added something to their cart). - -`AppEngageTravelClient` is responsible for publishing clusters. - -There are following APIs to publish clusters in the client: - -- `isServiceAvailable` -- `publishRecommendationClusters` -- `publishFeaturedCluster` -- `publishReservationCluster` -- `publishContinueSearchCluster` -- `publishUserAccountManagementRequest` -- `updatePublishStatus` -- `deleteRecommendationsClusters` -- `deleteFeaturedCluster` -- `deleteReservationCluster` -- `deleteContinueSearchCluster` -- `deleteUserManagementCluster` -- `deleteClusters` - -#### `isServiceAvailable` - -This API is used to check if the service is available for integration and -whether the content can be presented on the device. - -##### For Engage SDK v1.6.0 and higher (Recommended) - - -## Android skills - -[View on GitHub](https://github.com/android/skills/tree/main/play/engage-sdk-integration) - -### Engage SDK integration - -To install the skill from the [Android CLI](https://developer.android.com/tools/agents/android-cli), run: - - android skills add engage-sdk-integration - -If your team uses AI coding tools (such as Gemini in Android Studio), you can automate this migration by prompting your AI assistant: - - Use the engage-sdk-integration skill to use Engage SDK 1.6.0 and refactor isServiceAvailable to pass ServiceAvailabilityRequest for publishing all cluster types. - -
- -You can check the service availability for every cluster type that you intend to -publish. The `isServiceAvailable` API accepts a request object, -`ServiceAvailabilityRequest`, which contains the cluster types for which service -availability needs to be checked. You can find the `ClusterType` enum values -required for `ServiceAvailabilityRequest` from the following table. - -| Cluster Type | Cluster Type Constant | Integer Value | -|---|---|---| -| Unknown | `TYPE_UNKNOWN` | 0 | -| Recommendation Cluster | `TYPE_RECOMMENDATION` | 1 | -| Featured Cluster | `TYPE_FEATURED` | 2 | -| Continuation Cluster | `TYPE_CONTINUATION` | 3 | -| User Management Cluster | `TYPE_ENGAGEMENT` | 8 | -| Subscription Cluster | `TYPE_SUBSCRIPTION` | 12 | -| Continue Search Cluster | `TYPE_CONTINUE_SEARCH` | 13 | -| Reservation Cluster | `TYPE_RESERVATION` | 14 | - -### Kotlin - - val request = ServiceAvailabilityRequest.Builder() - .addIntendedClusterType(ClusterType.TYPE_CONTINUATION) - .addIntendedClusterType(ClusterType.TYPE_RECOMMENDATION) - .build() - - client.isServiceAvailable(request).addOnCompleteListener { task -> - if (task.isSuccessful) { - val availabilityMap = task.result - if (availabilityMap[ClusterType.TYPE_CONTINUATION] == true) { - // Proceed with publishing continuation content - } - if (availabilityMap[ClusterType.TYPE_RECOMMENDATION] == true) { - // Proceed with publishing recommendation content - } - } else { - // The IPC call itself fails, proceed with error handling logic here, - // such as retry. - } - } - -### Java - - ServiceAvailabilityRequest request = - new ServiceAvailabilityRequest.Builder() - .addIntendedClusterType(ClusterType.TYPE_CONTINUATION) - .addIntendedClusterType(ClusterType.TYPE_RECOMMENDATION) - .build(); - - client.isServiceAvailable(request).addOnCompleteListener(task -> { - if (task.isSuccessful()) { - Map availabilityMap = task.getResult(); - if (Boolean.TRUE.equals(availabilityMap.get(ClusterType.TYPE_CONTINUATION))) { - // Proceed with publishing continuation content - } - if (Boolean.TRUE.equals(availabilityMap.get(ClusterType.TYPE_RECOMMENDATION))) { - // Proceed with publishing recommendation content - } - } else { - // The IPC call itself fails, proceed with error handling logic here, - // such as retry. - } - }); - -###### Conditional Service Availability Feature - -Some integrated apps request a special configuration that enables and disables -the Engage service intermittently in order to reduce their serving cost. This -intermittent content ingestion strategy, although possible, negatively affects -the user and the product -- stale content will not be presented and some surfaces -will not be served at all. - -Starting with v1.6.0, the Engage SDK allows checking availability for specific -cluster types. This provides more flexibility so that if the intermittent -content strategy was adopted by a given application, some cluster types can -follow that intermittent strategy while other cluster types are always enabled -(i.e. continuation clusters). - -If the Engage service should not be 'continuously' enabled on all supported -devices for whatever reason, and is configured for intermittent ingestion for -any set of devices, all continuation cluster publications (e.g. Reservation -and Continue Search) will be still enabled by default configuration, and the -rest of the cluster types will be enabled and disabled intermittently. If -intermittent ingestion applies to you but this default configuration is not -suitable for your needs, please contact engage-developers@google.com. - -##### For SDK versions prior to v1.6.0 (Deprecated) - -### Kotlin - - client.isServiceAvailable.addOnCompleteListener { task -> - if (task.isSuccessful) { - // Handle IPC call success - if(task.result) { - // Service is available on the device, proceed with content publish - // calls. - } else { - // Service is not available, no further action is needed. - } - } else { - // The IPC call itself fails, proceed with error handling logic here, - // such as retry. - } - } - -### Java - - client.isServiceAvailable().addOnCompleteListener(task - > { - if (task.isSuccessful()) { - // Handle success - if(task.getResult()) { - // Service is available on the device, proceed with content publish - // calls. - } else { - // Service is not available, no further action is needed. - } - } else { - // The IPC call itself fails, proceed with error handling logic here, - // such as retry. - } - }); - -> [!NOTE] -> **Note:** We highly recommend keeping a periodic job running to check if the service becomes available at a later point in time. The availability of the service may change with Android version upgrades, app upgrades, installs, and uninstalls. By ensuring periodic job checks at a certain time interval, data can be published once the service becomes available. - -#### `publishRecommendationClusters` - -This API is used to publish a list of `RecommendationCluster` objects. - -> [!IMPORTANT] -> **Important:** The publish APIs are upsert APIs; it replaces the existing content. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. - -### Kotlin - - client.publishRecommendationClusters( - PublishRecommendationClustersRequest.Builder() - .addRecommendationCluster( - RecommendationCluster.Builder() - .addEntity(entity1) - .addEntity(entity2) - .setTitle("Top Picks For You") - .build() - ) - .build() - ) - -### Java - - client.publishRecommendationClusters( - new PublishRecommendationClustersRequest.Builder() - .addRecommendationCluster( - new RecommendationCluster.Builder() - .addEntity(entity1) - .addEntity(entity2) - .setTitle("Top Picks For You") - .build()) - .build()); - -When the service receives the request, the following actions take place within -one transaction: - -- Existing `RecommendationCluster` data from the developer partner is removed. -- Data from the request is parsed and stored in the updated Recommendation Cluster. - -In case of an error, the entire request is rejected and the existing state is -maintained. - -#### `publishFeaturedCluster` - -This API is used to publish a list of `FeaturedCluster` objects. - -> [!IMPORTANT] -> **Important:** The publish APIs are upsert APIs; it replaces the existing content. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. - -### Kotlin - - client.publishFeaturedCluster( - PublishFeaturedClusterRequest.Builder() - .setFeaturedCluster( - FeaturedCluster.Builder() - .addEntity(entity1) - .addEntity(entity2) - .build()) - .build()) - -### Java - - client.publishFeaturedCluster( - new PublishFeaturedClustersRequest.Builder() - .addFeaturedCluster( - new FeaturedCluster.Builder() - .addEntity(entity1) - .addEntity(entity2) - .build()) - .build()); - -When the service receives the request, the following actions take place within -one transaction: - -- Existing `FeaturedCluster` data from the developer partner is removed. -- Data from the request is parsed and stored in the updated Featured Cluster. - -In case of an error, the entire request is rejected and the existing state is -maintained. - -#### `publishReservationCluster` - -This API is used to publish a `ReservationCluster` object. - -> [!IMPORTANT] -> **Important:** The publish APIs are upsert APIs; it replaces the existing content. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. - -### Kotlin - - client.publishReservationCluster( - PublishReservationClusterRequest.Builder() - .setReservationCluster( - ReservationCluster.Builder() - .addLodgingReservationEntity(lodgingReservationEntity) - .addVehicleRentalReservationEntity(vehicleRentalReservationEntity) - .addTransportationReservationEntity(transportationReservationEntity) - .addEventReservationEntity(eventReservationEntity) - .addRestaurantReservationEntity(restaurantReservationEntity) - .build()) - .build()) - -### Java - - client.publishReservationCluster( - new PublishReservationClusterRequest.Builder() - .setReservationCluster( - new ReservationCluster.Builder() - .addLodgingReservationEntity(lodgingReservationEntity) - .addVehicleRentalReservationEntity(vehicleRentalReservationEntity) - .addTransportationReservationEntity(transportationReservationEntity) - .addEventReservationEntity(eventReservationEntity) - .addRestaurantReservationEntity(restaurantReservationEntity) - .build()) - .build()); - -When the service receives the request, the following actions take place within -one transaction: - -- Existing `ReservationCluster` data from the developer partner is removed. -- Data from the request is parsed and stored in the updated Reservation Cluster. - -In case of an error, the entire request is rejected and the existing state is -maintained. - -#### `publishContinueSearchCluster` - -This API is used to publish a list of `ContinueSearchCluster` objects. - -> [!IMPORTANT] -> **Important:** The publish APIs replace any existing content. **Don't** call delete and publish APIs subsequently to replace the content, as the publish APIs do that inherently. - -### Kotlin - - client.publishContinueSearchCluster( - PublishContinueSearchClusterRequest.Builder() - .setContinueSearchCluster( - ContinueSearchCluster.Builder() - .addPointOfInterestEntity(entity1) - .addPointOfInterestEntity(entity2) - .build()) - .build()) - -### Java - - client.publishContinueSearchCluster( - new PublishContinueSearchClusterRequest.Builder() - .setContinueSearchCluster( - new ContinueSearchCluster.Builder() - .addPointOfInterestEntity(entity1) - .addPointOfInterestEntity(entity2) - .build()) - .build()); - -When the service receives the request, the following actions take place within -one transaction: - -- Existing `ContinueSearchCluster` data from the developer partner is removed. -- Data from the request is parsed and stored in the updated Continue Search Cluster. - -In case of an error, the entire request is rejected and the existing state is -maintained. - -#### `publishUserAccountManagementRequest` - -This API is used to publish a Sign In card . The signin action directs users to -the app's sign in page so that the app can publish content (or provide more -personalized content) - -The following metadata is part of the Sign In Card - - -| Attribute | Requirement | Description | -|---|---|---| -| Action Uri | Required | Deeplink to Action (i.e. navigates to app sign in page) | -| Image | Optional - If not provided, Title must be provided | Image Shown on the Card 16x9 aspect ratio images with a resolution of 1264x712 | -| Title | Optional - If not provided, Image must be provided | Title on the Card | -| Action Text | Optional | Text Shown on the CTA (i.e. Sign in) | -| Subtitle | Optional | Optional Subtitle on the Card | - -> [!IMPORTANT] -> **Important:** The publish APIs are upsert APIs; it replaces the existing content. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. - -### Kotlin - - var SIGN_IN_CARD_ENTITY = - SignInCardEntity.Builder() - .addPosterImage( - Image.Builder() - .setImageUri(Uri.parse("http://www.x.com/image.png")) - .setImageHeightInPixel(500) - .setImageWidthInPixel(500) - .build()) - .setActionText("Sign In") - .setActionUri(Uri.parse("http://xx.com/signin")) - .build() - - client.publishUserAccountManagementRequest( - PublishUserAccountManagementRequest.Builder() - .setSignInCardEntity(SIGN_IN_CARD_ENTITY) - .build()); - -### Java - - SignInCardEntity SIGN_IN_CARD_ENTITY = - new SignInCardEntity.Builder() - .addPosterImage( - new Image.Builder() - .setImageUri(Uri.parse("http://www.x.com/image.png")) - .setImageHeightInPixel(500) - .setImageWidthInPixel(500) - .build()) - .setActionText("Sign In") - .setActionUri(Uri.parse("http://xx.com/signin")) - .build(); - - client.publishUserAccountManagementRequest( - new PublishUserAccountManagementRequest.Builder() - .setSignInCardEntity(SIGN_IN_CARD_ENTITY) - .build()); - -When the service receives the request, the following actions take place within -one transaction: - -- Existing `UserAccountManagementCluster` data from the developer partner is removed. -- Data from the request is parsed and stored in the updated UserAccountManagementCluster Cluster. - -In case of an error, the entire request is rejected and the existing state is -maintained. - -#### `updatePublishStatus` - -If for any internal business reason, none of the clusters is published, we -**strongly recommend** updating the publish status using the -**updatePublishStatus** API. This is important because : - -- Providing the status in all scenarios, even when the content is published (STATUS == PUBLISHED), is critical to populate dashboards that use this explicit status to convey the health and other metrics of your integration. -- If no content is published but the integration status isn't broken (STATUS == NOT_PUBLISHED), Google can avoid triggering alerts in the app health dashboards. It confirms that content is not published due to an **expected** situation from the provider's standpoint. -- It helps developers provide insights into when the data is published versus not. -- Google may use the status codes to nudge the user to do certain actions in the app so they can see the app content or overcome it. - -The list of eligible publish status codes are : - - // Content is published - AppEngagePublishStatusCode.PUBLISHED, - - // Content is not published as user is not signed in - AppEngagePublishStatusCode.NOT_PUBLISHED_REQUIRES_SIGN_IN, - - // Content is not published as user is not subscribed - AppEngagePublishStatusCode.NOT_PUBLISHED_REQUIRES_SUBSCRIPTION, - - // Content is not published as user location is ineligible - AppEngagePublishStatusCode.NOT_PUBLISHED_INELIGIBLE_LOCATION, - - // Content is not published as there is no eligible content - AppEngagePublishStatusCode.NOT_PUBLISHED_NO_ELIGIBLE_CONTENT, - - // Content is not published as the feature is disabled by the client - // Available in v1.3.1 - AppEngagePublishStatusCode.NOT_PUBLISHED_FEATURE_DISABLED_BY_CLIENT, - - // Content is not published as the feature due to a client error - // Available in v1.3.1 - AppEngagePublishStatusCode.NOT_PUBLISHED_CLIENT_ERROR, - - // Content is not published as the feature due to a service error - // Available in v1.3.1 - AppEngagePublishStatusCode.NOT_PUBLISHED_SERVICE_ERROR, - - // Content is not published due to some other reason - // Reach out to engage-developers@ before using this enum. - AppEngagePublishStatusCode.NOT_PUBLISHED_OTHER - -If the content is not published due to a user not logged in, Google would -recommend publishing the Sign In Card. If for any reason providers are not able -to publish the Sign In Card then we recommend calling the -**updatePublishStatus** API with the status code -**NOT_PUBLISHED_REQUIRES_SIGN_IN** - -### Kotlin - - client.updatePublishStatus( - PublishStatusRequest.Builder() - .setStatusCode(AppEngagePublishStatusCode.NOT_PUBLISHED_REQUIRES_SIGN_IN) - .build()) - -### Java - - client.updatePublishStatus( - new PublishStatusRequest.Builder() - .setStatusCode(AppEngagePublishStatusCode.NOT_PUBLISHED_REQUIRES_SIGN_IN) - .build()); - -#### `deleteRecommendationClusters` - -This API is used to delete the content of Recommendation Clusters. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteRecommendationClusters() - -### Java - - client.deleteRecommendationClusters(); - -When the service receives the request, it removes the existing data from the -Recommendation Clusters. In case of an error, the entire request is rejected and -the existing state is maintained. - -> [!NOTE] -> **Note:** This api is available from version 1.1.0 onwards. - -#### `deleteFeaturedCluster` - -This API is used to delete the content of Featured Cluster. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteFeaturedCluster() - -### Java - - client.deleteFeaturedCluster(); - -When the service receives the request, it removes the existing data from the -Featured Cluster. In case of an error, the entire request is rejected and the -existing state is maintained. - -> [!NOTE] -> **Note:** This api is available from version 1.1.0 onwards. - -#### `deleteReservationCluster` - -This API is used to delete the content of Reservation Cluster. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteReservationCluster() - -### Java - - client.deleteReservationCluster(); - -When the service receives the request, it removes the existing data from the -Reservation Cluster. In case of an error, the entire request is rejected and -the existing state is maintained. - -> [!NOTE] -> **Note:** This api is available from version 1.1.0 onwards. - -#### `deleteUserManagementCluster` - -This API is used to delete the content of UserAccountManagement Cluster. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteUserManagementCluster() - -### Java - - client.deleteUserManagementCluster(); - -When the service receives the request, it removes the existing data from the -UserAccountManagement Cluster. In case of an error, the entire request is -rejected and the existing state is maintained. - -> [!NOTE] -> **Note:** This api is available from version 1.1.0 onwards. - -#### `deleteContinueSearchCluster` - -This API is used to delete the content of Continue Search Cluster. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteContinueSearchCluster() - -### Java - - client.deleteContinueSearchCluster(); - -When the service receives the request, it removes the existing data from the -Continue Search Cluster. In case of an error, the entire request is -rejected, and the existing state is maintained. - -> [!NOTE] -> **Note:** This API is available from version 1.5.6 onwards. - -#### `deleteClusters` - -This API is used to delete the content of a given cluster type. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteClusters( - DeleteClustersRequest.Builder() - .addClusterType(ClusterType.TYPE_RESERVATION) - .addClusterType(ClusterType.TYPE_FEATURED) - .addClusterType(ClusterType.TYPE_RECOMMENDATION) - .addClusterType(ClusterType.TYPE_CONTINUE_SEARCH) - .build()) - -### Java - - client.deleteClusters( - new DeleteClustersRequest.Builder() - .addClusterType(ClusterType.TYPE_RESERVATION) - .addClusterType(ClusterType.TYPE_FEATURED) - .addClusterType(ClusterType.TYPE_RECOMMENDATION) - .addClusterType(ClusterType.TYPE_CONTINUE_SEARCH) - .build()); - -When the service receives the request, it removes the existing data from all -clusters matching the specified cluster types. Clients can choose to pass one or -many cluster types. In case of an error, the entire request is rejected and the -existing state is maintained. - -#### Error handling - -It is highly recommended to listen to the task result from the publish APIs such -that a follow-up action can be taken to recover and resubmit an successful task. - -### Kotlin - - client.publishRecommendationClusters( - PublishRecommendationClustersRequest.Builder() - .addRecommendationCluster(..) - .build()) - .addOnCompleteListener { task -> - if (task.isSuccessful) { - // do something - } else { - val exception = task.exception - if (exception is AppEngageException) { - @AppEngageErrorCode val errorCode = exception.errorCode - if (errorCode == AppEngageErrorCode.SERVICE_NOT_FOUND) { - // do something - } - } - } - } - -### Java - - client.publishRecommendationClusters( - new PublishRecommendationClustersRequest.Builder() - .addRecommendationCluster(...) - .build()) - .addOnCompleteListener( - task -> { - if (task.isSuccessful()) { - // do something - } else { - Exception exception = task.getException(); - if (exception instanceof AppEngageException) { - @AppEngageErrorCode - int errorCode = ((AppEngageException) exception).getErrorCode(); - if (errorCode == AppEngageErrorCode.SERVICE_NOT_FOUND) { - // do something - } - } - } - }); - -The error is returned as an `AppEngageException` with the cause included as an -error code. - -| Error code | Error name | Note | -|---|---|---| -| `1` | `SERVICE_NOT_FOUND` | The service is not available on the given device. | -| `2` | `SERVICE_NOT_AVAILABLE` | The service is available on the given device, but it is not available at the time of the call (for example, it is explicitly disabled). | -| `3` | `SERVICE_CALL_EXECUTION_FAILURE` | The task execution failed due to threading issues. In this case, it can be retried. | -| `4` | `SERVICE_CALL_PERMISSION_DENIED` | The caller is not allowed to make the service call. | -| `5` | `SERVICE_CALL_INVALID_ARGUMENT` | The request contains invalid data (for example, more than the allowed number of clusters). | -| `6` | `SERVICE_CALL_INTERNAL` | There is an error on the service side. | -| `7` | `SERVICE_CALL_RESOURCE_EXHAUSTED` | The service call is made too frequently. | - -### Step 3: Handle broadcast intents - -In addition to making publish content API calls through a job, it is also -required to set up a -[`BroadcastReceiver`](https://developer.android.com/reference/android/content/BroadcastReceiver) to receive -the request for a content publish. - -The goal of broadcast intents is mainly for app reactivation and forcing data -sync. Broadcast intents are not designed to be sent very frequently. It is only -triggered when the Engage Service determines the content might be stale (for -example, a week old). That way, there is more confidence that the user can have -a fresh content experience, even if the application has not been executed for a -long period of time. - -The `BroadcastReceiver` must be set up in the following two ways: - -- Dynamically register an instance of the `BroadcastReceiver` class using - `Context.registerReceiver()`. This enables communication from applications - that are still live in memory. - -### Kotlin - - class AppEngageBroadcastReceiver : BroadcastReceiver(){ - // Trigger recommendation cluster publish when PUBLISH_RECOMMENDATION broadcast - // is received - // Trigger featured cluster publish when PUBLISH_FEATURED broadcast is received - // Trigger continue search cluster publish when PUBLISH_CONTINUE_SEARCH - // broadcast is received - // Trigger reservation cluster publish when PUBLISH_RESERVATION broadcast is - // received - } - - fun registerBroadcastReceivers(context: Context){ - var context = context - context = context.applicationContext - - // Register Recommendation Cluster Publish Intent - context.registerReceiver(AppEngageBroadcastReceiver(), - IntentFilter(com.google.android.engage.service.Intents.ACTION_PUBLISH_RECOMMENDATION), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null) - - // Register Featured Cluster Publish Intent - context.registerReceiver(AppEngageBroadcastReceiver(), - IntentFilter(com.google.android.engage.service.Intents.ACTION_PUBLISH_FEATURED), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null) - - // Register Continue Search Cluster Publish Intent - context.registerReceiver(AppEngageBroadcastReceiver(), - IntentFilter(com.google.android.engage.travel.service.Intents.ACTION_PUBLISH_CONTINUE_SEARCH), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null) - - // Register Reservation Cluster Publish Intent - context.registerReceiver(AppEngageBroadcastReceiver(), - IntentFilter(com.google.android.engage.travel.service.Intents.ACTION_PUBLISH_RESERVATION), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null) - } - -### Java - - class AppEngageBroadcastReceiver extends BroadcastReceiver { - // Trigger recommendation cluster publish when PUBLISH_RECOMMENDATION broadcast - // is received - - // Trigger featured cluster publish when PUBLISH_FEATURED broadcast is received - - // Trigger continue search cluster publish when PUBLISH_CONTINUE_SEARCH - // broadcast is received - - // Trigger reservation cluster publish when PUBLISH_RESERVATION broadcast is - // received - } - - public static void registerBroadcastReceivers(Context context) { - - context = context.getApplicationContext(); - - // Register Recommendation Cluster Publish Intent - context.registerReceiver(new AppEngageBroadcastReceiver(), - new IntentFilter(com.google.android.engage.service.Intents.ACTION_PUBLISH_RECOMMENDATION), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null); - - // Register Featured Cluster Publish Intent - context.registerReceiver(new AppEngageBroadcastReceiver(), - new IntentFilter(com.google.android.engage.service.Intents.ACTION_PUBLISH_FEATURED), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null); - - // Register Continue Search Cluster Publish Intent - context.registerReceiver(new AppEngageBroadcastReceiver(), - new IntentFilter(com.google.android.engage.travel.service.Intents.ACTION_PUBLISH_CONTINUE_SEARCH), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null); - - // Register Reservation Cluster Publish Intent - context.registerReceiver(new AppEngageBroadcastReceiver(), - new IntentFilter(com.google.android.engage.travel.service.Intents.ACTION_PUBLISH_RESERVATION), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null); - - } - -- Statically declare an implementation with the `` tag in your - `AndroidManifest.xml` file. This allows the application to receive broadcast - intents when it is not running, and also allows the application to publish - the content. - - - - - - - - - - - - - - - - - - -The following [intents](https://developer.android.com/reference/android/content/Intent) is sent by the -service: - -- `com.google.android.engage.action.PUBLISH_RECOMMENDATION` It is recommended to start a `publishRecommendationClusters` call when receiving this intent. -- `com.google.android.engage.action.PUBLISH_FEATURED` It is recommended to start a `publishFeaturedCluster` call when receiving this intent. -- `com.google.android.engage.action.travel.PUBLISH_CONTINUE_SEARCH` It is recommended to start a `publishContinueSearchCluster` call when receiving this intent. -- `com.google.android.engage.action.travel.PUBLISH_RESERVATION` It is recommended to start a `publishReservationCluster` call when receiving this intent. - -## Integration workflow - -For a step-by-step guide on verifying your integration after it is complete, see -[Engage developer integration workflow](https://developer.android.com/guide/playcore/engage/workflow). - -## FAQs - -See [Engage SDK Frequently Asked Questions](https://developer.android.com/guide/playcore/engage/faq) for -FAQs. - -## Contact - -Contact -[`engage-developers@google.com`](mailto:engage-developers@google.com) if there are -any questions during the integration process. - -## Next steps - -After completing this integration, your next steps are as follows: - -- Send an email to [`engage-developers@google.com`](mailto:engage-developers@google.com) and attach your integrated APK that is ready for testing by Google. -- Google performs a verification and reviews internally to make sure the integration works as expected. If changes are needed, Google contacts you with any necessary details. -- When testing is complete and no changes are needed, Google contacts you to notify you that you can start publishing the updated and integrated APK to the Play Store. -- After Google has confirmed that your updated APK has been published to the Play Store, your **Recommendation** , **Featured** , **Reservation** , and **Continue Search** clusters may be published and visible to users. \ No newline at end of file diff --git a/play/engage-sdk-integration/references/android/guide/playcore/engage/tv/continue-watching/index.md b/play/engage-sdk-integration/references/android/guide/playcore/engage/tv/continue-watching/index.md deleted file mode 100644 index 555b293..0000000 --- a/play/engage-sdk-integration/references/android/guide/playcore/engage/tv/continue-watching/index.md +++ /dev/null @@ -1,19 +0,0 @@ -Continue watching leverages the **Continuation cluster** to show unfinished -videos, and next episodes to be watched from the same TV show, from multiple -apps in one UI grouping. You can feature your entities in this continuation -cluster. Follow this guide to learn how to enhance user engagement through the -continue watching experience using [Engage SDK](https://developer.android.com/guide/playcore/engage). - -You manage the continuation cluster by using the client API in a TV app or from -a REST API: - -- [Integrate continue watching on Android TV](https://developer.android.com/guide/playcore/engage/tv/continue-watching/client) -- [Integrate continue watching using REST API](https://developer.android.com/guide/playcore/engage/tv/continue-watching/rest) - -## Sample code - -This [sample app](https://github.com/googlesamples/tv-video-discovery-samples) demonstrates how you can integrate with Engage SDK -to send personalized user data to Google. It shows how to build a common module -that you can import in both mobile and TV apps. The sample also illustrates -when to call the publish and delete APIs, as well as how to use Workers to -make these calls. \ No newline at end of file diff --git a/play/engage-sdk-integration/references/android/guide/playcore/engage/tv/entitlements.md b/play/engage-sdk-integration/references/android/guide/playcore/engage/tv/entitlements.md deleted file mode 100644 index a8f8c30..0000000 --- a/play/engage-sdk-integration/references/android/guide/playcore/engage/tv/entitlements.md +++ /dev/null @@ -1,353 +0,0 @@ -This guide contains instructions for developers to share app subscription and -entitlement data with Google TV using [Engage SDK](https://developer.android.com/guide/playcore/engage). Users can find -content they are entitled to and enable Google TV to deliver highly relevant -content recommendations to users, directly within Google TV experiences on TV, -mobile, and tablet. - -## Prerequisites - -> [!IMPORTANT] -> **Important:** [Express interest in developing with Engage](http://g.co/tv/vda). - -Onboarding the media actions feed is required before you can use the device -entitlement API. If you haven't already done so, complete the [media actions -feed](https://developers.google.com/actions/media?authuser=0) onboarding process. - -## Pre-work - -Complete the [Pre-work](https://developer.android.com/guide/playcore/engage/tv/getting-started#pre-work) instructions in the Getting Started guide. - -1. Publish subscription information on the following events: - 1. User logs in to your app. - 2. User switches between profiles (if profiles are supported). - 3. User purchases a new subscription. - 4. User upgrades an existing subscription. - 5. User subscription expires. - -## Integration - -This section provides the necessary code examples and instructions for -implementing `SubscriptionEntity` to manage various subscription types. - -### Common tier subscription - -For users with basic subscriptions to media provider services, for example, a -service that has one subscription tier that grants access to all the paid -content, provide these essential details: - -1. `SubscriptionType`: Clearly indicate the specific subscription plan the user - has. - - - `SUBSCRIPTION_TYPE_ACTIVE`: User has an active paid subscription. - - `SUBSCRIPTION_TYPE_ACTIVE_TRIAL`: User has a trial subscription. - - `SUBSCRIPTION_TYPE_INACTIVE`: User has an account but no active subscription or trial. - - > [!IMPORTANT] - > **Important:** Only users with `SUBSCRIPTION_TYPE_ACTIVE` or `SUBSCRIPTION_TYPE_ACTIVE_TRIAL` are eligible for personalized content recommendations based on their subscription. - -2. `ExpirationTimeMillis`: Optional time in milliseconds. Specify when the - subscription is set to expire. - -3. `ProviderPackageName`: Specify the package name of the app that handles the - subscription. - -Example for the sample media provider feed. - - "actionAccessibilityRequirement": [ - { - "@type": "ActionAccessSpecification", - "category": "subscription", - "availabilityStarts": "2022-06-01T07:00:00Z", - "availabilityEnds": "2026-05-31T07:00:00Z", - "requiresSubscription": { - "@type": "MediaSubscription", - // Don't match this string, - // ID is only used to for reconciliation purpose - "@id": "https://www.example.com/971bfc78-d13a-4419", - // Don't match this, as name is only used for displaying purpose - "name": "Basic common name", - "commonTier": true - } - -The following example creates a `SubscriptionEntity` for a user: - - val subscription = SubscriptionEntity.Builder() - setSubscriptionType( - SubscriptionType.SUBSCRIPTION_TYPE_ACTIVE - ) - .setProviderPackageName("com.google.android.example") - // Optional - // December 30, 2025 12:00:00AM in milliseconds since epoch - .setExpirationTimeMillis(1767052800000) - .build() - -### Premium subscription - -If app offer multi-tiered premium subscription packages, which includes expanded -content or features beyond the common tier, represent this by adding one or more -entitlements to Subscription. - -This entitlement has the following fields: - -1. `Identifier`: Required identifier string for this entitlement. This must match one of the [entitlement identifiers](https://developers.google.com/actions/media/concepts/access-requirements#entitlement-identifier) (note that this isn't the ID field) provided in the media provider's feed published to Google TV. -2. `Name`: This is auxiliary information and is used for entitlement matching. While optional, providing a human readable entitlement name enhances understanding of user entitlements for both developers and support teams. For example: Sling Orange. -3. `ExpirationTimeMillis`: Optionally specify the expiration time in milliseconds for this entitlement, if it differs from the subscription expiration time. By default, the entitlement will expire with the expiry of subscription. - -For the following sample media provider feed snippet: - - "actionAccessibilityRequirement": [ - { - "@type": "ActionAccessSpecification", - "category": "subscription", - "availabilityStarts": "2022-06-01T07:00:00Z", - "availabilityEnds": "2026-05-31T07:00:00Z", - "requiresSubscription": { - "@type": "MediaSubscription", - // Don't match this string, - // ID is only used to for reconciliation purpose - "@id": "https://www.example.com/971bfc78-d13a-4419", - - // Don't match this, as name is only used for displaying purpose - "name": "Example entitlement name", - "commonTier": false, - // match this identifier in your API. This is the crucial - // entitlement identifier used for recommendation purpose. - "identifier": "example.com:entitlementString1" - } - -The following example creates a `SubscriptionEntity` for a subscribed user: - - // Subscription with entitlements. - // The entitlement expires at the same time as its subscription. - val subscription = SubscriptionEntity.Builder() - .setSubscriptionType( - SubscriptionType.SUBSCRIPTION_TYPE_ACTIVE - ) - .setProviderPackageName("com.google.android.example") - // Optional - // December 30, 2025 12:00:00AM in milliseconds - .setExpirationTimeMillis(1767052800000) - .addEntitlement( - SubscriptionEntitlement.Builder() - // matches with the identifier in media provider feed - .setEntitlementId("example.com:entitlementString1") - .setDisplayName("entitlement name1") - .build() - ) - .build() - - // Subscription with entitlements - // The entitement has different expiration time from its subscription - val subscription = SubscriptionEntity.Builder() - .setSubscriptionType( - SubscriptionType.SUBSCRIPTION_TYPE_ACTIVE - ) - .setProviderPackageName("com.google.android.example") - // Optional - // December 30, 2025 12:00:00AM in milliseconds - .setExpirationTimeMillis(1767052800000) - .addEntitlement( - SubscriptionEntitlement.Builder() - .setEntitlementId("example.com:entitlementString1") - .setDisplayName("entitlement name1") - // You may set the expiration time for entitlement - // December 15, 2025 10:00:00 AM in milliseconds - .setExpirationTimeMillis(1765792800000) - .build()) - .build() - -### Subscription for linked service package - -While subscriptions typically belong to the originating app's media provider, a -subscription can be attributed to a linked service package by specifying the -linked service package name within the subscription. - -Following code sample demonstrate how to create user subscription. - - // Subscription for linked service package - val subscription = SubscriptionEntity.Builder() - .setSubscriptionType( - SubscriptionType.SUBSCRIPTION_TYPE_ACTIVE - ) - .setProviderPackageName("com.google.android.example") - // Optional - // December 30, 2025 12:00:00AM in milliseconds since epoch - .setExpirationTimeMillis(1767052800000) - .build() - -In addition, if the user has another subscription to a subsidiary service, add -another subscription and set the linked service package name accordingly. - - // Subscription for linked service package - val linkedSubscription = Subscription.Builder() - .setSubscriptionType( - SubscriptionType.SUBSCRIPTION_TYPE_ACTIVE - ) - .setProviderPackageName("linked service package name") - // Optional - // December 30, 2025 12:00:00AM in milliseconds since epoch - .setExpirationTimeMillis(1767052800000) - .addBundledSubscription( - BundledSubscription.Builder() - .setBundledSubscriptionProviderPackageName( - "bundled-subscription-package-name" - ) - .setSubscriptionType(SubscriptionType.SUBSCRIPTION_TYPE_ACTIVE) - .setExpirationTimeMillis(111) - .addEntitlement( - SubscriptionEntitlement.Builder() - .setExpirationTimeMillis(111) - .setDisplayName("Silver subscription") - .setEntitlementId("subscription.tier.platinum") - .build() - ) - .build() - ) - .build() - -Optionally, add entitlements to a linked service subscription too. - -### Provide subscription set - -Run the content publish job while the app is in the foreground. - -Use the `publishSubscriptionCluster()` method, from the -`AppEngagePublishClient` class, to publish a `SubscriptionCluster` object. - -Make sure to initialize the client and check for service availability as -described in the [Getting Started guide](https://developer.android.com/guide/playcore/engage/tv/getting-started#common-integration). - - client.publishSubscription( - PublishSubscriptionRequest.Builder() - .setAccountProfile(accountProfile) - .setSubscription(subscription) - .build() - ) - -Use `setSubscription()` to verify that user should have only one subscription to -the service. - -Use `addLinkedSubscription()`, or `addLinkedSubscriptions()` which accept a list -of linked subscriptions, to enable user to have zero or more linked -subscriptions. - -When the service receives the request, a new entry is created and the old entry -is automatically deleted after 60 days. The system always uses the latest entry. -In case of an error, the entire request is rejected and the existing state is -maintained. - -### Keep subscription up-to-date - -1. To provide immediate updates upon changes, call - `publishSubscriptionCluster` whenever a user's subscription state changes - like activation, deactivation, upgrades, downgrades. - -2. To provide regular validation for ongoing accuracy, call - `publishSubscriptionCluster` at least once per month. - - > [!NOTE] - > **Note:** As Google TV automatically deletes historical data beyond 60 days to safeguard user privacy, publishing user subscription data at least once per month verify the validity of data. Unlike `publishContinuationCluster` for continue watching data, don't set `syncAcrossDevices` flag, as subscription information is by default used to provide content across all devices. - -3. To delete the Engage data, manually delete a user's data from the - Google TV server before the standard 60-day retention period, use the - `client.deleteClusters` method. This deletes all existing Engage - data for the account profile, or for the entire account depending on the - given [`DeleteReason`](https://developer.android.com/reference/com/google/android/engage/service/DeleteReason). - - The following code snippet shows how to remove a user subscription: - - // If the user logs out from your media app, you must make the following call - // to remove subscription and other Engage data from the current - // google TV device. - client.deleteClusters( - new DeleteClustersRequest.Builder() - .setAccountProfile(accountProfile) - .setReason(DeleteReason.DELETE_REASON_USER_LOG_OUT) - .build() - ) - - The following code snippet demonstrates removal of user subscription - when user revokes the consent: - - // If the user revokes the consent to share across device, make the call - // to remove subscription and other Engage data from all google - // TV devices. - client.deleteClusters( - new DeleteClustersRequest.Builder() - .setAccountProfile(accountProfile) - .setReason(DeleteReason.DELETE_REASON_LOSS_OF_CONSENT) - .build() - ) - - Following code demonstrates how to remove subscription data on user profile - deletion. - - // If the user delete a specific profile, you must make the following call - // to remove subscription data and other Engage data. - client.deleteClusters( - new DeleteClustersRequest.Builder() - .setAccountProfile(accountProfile) - .setReason(DeleteReason.DELETE_REASON_ACCOUNT_PROFILE_DELETION) - .build() - ) - -### Testing - -This section provides a step-by-step guide for testing subscription -implementation. Verify data accuracy and proper functionality before launch. - -#### Publish Integration checklist - -1. Publishing should happen when the app is in the foreground and user the - actively interacting with it. - -2. Publish when: - - - User logs in for the first time. - - User changes profile (if profiles are supported). - - User purchases new subscription. - - User upgrades subscription. - - User subscription expires. -3. Check if app is correctly calling `isServiceAvailable` and - `publishClusters` APIs in logcat, on the publishing events. - -4. Verify that data is visible in the verification app. The verification app - should display subscription as a separate row. When the publish API is - invoked, the data should show up in the verification app. - - > [!IMPORTANT] - > **Important:** Verify that the [Engage Service Flag](https://developer.android.com/guide/playcore/engage/workflow#switch-to-prod) is **not** set to production. - -5. Go to app and perform each of the following actions: - - - Sign in. - - Switch between profiles (if supported). - - Purchase a new subscription. - - Upgrade an existing subscription. - - Expire the subscription. - -#### Verify integration - -To test your integration, use the [verification app](https://developer.android.com/guide/playcore/engage/tv/getting-started#testing). - -1. For each of the events, check if app has invoked the `publishSubscription` API. Verify the published data in the verification app. **Verify that everything is green in verification app** -2. If all the entity's information is correct, it shows an "All Good" green - check in all entities. - - ![Verification App Success Screenshot](https://developer.android.com/static/images/guide/playcore/engage/ett-va-success.png) **Figure 1.** Successful subscription -3. Problems are also highlighted in verification app - - ![Verification App Error Screenshot](https://developer.android.com/static/images/guide/playcore/engage/ett-va-error.png) **Figure 2.**Subscription unsuccessful -4. To see the problems in the bundled subscription, use the TV remote to focus - on that specific bundled subscription and click to see the problems. You - might have to first focus on the row and move to the right to find Bundled - Subscription card. The problems are highlighted as red as shown in Fig 3. - Also, use the remote to move down to see problems in the entitlements within - bundled subscription - - ![Verification App Error Details Screenshot](https://developer.android.com/static/images/guide/playcore/engage/ett-va-error-details.png) **Figure 3.**Subscription Errors -5. To see the problems in the entitlement, use the TV remote to focus on that - specific entitlement and click to see the problems. The problems are - highlighted as red. - - ![Verification App Error Screenshot](https://developer.android.com/static/images/guide/playcore/engage/ett-va-details.png) **Figure 4.**Subscription Error Details \ No newline at end of file diff --git a/play/engage-sdk-integration/references/android/guide/playcore/engage/tv/getting-started.md b/play/engage-sdk-integration/references/android/guide/playcore/engage/tv/getting-started.md deleted file mode 100644 index 6936360..0000000 --- a/play/engage-sdk-integration/references/android/guide/playcore/engage/tv/getting-started.md +++ /dev/null @@ -1,202 +0,0 @@ -
- -[Engage SDK](https://developer.android.com/guide/playcore/engage) lets you deliver personalized recommendations -and continuation content directly to users on Google TV. - -This guide covers how to get started with Engage SDK integrations for TV. After -you complete the pre-work on this page, you can integrate one or more -of the TV features: - -- [Publish continue watching data](https://developer.android.com/guide/playcore/engage/tv/continue-watching) -- [Publish device entitlements](https://developer.android.com/guide/playcore/engage/tv/entitlements) -- [Publish recommendations](https://developer.android.com/guide/playcore/engage/tv/recommendations) - -## Pre-work - -Before you begin, complete the following steps: - -1. [Express interest in developing with Engage SDK](http://g.co/tv/engage) to enroll in - the program, if eligible. - -2. Verify that your app targets Android 4.4 (API level 19) or higher for this - integration. - -3. Add the `com.google.android.engage` library to your app: - - There are separate SDKs to use in the integration: one for mobile apps and - one for TV apps. - - ### Mobile - - dependencies { - implementation 'com.google.android.engage:engage-core:1.6.0' - } - - ### TV - - dependencies { - implementation 'com.google.android.engage:engage-tv:1.1.0' - } - -4. Add permission for `WRITE_EPG_DATA` for TV APK - - - -5. Verify reliable content publishing by using a background service, such as - `androidx.work`, for scheduling. - -6. Test your implementation using the verification app as outlined in the - [Testing section](https://developer.android.com/guide/playcore/engage/tv/getting-started#testing). - -7. In your production app, set the Engage service environment to production by - adding the `` element directly under the `` tag - in your `AndroidManifest.xml` file. - Don't place this tag inside an ``. - - - - - - - - > [!IMPORTANT] - > **Important:** The Engage service environment declaration is only needed for your release build. For your local testing with the verification app, don't include this element. - -## Common integration steps - -### Initialize the client - -Use `AppEngagePublishClient` to interact with the service. Always check if the -service is available before publishing. - -You can check the service availability for every cluster type that you intend to -publish. The `isServiceAvailable` API accepts a request object, -`ServiceAvailabilityRequest`, which contains the cluster types for which service -availability needs to be checked. You can find the `ClusterType` enum values -required for `ServiceAvailabilityRequest` from the following table. - -| Cluster Type | Cluster Type Constant | Integer Value | -|---|---|---| -| Unknown | `TYPE_UNKNOWN` | 0 | -| Recommendation Cluster | `TYPE_RECOMMENDATION` | 1 | -| Continuation Cluster | `TYPE_CONTINUATION` | 3 | - - val client = AppEngagePublishClient(context) - - val request = ServiceAvailabilityRequest.Builder() - .addIntendedClusterType(ClusterType.TYPE_CONTINUATION) - .addIntendedClusterType(ClusterType.TYPE_RECOMMENDATION) - .build() - - client.isServiceAvailable(request).addOnCompleteListener { task -> - if (task.isSuccessful) { - val availabilityMap = task.result - if (availabilityMap[ClusterType.TYPE_CONTINUATION] == true) { - // Proceed with publishing continuation content - } - if (availabilityMap[ClusterType.TYPE_RECOMMENDATION] == true) { - // Proceed with publishing recommendation content - } - } else { - // Service is not available or call failed - } - } - -### Create an account profile - -`AccountProfile` identifies the user. You can specify an account ID, and -optionally a profile ID and locale. - - val accountProfile = AccountProfile.Builder() - .setAccountId("your_users_account_id") - .setProfileId("your_users_profile_id") // Optional - .setLocale(Locale.US.toLanguageTag()) // Optional, e.g., "en-US" - .build() - -An `AccountProfile` must be provided with an account ID in order for content to -be synchronized between devices. See [Cross-device syncing](https://developer.android.com/guide/playcore/engage/tv/continue-watching/client#cross-device_syncing). - -## Testing - -To test your integration, download the verification app: - -[Download verification -app](https://developer.android.com/guide/playcore/engage/tv/getting-started#lightbox-trigger) - -The verification app is an Android app with capabilities to help you test your -integration. It let you to check data accuracy and proper functionality by -verifying published data and broadcast intents before launch. - -1. Install and open the Engage Verification app. -2. If the value of `isServiceAvailable` is `false` in the verification app, click the **Toggle** button within the verification app to set it to `true`. -3. Enter the package name of your app. It automatically shows the published data. -4. Run your app and perform publishing actions, such as logging in or watching a video. -5. Verify that the data shows up in the verification app. - -## Download - -Before downloading, you must agree to the following terms and conditions. - -## Terms and Conditions - -This is the Android Software Development Kit License Agreement - -### 1. Introduction - -1.1 The Android Software Development Kit (referred to in the License Agreement as the "SDK" and specifically including the Android system files, packaged APIs, and Google APIs add-ons) is licensed to you subject to the terms of the License Agreement. The License Agreement forms a legally binding contract between you and Google in relation to your use of the SDK. 1.2 "Android" means the Android software stack for devices, as made available under the Android Open Source Project, which is located at the following URL: https://source.android.com , as updated from time to time. 1.3 A "compatible implementation" means any Android device that (i) complies with the Android Compatibility Definition document, which can be found at the Android compatibility website (https://source.android.com/compatibility) and which may be updated from time to time; and (ii) successfully passes the Android Compatibility Test Suite (CTS). 1.4 "Google" means Google LLC, organized under the laws of the State of Delaware, USA, and operating under the laws of the USA with principal place of business at 1600 Amphitheatre Parkway, Mountain View, CA 94043, USA. - -### 2. Accepting this License Agreement - -2.1 In order to use the SDK, you must first agree to the License Agreement. You may not use the SDK if you do not accept the License Agreement. 2.2 By clicking to accept and/or using this SDK, you hereby agree to the terms of the License Agreement. 2.3 You may not use the SDK and may not accept the License Agreement if you are a person barred from receiving the SDK under the laws of the United States or other countries, including the country in which you are resident or from which you use the SDK. 2.4 If you are agreeing to be bound by the License Agreement on behalf of your employer or other entity, you represent and warrant that you have full legal authority to bind your employer or such entity to the License Agreement. If you do not have the requisite authority, you may not accept the License Agreement or use the SDK on behalf of your employer or other entity. - -### 3. SDK License from Google - -3.1 Subject to the terms of the License Agreement, Google grants you a limited, worldwide, royalty-free, non-assignable, non-exclusive, and non-sublicensable license to use the SDK solely to develop applications for compatible implementations of Android. 3.2 You may not use this SDK to develop applications for other platforms (including non-compatible implementations of Android) or to develop another SDK. You are of course free to develop applications for other platforms, including non-compatible implementations of Android, provided that this SDK is not used for that purpose. 3.3 You agree that Google or third parties own all legal right, title and interest in and to the SDK, including any Intellectual Property Rights that subsist in the SDK. "Intellectual Property Rights" means any and all rights under patent law, copyright law, trade secret law, trademark law, and any and all other proprietary rights. Google reserves all rights not expressly granted to you. 3.4 You may not use the SDK for any purpose not expressly permitted by the License Agreement. Except to the extent required by applicable third party licenses, you may not copy (except for backup purposes), modify, adapt, redistribute, decompile, reverse engineer, disassemble, or create derivative works of the SDK or any part of the SDK. 3.5 Use, reproduction and distribution of components of the SDK licensed under an open source software license are governed solely by the terms of that open source software license and not the License Agreement. 3.6 You agree that the form and nature of the SDK that Google provides may change without prior notice to you and that future versions of the SDK may be incompatible with applications developed on previous versions of the SDK. You agree that Google may stop (permanently or temporarily) providing the SDK (or any features within the SDK) to you or to users generally at Google's sole discretion, without prior notice to you. 3.7 Nothing in the License Agreement gives you a right to use any of Google's trade names, trademarks, service marks, logos, domain names, or other distinctive brand features. 3.8 You agree that you will not remove, obscure, or alter any proprietary rights notices (including copyright and trademark notices) that may be affixed to or contained within the SDK. - -### 4. Use of the SDK by You - -4.1 Google agrees that it obtains no right, title or interest from you (or your licensors) under the License Agreement in or to any software applications that you develop using the SDK, including any intellectual property rights that subsist in those applications. 4.2 You agree to use the SDK and write applications only for purposes that are permitted by (a) the License Agreement and (b) any applicable law, regulation or generally accepted practices or guidelines in the relevant jurisdictions (including any laws regarding the export of data or software to and from the United States or other relevant countries). 4.3 You agree that if you use the SDK to develop applications for general public users, you will protect the privacy and legal rights of those users. If the users provide you with user names, passwords, or other login information or personal information, you must make the users aware that the information will be available to your application, and you must provide legally adequate privacy notice and protection for those users. If your application stores personal or sensitive information provided by users, it must do so securely. If the user provides your application with Google Account information, your application may only use that information to access the user's Google Account when, and for the limited purposes for which, the user has given you permission to do so. 4.4 You agree that you will not engage in any activity with the SDK, including the development or distribution of an application, that interferes with, disrupts, damages, or accesses in an unauthorized manner the servers, networks, or other properties or services of any third party including, but not limited to, Google or any mobile communications carrier. 4.5 You agree that you are solely responsible for (and that Google has no responsibility to you or to any third party for) any data, content, or resources that you create, transmit or display through Android and/or applications for Android, and for the consequences of your actions (including any loss or damage which Google may suffer) by doing so. 4.6 You agree that you are solely responsible for (and that Google has no responsibility to you or to any third party for) any breach of your obligations under the License Agreement, any applicable third party contract or Terms of Service, or any applicable law or regulation, and for the consequences (including any loss or damage which Google or any third party may suffer) of any such breach. - -### 5. Your Developer Credentials - -5.1 You agree that you are responsible for maintaining the confidentiality of any developer credentials that may be issued to you by Google or which you may choose yourself and that you will be solely responsible for all applications that are developed under your developer credentials. - -### 6. Privacy and Information - -6.1 In order to continually innovate and improve the SDK, Google may collect certain usage statistics from the software including but not limited to a unique identifier, associated IP address, version number of the software, and information on which tools and/or services in the SDK are being used and how they are being used. Before any of this information is collected, the SDK will notify you and seek your consent. If you withhold consent, the information will not be collected. 6.2 The data collected is examined in the aggregate to improve the SDK and is maintained in accordance with Google's Privacy Policy, which is located at the following URL: https://policies.google.com/privacy 6.3 Anonymized and aggregated sets of the data may be shared with Google partners to improve the SDK. - -### 7. Third Party Applications - -7.1 If you use the SDK to run applications developed by a third party or that access data, content or resources provided by a third party, you agree that Google is not responsible for those applications, data, content, or resources. You understand that all data, content or resources which you may access through such third party applications are the sole responsibility of the person from which they originated and that Google is not liable for any loss or damage that you may experience as a result of the use or access of any of those third party applications, data, content, or resources. 7.2 You should be aware the data, content, and resources presented to you through such a third party application may be protected by intellectual property rights which are owned by the providers (or by other persons or companies on their behalf). You may not modify, rent, lease, loan, sell, distribute or create derivative works based on these data, content, or resources (either in whole or in part) unless you have been specifically given permission to do so by the relevant owners. 7.3 You acknowledge that your use of such third party applications, data, content, or resources may be subject to separate terms between you and the relevant third party. In that case, the License Agreement does not affect your legal relationship with these third parties. - -### 8. Using Android APIs - -8.1 Google Data APIs 8.1.1 If you use any API to retrieve data from Google, you acknowledge that the data may be protected by intellectual property rights which are owned by Google or those parties that provide the data (or by other persons or companies on their behalf). Your use of any such API may be subject to additional Terms of Service. You may not modify, rent, lease, loan, sell, distribute or create derivative works based on this data (either in whole or in part) unless allowed by the relevant Terms of Service. 8.1.2 If you use any API to retrieve a user's data from Google, you acknowledge and agree that you will retrieve data only with the user's explicit consent and only when, and for the limited purposes for which, the user has given you permission to do so. If you use the Android Recognition Service API, documented at the following URL: https://developer.android.com/reference/android/speech/RecognitionService, as updated from time to time, you acknowledge that the use of the API is subject to the Data Processing Addendum for Products where Google is a Data Processor, which is located at the following URL: https://privacy.google.com/businesses/gdprprocessorterms/, as updated from time to time. By clicking to accept, you hereby agree to the terms of the Data Processing Addendum for Products where Google is a Data Processor. - -### 9. Terminating this License Agreement - -9.1 The License Agreement will continue to apply until terminated by either you or Google as set out below. 9.2 If you want to terminate the License Agreement, you may do so by ceasing your use of the SDK and any relevant developer credentials. 9.3 Google may at any time, terminate the License Agreement with you if: (A) you have breached any provision of the License Agreement; or (B) Google is required to do so by law; or (C) the partner with whom Google offered certain parts of SDK (such as APIs) to you has terminated its relationship with Google or ceased to offer certain parts of the SDK to you; or (D) Google decides to no longer provide the SDK or certain parts of the SDK to users in the country in which you are resident or from which you use the service, or the provision of the SDK or certain SDK services to you by Google is, in Google's sole discretion, no longer commercially viable. 9.4 When the License Agreement comes to an end, all of the legal rights, obligations and liabilities that you and Google have benefited from, been subject to (or which have accrued over time whilst the License Agreement has been in force) or which are expressed to continue indefinitely, will be unaffected by this cessation, and the provisions of paragraph 14.7 will continue to apply to such rights, obligations and liabilities indefinitely. - -### 10. DISCLAIMER OF WARRANTIES - -10.1 YOU EXPRESSLY UNDERSTAND AND AGREE THAT YOUR USE OF THE SDK IS AT YOUR SOLE RISK AND THAT THE SDK IS PROVIDED "AS IS" AND "AS AVAILABLE" WITHOUT WARRANTY OF ANY KIND FROM GOOGLE. 10.2 YOUR USE OF THE SDK AND ANY MATERIAL DOWNLOADED OR OTHERWISE OBTAINED THROUGH THE USE OF THE SDK IS AT YOUR OWN DISCRETION AND RISK AND YOU ARE SOLELY RESPONSIBLE FOR ANY DAMAGE TO YOUR COMPUTER SYSTEM OR OTHER DEVICE OR LOSS OF DATA THAT RESULTS FROM SUCH USE. 10.3 GOOGLE FURTHER EXPRESSLY DISCLAIMS ALL WARRANTIES AND CONDITIONS OF ANY KIND, WHETHER EXPRESS OR IMPLIED, INCLUDING, BUT NOT LIMITED TO THE IMPLIED WARRANTIES AND CONDITIONS OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. - -### 11. LIMITATION OF LIABILITY - -11.1 YOU EXPRESSLY UNDERSTAND AND AGREE THAT GOOGLE, ITS SUBSIDIARIES AND AFFILIATES, AND ITS LICENSORS WILL NOT BE LIABLE TO YOU UNDER ANY THEORY OF LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL OR EXEMPLARY DAMAGES THAT MAY BE INCURRED BY YOU, INCLUDING ANY LOSS OF DATA, WHETHER OR NOT GOOGLE OR ITS REPRESENTATIVES HAVE BEEN ADVISED OF OR SHOULD HAVE BEEN AWARE OF THE POSSIBILITY OF ANY SUCH LOSSES ARISING. - -### 12. Indemnification - -12.1 To the maximum extent permitted by law, you agree to defend, indemnify and hold harmless Google, its affiliates and their respective directors, officers, employees and agents from and against any and all claims, actions, suits or proceedings, as well as any and all losses, liabilities, damages, costs and expenses (including reasonable attorneys fees) arising out of or accruing from (a) your use of the SDK, (b) any application you develop on the SDK that infringes any copyright, trademark, trade secret, trade dress, patent or other intellectual property right of any person or defames any person or violates their rights of publicity or privacy, and (c) any non-compliance by you with the License Agreement. - -### 13. Changes to the License Agreement - -13.1 Google may make changes to the License Agreement as it distributes new versions of the SDK. When these changes are made, Google will make a new version of the License Agreement available on the website where the SDK is made available. - -### 14. General Legal Terms - -14.1 The License Agreement constitutes the whole legal agreement between you and Google and governs your use of the SDK (excluding any services which Google may provide to you under a separate written agreement), and completely replaces any prior agreements between you and Google in relation to the SDK. 14.2 You agree that if Google does not exercise or enforce any legal right or remedy which is contained in the License Agreement (or which Google has the benefit of under any applicable law), this will not be taken to be a formal waiver of Google's rights and that those rights or remedies will still be available to Google. 14.3 If any court of law, having the jurisdiction to decide on this matter, rules that any provision of the License Agreement is invalid, then that provision will be removed from the License Agreement without affecting the rest of the License Agreement. The remaining provisions of the License Agreement will continue to be valid and enforceable. 14.4 You acknowledge and agree that each member of the group of companies of which Google is the parent will be third party beneficiaries to the License Agreement and that such other companies will be entitled to directly enforce, and rely upon, any provision of the License Agreement that confers a benefit on (or rights in favor of) them. Other than this, no other person or company will be third party beneficiaries to the License Agreement. 14.5 EXPORT RESTRICTIONS. THE SDK IS SUBJECT TO UNITED STATES EXPORT LAWS AND REGULATIONS. YOU MUST COMPLY WITH ALL DOMESTIC AND INTERNATIONAL EXPORT LAWS AND REGULATIONS THAT APPLY TO THE SDK. THESE LAWS INCLUDE RESTRICTIONS ON DESTINATIONS, END USERS AND END USE. 14.6 The rights granted in the License Agreement may not be assigned or transferred by either you or Google without the prior written approval of the other party. Neither you nor Google will be permitted to delegate their responsibilities or obligations under the License Agreement without the prior written approval of the other party. 14.7 The License Agreement, and your relationship with Google under the License Agreement, will be governed by the laws of the State of California without regard to its conflict of laws provisions. You and Google agree to submit to the exclusive jurisdiction of the courts located within the county of Santa Clara, California to resolve any legal matter arising from the License Agreement. Notwithstanding this, you agree that Google will still be allowed to apply for injunctive remedies (or an equivalent type of urgent legal relief) in any jurisdiction. *April 28, 2026* I have read and agree with the above terms and conditions [Download](https://dl.google.com/developers/android/channels/verify_app_multiplatform_public_20260701.apk) - -*verify_app_multiplatform_public_20260701.apk* \ No newline at end of file diff --git a/play/engage-sdk-integration/references/android/guide/playcore/engage/tv/recommendations.md b/play/engage-sdk-integration/references/android/guide/playcore/engage/tv/recommendations.md deleted file mode 100644 index a2fd19b..0000000 --- a/play/engage-sdk-integration/references/android/guide/playcore/engage/tv/recommendations.md +++ /dev/null @@ -1,471 +0,0 @@ -This guide contains instructions for developers to integrate their recommended -video content, using the [Engage SDK](https://developer.android.com/guide/playcore/engage), to populate recommendations -experiences across Google surfaces, such as TV, mobile, and tablet. - -Recommendation leverages the **Recommendation cluster** to show movies and TV -shows, from multiple apps in one UI grouping. Each developer partner can -broadcast a maximum **of 25 entities** in each recommendations cluster and there -can be a maximum **of 7** recommendation clusters per request. - -## Pre-work - -> [!IMPORTANT] -> **Important:** [Express interest in developing with Engage](http://g.co/tv/vda). - -Complete the [Pre-work](https://developer.android.com/guide/playcore/engage/tv/getting-started#pre-work) instructions in the Getting Started guide. - -1. Execute publishing on a foreground service. -2. Publish recommendations data at most once daily, triggered by either of - - User's first login of the day. (*or*) - - When the user starts interacting with the application. - -## Integration - -`AppEngagePublishClient` publishes the recommendation cluster. Use the -`publishRecommendationClusters` method to publish a recommendations object. - -Make sure to initialize the client and check for service availability as -described in the [Getting Started guide](https://developer.android.com/guide/playcore/engage/tv/getting-started#common-integration). - - client.publishRecommendationClusters(recommendationRequest) - -### Upserting recommendation clusters - -Clusters are logical grouping of the entities. The following code examples -explains how to build the clusters based on your preference and how to create a -publishing request and upsert all clusters. - -The [`RecommendationClusterType`](https://developer.android.com/reference/com/google/android/engage/common/datamodel/RecommendationClusterType) determines how the -cluster will be displayed. - - // cluster for popular movies - val recommendationCluster1 = RecommendationCluster - .Builder() - .addEntity(movie1) - .addEntity(movie2) - .addEntity(movie3) - .addEntity(movie4) - .addEntity(tvShow) - // This cluster is meant to be used as an individual provider row - .setRecommendationClusterType(TYPE_PROVIDER_ROW) - .setTitle("Popular Movies") - .build() - - // cluster for live TV programs - val recommendationCluster2 = RecommendationCluster - .Builder() - .addEntity(liveTvProgramEntity1) - .addEntity(liveTvProgramEntity2) - .addEntity(liveTvProgramEntity3) - .addEntity(liveTvProgramEntity4) - .addEntity(liveTvProgramEntity5) - // This cluster is meant to be used as an individual provider row - .setRecommendationClusterType(TYPE_PROVIDER_ROW) - .setTitle("Popular Live TV Programs") - .build() - - // creating a publishing request - val recommendationRequest = PublishRecommendationClustersRequest - .Builder() - .setSyncAcrossDevices(true) - .setAccountProfile(accountProfile) - .addRecommendationCluster(recommendationCluster1) - .addRecommendationCluster(recommendationCluster2) - .build() - -When the service receives the request, the following actions occur within one -transaction: - -- Existing `RecommendationsCluster` data from the developer partner is removed. -- Data from the request is parsed and stored in the updated `RecommendationsCluster`. In case of an error, the entire request is rejected and the existing state is maintained. - -> [!NOTE] -> **Note:** Publish APIs are upsert operations, replacing existing content; update entities by republishing the entire cluster. Only publish recommendations for adult accounts. Avoid using delete APIs followed by publish, as the latter inherently replaces content. - -### Cross-device sync - -`SyncAcrossDevices` flag controls whether a user's recommendations cluster data -is shared with Google TV and available across their devices such as TV, phone, -tablets. In order for the recommendation to work, it must be set to true. - -### Obtain consent - -The media application must provide a clear setting to enable or disable -cross-device syncing. Explain the benefits to the user and store the user's -preference once and apply it in `publishRecommendations` Request accordingly. To -get the most out of cross-device feature, verify app obtains user -consent and enables `SyncAcrossDevices` to `true`. - -### Delete the Engage data - -To manually delete a user's data from the Google TV server before the standard -60-day retention period, use the `client.deleteClusters()` method. Upon -receiving the request, the service deletes all existing Engage -data for the account profile, or for the entire account. - -The [`DeleteReason`](https://developer.android.com/reference/com/google/android/engage/service/DeleteReason) enum defines the reason for data deletion. -The following code removes recommendations on logout. - - // If the user logs out from your media app, you must make the following call - // to remove recommendations data from the current Google TV device, otherwise, - // the recommendations data persists on the current Google TV device until 60 - // days later. - client.deleteClusters( - new DeleteClustersRequest.Builder() - .setAccountProfile(AccountProfile()) - .setReason(DeleteReason.DELETE_REASON_USER_LOG_OUT) - .build() - ) - - // If the user revokes the consent to share data with Google TV, you must make - // the following call to remove recommendations data from all current Google TV - // devices. Otherwise, the recommendations data persists until 60 days later. - client.deleteClusters( - new DeleteClustersRequest.Builder() - .setAccountProfile(AccountProfile()) - .setReason(DeleteReason.DELETE_REASON_LOSS_OF_CONSENT) - .build() - ) - -## Create entities - -The SDK has defined different entities to represent each item type. Following -entities are supported for the Recommendation cluster: - -1. [`MediaActionFeedEntity`](https://developer.android.com/reference/com/google/android/engage/video/datamodel/MediaActionFeedEntity) -2. [`MovieEntity`](https://developer.android.com/reference/com/google/android/engage/video/datamodel/MovieEntity) -3. [`TvShowEntity`](https://developer.android.com/reference/com/google/android/engage/video/datamodel/TvShowEntity) -4. [`LiveTvChannelEntity`](https://developer.android.com/reference/com/google/android/engage/video/datamodel/LiveTvChannelEntity) -5. [`LiveTvProgramEntity`](https://developer.android.com/reference/com/google/android/engage/video/datamodel/LiveTvProgramEntity) - -### Provide descriptions - -Provide a short description for each entity; this description will be displayed -when users hover over the entity, providing them with additional details. - -### Call to action text - -Provide an optional call to action text for each entity. This text will be -displayed to the user to encourage engagement. - -### Tags - -Optionally provide a list of tags for each entity. Tags can be used for -categorization and filtering. - -### Platform specific playBack URIs - -Create playback URIs for each supported platform: Android TV, Android, or iOS. -This allows the system to select the appropriate URI for video playback on the -respective platform. - -In the rare case when the playback URIs are identical for all platforms, -repeat it for every platform. - - // Required. Set this when you want recommended entities to show up on - // Google TV - val playbackUriTv = PlatformSpecificUri - .Builder() - .setPlatformType(PlatformType.TYPE_ANDROID_TV) - .setActionUri(Uri.parse("https://www.example.com/entity_uri_for_tv")) - .build() - - // Optional. Set this when you want recommended entities to show up on - // Google TV Android app - val playbackUriAndroid = PlatformSpecificUri - .Builder() - .setPlatformType(PlatformType.TYPE_ANDROID_MOBILE) - .setActionUri(Uri.parse("https://www.example.com/entity_uri_for_android")) - .build() - - // Optional. Set this when you want recommended entities to show up on - // Google TV iOS app - val playbackUriIos = PlatformSpecificUri - .Builder() - .setPlatformType(PlatformType.TYPE_IOS) - .setActionUri(Uri.parse("https://www.example.com/entity_uri_for_ios")) - .build() - - val platformSpecificPlaybackUris = - Arrays.asList(playbackUriTv, playbackUriAndroid, playbackUriIos) - - // Provide appropriate rating for the system. - val contentRating = new RatingSystem - .Builder() - .setAgencyName("MPAA") - .setRating("PG-13") - .build() - -### Poster images - -Poster images require a URI and pixel dimensions (height and width). Target -different form factors by providing multiple poster images, but verify all -images maintain a 16:9 aspect ratio and a minimum height of 200 pixels for -correct display of the "Recommendations" entity, especially within Google's -[Entertainment Space](https://support.google.com/entertainmentspace/answer/10346911). Images with a height less than 200 -pixels may not be shown. - - Image image1 = new Image.Builder() - .setImageUri(Uri.parse("http://www.example.com/entity_image1.png");) - .setImageHeightInPixel(300) - .setImageWidthInPixel(169) - .build() - - Image image2 = new Image.Builder() - .setImageUri(Uri.parse("http://www.example.com/entity_image2.png");) - .setImageHeightInPixel(640) - .setImageWidthInPixel(360) - .build() - - // And other images for different form factors. - val images = Arrays.asList(image1, image2) - -### Recommendation reason - -Optionally provide a recommendation reason which can be used by Google -TV to construct reasons as to why to suggest a specific Movie or TV Show to -the user. - - //Allows us to construct reason: "Because it is top 10 on your Channel" - val topOnPartner = RecommendationReasonTopOnPartner - .Builder() - .setNum(10) //any valid integer value - .build() - - //Allows us to construct reason: "Because it is popular on your Channel" - val popularOnPartner = RecommendationReasonPopularOnPartner - .Builder() - .build() - - //Allows us to construct reason: "New to your channel, or Just added" - val newOnPartner = RecommendationReasonNewOnPartner - .Builder() - .build() - - //Allows us to construct reason: "Because you watched Star Wars" - val watchedSimilarTitles = RecommendationReasonWatchedSimilarTitles - .addSimilarWatchedTitleName("Movie or TV Show Title") - .addSimilarWatchedTitleName("Movie or TV Show Title") - .Builder() - .build() - - //Allows us to construct reason: "Recommended for you by ChannelName" - val recommendedForUser = RecommendationReasonRecommendedForUser - .Builder() - .build() - - val watchAgain = RecommendationReasonWatchAgain - .Builder() - .build() - - val fromUserWatchList = RecommendationReasonFromUserWatchlist - .Builder() - .build() - - val userLikedOnPartner = RecommendationReasonUserLikedOnPartner - .Builder() - .setTitleName("Movie or TV Show Title") - .build() - - val generic = RecommendationReasonGeneric.Builder().build() - -### Display time window - -If an entity should only be available for a limited time, set a custom -expiration time. Without an explicit expiration time, entities will -automatically expire and be erased after 60 days. So set an expiration time only -when the entities need to be expired sooner. Specify multiple such -availability windows. - - val window1 = DisplayTimeWindow - .Builder() - .setStartTimeStampMillis(now()+ 1.days.toMillis()) - .setEndTimeStampMillis(now()+ 30.days.toMillis()) - - val window2 = DisplayTimeWindow - .Builder() - .setEndTimeStampMillis(now()+ 30.days.toMillis()) - - val availabilityTimeWindows: List = listof(window1,window2) - -### DataFeedElementId - -If you have integrated your Media catalogue or Media action feed with Google TV, -you need not create separate entities for Movie or TV Show and instead you can -create a [`MediaActionFeedEntity`](https://developer.android.com/reference/com/google/android/engage/video/datamodel/MediaActionFeedEntity) which includes the -required field DataFeedElementId. This Id must be unique and must match with the -ID in Media Action Feed as it helps to identify ingested feed content and -perform media content lookups. - - val id = "dataFeedElementId" - -### `MovieEntity` - -Here's an example of creating a `MovieEntity` with all the required fields: - - val movieEntity = MovieEntity.Builder() - .setName("Movie name") - .setDescription("A sentence describing movie.") - .addPlatformSpecificPlaybackUri(platformSpecificPlaybackUris) - .addPosterImages(images) - // Suppose the duration is 2 hours, it is 72000000 in milliseconds - .setDurationMills(72000000) - .setCallToActionText("Watch Now") - .addTag("Action") - .build() - -You can provide additional data such as genres, content ratings, release date, -recommendation reason and availability time windows, which may be used by Google -TV for enhanced displays or filtering purposes. - - val genres = Arrays.asList("Action", "Science fiction"); - val rating1 = RatingSystem.Builder().setAgencyName("MPAA").setRating("pg-13").build(); - val contentRatings = Arrays.asList(rating1); - //Suppose release date is 11-02-2025 - val releaseDate = 1739233800000L - val movieEntity = MovieEntity.Builder() - ... - .addGenres(genres) - .setReleaseDateEpochMillis(releaseDate) - .addContentRatings(contentRatings) - .setRecommendationReason(topOnPartner or watchedSimilarTitles) - .addAllAvailabilityTimeWindows(availabilityTimeWindows) - .build() - -### `TvShowEntity` - -Here's an example of creating a `TvShowEntity` with all the required fields: - - val tvShowEntity = TvShowEntity.Builder() - .setName("Show title") - .setDescription("A sentence describing TV Show.") - .addPlatformSpecificPlaybackUri(platformSpecificPlaybackUris) - .addPosterImages(images) - .setCallToActionText("Watch Now") - .addTag("Drama") - .build(); - -Optionally provide additional data such as genres, content ratings, -recommendation reason, offer price, season count or availability time window, -which may be used by Google TV for enhanced displays or filtering purposes. - - val genres = Arrays.asList("Action", "Science fiction"); - val rating1 = RatingSystem.Builder() - .setAgencyName("MPAA") - .setRating("pg-13") - .build(); - val price = Price.Builder() - .setCurrentPrice("$14.99") - .setStrikethroughPrice("$16.99") - .build(); - val contentRatings = Arrays.asList(rating1); - val seasonCount = 5; - val tvShowEntity = TvShowEntity.Builder() - ... - .addGenres(genres) - .addContentRatings(contentRatings) - .setRecommendationReason(topOnPartner or watchedSimilarTitles) - .addAllAvailabilityTimeWindows(availabilityTimeWindows) - .setSeasonCount(seasonCount) - .setPrice(price) - .build() - -### `MediaActionFeedEntity` - -Here's an example of creating an `MediaActionFeedEntity` with all the required -fields: - - val mediaActionFeedEntity = MediaActionFeedEntity.Builder() - .setDataFeedElementId(id) - .setCallToActionText("Watch Now") - .addTag("Action") - .build() - -Optionally provide additional data such as description, recommendation reason -and display time window, which may be used by Google TV for enhanced displays or -filtering purposes. - - val mediaActionFeedEntity = MediaActionFeedEntity.Builder() - .setName("Movie name or TV Show name") - .setDescription("A sentence describing an entity") - .setRecommendationReason(topOnPartner or watchedSimilarTitles) - .addPosterImages(images) - .build() - -### `LiveTvChannelEntity` - -This represents a live TV channel. Here's an example of creating a -`LiveTvChannelEntity` with all the required fields: - - val liveTvChannelEntity = LiveTvChannelEntity.Builder() - .setName("Channel Name") - // ID of the live TV channel - .setEntityId("https://www.example.com/channel/12345") - .setDescription("A sentence describing this live TV channel.") - // channel playback uri must contain at least PlatformType.TYPE_ANDROID_TV - .addPlatformSpecificPlaybackUri(channelPlaybackUris) - .addLogoImage(logoImage) - .setCallToActionText("Watch Now") - .addTag("News") - .build() - -Optionally provide additional data such as content ratings or -recommendation reason. - - val rating1 = RatingSystem.Builder() - .setAgencyName("MPAA") - .setRating("pg-13") - .build() - val contentRatings = Arrays.asList(rating1) - - val liveTvChannelEntity = LiveTvChannelEntity.Builder() - ... - .addContentRatings(contentRatings) - .setRecommendationReason(topOnPartner) - .build() - -### `LiveTvProgramEntity` - -This represents a live TV program card airing or scheduled to air on -a live TV channel. Here's an example of creating a `LiveTvProgramEntity` -with all the required fields: - - val liveTvProgramEntity = LiveTvProgramEntity.Builder() - // First set the channel information - .setChannelName("Channel Name") - .setChannelId("https://www.example.com/channel/12345") - // channel playback uri must contain at least PlatformType.TYPE_ANDROID_TV - .addPlatformSpecificPlaybackUri(channelPlaybackUris) - .setChannelLogoImage(channelLogoImage) - // Then set the program or card specific information. - .setName("Program Name") - .setEntityId("https://www.example.com/schedule/123") - .setDescription("Program Description") - .addAvailabilityTimeWindow( - DisplayTimeWindow.Builder() - .setStartTimestampMillis(1756713600000L)// 2025-09-01T07:30:00+0000 - .setEndTimestampMillis(1756715400000L))// 2025-09-01T08:00:00+0000 - .addPosterImage(programImage) - .setCallToActionText("Watch Now") - .addTag("Sports") - .build() - -Optionally provide additional data such as content ratings, genres, or -recommendation reason. - - val rating1 = RatingSystem.Builder() - .setAgencyName("MPAA") - .setRating("pg-13") - .build() - val contentRatings = Arrays.asList(rating1) - val genres = Arrays.asList("Action", "Science fiction") - - val liveTvProgramEntity = LiveTvProgramEntity.Builder() - ... - .addContentRatings(contentRatings) - .addGenres(genres) - .setRecommendationReason(topOnPartner) - .build() - -> [!NOTE] -> **Note:** If the additional fields are provided directly within the entity, these values will be prioritized. The locale, if provided in the Account Profile, will be used to fetch the remaining entity metadata from Google's database in the language consistent with the request. \ No newline at end of file diff --git a/play/engage-sdk-integration/references/android/guide/playcore/engage/watch.md b/play/engage-sdk-integration/references/android/guide/playcore/engage/watch.md deleted file mode 100644 index 21c0938..0000000 --- a/play/engage-sdk-integration/references/android/guide/playcore/engage/watch.md +++ /dev/null @@ -1,1105 +0,0 @@ -> [!IMPORTANT] -> **Important:** Engage SDK has superseded Media Home, which is now deprecated. If you have an existing Media Home integration, follow the instructions in these guides to migrate your content to Engage SDK, which allows your content to be published to more devices and form factors. Please contact [`engage-developers@google.com`](mailto:engage-developers@google.com) if you have any questions. - -Boost app engagement by reaching your users where they are. Integrate Engage -SDK to deliver Continue Watching content and personalized recommendations -directly to users across multiple on-device surfaces -**[Collections](https://android-developers.googleblog.com/2024/07/introducing-collections-powered-by-engage-sdk.html)** , -**[Entertainment Space](https://blog.google/products/android/entertainment-space/)** , and the Play Store. The -integration adds less than 50 KB (compressed) to the average APK and takes -most apps about a week of developer time. Learn more at our -**[business site](http://play.google.com/console/about/programs/EngageSDK)**. - -This guide contains instructions for developer partners to integrate their video -content, using the Engage SDK to populate both this new surface area and -existing Google surfaces. - -> [!NOTE] -> **Note:** Check the [TV integration guide](https://developer.android.com/guide/playcore/engage/tv) to learn more about the Continue Watching experience on Google TV. - -## Category support and surfaces - -Engage SDK surface eligibility is based on your app's content category. Use the -following table to determine your eligibility for specific surfaces: - -| Status | Content category or use case | Supported surfaces | -|---|---|---| -| **Supported** (Eligible on all surfaces) | - Movies \& TV streaming - Live TV / Sports - On demand video clips - Sports videos - Drama Shorts - Video podcasts | - Collections - Entertainment Space - Play Store Apps tab - Play Store You tab - Play Store store listing page | -| **Partially supported** (Eligible on some surfaces) | - Video clips from news apps | - Collections - Entertainment Space | -| **Not supported** | - Remote controls - Video downloaders, players, and editors - Movie \& TV reviews - Movie tickets - TV guides - Video calls \& chatrooms | | - - -## Android skills - -[View on GitHub](https://github.com/android/skills/tree/main/play/engage-sdk-integration) - -### Integrate Engage SDK - -Use the Engage SDK [Android skill](https://developer.android.com/tools/agents/android-skills) to integrate, debug, and resolve implementation issues. To install the skill from the [Android CLI](https://developer.android.com/tools/agents/android-cli), run: - - android skills add engage-sdk-integration - -
- -## Integration detail - -### Terminology - -This integration includes the following three cluster types: **Recommendation** , -**Continuation** , and **Featured**. - -- **Recommendation** clusters show personalized suggestions for content to watch - from an individual developer partner. - - Your recommendations take the following structure: - - **Recommendation Cluster:** A UI view that contains a group of - recommendations from the same developer partner. - - ![](https://developer.android.com/static/images/guide/playcore/engage/watch-term-1.png) **Figure 1.** Entertainment Space UI showing a Recommendation Cluster from a single partner. - - **Entity:** An object representing a single item in a cluster. An entity - can be a movie, a TV show, a TV series, live video, and more. See the - [Provide entity data](https://developer.android.com/guide/playcore/engage/watch#provide-entity-data) section for a list of - supported entity types. - - ![](https://developer.android.com/static/images/guide/playcore/engage/watch-term-2.png) **Figure 2.** Entertainment Space UI showing a single Entity within a single partner's Recommendation Cluster. -- The **Continuation** cluster shows unfinished videos and relevant newly - released episodes from multiple developer partners in one UI grouping. Each - developer partner will be allowed to broadcast a maximum of 10 entities in the - Continuation cluster. Research has shown that personalized recommendations - along with personalized Continuation content creates the best user engagement. - - ![](https://developer.android.com/static/images/guide/playcore/engage/watch-term-3.png) **Figure 3.** Entertainment Space UI showing a Continuation cluster with unfinished recommendations from multiple partners (only one recommendation is currently visible). -- The **Featured** cluster showcases a selection of entities from multiple - developer partners in one UI grouping. There will be a single Featured - cluster, which is surfaced near the top of the UI with a priority placement - above all Recommendation clusters. Each developer partner will be allowed to - broadcast up to 10 entities in the Featured cluster. - - ![](https://developer.android.com/static/images/guide/playcore/engage/watch-term-4.png) **Figure 4.** Entertainment Space UI showing a Featured cluster with recommendations from multiple partners (only one recommendation is currently visible). - -### Pre-work - -Minimum API level: 19 - -Add the `com.google.android.engage:engage-core` library to your app: - - dependencies { - // Make sure you also include that repository in your project's build.gradle file. - implementation 'com.google.android.engage:engage-core:1.6.0' - } - -For more information, see [Package visibility in Android -11](https://developer.android.com/about/versions/11/privacy/package-visibility). - -### Summary - -The design is based on an implementation of a [bound -service](https://developer.android.com/guide/components/bound-services). - -The data a client can publish is subject to the following limits for different -cluster types: - -| Cluster type | Cluster limits | Maximum entity limits in a cluster | -|---|---|---| -| Recommendation Cluster(s) | At most 7 | At most 50 | -| Continuation Cluster | At most 1 | At most 20 | -| Featured Cluster | At most 1 | At most 20 | - -> [!IMPORTANT] -> **Important:** Engage SDK has superseded Media Home, which is now deprecated. If you have an existing Media Home integration, follow the instructions in [Step -> 0](https://developer.android.com/guide/playcore/engage/watch#migration) to migrate your content to Engage SDK, which allows your content to be published to more devices and form factors. Contact [`engage-developers@google.com`](mailto:engage-developers@google.com). If you don't have an existing Media Home integration, skip to [Step -> 1](https://developer.android.com/guide/playcore/engage/watch#provide-entity-data). - -### Step 0: Migration from existing Media Home SDK integration - -#### Map data models from existing integration - -If you are migrating from an existing Media Home integration, the following -table outlines how to map data models in existing SDKs to the new Engage SDK: - -| MediaHomeVideoContract integration equivalent | Engage SDK integration equivalent | -|---|---| -| `com.google.android.mediahome.video.PreviewChannel` | ` com.google.android.engage.common.datamodel.RecommendationCluster ` | -| `com.google.android.mediahome.video.PreviewChannel.Builder` | ` com.google.android.engage.common.datamodel.RecommendationCluster.Builder ` | -| `com.google.android.mediahome.video.PreviewChannelHelper` | `com.google.android.engage.video.service.AppEngageVideoClient` | -| `com.google.android.mediahome.video.PreviewProgram` | Divided into separate classes: `EventVideo`, `LiveStreamingVideo`, `Movie`, `TvEpisode`, `TvSeason`, `TvShow`, `VideoClipEntity` | -| `com.google.android.mediahome.video.PreviewProgram.Builder` | Divided into builders in separate classes: `EventVideo`, `LiveStreamingVideo`, `Movie`, `TvEpisode`, `TvSeason`, `TvShow`, `VideoClipEntity` | -| `com.google.android.mediahome.video.VideoContract` | No longer needed. | -| `com.google.android.mediahome.video.WatchNextProgram` | Divided into attributes in separate classes: `EventVideoEntity`, `LiveStreamingVideoEntity`, `MovieEntity`, `TvEpisodeEntity`, `TvSeasonEntity`, `TvShowEntity`, `VideoClipEntity` | -| `com.google.android.mediahome.video.WatchNextProgram.Builder` | Divided into attributes in separate classes: `EventVideoEntity`, `LiveStreamingVideoEntity`, `MovieEntity`, `TvEpisodeEntity`, `TvSeasonEntity`, `TvShowEntity`, `VideoClipEntity` | - -#### Publishing clusters in Media Home SDK vs Engage SDK - -With Media Home SDK, clusters and entities were published through separate APIs: - - // 1. Fetch existing channels - List channels = PreviewChannelHelper.getAllChannels(); - - // 2. If there are no channels, publish new channels - long channelId = PreviewChannelHelper.publishChannel(builder.build()); - - // 3. If there are existing channels, decide whether to update channel contents - PreviewChannelHelper.updatePreviewChannel(channelId, builder.build()); - - // 4. Delete all programs in the channel - PreviewChannelHelper.deleteAllPreviewProgramsByChannelId(channelId); - - // 5. publish new programs in the channel - PreviewChannelHelper.publishPreviewProgram(builder.build()); - -With Engage SDK, cluster and entity publishing are combined into a single API -call. All entities that belong to a cluster are published together with that -cluster: - -### Kotlin - - RecommendationCluster.Builder() - .addEntity(MOVIE_ENTITY) - .addEntity(MOVIE_ENTITY) - .addEntity(MOVIE_ENTITY) - .setTitle("Top Picks For You") - .build() - -### Java - - new RecommendationCluster.Builder() - .addEntity(MOVIE_ENTITY) - .addEntity(MOVIE_ENTITY) - .addEntity(MOVIE_ENTITY) - .setTitle("Top Picks For You") - .build(); - -### Step 1: Provide entity data - -The SDK has defined different entities to represent each item type. We support -the following entities for the Watch category: - -1. [`MovieEntity`](https://developer.android.com/guide/playcore/engage/watch#movieentity) -2. [`TvShowEntity`](https://developer.android.com/guide/playcore/engage/watch#tvshowentity) -3. [`TvSeasonEntity`](https://developer.android.com/guide/playcore/engage/watch#tvseasonentity) -4. [`TvEpisodeEntity`](https://developer.android.com/guide/playcore/engage/watch#tvepisodeentity) -5. [`LiveStreamingVideoEntity`](https://developer.android.com/guide/playcore/engage/watch#livestreamingvideoentity) -6. [`VideoClipEntity`](https://developer.android.com/guide/playcore/engage/watch#videoclipentity) - -The following chart outlines attributes and requirements for each type. - -#### `MovieEntity` - -| Attribute | Requirement | Notes | -|---|---|---| -| Name | **Required** | | -| Poster images | **Required** | At least one image is required, and must be provided with an aspect ratio. (Landscape is preferred but passing both portrait and landscape images for different scenarios is recommended.) See [Image Specifications](https://developer.android.com/guide/playcore/engage/watch#image-specs) for guidance. | -| Playback uri | **Required** | The deep link to the provider app to start playing the movie. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | -| Info page uri | Optional | The deep link to the provider app to show details about the movie. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | -| Release date | Optional | In epoch milliseconds. | -| Availability | **Required** | AVAILABLE: The content is available to the user without any further action. FREE_WITH_SUBSCRIPTION: The content is available after the user purchases a subscription. PAID_CONTENT: The content requires user purchase or rental. PURCHASED: The content has been purchased or rented by the user. | -| Offer price | Optional | Free text | -| Duration | **Required** | In milliseconds. | -| Genre | **Required** | Free text | -| Content ratings | Optional | Free text, follow the industry standard. ([Example](https://www.spectrum.net/support/tv/tv-and-movie-ratings-descriptions)) | -| Call to action text | Optional | Free text to be displayed as a call to action. | -| Tags | Optional | List of tags associated with the entity. | -| Watch next type | Conditionally required | Must be provided when the item is in the Continuation cluster and must be one of the following four types: CONTINUE: The user has already watched more than 1 minute of this content. NEW: The user has watched all available episodes from some episodic content, but a new episode has become available and there is exactly one unwatched episode. This works for TV shows, recorded soccer matches in a series, and so on. NEXT: The user has watched one or more complete episodes from some episodic content, but there remains either more than one episode remaining or exactly one episode remaining where the last episode is not "NEW" and was released before the user started watching the episodic content. WATCHLIST: The user has explicitly elected to add a movie, event, or series to a watchlist to manually curate what they want to watch next. | -| Last engagement time | Conditionally required | Must be provided when the item is in the Continuation cluster. In epoch milliseconds. | -| Last playback position time | Conditionally required | Must be provided when the item is in the Continuation cluster and WatchNextType is CONTINUE. In epoch milliseconds. | - -#### `TvShowEntity` - -| Attribute | Requirement | Notes | -|---|---|---| -| Name | **Required** | | -| Poster images | **Required** | At least one image is required, and must be provided with an aspect ratio. (Landscape is preferred but passing both portrait and landscape images for different scenarios is recommended.) See [Image Specifications](https://developer.android.com/guide/playcore/engage/watch#image-specs) for guidance. | -| Info page uri | **Required** | The deep link to the provider app to show the details of the TV show. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | -| Playback uri | Optional | The deep link to the provider app to start playing the TV show. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | -| First episode air date | Optional | In epoch milliseconds. | -| Latest episode air date | Optional | In epoch milliseconds. | -| Availability | **Required** | AVAILABLE: The content is available to the user without any further action. FREE_WITH_SUBSCRIPTION: The content is available after the user purchases a subscription. PAID_CONTENT: The content requires user purchase or rental. PURCHASED: The content has been purchased or rented by the user. | -| Offer price | Optional | Free text | -| Season count | **Required** | Positive integer | -| Genre | **Required** | Free text | -| Content ratings | Optional | Free text, follow the industry standard. ([Example](https://www.spectrum.net/support/tv/tv-and-movie-ratings-descriptions)) | -| Call to action text | Optional | Free text to be displayed as a call to action. | -| Tags | Optional | List of tags associated with the entity. | -| Watch next type | Conditionally required | Must be provided when the item is in the Continuation cluster and must be one of the following four types: CONTINUE: The user has already watched more than 1 minute of this content. NEW: The user has watched all available episodes from some episodic content, but a new episode has become available and there is exactly one unwatched episode. This works for TV shows, recorded soccer matches in a series, and so on. NEXT: The user has watched one or more complete episodes from some episodic content, but there remains either more than one episode remaining or exactly one episode remaining where the last episode is not "NEW" and was released before the user started watching the episodic content. WATCHLIST: The user has explicitly elected to add a movie, event, or series to a watchlist to manually curate what they want to watch next. | -| Last engagement time | Conditionally required | Must be provided when the item is in the Continuation cluster. In epoch milliseconds. | -| Last playback position time | Conditionally required | Must be provided when the item is in the Continuation cluster and WatchNextType is CONTINUE. In epoch milliseconds. | - -#### `TvSeasonEntity` - -| Attribute | Requirement | Notes | -|---|---|---| -| Name | **Required** | | -| Poster images | **Required** | At least one image is required, and must be provided with an aspect ratio. (Landscape is preferred but passing both portrait and landscape images for different scenarios is recommended.) See [Image Specifications](https://developer.android.com/guide/playcore/engage/watch#image-specs) for guidance. | -| Info page uri | **Required** | The deep link to the provider app to show the details of the TV show season. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | -| Playback uri | Optional | The deep link to the provider app to start playing the TV show season. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | -| Display Season number | **Optional** **Available in v1.3.1** | String | -| First episode air date | Optional | In epoch milliseconds. | -| Latest episode air date | Optional | In epoch milliseconds. | -| Availability | **Required** | AVAILABLE: The content is available to the user without any further action. FREE_WITH_SUBSCRIPTION: The content is available after the user purchases a subscription. PAID_CONTENT: The content requires user purchase or rental. PURCHASED: The content has been purchased or rented by the user. | -| Offer price | Optional | Free text | -| Episode count | **Required** | Positive integer | -| Genre | **Required** | Free text | -| Content ratings | Optional | Free text, follow the industry standard. ([Example](https://www.spectrum.net/support/tv/tv-and-movie-ratings-descriptions)) | -| Call to action text | Optional | Free text to be displayed as a call to action. | -| Tags | Optional | List of tags associated with the entity. | -| Watch next type | Conditionally required | Must be provided when the item is in the Continuation cluster and must be one of the following four types: CONTINUE: The user has already watched more than 1 minute of this content. NEW: The user has watched all available episodes from some episodic content, but a new episode has become available and there is exactly one unwatched episode. This works for TV shows, recorded soccer matches in a series, and so on. NEXT: The user has watched one or more complete episodes from some episodic content, but there remains either more than one episode remaining or exactly one episode remaining where the last episode is not "NEW" and was released before the user started watching the episodic content. WATCHLIST: The user has explicitly elected to add a movie, event, or series to a watchlist to manually curate what they want to watch next. | -| Last engagement time | Conditionally required | Must be provided when the item is in the Continuation cluster. In epoch milliseconds. | -| Last playback position time | Conditionally required | Must be provided when the item is in the Continuation cluster and WatchNextType is CONTINUE. In epoch milliseconds. | - -#### `TvEpisodeEntity` - -| Attribute | Requirement | Notes | -|---|---|---| -| Name | **Required** | | -| Poster images | **Required** | At least one image is required, and must be provided with an aspect ratio. (Landscape is preferred but passing both portrait and landscape images for different scenarios is recommended.) See [Image Specifications](https://developer.android.com/guide/playcore/engage/watch#image-specs) for guidance. | -| Playback uri | **Required** | The deep link to the provider app to start playing the episode. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | -| Info page uri | Optional | The deep link to the provider app to show details about the TV show episode. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | -| Display Episode number | **Optional** **Available in v1.3.1** | String | -| Air date | **Required** | In epoch milliseconds. | -| Availability | **Required** | AVAILABLE: The content is available to the user without any further action. FREE_WITH_SUBSCRIPTION: The content is available after the user purchases a subscription. PAID_CONTENT: The content requires user purchase or rental. PURCHASED: The content has been purchased or rented by the user. | -| Offer price | Optional | Free text | -| Duration | **Required** | Must be a positive value in milliseconds. | -| Genre | **Required** | Free text | -| Content ratings | Optional | Free text, follow the industry standard. ([Example](https://www.spectrum.net/support/tv/tv-and-movie-ratings-descriptions)) | -| Call to action text | Optional | Free text to be displayed as a call to action. | -| Tags | Optional | List of tags associated with the entity. | -| Watch next type | Conditionally required | Must be provided when the item is in the Continuation cluster and must be one of the following four types: CONTINUE: The user has already watched more than 1 minute of this content. NEW: The user has watched all available episodes from some episodic content, but a new episode has become available and there is exactly one unwatched episode. This works for TV shows, recorded soccer matches in a series, and so on. NEXT: The user has watched one or more complete episodes from some episodic content, but there remains either more than one episode remaining or exactly one episode remaining where the last episode is not "NEW" and was released before the user started watching the episodic content. WATCHLIST: The user has explicitly elected to add a movie, event, or series to a watchlist to manually curate what they want to watch next. | -| Last engagement time | Conditionally required | Must be provided when the item is in the Continuation cluster. In epoch milliseconds. | -| Last playback position time | Conditionally required | Must be provided when the item is in the Continuation cluster and WatchNextType is CONTINUE. In epoch milliseconds. | - -#### `LiveStreamingVideoEntity` - -| Attribute | Requirement | Notes | -|---|---|---| -| Name | **Required** | | -| Poster images | **Required** | At least one image is required, and must be provided with an aspect ratio. (Landscape is preferred but passing both portrait and landscape images for different scenarios is recommended.) See [Image Specifications](https://developer.android.com/guide/playcore/engage/watch#image-specs) for guidance. | -| Playback uri | **Required** | The deep link to the provider app to start playing the video. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | -| Broadcaster | **Required** | Free text | -| Start time | Optional | In epoch milliseconds. | -| End time | Optional | In epoch milliseconds. | -| View count | Optional | Free text, must be localized. | -| Call to action text | Optional | Free text to be displayed as a call to action. | -| Tags | Optional | List of tags associated with the entity. | -| Watch next type | Conditionally required | Must be provided when the item is in the Continuation cluster and must be one of the following four types: CONTINUE: The user has already watched more than 1 minute of this content. NEW: The user has watched all available episodes from some episodic content, but a new episode has become available and there is exactly one unwatched episode. This works for TV shows, recorded soccer matches in a series, and so on. NEXT: The user has watched one or more complete episodes from some episodic content, but there remains either more than one episode remaining or exactly one episode remaining where the last episode is not "NEW" and was released before the user started watching the episodic content. WATCHLIST: The user has explicitly elected to add a movie, event, or series to a watchlist to manually curate what they want to watch next. | -| Last engagement time | Conditionally required | Must be provided when the item is in the Continuation cluster. In epoch milliseconds. | -| Last playback position time | Conditionally required | Must be provided when the item is in the Continuation cluster and WatchNextType is CONTINUE. In epoch milliseconds. | - -#### `VideoClipEntity` - -The `VideoClipEntity` object represents a video entity coming from social media, -such as TikTok or YouTube. - -| Attribute | Requirement | Notes | -|---|---|---| -| Name | **Required** | | -| Poster images | **Required** | At least one image is required, and must be provided with an aspect ratio. (Landscape is preferred but passing both portrait and landscape images for different scenarios is recommended.) See [Image Specifications](https://developer.android.com/guide/playcore/engage/watch#image-specs) for guidance. | -| Playback uri | **Required** | The deep link to the provider app to start playing the video. Note: You can use deep links for attribution. [Refer to this FAQ](https://developer.android.com/guide/playcore/engage/faq#deeplinks-attribution) | -| Created time | **Required** | In epoch milliseconds. | -| Duration | **Required** | Must be a positive value in milliseconds. | -| Creator | **Required** | Free text | -| Creator image | Optional | Image of the Creator avatar | -| View count | Optional | Free text, must be localized. | -| Call to action text | Optional | Free text to be displayed as a call to action. | -| Tags | Optional | List of tags associated with the entity. | -| Watch next type | Conditionally required | Must be provided when the item is in the Continuation cluster and must be one of the following four types: CONTINUE: The user has already watched more than 1 minute of this content. NEW: The user has watched all available episodes from some episodic content, but a new episode has become available and there is exactly one unwatched episode. This works for TV shows, recorded soccer matches in a series, and so on. NEXT: The user has watched one or more complete episodes from some episodic content, but there remains either more than one episode remaining or exactly one episode remaining where the last episode is not "NEW" and was released before the user started watching the episodic content. WATCHLIST: The user has explicitly elected to add a movie, event, or series to a watchlist to manually curate what they want to watch next. | -| Last engagement time | Conditionally required | Must be provided when the item is in the Continuation cluster. In epoch milliseconds. | -| Last playback position time | Conditionally required | Must be provided when the item is in the Continuation cluster and WatchNextType is CONTINUE. In epoch milliseconds. | - -#### Image specifications - -The following section lists the required specifications for image assets: - -*File formats* - -PNG, JPG, static GIF, WebP - -*Maximum file size* - -5120 KB - -*Additional recommendations* - -- **Image safe area:** Put your important content in the center 80% of the image. - -#### Example - -### Kotlin - - var movie = MovieEntity.Builder() - .setName("Avengers") - .addPosterImage(Image.Builder() - .setImageUri(Uri.parse("http://www.x.com/image.png")) - .setImageHeightInPixel(960) - .setImageWidthInPixel(408) - .build()) - .setPlayBackUri(Uri.parse("http://tv.com/playback/1")) - .setReleaseDateEpochMillis(1633032895L) - .setAvailability(ContentAvailability.AVAILABILITY_AVAILABLE) - .setDurationMillis(12345678L) - .addGenre("action") - .addContentRating("R") - .setWatchNextType(WatchNextType.TYPE_NEW) - .setLastEngagementTimeMillis(1664568895L) - .setCallToActionText("Watch Now") - .addTag("Action") - .build() - -### Java - - MovieEntity movie = new MovieEntity.Builder() - .setName("Avengers") - .addPosterImage( - new Image.Builder() - .setImageUri(Uri.parse("http://www.x.com/image.png")) - .setImageHeightInPixel(960) - .setImageWidthInPixel(408) - .build()) - .setPlayBackUri(Uri.parse("http://tv.com/playback/1")) - .setReleaseDateEpochMillis(1633032895L) - .setAvailability(ContentAvailability.AVAILABILITY_AVAILABLE) - .setDurationMillis(12345678L) - .addGenre("action") - .addContentRating("R") - .setWatchNextType(WatchNextType.TYPE_NEW) - .setLastEngagementTimeMillis(1664568895L) - .setCallToActionText("Watch Now") - .addTag("Action") - .build(); - -### Step 2: Provide Cluster data - -It's recommended to have the content publish job executed in the background -(for example, using [WorkManager](https://developer.android.com/topic/libraries/architecture/workmanager)) -and scheduled on a regular basis or on an event basis (for example, every time -the user opens the app or when the user just added something to their cart). - -`AppEngagePublishClient` is responsible for publishing clusters. Following -APIs are available in the client: - -- `isServiceAvailable` -- `publishRecommendationClusters` -- `publishFeaturedCluster` -- `publishContinuationCluster` -- `publishUserAccountManagementRequest` -- `updatePublishStatus` -- `deleteRecommendationsClusters` -- `deleteFeaturedCluster` -- `deleteContinuationCluster` -- `deleteUserManagementCluster` -- `deleteClusters` - -#### `isServiceAvailable` - -This API is used to check if the service is available for integration and -whether the content can be presented on the device. - -##### For Engage SDK v1.6.0 and higher (Recommended) - - -## Android skills - -[View on GitHub](https://github.com/android/skills/tree/main/play/engage-sdk-integration) - -### Engage SDK integration - -To install the skill from the [Android CLI](https://developer.android.com/tools/agents/android-cli), run: - - android skills add engage-sdk-integration - -If your team uses AI coding tools (such as Gemini in Android Studio), you can automate this migration by prompting your AI assistant: - - Use the engage-sdk-integration skill to use Engage SDK 1.6.0 and refactor isServiceAvailable to pass ServiceAvailabilityRequest for publishing all cluster types. - -
- -You can check the service availability for every cluster type that you intend to -publish. The `isServiceAvailable` API accepts a request object, -`ServiceAvailabilityRequest`, which contains the cluster types for which service -availability needs to be checked. You can find the `ClusterType` enum values -required for `ServiceAvailabilityRequest` from the following table. - -| Cluster Type | Cluster Type Constant | Integer Value | -|---|---|---| -| Unknown | `TYPE_UNKNOWN` | 0 | -| Recommendation Cluster | `TYPE_RECOMMENDATION` | 1 | -| Featured Cluster | `TYPE_FEATURED` | 2 | -| Continuation Cluster | `TYPE_CONTINUATION` | 3 | -| User Management Cluster | `TYPE_ENGAGEMENT` | 8 | -| Subscription Cluster | `TYPE_SUBSCRIPTION` | 12 | - -### Kotlin - - val request = ServiceAvailabilityRequest.Builder() - .addIntendedClusterType(ClusterType.TYPE_CONTINUATION) - .addIntendedClusterType(ClusterType.TYPE_RECOMMENDATION) - .build() - - client.isServiceAvailable(request).addOnCompleteListener { task -> - if (task.isSuccessful) { - val availabilityMap = task.result - if (availabilityMap[ClusterType.TYPE_CONTINUATION] == true) { - // Proceed with publishing continuation content - } - if (availabilityMap[ClusterType.TYPE_RECOMMENDATION] == true) { - // Proceed with publishing recommendation content - } - } else { - // The IPC call itself fails, proceed with error handling logic here, - // such as retry. - } - } - -### Java - - ServiceAvailabilityRequest request = - new ServiceAvailabilityRequest.Builder() - .addIntendedClusterType(ClusterType.TYPE_CONTINUATION) - .addIntendedClusterType(ClusterType.TYPE_RECOMMENDATION) - .build(); - - client.isServiceAvailable(request).addOnCompleteListener(task -> { - if (task.isSuccessful()) { - Map availabilityMap = task.getResult(); - if (Boolean.TRUE.equals(availabilityMap.get(ClusterType.TYPE_CONTINUATION))) { - // Proceed with publishing continuation content - } - if (Boolean.TRUE.equals(availabilityMap.get(ClusterType.TYPE_RECOMMENDATION))) { - // Proceed with publishing recommendation content - } - } else { - // The IPC call itself fails, proceed with error handling logic here, - // such as retry. - } - }); - -###### Conditional Service Availability Feature - -Some integrated apps request a special configuration that enables and disables -the Engage service intermittently in order to reduce their serving cost. This -intermittent content ingestion strategy, although possible, negatively affects -the user and the product -- stale content will not be presented and some surfaces -will not be served at all. - -Starting with v1.6.0, the Engage SDK allows checking availability for specific -cluster types. This provides more flexibility so that if the intermittent -content strategy was adopted by a given application, some cluster types can -follow that intermittent strategy while other cluster types are always enabled -(i.e. continuation clusters). - -If the Engage service should not be 'continuously' enabled on all supported -devices for whatever reason, and is configured for intermittent ingestion for -any set of devices, all continuation cluster publications (e.g. Continue -Watching) will be still enabled by default configuration, and the rest of the -cluster types will be enabled and disabled intermittently. If intermittent -ingestion applies to you but this default configuration is not suitable for -your needs, please contact engage-developers@google.com. - -##### For SDK versions prior to v1.6.0 (Deprecated) - -### Kotlin - - client.isServiceAvailable.addOnCompleteListener { task -> - if (task.isSuccessful) { - // Handle IPC call success - if(task.result) { - // Service is available on the device, proceed with content publish - // calls. - } else { - // Service is not available, no further action is needed. - } - } else { - // The IPC call itself fails, proceed with error handling logic here, - // such as retry. - } - } - -### Java - - client.isServiceAvailable().addOnCompleteListener(task - > { - if (task.isSuccessful()) { - // Handle success - if(task.getResult()) { - // Service is available on the device, proceed with content publish - // calls. - } else { - // Service is not available, no further action is needed. - } - } else { - // The IPC call itself fails, proceed with error handling logic here, - // such as retry. - } - }); - -> [!NOTE] -> **Note:** We highly recommend keeping a periodic job running to check if the service becomes available at a later point in time. The availability of the service may change with Android version upgrades, app upgrades, installs, and uninstalls. By ensuring periodic job checks at a certain time interval, data can be published once the service becomes available. - -#### `publishRecommendationClusters` - -This API is used to publish a list of `RecommendationCluster` objects. - -> [!IMPORTANT] -> **Important:** The publish APIs are upsert APIs; it replaces the existing content. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. - -### Kotlin - - client.publishRecommendationClusters( - PublishRecommendationClustersRequest.Builder() - .addRecommendationCluster( - RecommendationCluster.Builder() - .addEntity(entity1) - .addEntity(entity2) - .setTitle("Top Picks For You") - .build() - ) - .build() - ) - -### Java - - client.publishRecommendationClusters( - new PublishRecommendationClustersRequest.Builder() - .addRecommendationCluster( - new RecommendationCluster.Builder() - .addEntity(entity1) - .addEntity(entity2) - .setTitle("Top Picks For You") - .build()) - .build()); - -When the service receives the request, the following actions take place within -one transaction: - -- Existing `RecommendationCluster` data from the developer partner is removed. -- Data from the request is parsed and stored in the updated Recommendation Cluster. - -In case of an error, the entire request is rejected and the existing state is -maintained. - -#### `publishFeaturedCluster` - -This API is used to publish a list of `FeaturedCluster` objects. - -> [!IMPORTANT] -> **Important:** The publish APIs are upsert APIs; it replaces the existing content. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. - -### Kotlin - - client.publishFeaturedCluster( - PublishFeaturedClusterRequest.Builder() - .setFeaturedCluster( - FeaturedCluster.Builder() - .addEntity(entity1) - .addEntity(entity2) - .build()) - .build()) - -### Java - - client.publishFeaturedCluster( - new PublishFeaturedClustersRequest.Builder() - .addFeaturedCluster( - new FeaturedCluster.Builder() - .addEntity(entity1) - .addEntity(entity2) - .build()) - .build()); - -When the service receives the request, the following actions take place within -one transaction: - -- Existing `FeaturedCluster` data from the developer partner is removed. -- Data from the request is parsed and stored in the updated Featured Cluster. - -In case of an error, the entire request is rejected and the existing state is -maintained. - -#### `publishContinuationCluster` - -This API is used to publish a `ContinuationCluster` object. - -> [!IMPORTANT] -> **Important:** The publish APIs are upsert APIs; it replaces the existing content. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. - -### Kotlin - - client.publishContinuationCluster( - PublishContinuationClusterRequest.Builder() - .setContinuationCluster( - ContinuationCluster.Builder() - .addEntity(entity1) - .addEntity(entity2) - .build()) - .build()) - -### Java - - client.publishContinuationCluster( - new PublishContinuationClusterRequest.Builder() - .setContinuationCluster( - new ContinuationCluster.Builder() - .addEntity(entity1) - .addEntity(entity2) - .build()) - .build()); - -When the service receives the request, the following actions take place within -one transaction: - -- Existing `ContinuationCluster` data from the developer partner is removed. -- Data from the request is parsed and stored in the updated Continuation Cluster. - -In case of an error, the entire request is rejected and the existing state is -maintained. - -#### `publishUserAccountManagementRequest` - -This API is used to publish a Sign In card . The signin action directs users to -the app's sign in page so that the app can publish content (or provide more -personalized content) - -The following metadata is part of the Sign In Card - - -| Attribute | Requirement | Description | -|---|---|---| -| Action Uri | Required | Deeplink to Action (i.e. navigates to app sign in page) | -| Image | Optional - If not provided, Title must be provided | Image Shown on the Card 16x9 aspect ratio images with a resolution of 1264x712 | -| Title | Optional - If not provided, Image must be provided | Title on the Card | -| Action Text | Optional | Text Shown on the CTA (i.e. Sign in) | -| Subtitle | Optional | Optional Subtitle on the Card | - -> [!IMPORTANT] -> **Important:** The publish APIs are upsert APIs; it replaces the existing content. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. - -### Kotlin - - var SIGN_IN_CARD_ENTITY = - SignInCardEntity.Builder() - .addPosterImage( - Image.Builder() - .setImageUri(Uri.parse("http://www.x.com/image.png")) - .setImageHeightInPixel(500) - .setImageWidthInPixel(500) - .build()) - .setActionText("Sign In") - .setActionUri(Uri.parse("http://xx.com/signin")) - .build() - - client.publishUserAccountManagementRequest( - PublishUserAccountManagementRequest.Builder() - .setSignInCardEntity(SIGN_IN_CARD_ENTITY) - .build()); - -### Java - - SignInCardEntity SIGN_IN_CARD_ENTITY = - new SignInCardEntity.Builder() - .addPosterImage( - new Image.Builder() - .setImageUri(Uri.parse("http://www.x.com/image.png")) - .setImageHeightInPixel(500) - .setImageWidthInPixel(500) - .build()) - .setActionText("Sign In") - .setActionUri(Uri.parse("http://xx.com/signin")) - .build(); - - client.publishUserAccountManagementRequest( - new PublishUserAccountManagementRequest.Builder() - .setSignInCardEntity(SIGN_IN_CARD_ENTITY) - .build()); - -When the service receives the request, the following actions take place within -one transaction: - -- Existing `UserAccountManagementCluster` data from the developer partner is removed. -- Data from the request is parsed and stored in the updated UserAccountManagementCluster Cluster. - -In case of an error, the entire request is rejected and the existing state is -maintained. - -#### `updatePublishStatus` - -If for any internal business reason, none of the clusters is published, -we **strongly recommend** updating the publish status using the -**updatePublishStatus** API. -This is important because : - -- Providing the status in all scenarios, even when the content is published (STATUS == PUBLISHED), is critical to populate dashboards that use this explicit status to convey the health and other metrics of your integration. -- If no content is published but the integration status isn't broken (STATUS == NOT_PUBLISHED), Google can avoid triggering alerts in the app health dashboards. It confirms that content is not published due to an **expected** situation from the provider's standpoint. -- It helps developers provide insights into when the data is published versus not. -- Google may use the status codes to nudge the user to do certain actions in the app so they can see the app content or overcome it. - -The list of eligible publish status codes are : - - // Content is published - AppEngagePublishStatusCode.PUBLISHED, - - // Content is not published as user is not signed in - AppEngagePublishStatusCode.NOT_PUBLISHED_REQUIRES_SIGN_IN, - - // Content is not published as user is not subscribed - AppEngagePublishStatusCode.NOT_PUBLISHED_REQUIRES_SUBSCRIPTION, - - // Content is not published as user location is ineligible - AppEngagePublishStatusCode.NOT_PUBLISHED_INELIGIBLE_LOCATION, - - // Content is not published as there is no eligible content - AppEngagePublishStatusCode.NOT_PUBLISHED_NO_ELIGIBLE_CONTENT, - - // Content is not published as the feature is disabled by the client - // Available in v1.3.1 - AppEngagePublishStatusCode.NOT_PUBLISHED_FEATURE_DISABLED_BY_CLIENT, - - // Content is not published as the feature due to a client error - // Available in v1.3.1 - AppEngagePublishStatusCode.NOT_PUBLISHED_CLIENT_ERROR, - - // Content is not published as the feature due to a service error - // Available in v1.3.1 - AppEngagePublishStatusCode.NOT_PUBLISHED_SERVICE_ERROR, - - // Content is not published due to some other reason - // Reach out to engage-developers@ before using this enum. - AppEngagePublishStatusCode.NOT_PUBLISHED_OTHER - -If the content is not published due to a user not logged in, -Google would recommend publishing the Sign In Card. -If for any reason providers are not able to publish the Sign In Card -then we recommend calling the **updatePublishStatus** API -with the status code **NOT_PUBLISHED_REQUIRES_SIGN_IN** - -### Kotlin - - client.updatePublishStatus( - PublishStatusRequest.Builder() - .setStatusCode(AppEngagePublishStatusCode.NOT_PUBLISHED_REQUIRES_SIGN_IN) - .build()) - -### Java - - client.updatePublishStatus( - new PublishStatusRequest.Builder() - .setStatusCode(AppEngagePublishStatusCode.NOT_PUBLISHED_REQUIRES_SIGN_IN) - .build()); - -#### `deleteRecommendationClusters` - -This API is used to delete the content of Recommendation Clusters. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteRecommendationClusters() - -### Java - - client.deleteRecommendationClusters(); - -When the service receives the request, it removes the existing data from the -Recommendation Clusters. In case of an error, the entire request is rejected -and the existing state is maintained. - -> [!NOTE] -> **Note:** This api is available from version 1.1.0 onwards. - -#### `deleteFeaturedCluster` - -This API is used to delete the content of Featured Cluster. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteFeaturedCluster() - -### Java - - client.deleteFeaturedCluster(); - -When the service receives the request, it removes the existing data from the -Featured Cluster. In case of an error, the entire request is rejected -and the existing state is maintained. - -> [!NOTE] -> **Note:** This api is available from version 1.1.0 onwards. - -#### `deleteContinuationCluster` - -This API is used to delete the content of Continuation Cluster. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteContinuationCluster() - -### Java - - client.deleteContinuationCluster(); - -When the service receives the request, it removes the existing data from the -Continuation Cluster. In case of an error, the entire request is rejected -and the existing state is maintained. - -> [!NOTE] -> **Note:** This api is available from version 1.1.0 onwards. - -#### `deleteUserManagementCluster` - -This API is used to delete the content of UserAccountManagement Cluster. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteUserManagementCluster() - -### Java - - client.deleteUserManagementCluster(); - -When the service receives the request, it removes the existing data from the -UserAccountManagement Cluster. In case of an error, the entire request is -rejected and the existing state is maintained. - -> [!NOTE] -> **Note:** This api is available from version 1.1.0 onwards. - -#### `deleteClusters` - -This API is used to delete the content of a given cluster type. - -> [!IMPORTANT] -> **Important:** Delete APIs should only be called when there is no content to publish. **Don't** call delete and publish APIs subsequently to replace the content as the publish APIs do that inherently. Reach out to [`engage-developers@google.com`](mailto:engage-developers@google.com) before using delete APIs. - -### Kotlin - - client.deleteClusters( - DeleteClustersRequest.Builder() - .addClusterType(ClusterType.TYPE_CONTINUATION) - .addClusterType(ClusterType.TYPE_FEATURED) - .addClusterType(ClusterType.TYPE_RECOMMENDATION) - .build()) - -### Java - - client.deleteClusters( - new DeleteClustersRequest.Builder() - .addClusterType(ClusterType.TYPE_CONTINUATION) - .addClusterType(ClusterType.TYPE_FEATURED) - .addClusterType(ClusterType.TYPE_RECOMMENDATION) - .build()); - -When the service receives the request, it removes the existing data from all -clusters matching the specified cluster types. Clients can choose to pass one or -many cluster types. In case of an error, the entire request is rejected and the -existing state is maintained. - -#### Error handling - -It is highly recommended to listen to the task result from the publish APIs such -that a follow-up action can be taken to recover and resubmit an successful task. - -### Kotlin - - client.publishRecommendationClusters( - PublishRecommendationClustersRequest.Builder() - .addRecommendationCluster(..) - .build()) - .addOnCompleteListener { task -> - if (task.isSuccessful) { - // do something - } else { - val exception = task.exception - if (exception is AppEngageException) { - @AppEngageErrorCode val errorCode = exception.errorCode - if (errorCode == AppEngageErrorCode.SERVICE_NOT_FOUND) { - // do something - } - } - } - } - -### Java - - client.publishRecommendationClusters( - new PublishRecommendationClustersRequest.Builder() - .addRecommendationCluster(...) - .build()) - .addOnCompleteListener( - task -> { - if (task.isSuccessful()) { - // do something - } else { - Exception exception = task.getException(); - if (exception instanceof AppEngageException) { - @AppEngageErrorCode - int errorCode = ((AppEngageException) exception).getErrorCode(); - if (errorCode == AppEngageErrorCode.SERVICE_NOT_FOUND) { - // do something - } - } - } - }); - -The error is returned as an `AppEngageException` with the cause included as an -error code. - -| Error code | Error name | Note | -|---|---|---| -| `1` | `SERVICE_NOT_FOUND` | The service is not available on the given device. | -| `2` | `SERVICE_NOT_AVAILABLE` | The service is available on the given device, but it is not available at the time of the call (for example, it is explicitly disabled). | -| `3` | `SERVICE_CALL_EXECUTION_FAILURE` | The task execution failed due to threading issues. In this case, it can be retried. | -| `4` | `SERVICE_CALL_PERMISSION_DENIED` | The caller is not allowed to make the service call. | -| `5` | `SERVICE_CALL_INVALID_ARGUMENT` | The request contains invalid data (for example, more than the allowed number of clusters). | -| `6` | `SERVICE_CALL_INTERNAL` | There is an error on the service side. | -| `7` | `SERVICE_CALL_RESOURCE_EXHAUSTED` | The service call is made too frequently. | - -### Step 3: Handle broadcast intents - -In addition to making publish content API calls through a job, it is also -required to set up a -[`BroadcastReceiver`](https://developer.android.com/reference/android/content/BroadcastReceiver) to receive -the request for a content publish. - -The goal of broadcast intents is mainly for app reactivation and forcing data -sync. Broadcast intents are not designed to be sent very frequently. It is only -triggered when the Engage Service determines the content might be stale (for -example, a week old). That way, there is more confidence that the user can have -a fresh content experience, even if the application has not been executed for a -long period of time. - -The `BroadcastReceiver` must be set up in the following two ways: - -- Dynamically register an instance of the `BroadcastReceiver` class using - `Context.registerReceiver()`. This enables communication from applications - that are still live in memory. - -### Kotlin - - class AppEngageBroadcastReceiver : BroadcastReceiver(){ - // Trigger recommendation cluster publish when PUBLISH_RECOMMENDATION broadcast - // is received - // Trigger featured cluster publish when PUBLISH_FEATURED broadcast is received - // Trigger continuation cluster publish when PUBLISH_CONTINUATION broadcast is - // received - } - - fun registerBroadcastReceivers(context: Context){ - var context = context - context = context.applicationContext - - // Register Recommendation Cluster Publish Intent - context.registerReceiver(AppEngageBroadcastReceiver(), - IntentFilter(Intents.ACTION_PUBLISH_RECOMMENDATION), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null) - - // Register Featured Cluster Publish Intent - context.registerReceiver(AppEngageBroadcastReceiver(), - IntentFilter(Intents.ACTION_PUBLISH_FEATURED), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null) - - // Register Continuation Cluster Publish Intent - context.registerReceiver(AppEngageBroadcastReceiver(), - IntentFilter(Intents.ACTION_PUBLISH_CONTINUATION), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null) - } - -### Java - - class AppEngageBroadcastReceiver extends BroadcastReceiver { - // Trigger recommendation cluster publish when PUBLISH_RECOMMENDATION broadcast - // is received - - // Trigger featured cluster publish when PUBLISH_FEATURED broadcast is received - - // Trigger continuation cluster publish when PUBLISH_CONTINUATION broadcast is - // received - } - - public static void registerBroadcastReceivers(Context context) { - - context = context.getApplicationContext(); - - // Register Recommendation Cluster Publish Intent - context.registerReceiver(new AppEngageBroadcastReceiver(), - new IntentFilter(com.google.android.engage.service.Intents. ACTION_PUBLISH_RECOMMENDATION), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null); - - // Register Featured Cluster Publish Intent - context.registerReceiver(new AppEngageBroadcastReceiver(), - new IntentFilter(com.google.android.engage.service.Intents.ACTION_PUBLISH_FEATURED), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null); - - // Register Continuation Cluster Publish Intent - context.registerReceiver(new AppEngageBroadcastReceiver(), - new IntentFilter(com.google.android.engage.service.Intents.ACTION_PUBLISH_CONTINUATION), - com.google.android.engage.service.BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - /*scheduler=*/null); - - } - -- Statically declare an implementation with the `` tag in your - `AndroidManifest.xml` file. This allows the application to receive broadcast - intents when it is not running, and also allows the application to publish - the content. - - - - - - - - - - - - - - - -The following [intents](https://developer.android.com/reference/android/content/Intent) is sent by the -service: - -- `com.google.android.engage.action.PUBLISH_RECOMMENDATION` It is recommended to start a `publishRecommendationClusters` call when receiving this intent. -- `com.google.android.engage.action.PUBLISH_FEATURED` It is recommended to start a `publishFeaturedCluster` call when receiving this intent. -- `com.google.android.engage.action.PUBLISH_CONTINUATION` It is recommended to start a `publishContinuationCluster` call when receiving this intent. - -## Integration workflow - -For a step-by-step guide on verifying your integration after it is complete, see -[Engage developer integration workflow](https://developer.android.com/guide/playcore/engage/workflow). - -## FAQs - -See [Engage SDK Frequently Asked Questions](https://developer.android.com/guide/playcore/engage/faq) for -FAQs. - -## Contact - -Contact -[`engage-developers@google.com`](mailto:engage-developers@google.com) if there are -any questions during the integration process. - -## Next steps - -After completing this integration, your next steps are as follows: - -- Send an email to [`engage-developers@google.com`](mailto:engage-developers@google.com) and attach your integrated APK that is ready for testing by Google. -- Google performs a verification and reviews internally to make sure the integration works as expected. If changes are needed, Google contacts you with any necessary details. -- When testing is complete and no changes are needed, Google contacts you to notify you that you can start publishing the updated and integrated APK to the Play Store. -- After Google has confirmed that your updated APK has been published to the Play Store, your **Recommendation** , **Featured** , and **Continuation** clusters may be published and visible to users. \ No newline at end of file diff --git a/play/engage-sdk-integration/references/clusters.md b/play/engage-sdk-integration/references/clusters.md deleted file mode 100644 index f8976bb..0000000 --- a/play/engage-sdk-integration/references/clusters.md +++ /dev/null @@ -1,154 +0,0 @@ -The Engage SDK defines cluster structures as shown in this reference: - - { - "clusters": { - "FeaturedCluster": { - "package": "com.google.android.engage.common.datamodel.FeaturedCluster", - "fields": { - "entities": { - "type": "List", - "requirement": "Required", - "adder": "addEntity(Entity)", - "getter": "getEntities()" - }, - "displayTimeWindows": { - "type": "List", - "requirement": "Optional", - "adder": "addDisplayTimeWindow(DisplayTimeWindow)", - "getter": "getDisplayTimeWindows()" - }, - "allDisplayTimeWindows": { - "type": "List>", - "requirement": "Optional", - "adder": "addAllDisplayTimeWindow(DisplayTimeWindow)", - "adderAll": "addAllDisplayTimeWindow(List)" - } - }, - "associatedClusterType": "ClusterType.TYPE_FEATURED" - }, - "ContinuationCluster": { - "package": "com.google.android.engage.common.datamodel.ContinuationCluster", - "fields": { - "syncAcrossDevices": { - "type": "Boolean", - "requirement": "Optional", - "setter": "setSyncAcrossDevices(boolean)", - "getter": "getSyncAcrossDevices()" - }, - "accountProfile": { - "type": "AccountProfile", - "requirement": "Optional", - "setter": "setAccountProfile(AccountProfile)", - "getter": "getAccountProfile()" - }, - "entities": { - "type": "List", - "requirement": "Required", - "adder": "addEntity(Entity)", - "getter": "getEntities()" - }, - "displayTimeWindows": { - "type": "List", - "requirement": "Optional", - "adder": "addDisplayTimeWindow(DisplayTimeWindow)", - "getter": "getDisplayTimeWindows()" - }, - "allDisplayTimeWindows": { - "type": "List>", - "requirement": "Optional", - "adder": "addAllDisplayTimeWindow(DisplayTimeWindow)", - "adderAll": "addAllDisplayTimeWindow(List)" - } - }, - "associatedClusterType": "ClusterType.TYPE_CONTINUATION" - }, - "RecommendationCluster": { - "package": "com.google.android.engage.common.datamodel.RecommendationCluster", - "fields": { - "entities": { - "type": "List", - "requirement": "Required", - "adder": "addEntity(Entity)", - "getter": "getEntities()" - }, - "title": { - "type": "String", - "requirement": "Required", - "setter": "setTitle(String)", - "getter": "getTitle()" - }, - "subtitle": { - "type": "String", - "requirement": "Optional", - "setter": "setSubtitle(String)", - "getter": "getSubtitle()" - }, - "actionText": { - "type": "String", - "requirement": "Optional", - "setter": "setActionText(String)", - "getter": "getActionText()" - }, - "actionUri": { - "type": "Uri", - "requirement": "Optional", - "setter": "setActionUri(Uri)", - "getter": "getActionUri()" - }, - "displayTimeWindows": { - "type": "List", - "requirement": "Optional", - "adder": "addDisplayTimeWindow(DisplayTimeWindow)", - "getter": "getDisplayTimeWindows()" - }, - "allDisplayTimeWindows": { - "type": "List>", - "requirement": "Optional", - "adder": "addAllDisplayTimeWindow(DisplayTimeWindow)", - "adderAll": "addAllDisplayTimeWindow(List)" - }, - "recommendationClusterType": { - "type": "@RecommendationClusterType int", - "requirement": "Required", - "setter": "setRecommendationClusterType(@RecommendationClusterType int)", - "getter": "getRecommendationClusterType()" - } - }, - "associatedClusterType": "ClusterType.TYPE_RECOMMENDATION" - }, - "SubscriptionCluster": { - "package": "com.google.android.engage.common.datamodel.SubscriptionCluster", - "fields": { - "accountProfile": { - "type": "AccountProfile", - "requirement": "Optional", - "setter": "setAccountProfile(AccountProfile)", - "getter": "getAccountProfile()" - }, - "subscriptionEntities": { - "type": "List", - "requirement": "Required", - "adder": "addSubscriptionEntity(SubscriptionEntity)", - "getter": "getSubscriptionEntities()" - } - }, - "associatedClusterType": "ClusterType.TYPE_SUBSCRIPTION" - }, - "EngagementCluster": { - "package": "com.google.android.engage.common.datamodel.EngagementCluster", - "associatedClusterType": "ClusterType.TYPE_ENGAGEMENT", - "fields": { - "signInCardEntity": { - "type": "SignInCardEntity", - "requirement": "Required", - "setter": "setSignInCardEntity(SignInCardEntity)" - }, - "userSettingsCardEntity": { - "type": "UserSettingsCardEntity", - "requirement": "Required", - "setter": "setUserSettingsCardEntity(UserSettingsCardEntity)" - } - } - } - } - } \ No newline at end of file diff --git a/play/engage-sdk-integration/references/common.md b/play/engage-sdk-integration/references/common.md deleted file mode 100644 index 52c3d5e..0000000 --- a/play/engage-sdk-integration/references/common.md +++ /dev/null @@ -1,519 +0,0 @@ -This file defines common data models and entities used across the Engage SDK. - - { - "entities": { - "Image": { - "package": "com.google.android.engage.common.datamodel.Image", - "fields": { - "imageUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setImageUri(Uri)", - "getter": "getImageUri()" - }, - "height": { - "type": "Integer", - "requirement": "Optional" - }, - "width": { - "type": "Integer", - "requirement": "Optional" - }, - "accessibilityText": { - "type": "String", - "requirement": "Required", - "setter": "setAccessibilityText(String)", - "getter": "getAccessibilityText()" - }, - "theme": { - "type": "ImageThemeEnum", - "requirement": "Optional" - }, - "cropType": { - "type": "ImageCropTypeEnum", - "requirement": "Optional" - }, - "imageHeightInPixel": { - "requirement": "Required", - "setter": "setImageHeightInPixel(int)", - "type": "Integer", - "getter": "getImageHeightInPixel()" - }, - "imageWidthInPixel": { - "requirement": "Required", - "setter": "setImageWidthInPixel(int)", - "type": "Integer", - "getter": "getImageWidthInPixel()" - }, - "imageTheme": { - "requirement": "Optional", - "setter": "setImageTheme(@ImageTheme int)", - "type": "@ImageTheme int", - "getter": "getImageTheme()" - }, - "imageCropType": { - "requirement": "Optional", - "setter": "setImageCropType(@ImageCropType int)", - "type": "@ImageCropType int", - "getter": "getImageCropType()" - } - } - }, - "Price": { - "package": "com.google.android.engage.common.datamodel.Price", - "fields": { - "currentPrice": { - "type": "String", - "requirement": "Required", - "setter": "setCurrentPrice(String)", - "getter": "getCurrentPrice()" - }, - "strikethroughPrice": { - "type": "String", - "requirement": "Optional", - "setter": "setStrikethroughPrice(String)", - "getter": "getStrikethroughPrice()" - } - } - }, - "Rating": { - "package": "com.google.android.engage.common.datamodel.Rating", - "fields": { - "ratingValue": { - "type": "Float", - "requirement": "Required" - }, - "maxValue": { - "type": "Double", - "requirement": "Required", - "setter": "setMaxValue(double)", - "getter": "getMaxValue()" - }, - "ratingCount": { - "type": "Integer", - "requirement": "Optional" - }, - "ratingCountValue": { - "type": "String", - "requirement": "Optional" - }, - "currentValue": { - "requirement": "Required", - "setter": "setCurrentValue(double)", - "type": "Double", - "getter": "getCurrentValue()" - }, - "count": { - "requirement": "Optional", - "setter": "setCount(String)", - "type": "String", - "getter": "getCount()" - }, - "countValue": { - "requirement": "Optional", - "setter": "setCountValue(long)", - "type": "Long", - "getter": "getCountValue()" - } - } - }, - "DisplayTimeWindow": { - "package": "com.google.android.engage.common.datamodel.DisplayTimeWindow", - "fields": { - "startTimeMillis": { - "type": "Long", - "requirement": "Required" - }, - "endTimeMillis": { - "type": "Long", - "requirement": "Required" - }, - "startTimestampMillis": { - "requirement": "Optional", - "setter": "setStartTimestampMillis(long)", - "type": "Long", - "getter": "getStartTimestampMillis()" - }, - "endTimestampMillis": { - "requirement": "Optional", - "setter": "setEndTimestampMillis(long)", - "type": "Long", - "getter": "getEndTimestampMillis()" - } - } - }, - "AccountProfile": { - "package": "com.google.android.engage.common.datamodel.AccountProfile", - "fields": { - "accountId": { - "type": "@NonNull String", - "requirement": "Required", - "setter": "setAccountId(@NonNull String)", - "getter": "getAccountId()" - }, - "profileId": { - "type": "@NonNull String", - "requirement": "Optional", - "setter": "setProfileId(@NonNull String)", - "getter": "getProfileId()" - }, - "accountName": { - "type": "String", - "requirement": "Optional" - }, - "profileImage": { - "type": "Image", - "requirement": "Optional" - }, - "locale": { - "requirement": "Optional", - "setter": "setLocale(@NonNull String)", - "type": "@NonNull String", - "getter": "getLocale()" - } - } - }, - "Address": { - "package": "com.google.android.engage.common.datamodel.Address", - "fields": { - "city": { - "type": "@NonNull String", - "requirement": "Required", - "setter": "setCity(@NonNull String)", - "getter": "getCity()" - }, - "country": { - "type": "@NonNull String", - "requirement": "Required", - "setter": "setCountry(@NonNull String)", - "getter": "getCountry()" - }, - "displayAddress": { - "type": "@NonNull String", - "requirement": "Required", - "setter": "setDisplayAddress(@NonNull String)", - "getter": "getDisplayAddress()" - }, - "streetAddress": { - "type": "String", - "requirement": "Optional", - "setter": "setStreetAddress(String)", - "getter": "getStreetAddress()" - }, - "state": { - "type": "String", - "requirement": "Optional", - "setter": "setState(String)", - "getter": "getState()" - }, - "zipCode": { - "type": "String", - "requirement": "Optional", - "setter": "setZipCode(String)", - "getter": "getZipCode()" - }, - "neighborhood": { - "type": "String", - "requirement": "Optional", - "setter": "setNeighborhood(String)", - "getter": "getNeighborhood()" - } - } - }, - "Badge": { - "package": "com.google.android.engage.common.datamodel.Badge", - "fields": { - "text": { - "type": "String", - "requirement": "Optional", - "setter": "setText(String)", - "getter": "getText()" - }, - "image": { - "type": "Image", - "requirement": "Optional", - "setter": "setImage(Image)", - "getter": "getImage()" - } - } - }, - "LocalizedTimestamp": { - "package": "com.google.android.engage.common.datamodel.LocalizedTimestamp", - "fields": { - "timestamp": { - "type": "Instant", - "requirement": "Required", - "setter": "setTimestamp(Instant)", - "getter": "getTimestamp()" - }, - "timezone": { - "type": "DateTimeZone", - "requirement": "Required", - "setter": "setTimezone(DateTimeZone)", - "getter": "getTimezone()" - } - } - }, - "SignInCardEntity": { - "package": "com.google.android.engage.common.datamodel.SignInCardEntity", - "fields": { - "actionText": { - "type": "String", - "requirement": "Required", - "setter": "setActionText(String)", - "getter": "getActionText()" - }, - "actionUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setActionUri(Uri)", - "getter": "getActionUri()" - }, - "title": { - "type": "String", - "requirement": "Optional", - "setter": "setTitle(String)", - "getter": "getTitle()" - }, - "subtitle": { - "type": "String", - "requirement": "Optional", - "setter": "setSubtitle(String)", - "getter": "getSubtitle()" - }, - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "posterImages": { - "type": "List", - "requirement": "Required", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - } - } - }, - "GenericFeaturedEntity": { - "package": "com.google.android.engage.common.datamodel.GenericFeaturedEntity", - "fields": { - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "posterImages": { - "type": "List", - "requirement": "Required", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "actionUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setActionUri(Uri)", - "getter": "getActionUri()" - }, - "title": { - "type": "String", - "requirement": "Optional", - "setter": "setTitle(String)", - "getter": "getTitle()" - }, - "description": { - "type": "String", - "requirement": "Optional", - "setter": "setDescription(String)", - "getter": "getDescription()" - }, - "subtitles": { - "type": "List", - "requirement": "Optional", - "adder": "addSubtitle(String)", - "adderAll": "addSubtitles(List)" - }, - "badges": { - "type": "List", - "requirement": "Optional", - "adder": "addBadge(Badge)", - "adderAll": "addBadges(List)" - }, - "contentCategories": { - "type": "List<@EligibleContentCategory int>", - "requirement": "Optional", - "adder": "addContentCategory(@EligibleContentCategory int)", - "adderAll": "addContentCategories(List)" - }, - "displayTimeWindows": { - "type": "List", - "requirement": "Optional", - "adder": "addDisplayTimeWindow(DisplayTimeWindow)", - "getter": "getDisplayTimeWindows()" - }, - "allDisplayTimeWindows": { - "type": "List>", - "requirement": "Optional", - "adder": "addAllDisplayTimeWindow(DisplayTimeWindow)", - "adderAll": "addAllDisplayTimeWindow(List)" - } - } - }, - "SubscriptionEntity": { - "package": "com.google.android.engage.common.datamodel.SubscriptionEntity", - "fields": {} - }, - "UserSettingsCardEntity": { - "package": "com.google.android.engage.common.datamodel.UserSettingsCardEntity", - "fields": { - "actionText": { - "type": "String", - "requirement": "Required", - "setter": "setActionText(String)", - "getter": "getActionText()" - }, - "actionUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setActionUri(Uri)", - "getter": "getActionUri()" - }, - "title": { - "type": "String", - "requirement": "Optional", - "setter": "setTitle(String)", - "getter": "getTitle()" - }, - "subtitle": { - "type": "String", - "requirement": "Optional", - "setter": "setSubtitle(String)", - "getter": "getSubtitle()" - }, - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "posterImages": { - "type": "List", - "requirement": "Required", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - } - } - }, - "ArticleEntity": { - "package": "com.google.android.engage.common.datamodel.ArticleEntity", - "fields": { - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "posterImages": { - "type": "List", - "requirement": "Required", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "actionUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setActionUri(Uri)", - "getter": "getActionUri()" - }, - "title": { - "type": "String", - "requirement": "Optional", - "setter": "setTitle(String)", - "getter": "getTitle()" - }, - "description": { - "type": "String", - "requirement": "Optional", - "setter": "setDescription(String)", - "getter": "getDescription()" - }, - "subtitles": { - "type": "List", - "requirement": "Optional", - "adder": "addSubtitle(String)", - "adderAll": "addSubtitles(List)" - }, - "badges": { - "type": "List", - "requirement": "Optional", - "adder": "addBadge(Badge)", - "adderAll": "addBadges(List)" - }, - "contentCategories": { - "type": "List<@EligibleContentCategory int>", - "requirement": "Optional", - "adder": "addContentCategory(@EligibleContentCategory int)", - "adderAll": "addContentCategories(List)" - }, - "progressPercentage": { - "type": "Integer", - "requirement": "Required", - "setter": "setProgressPercentage(int)", - "getter": "getProgressPercentage()", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "lastEngagementTimestampMillis": { - "type": "Long", - "requirement": "Required", - "setter": "setLastEngagementTimestampMillis(long)", - "getter": "getLastEngagementTimestampMillis()", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "source": { - "type": "Badge", - "requirement": "Optional", - "setter": "setSource(Badge)", - "getter": "getSource()" - }, - "lastContentPublishTimestampMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLastContentPublishTimestampMillis(Long)", - "getter": "getLastContentPublishTimestampMillis()" - }, - "displayTimeWindows": { - "type": "List", - "requirement": "Optional", - "adder": "addDisplayTimeWindow(DisplayTimeWindow)", - "getter": "getDisplayTimeWindows()" - }, - "allDisplayTimeWindows": { - "type": "List>", - "requirement": "Optional", - "adder": "addAllDisplayTimeWindow(DisplayTimeWindow)", - "adderAll": "addAllDisplayTimeWindow(List)" - } - } - } - }, - "intents": { - "ACTION_PUBLISH_RECOMMENDATION": "com.google.android.engage.action.PUBLISH_RECOMMENDATION", - "ACTION_PUBLISH_FEATURED": "com.google.android.engage.action.PUBLISH_FEATURED", - "ACTION_PUBLISH_CONTINUATION": "com.google.android.engage.action.PUBLISH_CONTINUATION" - }, - "imports": [ - "com.google.android.engage.service.AppEngagePublishClient", - "com.google.android.engage.service.AppEngageErrorCode", - "com.google.android.engage.service.AppEngageException", - "com.google.android.engage.service.AppEngagePublishStatusCode" - ] - } \ No newline at end of file diff --git a/play/engage-sdk-integration/references/patterns.md b/play/engage-sdk-integration/references/patterns.md deleted file mode 100644 index 099c410..0000000 --- a/play/engage-sdk-integration/references/patterns.md +++ /dev/null @@ -1,527 +0,0 @@ -## EngageBroadcastReceiver - -Setting up the `BroadcastReceiver` correctly requires **both** static and -dynamic registration. Static registration allows the app to receive broadcasts -even when it isn't running, while dynamic registration is required on newer -Android versions to safely receive broadcasts when the app is live in memory. - -### BroadcastReceiver implementation - - -```kotlin -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import androidx.core.content.ContextCompat -import com.google.android.engage.service.BroadcastReceiverPermissions - -class EngageBroadcastReceiver : BroadcastReceiver() { - // IMPORTANT: Only trigger the specific publish job for the received intent action. - // DO NOT publish all clusters at once. - override fun onReceive(context: Context?, intent: Intent?) { - if (intent == null || context == null) return - when (intent.action) { - com.google.android.engage.service.Intents.ACTION_PUBLISH_RECOMMENDATION - -> EngagePublisher.publishOneTime(context, Constants.PUBLISH_TYPE_RECOMMENDATIONS) - // Note: If app handles other publish actions (e.g. Featured, Continuation), add them here. - com.google.android.engage.service.Intents.ACTION_PUBLISH_FEATURED - -> EngagePublisher.publishOneTime(context, Constants.PUBLISH_TYPE_FEATURED) - - /** Note: If vertical has other intents (e.g. FOOD shopping cart, etc.), add them here. - * com.google.android.engage.food.service.Intents.ACTION_PUBLISH_FOOD_SHOPPING_CART - * -> EngagePublisher.publishOneTime(context, Constants.PUBLISH_TYPE_FOOD_SHOPPING_CARD) - * com.google.android.engage.travel.service.Intents.ACTION_PUBLISH_RESERVATION - * -> EngagePublisher.publishOneTime(context, Constants.PUBLISH_TYPE_RESERVATION ) - **/ - } - } - - companion object { - /** - * Dynamically registers the receiver. - * This is required in addition to static registration in AndroidManifest.xml. - * Call this method in your Application's onCreate() or your main Activity's onCreate(). - */ - fun register(context: Context) { - val appContext = context.applicationContext - val receiver = EngageBroadcastReceiver() - - // Register Cluster Publish Intents - val filter = IntentFilter().apply { - addAction(com.google.android.engage.service.Intents.ACTION_PUBLISH_RECOMMENDATION) - addAction(com.google.android.engage.service.Intents.ACTION_PUBLISH_FEATURED) - addAction(com.google.android.engage.service.Intents.ACTION_PUBLISH_CONTINUATION) - } - ContextCompat.registerReceiver( - appContext, - receiver, - filter, - BroadcastReceiverPermissions.BROADCAST_REQUEST_DATA_PUBLISH_PERMISSION, - null, - ContextCompat.RECEIVER_EXPORTED - ) - // Note: Add vertical-specific intents here if applicable (e.g., FOOD shopping cart, etc.) - } - } -} -``` - -
- -### Static registration in AndroidManifest.xml - -Add the `` tag inside the `` block in -`AndroidManifest.xml` - - -```xml - - - - - - - - - - -``` - -
- -## EngageWorker - - -```kotlin -import android.content.Context -import android.util.Log -import androidx.work.CoroutineWorker -import androidx.work.WorkerParameters -import com.google.android.engage.common.datamodel.ClusterType -import com.google.android.engage.service.AppEngageErrorCode -import com.google.android.engage.service.AppEngageException -import com.google.android.engage.service.AppEngagePublishClient -import com.google.android.engage.service.AppEngagePublishStatusCode -import com.google.android.engage.service.PublishStatusRequest -import com.google.android.engage.service.ServiceAvailabilityRequest -import com.google.android.gms.tasks.Task -import kotlinx.coroutines.tasks.await - -class EngageWorker(context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams) { - // Replace {AppEngagePublishClient} with the "client" class found in references/schemas/{VERTICAL}.md. - // Client class can vary based on app's vertical. - // Refer to the references/schemas/{VERTICAL}.md to find the right class. - // This is an example of using AppEngagePublishClient. - private val client = AppEngagePublishClient(context) - private val clusterRequestFactory = ClusterRequestFactory(context) - - override suspend fun doWork(): Result { - if (runAttemptCount > Constants.MAX_PUBLISHING_ATTEMPTS) { - // If we keep failing, report it as a service error before giving up. - updatePublishStatus(AppEngagePublishStatusCode.NOT_PUBLISHED_SERVICE_ERROR) - return Result.failure() - } - - val publishType = inputData.getString(Constants.PUBLISH_TYPE_KEY) - val intendedClusterType = when (publishType) { - Constants.PUBLISH_TYPE_RECOMMENDATIONS -> ClusterType.TYPE_RECOMMENDATION - Constants.PUBLISH_TYPE_FEATURED -> ClusterType.TYPE_FEATURED - Constants.PUBLISH_TYPE_CONTINUATION -> ClusterType.TYPE_CONTINUATION - Constants.PUBLISH_TYPE_USER_ACCOUNT_MANAGEMENT -> ClusterType.TYPE_ENGAGEMENT - else -> ClusterType.TYPE_UNKNOWN - } - - if (intendedClusterType != ClusterType.TYPE_UNKNOWN) { - val request = ServiceAvailabilityRequest.Builder() - .addIntendedClusterType(intendedClusterType) - .build() - val availabilityMap = client.isServiceAvailable(request).await() - if (availabilityMap[intendedClusterType] != true) { - return Result.failure() - } - } - - return when (publishType) { - Constants.PUBLISH_TYPE_RECOMMENDATIONS -> publishRecommendations() - // Constants.PUBLISH_TYPE_FEATURED -> publishFeatured() - Constants.PUBLISH_TYPE_CONTINUATION -> publishContinuation() - Constants.PUBLISH_TYPE_USER_ACCOUNT_MANAGEMENT -> publishUserAccountManagement() - else -> Result.failure() - } - } - - // Use similar patterns for other clusters (Featured, Continuation, FoodShoppingList, Reservation etc.) - private suspend fun publishRecommendations(): Result { - val publishTask: Task = - client.publishRecommendationClusters( - clusterRequestFactory.constructRecommendationClustersRequest() - ) - return publishAndProvideResult(publishTask) - } - - private suspend fun publishContinuation(): Result { - // Empty Continuation Guard: If there is no continuation content, - // we must delete the cluster instead of publishing an empty one on the UI. - if (getContinuationData().isEmpty()) { - val deleteTask = client.deleteContinuationCluster() - return publishAndProvideResult(deleteTask) - } - - val publishTask: Task = - client.publishContinuationCluster( - clusterRequestFactory.constructContinuationClusterRequest() - ) - return publishAndProvideResult(publishTask) - } - - private suspend fun publishUserAccountManagement(): Result { - val publishTask: Task - if (isAccountSignedIn()) { - // If signed in, we delete the sign-in card. - publishTask = client.deleteUserManagementCluster() - return publishAndProvideResult(publishTask) - } else { - // If not signed in, we publish the sign-in card. - // Note: Even though we are publishing a card, the status code is NOT_PUBLISHED_REQUIRES_SIGN_IN - // because the actual content (recommendations/continuation) is not published. - publishTask = - client.publishUserAccountManagementRequest( - clusterRequestFactory.constructUserAccountManagementClusterRequest() - ) - return try { - publishTask.await() - updatePublishStatus(AppEngagePublishStatusCode.NOT_PUBLISHED_REQUIRES_SIGN_IN) - Result.success() - } catch (publishException: Exception) { - handlePublishException(publishException) - } - } - } - - private fun isAccountSignedIn(): Boolean { - // Implement your app's sign-in check logic here. - // ... - } - - private fun getContinuationData(): List { - // Implement your app's data loading logic here. - // ... - } - - private suspend fun publishAndProvideResult( - publishTask: Task - ): Result { - return try { - // An AppEngageException may occur while publishing, so we may not be able to await the result. - publishTask.await() - // Update status to PUBLISHED only after successful publication. - updatePublishStatus(AppEngagePublishStatusCode.PUBLISHED) - Result.success() - } catch (publishException: Exception) { - handlePublishException(publishException) - } - } - - private fun handlePublishException(publishException: Exception): Result { - val appEngageException = publishException as? AppEngageException - if (appEngageException != null) { - logPublishing(appEngageException) - - // Map AppEngageException error codes to PublishStatusCodes - val errorStatusCode = when (appEngageException.errorCode) { - AppEngageErrorCode.SERVICE_CALL_INVALID_ARGUMENT -> - AppEngagePublishStatusCode.NOT_PUBLISHED_CLIENT_ERROR - - AppEngageErrorCode.SERVICE_CALL_PERMISSION_DENIED -> - AppEngagePublishStatusCode.NOT_PUBLISHED_CLIENT_ERROR - - else -> - AppEngagePublishStatusCode.NOT_PUBLISHED_SERVICE_ERROR - } - updatePublishStatus(errorStatusCode) - - // Some errors are recoverable, such as a threading issue, some are unrecoverable - // such as a cluster not containing all necessary fields. If an error is recoverable, we - // should attempt to publish again. Setting the result to retry means WorkManager will - // attempt to run the worker again, thus attempting to publish again. - return if (isErrorRecoverable(appEngageException)) Result.retry() else Result.failure() - } - return Result.failure() - } - - private fun updatePublishStatus(statusCode: Int) { - client - .updatePublishStatus(PublishStatusRequest.Builder().setStatusCode(statusCode).build()) - .addOnSuccessListener { - Log.i(TAG, "Successfully updated publish status code to $statusCode") - } - .addOnFailureListener { exception -> - Log.e(TAG, "Failed to update publish status code to $statusCode\n${exception.stackTrace}") - } - } - - private fun logPublishing(publishingException: AppEngageException) { - val message = when (publishingException.errorCode) { - AppEngageErrorCode.SERVICE_NOT_FOUND -> "Service not found" - AppEngageErrorCode.SERVICE_CALL_EXECUTION_FAILURE -> "Execution failure" - AppEngageErrorCode.SERVICE_NOT_AVAILABLE -> "Service not available" - AppEngageErrorCode.SERVICE_CALL_PERMISSION_DENIED -> "Permission denied" - AppEngageErrorCode.SERVICE_CALL_INVALID_ARGUMENT -> "Invalid argument" - AppEngageErrorCode.SERVICE_CALL_INTERNAL -> "Internal error" - AppEngageErrorCode.SERVICE_CALL_RESOURCE_EXHAUSTED -> "Resource exhausted" - else -> "Unknown error" - } - Log.d(TAG, message) - } - - private fun isErrorRecoverable(publishingException: AppEngageException): Boolean { - return when (publishingException.errorCode) { - // Recoverable Error codes - AppEngageErrorCode.SERVICE_CALL_EXECUTION_FAILURE, - AppEngageErrorCode.SERVICE_CALL_INTERNAL, - AppEngageErrorCode.SERVICE_CALL_RESOURCE_EXHAUSTED -> true - // Non recoverable error codes - AppEngageErrorCode.SERVICE_NOT_FOUND, - AppEngageErrorCode.SERVICE_CALL_INVALID_ARGUMENT, - AppEngageErrorCode.SERVICE_CALL_PERMISSION_DENIED, - AppEngageErrorCode.SERVICE_NOT_AVAILABLE -> false - else -> false - } - } -} -``` - -
- -## ClusterRequestFactory - - -```kotlin -class ClusterRequestFactory(context: Context) { - - // ... - - private val signInCard = - com.google.android.engage.common.datamodel.SignInCardEntity.Builder() - .addPosterImage( - com.google.android.engage.common.datamodel.Image.Builder() - .setImageUri(Uri.parse("http://www.x.com/image.png")) - .setImageHeightInPixel(500) - .setImageWidthInPixel(500) - .build() - ) - .setActionText(signInCardAction) - .setActionUri(Uri.parse("https://xyz.com/signin")) - .build() - - fun constructRecommendationClustersRequest(): com.google.android.engage.service.PublishRecommendationClustersRequest { - - val items = appDataRepository.getRecommendations() - val recommendationCluster = com.google.android.engage.common.datamodel.RecommendationCluster.Builder() - .setTitle("Recommended Content") // Required field - .setRecommendationClusterType(com.google.android.engage.common.datamodel.RecommendationClusterType.TYPE_TOP_PICKS_FOR_YOU) // Required field - for (item in items) { - recommendationCluster.addEntity(ItemToEntityConverter.convert(item)) - } - return com.google.android.engage.service.PublishRecommendationClustersRequest.Builder() - .addRecommendationCluster(recommendationCluster.build()) - .setAccountProfile(accountProfile) // Set the account profile on the request for personalization/sync - .build() - } - - fun constructContinuationClusterRequest(): com.google.android.engage.service.PublishContinuationClusterRequest { - val items = appDataRepository.getContinuationData() - - val continuationCluster = com.google.android.engage.common.datamodel.ContinuationCluster.Builder() - .setAccountProfile(accountProfile) // Set the account profile on the request for personalization/sync - - for (item in items) { - continuationCluster.addEntity(ItemToEntityConverter.convert(item)) - } - return com.google.android.engage.service.PublishContinuationClusterRequest.Builder() - .setContinuationCluster(continuationCluster.build()) - .build() - } - - fun constructUserAccountManagementClusterRequest(): com.google.android.engage.service.PublishUserAccountManagementRequest = - com.google.android.engage.service.PublishUserAccountManagementRequest.Builder() - .setSignInCardEntity(signInCard) - .build() -} -``` - -
- -## EngagePublisher - - -```kotlin -object EngagePublisher { - - fun publishPeriodically(context: Context, publishType: String) { - val workRequest = PeriodicWorkRequestBuilder(Constants.REPEAT_INTERVAL, TimeUnit.HOURS) - .setInputData(workDataOf(Constants.PUBLISH_TYPE_KEY to publishType)) - .build() - WorkManager.getInstance(context).enqueueUniquePeriodicWork("EngagePeriodic", ExistingPeriodicWorkPolicy.KEEP, workRequest) - } - - fun publishOneTime(context: Context, publishType: String) { - - val workRequest = OneTimeWorkRequestBuilder() - .setInputData(workDataOf(Constants.PUBLISH_TYPE_KEY to publishType)) - .build() - WorkManager.getInstance(context).enqueueUniqueWork("EngageOneTime", ExistingWorkPolicy.REPLACE, workRequest) - } -} -``` - -
- -## Constants - - -```kotlin -object Constants { - // Holds common values like attempt counts, publish types etc. - const val REPEAT_INTERVAL = 24L - const val MAX_PUBLISHING_ATTEMPTS = 3 - const val PUBLISH_TYPE_KEY = "PUBLISH_TYPE" - const val PUBLISH_TYPE_RECOMMENDATIONS = "RECOMMENDATIONS" - const val PUBLISH_TYPE_FEATURED = "FEATURED" - const val PUBLISH_TYPE_CONTINUATION = "CONTINUATION" - // ... - const val PUBLISH_TYPE_USER_ACCOUNT_MANAGEMENT = "USER_ACCOUNT_MANAGEMENT" - // const val PUBLISH_TYPE_FOOD_SHOPPING_CARD = "FOOD_SHOPPING_CARD" - // const val PUBLISH_TYPE_RESERVATION = "RESERVATION" -} -``` - -
- -## ItemToEntityConverter - - -```kotlin -object ItemToEntityConverter { - // Converts app's local models to appropriate engage entity models. - // Use `{VERTICAL}.md` in the `references/schemas/` directory to identify the correct Engage entities. - // This is an example of using EbookEntity model. - fun convert(item: AppData): EbookEntity { - return EbookEntity.Builder() - // Implement required data mapping logic here. - .setName(item.title) - .addAuthor(item.author) - .build() - } -} -``` - -
- -> **Strict image aspect ratio requirement** : Play Engage has strict -> requirements for image aspect ratios depending on the vertical and entity type -> (e.g., 16:9 for landscape, 1:1 for square, 2:3 for portrait). Ensure your -> `ItemToEntityConverter` maps images that conform to these strict requirements -> to avoid cropping or content rejection by Play. - -## Dependency specifications (libs.versions.toml) - -This skill specifies all dependencies using the `libs.versions.toml` format. -Adapt these definitions to other formats (such as standard Groovy `build.gradle` -or Kotlin DSL `build.gradle.kts` implementation lines) as required by the -project. - - [versions] - engage-core = "1.6.0" - engage-tv = "1.1.0" - playServicesOssLicenses = "17.5.1" - workManager = "2.11.2" - coroutines = "1.10.2" - - [libraries] - engage-core = { group = "com.google.android.engage", name = "engage-core", version.ref = "engage-core" } - engage-tv = { group = "com.google.android.engage", name = "engage-tv", version.ref = "engage-tv" } - play-services-oss-licenses = { group = "com.google.android.gms", name = "play-services-oss-licenses", version.ref = "playServicesOssLicenses" } - androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" } - androidx-work-testing = { group = "androidx.work", name = "work-testing", version.ref = "workManager" } - kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" } - kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "coroutines" } - kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } - -## Dual content rating fields for Watch and TV - -For Watch and TV integrations, you must populate both the new `contentRatings` -(which uses `RatingSystem`) and the legacy `contentRatingsLegacies` (which uses -`String` lists) to ensure compatibility across all Google Play surfaces. - - -```kotlin -fun convertMovie(movie: MovieData): MovieEntity { - val ratingSystem = RatingSystem.Builder() - .setAgencyName("MPAA") - .setRating("PG-13") - .build() - return MovieEntity.Builder() - .setEntityId(movie.id) - .setName(movie.title) - // ... other fields - .addContentRating(ratingSystem) // Recommended API - .addContentRatingsLegacy(listOf("MPAA:PG-13")) // Legacy API for backward compatibility - .build() -} -``` - -
- -## TV integrations - -The following patterns and configurations are specific to Android TV -integrations. - -### AndroidManifest.xml for TV - - -```xml - - -``` - -
- -### PlatformSpecificUri example - - -```kotlin -val platformSpecificPlaybackUris = listOf( - com.google.android.engage.common.datamodel.PlatformSpecificUri.Builder() - .setPlatformType(com.google.android.engage.common.datamodel.PlatformType.TYPE_ANDROID_TV) - .setActionUri(Uri.parse("https://www.example.com/tv/play/123")) - .build(), - com.google.android.engage.common.datamodel.PlatformSpecificUri.Builder() - .setPlatformType(com.google.android.engage.common.datamodel.PlatformType.TYPE_ANDROID_MOBILE) - .setActionUri(Uri.parse("https://www.example.com/mobile/play/123")) - .build() -) -``` - -
- -### AccountProfile example - - -```kotlin -val accountProfile: AccountProfile - get() = AccountProfile.Builder() - .setAccountId("user_123") - .setProfileId("profile_456") - // AppCompatDelegate.getApplicationLocales().get(0) for Per-App Language Preferences - .setLocale(Locale.getDefault().toLanguageTag()) - .build() -``` - -
\ No newline at end of file diff --git a/play/engage-sdk-integration/references/requests.md b/play/engage-sdk-integration/references/requests.md deleted file mode 100644 index 0975764..0000000 --- a/play/engage-sdk-integration/references/requests.md +++ /dev/null @@ -1,260 +0,0 @@ -Defines the request structures for publishing various data models in -the Engage SDK. - - { - "PublishRecommendationClustersRequest": { - "package": "com.google.android.engage.service.PublishRecommendationClustersRequest", - "fields": { - "recommendationClusters": { - "type": "List", - "requirement": "Required", - "adder": "addRecommendationCluster(RecommendationCluster)", - "getter": "getRecommendationClusters()" - }, - "accountProfile": { - "type": "@NonNull AccountProfile", - "requirement": "Optional", - "setter": "setAccountProfile(@NonNull AccountProfile)", - "getter": "getAccountProfile()", - "description": "Required for personalization and cross-device syncing of recommendations." - }, - "syncAcrossDevices": { - "type": "Boolean", - "requirement": "Optional", - "setter": "setSyncAcrossDevices(boolean)", - "getter": "getSyncAcrossDevices()" - } - }, - "associatedClusterType": "ClusterType.TYPE_RECOMMENDATION" - }, - "PublishFeaturedClusterRequest": { - "package": "com.google.android.engage.service.PublishFeaturedClusterRequest", - "fields": { - "featuredCluster": { - "type": "FeaturedCluster", - "requirement": "Required", - "setter": "setFeaturedCluster(FeaturedCluster)", - "getter": "getFeaturedCluster()" - } - }, - "associatedClusterType": "ClusterType.TYPE_FEATURED" - }, - "DeleteClustersRequest": { - "package": "com.google.android.engage.service.DeleteClustersRequest", - "fields": { - "clusterTypes": { - "type": "List<@ClusterType int>", - "requirement": "Optional", - "adder": "addClusterType(@ClusterType int)", - "description": "The ClusterType enum values for the clusters being deleted. Use the 'associatedClusterType' from the respective publish request or cluster definition." - }, - "deleteReason": { - "type": "@DeleteReason int", - "requirement": "Optional", - "setter": "setDeleteReason(@DeleteReason int)", - "getter": "getDeleteReason()" - }, - "accountProfile": { - "requirement": "Optional", - "setter": "setAccountProfile(AccountProfile)", - "type": "AccountProfile", - "getter": "getAccountProfile()" - }, - "syncAcrossDevices": { - "requirement": "Optional", - "setter": "setSyncAcrossDevices(boolean)", - "type": "Boolean", - "getter": "getSyncAcrossDevices()" - } - } - }, - "PublishContinuationClusterRequest": { - "package": "com.google.android.engage.service.PublishContinuationClusterRequest", - "fields": { - "continuationCluster": { - "type": "ContinuationCluster", - "requirement": "Required", - "setter": "setContinuationCluster(ContinuationCluster)", - "getter": "getContinuationCluster()" - } - }, - "associatedClusterType": "ClusterType.TYPE_CONTINUATION" - }, - "PublishStatusRequest": { - "package": "com.google.android.engage.service.PublishStatusRequest", - "fields": { - "statusCode": { - "type": "@AppEngagePublishStatusCode int", - "requirement": "Required", - "setter": "setStatusCode(@AppEngagePublishStatusCode int)", - "getter": "getStatusCode()" - } - } - }, - "PublishSubscriptionRequest": { - "package": "com.google.android.engage.service.PublishSubscriptionRequest", - "fields": { - "subscriptionClusters": { - "type": "List", - "requirement": "Required" - }, - "accountProfile": { - "type": "AccountProfile", - "requirement": "Required", - "setter": "setAccountProfile(AccountProfile)", - "getter": "getAccountProfile()" - }, - "subscription": { - "requirement": "Required", - "setter": "setSubscription(SubscriptionEntity)", - "type": "SubscriptionEntity", - "getter": "getSubscription()" - } - }, - "associatedClusterType": "ClusterType.TYPE_SUBSCRIPTION" - }, - "PublishUserAccountManagementRequest": { - "package": "com.google.android.engage.service.PublishUserAccountManagementRequest", - "associatedClusterType": "ClusterType.TYPE_ENGAGEMENT", - "fields": { - "actionUri": { - "type": "Uri", - "requirement": "Required" - }, - "signInCardEntity": { - "type": "SignInCardEntity", - "requirement": "Required", - "setter": "setSignInCardEntity(SignInCardEntity)" - }, - "userSettingsCardEntity": { - "requirement": "Required", - "setter": "setUserSettingsCardEntity(UserSettingsCardEntity)", - "type": "UserSettingsCardEntity" - } - } - }, - "PublishShoppingCartClusterRequest": { - "package": "com.google.android.engage.shopping.service.PublishShoppingCartClusterRequest", - "fields": { - "shoppingCart": { - "requirement": "Required", - "setter": "setShoppingCart(ShoppingCart)", - "type": "ShoppingCart", - "getter": "getShoppingCart()" - } - }, - "associatedClusterType": "ClusterType.TYPE_SHOPPING_CART" - }, - "PublishShoppingListsRequest": { - "package": "com.google.android.engage.shopping.service.PublishShoppingListsRequest", - "fields": { - "shoppingLists": { - "type": "List", - "requirement": "Optional", - "adder": "addShoppingList(ShoppingList)", - "getter": "getShoppingLists()", - "adderAll": "addShoppingLists(List)" - } - }, - "associatedClusterType": "ClusterType.TYPE_SHOPPING_LIST" - }, - "PublishShoppingOrderTrackingClusterRequest": { - "package": "com.google.android.engage.shopping.service.PublishShoppingOrderTrackingClusterRequest", - "fields": { - "shoppingOrderTrackingCluster": { - "type": "ShoppingOrderTrackingCluster", - "requirement": "Required", - "setter": "setShoppingOrderTrackingCluster(ShoppingOrderTrackingCluster)", - "getter": "getShoppingOrderTrackingCluster()" - } - }, - "associatedClusterType": "ClusterType.TYPE_SHOPPING_ORDER_TRACKING" - }, - "PublishShoppingReorderClusterRequest": { - "package": "com.google.android.engage.shopping.service.PublishShoppingReorderClusterRequest", - "fields": { - "reorderCluster": { - "type": "ShoppingReorderCluster", - "requirement": "Required", - "setter": "setReorderCluster(ShoppingReorderCluster)", - "getter": "getReorderCluster()" - } - }, - "associatedClusterType": "ClusterType.TYPE_SHOPPING_REORDER" - }, - "PublishFoodShoppingCartsRequest": { - "package": "com.google.android.engage.food.service.PublishFoodShoppingCartsRequest", - "fields": { - "foodShoppingCarts": { - "type": "List", - "requirement": "Optional", - "adder": "addFoodShoppingCart(FoodShoppingCart)", - "getter": "getFoodShoppingCarts()", - "adderAll": "addFoodShoppingCarts(List)" - } - }, - "associatedClusterType": "ClusterType.TYPE_FOOD_SHOPPING_CART" - }, - "PublishFoodShoppingListsRequest": { - "package": "com.google.android.engage.food.service.PublishFoodShoppingListsRequest", - "fields": { - "foodShoppingLists": { - "type": "List", - "requirement": "Optional", - "adder": "addFoodShoppingList(FoodShoppingList)", - "getter": "getFoodShoppingLists()", - "adderAll": "addFoodShoppingLists(List)" - } - }, - "associatedClusterType": "ClusterType.TYPE_FOOD_SHOPPING_LIST" - }, - "PublishReorderClusterRequest": { - "package": "com.google.android.engage.food.service.PublishReorderClusterRequest", - "fields": { - "reorderCluster": { - "requirement": "Required", - "setter": "setReorderCluster(FoodReorderCluster)", - "type": "FoodReorderCluster", - "getter": "getReorderCluster()" - } - }, - "associatedClusterType": "ClusterType.TYPE_FOOD_REORDER" - }, - "PublishContinueSearchClusterRequest": { - "package": "com.google.android.engage.travel.service.PublishContinueSearchClusterRequest", - "fields": { - "continueSearchCluster": { - "type": "ContinueSearchCluster", - "requirement": "Required", - "setter": "setContinueSearchCluster(ContinueSearchCluster)", - "getter": "getContinueSearchCluster()" - } - }, - "associatedClusterType": "ClusterType.TYPE_CONTINUE_SEARCH" - }, - "PublishReservationClusterRequest": { - "package": "com.google.android.engage.travel.service.PublishReservationClusterRequest", - "fields": { - "reservationCluster": { - "type": "ReservationCluster", - "requirement": "Required", - "setter": "setReservationCluster(ReservationCluster)", - "getter": "getReservationCluster()" - } - }, - "associatedClusterType": "ClusterType.TYPE_RESERVATION" - }, - "ServiceAvailabilityRequest": { - "package": "com.google.android.engage.service.ServiceAvailabilityRequest", - "fields": { - "intendedClusterTypes": { - "type": "List", - "requirement": "Required", - "adder": "addIntendedClusterType(@ClusterType int)", - "adderAll": "addAllIntendedClusterTypes(List)", - "getter": "getIntendedClusterTypes()", - "description": "The ClusterType enum values for the clusters being published (e.g. ClusterType.TYPE_ENGAGEMENT). Use the 'associatedClusterType' from the respective publish request or cluster definition." - } - } - } - } \ No newline at end of file diff --git a/play/engage-sdk-integration/references/schemas/food.md b/play/engage-sdk-integration/references/schemas/food.md deleted file mode 100644 index 5299824..0000000 --- a/play/engage-sdk-integration/references/schemas/food.md +++ /dev/null @@ -1,509 +0,0 @@ -This file defines the schema for the FOOD vertical in the Engage SDK. - - { - "client": "com.google.android.engage.food.service.AppEngageFoodClient", - "clusterTypes": [ - "TYPE_RECOMMENDATION", - "TYPE_FEATURED", - "TYPE_FOOD_SHOPPING_CART", - "TYPE_FOOD_SHOPPING_LIST", - "TYPE_FOOD_REORDER" - ], - "entities": { - "ProductEntity": { - "package": "com.google.android.engage.food.datamodel.ProductEntity", - "fields": { - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "actionLinkUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setActionLinkUri(Uri)", - "getter": "getActionLinkUri()" - }, - "posterImages": { - "type": "List", - "requirement": "Required", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "rating": { - "type": "Rating", - "requirement": "Optional", - "setter": "setRating(Rating)", - "getter": "getRating()", - "requiredFor": [ - "Required if strikethrough price is provided", - "Mutually required with other rating fields" - ] - }, - "title": { - "type": "String", - "requirement": "Optional", - "setter": "setTitle(String)", - "getter": "getTitle()" - }, - "displayTimeWindows": { - "type": "List", - "requirement": "Optional", - "adder": "addDisplayTimeWindow(DisplayTimeWindow)", - "getter": "getDisplayTimeWindows()" - }, - "allDisplayTimeWindows": { - "type": "List>", - "requirement": "Optional", - "adder": "addAllDisplayTimeWindow(DisplayTimeWindow)", - "adderAll": "addAllDisplayTimeWindow(List)" - }, - "callout": { - "type": "String", - "requirement": "Optional", - "setter": "setCallout(String)", - "getter": "getCallout()" - }, - "calloutFinePrint": { - "type": "String", - "requirement": "Optional", - "setter": "setCalloutFinePrint(String)", - "getter": "getCalloutFinePrint()" - }, - "price": { - "type": "Price", - "requirement": "Optional", - "setter": "setPrice(Price)", - "getter": "getPrice()", - "requiredFor": [ - "Required if strikethrough price is provided" - ] - } - } - }, - "StoreEntity": { - "package": "com.google.android.engage.food.datamodel.StoreEntity", - "fields": { - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "actionLinkUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setActionLinkUri(Uri)", - "getter": "getActionLinkUri()" - }, - "posterImages": { - "type": "List", - "requirement": "Required", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "rating": { - "type": "Rating", - "requirement": "Optional", - "setter": "setRating(Rating)", - "getter": "getRating()", - "requiredFor": [ - "Mutually required with other rating fields" - ] - }, - "title": { - "type": "String", - "requirement": "Optional", - "setter": "setTitle(String)", - "getter": "getTitle()" - }, - "location": { - "type": "String", - "requirement": "Optional", - "setter": "setLocation(String)", - "getter": "getLocation()" - }, - "category": { - "type": "String", - "requirement": "Optional", - "setter": "setCategory(String)", - "getter": "getCategory()" - }, - "callout": { - "type": "String", - "requirement": "Optional", - "setter": "setCallout(String)", - "getter": "getCallout()" - }, - "calloutFinePrint": { - "type": "String", - "requirement": "Optional", - "setter": "setCalloutFinePrint(String)", - "getter": "getCalloutFinePrint()" - }, - "description": { - "type": "String", - "requirement": "Optional", - "setter": "setDescription(String)", - "getter": "getDescription()" - }, - "displayTimeWindows": { - "type": "List", - "requirement": "Optional", - "adder": "addDisplayTimeWindow(DisplayTimeWindow)", - "getter": "getDisplayTimeWindows()" - }, - "allDisplayTimeWindows": { - "type": "List>", - "requirement": "Optional", - "adder": "addAllDisplayTimeWindow(DisplayTimeWindow)", - "adderAll": "addAllDisplayTimeWindow(List)" - } - } - }, - "RestaurantReservationEntity": { - "package": "com.google.android.engage.food.datamodel.RestaurantReservationEntity", - "fields": { - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "posterImages": { - "type": "List", - "requirement": "Required", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "actionUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setActionUri(Uri)", - "getter": "getActionUri()" - }, - "title": { - "type": "String", - "requirement": "Required", - "setter": "setTitle(String)", - "getter": "getTitle()" - }, - "description": { - "type": "String", - "requirement": "Optional", - "setter": "setDescription(String)", - "getter": "getDescription()" - }, - "subtitles": { - "type": "List", - "requirement": "Optional", - "adder": "addSubtitle(String)", - "adderAll": "addSubtitles(List)" - }, - "location": { - "type": "Address", - "requirement": "Required", - "setter": "setLocation(Address)", - "getter": "getLocation()" - }, - "reservationStartTime": { - "type": "Long", - "requirement": "Required", - "setter": "setReservationStartTime(Long)", - "getter": "getReservationStartTime()" - }, - "localizedReservationStartTime": { - "type": "LocalizedTimestamp", - "requirement": "Required", - "setter": "setLocalizedReservationStartTime(LocalizedTimestamp)", - "getter": "getLocalizedReservationStartTime()" - }, - "tableSize": { - "type": "Integer", - "requirement": "Optional", - "setter": "setTableSize(Integer)", - "getter": "getTableSize()" - }, - "reservationId": { - "type": "String", - "requirement": "Optional", - "setter": "setReservationId(String)", - "getter": "getReservationId()" - }, - "displayTimeWindows": { - "type": "List", - "requirement": "Optional", - "adder": "addDisplayTimeWindow(DisplayTimeWindow)", - "getter": "getDisplayTimeWindows()" - }, - "allDisplayTimeWindows": { - "type": "List>", - "requirement": "Optional", - "adder": "addAllDisplayTimeWindow(DisplayTimeWindow)", - "adderAll": "addAllDisplayTimeWindow(List)" - } - } - }, - "RecipeEntity": { - "package": "com.google.android.engage.food.datamodel.RecipeEntity", - "fields": { - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "actionLinkUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setActionLinkUri(Uri)", - "getter": "getActionLinkUri()" - }, - "posterImages": { - "type": "List", - "requirement": "Required", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "rating": { - "type": "Rating", - "requirement": "Optional", - "setter": "setRating(Rating)", - "getter": "getRating()", - "requiredFor": [ - "Mutually required with other rating fields" - ] - }, - "title": { - "type": "String", - "requirement": "Optional", - "setter": "setTitle(String)", - "getter": "getTitle()" - }, - "author": { - "type": "String", - "requirement": "Optional", - "setter": "setAuthor(String)", - "getter": "getAuthor()" - }, - "category": { - "type": "String", - "requirement": "Optional", - "setter": "setCategory(String)", - "getter": "getCategory()" - }, - "callout": { - "type": "String", - "requirement": "Optional", - "setter": "setCallout(String)", - "getter": "getCallout()" - }, - "cookTime": { - "type": "String", - "requirement": "Optional", - "setter": "setCookTime(String)", - "getter": "getCookTime()" - }, - "description": { - "type": "String", - "requirement": "Optional", - "setter": "setDescription(String)", - "getter": "getDescription()" - } - } - } - }, - "methods": { - "isServiceAvailable": "ServiceAvailabilityRequest", - "publishRecommendationClusters": "PublishRecommendationClustersRequest", - "publishFeaturedCluster": "PublishFeaturedClusterRequest", - "publishFoodShoppingCarts": "PublishFoodShoppingCartsRequest", - "publishReorderCluster": "PublishReorderClusterRequest", - "publishFoodShoppingLists": "PublishFoodShoppingListsRequest", - "publishUserAccountManagementRequest": "PublishUserAccountManagementRequest", - "updatePublishStatus": "PublishStatusRequest", - "deleteFoodShoppingCartCluster": "DeleteClustersRequest", - "deleteFoodShoppingListCluster": "DeleteClustersRequest", - "deleteReorderCluster": "DeleteClustersRequest", - "deleteRecommendationsClusters": "DeleteClustersRequest", - "deleteFeaturedCluster": "DeleteClustersRequest", - "deleteUserManagementCluster": "DeleteClustersRequest" - }, - "clusters": { - "FoodReorderCluster": { - "package": "com.google.android.engage.food.datamodel.FoodReorderCluster", - "fields": { - "title": { - "type": "String", - "requirement": "Optional", - "setter": "setTitle(String)", - "getter": "getTitle()" - }, - "actionText": { - "type": "String", - "requirement": "Optional", - "setter": "setActionText(String)", - "getter": "getActionText()" - }, - "posterImages": { - "type": "List", - "requirement": "Optional", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "itemLabels": { - "type": "List>", - "requirement": "Optional", - "adder": "addItemLabel(String)", - "getter": "getItemLabels()", - "adderAll": "addItemLabels(List)" - }, - "numberOfItems": { - "type": "Integer", - "requirement": "Optional", - "setter": "setNumberOfItems(int)", - "getter": "getNumberOfItems()" - }, - "actionLinkUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setActionLinkUri(Uri)", - "getter": "getActionLinkUri()" - }, - "displayTimeWindows": { - "type": "List", - "requirement": "Optional", - "adder": "addDisplayTimeWindow(DisplayTimeWindow)", - "getter": "getDisplayTimeWindows()" - }, - "allDisplayTimeWindows": { - "type": "List>", - "requirement": "Optional", - "adder": "addAllDisplayTimeWindow(DisplayTimeWindow)", - "adderAll": "addAllDisplayTimeWindow(List)" - } - } - }, - "FoodShoppingList": { - "package": "com.google.android.engage.food.datamodel.FoodShoppingList", - "fields": { - "title": { - "type": "String", - "requirement": "Optional", - "setter": "setTitle(String)", - "getter": "getTitle()" - }, - "actionText": { - "type": "String", - "requirement": "Optional", - "setter": "setActionText(String)", - "getter": "getActionText()" - }, - "posterImages": { - "type": "List", - "requirement": "Optional", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "itemLabels": { - "type": "List>", - "requirement": "Optional", - "adder": "addItemLabel(String)", - "getter": "getItemLabels()", - "adderAll": "addItemLabels(List)" - }, - "numberOfItems": { - "type": "Integer", - "requirement": "Optional", - "setter": "setNumberOfItems(int)", - "getter": "getNumberOfItems()" - }, - "actionLinkUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setActionLinkUri(Uri)", - "getter": "getActionLinkUri()" - }, - "lastUserInteractionTimestampMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLastUserInteractionTimestampMillis(long)", - "getter": "getLastUserInteractionTimestampMillis()" - } - } - }, - "FoodShoppingCart": { - "package": "com.google.android.engage.food.datamodel.FoodShoppingCart", - "fields": { - "title": { - "type": "String", - "requirement": "Optional", - "setter": "setTitle(String)", - "getter": "getTitle()" - }, - "actionText": { - "type": "String", - "requirement": "Optional", - "setter": "setActionText(String)", - "getter": "getActionText()" - }, - "posterImages": { - "type": "List", - "requirement": "Optional", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "itemLabels": { - "type": "List>", - "requirement": "Optional", - "adder": "addItemLabel(String)", - "getter": "getItemLabels()", - "adderAll": "addItemLabels(List)" - }, - "numberOfItems": { - "type": "Integer", - "requirement": "Optional", - "setter": "setNumberOfItems(int)", - "getter": "getNumberOfItems()" - }, - "actionLinkUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setActionLinkUri(Uri)", - "getter": "getActionLinkUri()" - }, - "displayTimeWindows": { - "type": "List", - "requirement": "Optional", - "adder": "addDisplayTimeWindow(DisplayTimeWindow)", - "getter": "getDisplayTimeWindows()" - }, - "allDisplayTimeWindows": { - "type": "List>", - "requirement": "Optional", - "adder": "addAllDisplayTimeWindow(DisplayTimeWindow)", - "adderAll": "addAllDisplayTimeWindow(List)" - }, - "lastUserInteractionTimestampMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLastUserInteractionTimestampMillis(long)", - "getter": "getLastUserInteractionTimestampMillis()" - } - } - } - }, - "intents": { - "ACTION_PUBLISH_FOOD_SHOPPING_CART": "com.google.android.engage.action.food.PUBLISH_FOOD_SHOPPING_CART", - "ACTION_PUBLISH_FOOD_SHOPPING_LIST": "com.google.android.engage.action.food.PUBLISH_FOOD_SHOPPING_LIST", - "ACTION_PUBLISH_REORDER_CLUSTER": "com.google.android.engage.action.food.PUBLISH_REORDER_CLUSTER" - } - } \ No newline at end of file diff --git a/play/engage-sdk-integration/references/schemas/listen.md b/play/engage-sdk-integration/references/schemas/listen.md deleted file mode 100644 index 56b04fe..0000000 --- a/play/engage-sdk-integration/references/schemas/listen.md +++ /dev/null @@ -1,842 +0,0 @@ -This file defines the schema for the LISTEN vertical in the Engage SDK. - - { - "client": "com.google.android.engage.service.AppEngagePublishClient", - "clusterTypes": [ - "TYPE_RECOMMENDATION", - "TYPE_FEATURED", - "TYPE_CONTINUATION" - ], - "entities": { - "MusicAlbumEntity": { - "package": "com.google.android.engage.audio.datamodel.MusicAlbumEntity", - "fields": { - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "name": { - "type": "String", - "requirement": "Required", - "setter": "setName(String)", - "getter": "getName()" - }, - "posterImages": { - "type": "List", - "requirement": "Required", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "description": { - "type": "String", - "requirement": "Optional", - "setter": "setDescription(String)", - "getter": "getDescription()" - }, - "infoPageUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setInfoPageUri(Uri)", - "getter": "getInfoPageUri()" - }, - "playBackUri": { - "type": "Uri", - "requirement": "Optional", - "setter": "setPlayBackUri(Uri)", - "getter": "getPlayBackUri()" - }, - "songsCount": { - "type": "Integer", - "requirement": "Optional", - "setter": "setSongsCount(int)", - "getter": "getSongsCount()" - }, - "releaseDateEpochMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setReleaseDateEpochMillis(long)", - "getter": "getReleaseDateEpochMillis()" - }, - "durationMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setDurationMillis(long)", - "getter": "getDurationMillis()" - }, - "genres": { - "type": "List", - "requirement": "Optional", - "adder": "addGenre(String)", - "getter": "getGenres()", - "adderAll": "addGenres(List)" - }, - "musicLabels": { - "type": "List", - "requirement": "Optional", - "adder": "addMusicLabel(String)", - "getter": "getMusicLabels()", - "adderAll": "addMusicLabels(List)" - }, - "artists": { - "type": "List", - "requirement": "Required", - "adder": "addArtist(String)", - "getter": "getArtists()", - "adderAll": "addArtists(List)" - }, - "downloadedOnDevice": { - "type": "Boolean", - "requirement": "Optional", - "setter": "setDownloadedOnDevice(boolean)", - "getter": "isDownloadedOnDevice()" - }, - "explicitContent": { - "type": "Boolean", - "requirement": "Optional", - "setter": "setExplicitContent(boolean)", - "getter": "isExplicitContent()" - }, - "musicAlbumType": { - "type": "@MusicAlbumType int", - "requirement": "Optional", - "setter": "setMusicAlbumType(@MusicAlbumType int)", - "getter": "getMusicAlbumType()" - }, - "lastEngagementTimeMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLastEngagementTimeMillis(long)", - "getter": "getLastEngagementTimeMillis()" - }, - "progressPercentComplete": { - "type": "Integer", - "requirement": "Optional", - "setter": "setProgressPercentComplete(int)", - "getter": "getProgressPercentComplete()" - } - } - }, - "MusicTrackEntity": { - "package": "com.google.android.engage.audio.datamodel.MusicTrackEntity", - "fields": { - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "name": { - "type": "String", - "requirement": "Required", - "setter": "setName(String)", - "getter": "getName()" - }, - "posterImages": { - "type": "List", - "requirement": "Required", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "description": { - "type": "String", - "requirement": "Optional", - "setter": "setDescription(String)", - "getter": "getDescription()" - }, - "playBackUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setPlayBackUri(Uri)", - "getter": "getPlayBackUri()" - }, - "durationMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setDurationMillis(long)", - "getter": "getDurationMillis()" - }, - "infoPageUri": { - "type": "Uri", - "requirement": "Optional", - "setter": "setInfoPageUri(Uri)", - "getter": "getInfoPageUri()" - }, - "album": { - "type": "String", - "requirement": "Optional", - "setter": "setAlbum(String)", - "getter": "getAlbum()" - }, - "artists": { - "type": "List", - "requirement": "Required", - "adder": "addArtist(String)", - "getter": "getArtists()", - "adderAll": "addArtists(List)" - }, - "downloadedOnDevice": { - "type": "Boolean", - "requirement": "Optional", - "setter": "setDownloadedOnDevice(boolean)", - "getter": "isDownloadedOnDevice()" - }, - "explicitContent": { - "type": "Boolean", - "requirement": "Optional", - "setter": "setExplicitContent(boolean)", - "getter": "isExplicitContent()" - }, - "lastEngagementTimeMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLastEngagementTimeMillis(long)", - "getter": "getLastEngagementTimeMillis()" - }, - "progressPercentComplete": { - "type": "Integer", - "requirement": "Optional", - "setter": "setProgressPercentComplete(int)", - "getter": "getProgressPercentComplete()" - } - } - }, - "PodcastEpisodeEntity": { - "package": "com.google.android.engage.audio.datamodel.PodcastEpisodeEntity", - "fields": { - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "name": { - "type": "String", - "requirement": "Required", - "setter": "setName(String)", - "getter": "getName()" - }, - "posterImages": { - "type": "List", - "requirement": "Required", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "description": { - "type": "String", - "requirement": "Optional", - "setter": "setDescription(String)", - "getter": "getDescription()" - }, - "playBackUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setPlayBackUri(Uri)", - "getter": "getPlayBackUri()" - }, - "infoPageUri": { - "type": "Uri", - "requirement": "Optional", - "setter": "setInfoPageUri(Uri)", - "getter": "getInfoPageUri()" - }, - "episodeIndex": { - "type": "Integer", - "requirement": "Optional", - "setter": "setEpisodeIndex(int)", - "getter": "getEpisodeIndex()" - }, - "podcastSeriesTitle": { - "type": "String", - "requirement": "Required", - "setter": "setPodcastSeriesTitle(String)", - "getter": "getPodcastSeriesTitle()" - }, - "productionName": { - "type": "String", - "requirement": "Required", - "setter": "setProductionName(String)", - "getter": "getProductionName()" - }, - "downloadedOnDevice": { - "type": "Boolean", - "requirement": "Optional", - "setter": "setDownloadedOnDevice(boolean)", - "getter": "isDownloadedOnDevice()" - }, - "durationMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setDurationMillis(long)", - "getter": "getDurationMillis()" - }, - "publishDateEpochMillis": { - "type": "Long", - "requirement": "Required", - "setter": "setPublishDateEpochMillis(long)", - "getter": "getPublishDateEpochMillis()" - }, - "genres": { - "type": "List>", - "requirement": "Optional", - "adder": "addGenre(String)", - "getter": "getGenres()", - "adderAll": "addGenres(List)" - }, - "hosts": { - "type": "List>", - "requirement": "Optional", - "adder": "addHost(String)", - "getter": "getHosts()", - "adderAll": "addHosts(List)" - }, - "explicitContent": { - "type": "Boolean", - "requirement": "Optional", - "setter": "setExplicitContent(boolean)", - "getter": "isExplicitContent()" - }, - "listenNextType": { - "type": "Integer", - "requirement": "Optional", - "setter": "setListenNextType(int)", - "getter": "getListenNextType()" - }, - "videoPodcast": { - "type": "Boolean", - "requirement": "Optional", - "setter": "setVideoPodcast(boolean)", - "getter": "isVideoPodcast()" - }, - "lastEngagementTimeMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLastEngagementTimeMillis(long)", - "getter": "getLastEngagementTimeMillis()" - }, - "progressPercentComplete": { - "type": "Integer", - "requirement": "Optional", - "setter": "setProgressPercentComplete(int)", - "getter": "getProgressPercentComplete()" - } - } - }, - "MusicVideoEntity": { - "package": "com.google.android.engage.audio.datamodel.MusicVideoEntity", - "fields": { - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "name": { - "type": "String", - "requirement": "Required", - "setter": "setName(String)", - "getter": "getName()" - }, - "posterImages": { - "type": "List", - "requirement": "Required", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "description": { - "type": "String", - "requirement": "Optional", - "setter": "setDescription(String)", - "getter": "getDescription()" - }, - "playBackUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setPlayBackUri(Uri)", - "getter": "getPlayBackUri()" - }, - "durationMillis": { - "type": "Long", - "requirement": "Required", - "setter": "setDurationMillis(long)", - "getter": "getDurationMillis()" - }, - "infoPageUri": { - "type": "Uri", - "requirement": "Optional", - "setter": "setInfoPageUri(Uri)", - "getter": "getInfoPageUri()" - }, - "viewCount": { - "type": "String", - "requirement": "Optional", - "setter": "setViewCount(String)", - "getter": "getViewCount()" - }, - "artists": { - "type": "List", - "requirement": "Optional", - "adder": "addArtist(String)", - "getter": "getArtists()", - "adderAll": "addArtists(List)" - }, - "contentRatings": { - "type": "List>", - "requirement": "Optional", - "adder": "addContentRating(String)", - "getter": "getContentRatings()", - "adderAll": "addContentRatings(List)" - }, - "downloadedOnDevice": { - "type": "Boolean", - "requirement": "Optional", - "setter": "setDownloadedOnDevice(boolean)", - "getter": "isDownloadedOnDevice()" - }, - "explicitContent": { - "type": "Boolean", - "requirement": "Optional", - "setter": "setExplicitContent(boolean)", - "getter": "isExplicitContent()" - }, - "lastEngagementTimeMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLastEngagementTimeMillis(long)", - "getter": "getLastEngagementTimeMillis()" - }, - "progressPercentComplete": { - "type": "Integer", - "requirement": "Optional", - "setter": "setProgressPercentComplete(int)", - "getter": "getProgressPercentComplete()" - } - } - }, - "LiveRadioStationEntity": { - "package": "com.google.android.engage.audio.datamodel.LiveRadioStationEntity", - "fields": { - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "name": { - "type": "String", - "requirement": "Required", - "setter": "setName(String)", - "getter": "getName()" - }, - "posterImages": { - "type": "List", - "requirement": "Required", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "description": { - "type": "String", - "requirement": "Optional", - "setter": "setDescription(String)", - "getter": "getDescription()" - }, - "lastEngagementTimeMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLastEngagementTimeMillis(long)", - "getter": "getLastEngagementTimeMillis()" - }, - "radioFrequencyId": { - "type": "String", - "requirement": "Optional", - "setter": "setRadioFrequencyId(String)", - "getter": "getRadioFrequencyId()" - }, - "playBackUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setPlayBackUri(Uri)", - "getter": "getPlayBackUri()" - }, - "infoPageUri": { - "type": "Uri", - "requirement": "Optional", - "setter": "setInfoPageUri(Uri)", - "getter": "getInfoPageUri()" - }, - "showTitle": { - "type": "String", - "requirement": "Optional", - "setter": "setShowTitle(String)", - "getter": "getShowTitle()" - }, - "hosts": { - "type": "List", - "requirement": "Optional", - "adder": "addHost(String)", - "getter": "getHosts()", - "adderAll": "addHosts(List)" - } - } - }, - "GenericAudioEntity": { - "package": "com.google.android.engage.audio.datamodel.GenericAudioEntity", - "fields": { - "entityId": { - "requirement": "Optional", - "setter": "setEntityId(String)", - "type": "String", - "getter": "getEntityId()" - }, - "name": { - "requirement": "Required", - "setter": "setName(String)", - "type": "String", - "getter": "getName()" - }, - "posterImages": { - "requirement": "Required", - "adder": "addPosterImage(Image)", - "type": "List", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "actionUri": { - "requirement": "Required", - "setter": "setActionUri(Uri)", - "type": "Uri", - "getter": "getActionUri()" - }, - "downloadedOnDevice": { - "requirement": "Optional", - "setter": "setDownloadedOnDevice(boolean)", - "type": "Boolean", - "getter": "isDownloadedOnDevice()" - }, - "explicitContent": { - "requirement": "Required", - "setter": "setExplicitContent(boolean)", - "type": "Boolean", - "getter": "isExplicitContent()" - }, - "progressPercentComplete": { - "requirement": "Optional", - "setter": "setProgressPercentComplete(int)", - "type": "Integer", - "getter": "getProgressPercentComplete()" - }, - "lastEngagementTimeMillis": { - "requirement": "Optional", - "setter": "setLastEngagementTimeMillis(long)", - "type": "Long", - "getter": "getLastEngagementTimeMillis()" - }, - "listenNextType": { - "requirement": "Optional", - "setter": "setListenNextType(@ListenNextType int)", - "type": "@ListenNextType int", - "getter": "getListenNextType()" - }, - "price": { - "requirement": "Optional", - "setter": "setPrice(Price)", - "type": "Price", - "getter": "getPrice()" - }, - "rating": { - "requirement": "Optional", - "setter": "setRating(Rating)", - "type": "Rating", - "getter": "getRating()" - }, - "callout": { - "requirement": "Optional", - "setter": "setCallout(String)", - "type": "String", - "getter": "getCallout()" - }, - "calloutFinePrint": { - "requirement": "Optional", - "setter": "setCalloutFinePrint(String)", - "type": "String", - "getter": "getCalloutFinePrint()" - }, - "displayTimeWindows": { - "requirement": "Optional", - "adder": "addDisplayTimeWindow(DisplayTimeWindow)", - "type": "List", - "getter": "getDisplayTimeWindows()" - }, - "allDisplayTimeWindows": { - "requirement": "Optional", - "adder": "addAllDisplayTimeWindow(DisplayTimeWindow)", - "type": "List>", - "adderAll": "addAllDisplayTimeWindow(List)" - }, - "isBook": { - "requirement": "Optional", - "setter": "setIsBook(boolean)", - "type": "Boolean", - "getter": "isBook()" - }, - "isTalk": { - "requirement": "Optional", - "setter": "setIsTalk(Boolean)", - "type": "Boolean", - "getter": "isTalk()" - }, - "isVideoSupported": { - "requirement": "Optional", - "setter": "setIsVideoSupported(Boolean)", - "type": "Boolean", - "getter": "isVideoSupported()" - }, - "isArtist": { - "requirement": "Optional", - "setter": "setIsArtist(Boolean)", - "type": "Boolean", - "getter": "isArtist()" - }, - "subtitles": { - "requirement": "Optional", - "adder": "addSubtitle(String)", - "type": "List", - "adderAll": "addSubtitles(List)" - } - } - }, - "PodcastSeriesEntity": { - "package": "com.google.android.engage.audio.datamodel.PodcastSeriesEntity", - "fields": { - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "name": { - "type": "String", - "requirement": "Required", - "setter": "setName(String)", - "getter": "getName()" - }, - "posterImages": { - "type": "List", - "requirement": "Required", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "description": { - "type": "String", - "requirement": "Optional", - "setter": "setDescription(String)", - "getter": "getDescription()" - }, - "infoPageUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setInfoPageUri(Uri)", - "getter": "getInfoPageUri()" - }, - "playBackUri": { - "type": "Uri", - "requirement": "Optional", - "setter": "setPlayBackUri(Uri)", - "getter": "getPlayBackUri()" - }, - "episodeCount": { - "type": "Integer", - "requirement": "Optional", - "setter": "setEpisodeCount(int)", - "getter": "getEpisodeCount()" - }, - "productionName": { - "type": "String", - "requirement": "Optional", - "setter": "setProductionName(String)", - "getter": "getProductionName()" - }, - "downloadedOnDevice": { - "type": "Boolean", - "requirement": "Optional", - "setter": "setDownloadedOnDevice(boolean)", - "getter": "isDownloadedOnDevice()" - }, - "genres": { - "type": "List>", - "requirement": "Optional", - "adder": "addGenre(String)", - "getter": "getGenres()", - "adderAll": "addGenres(List)" - }, - "hosts": { - "type": "List>", - "requirement": "Optional", - "adder": "addHost(String)", - "getter": "getHosts()", - "adderAll": "addHosts(List)" - }, - "explicitContent": { - "type": "Boolean", - "requirement": "Optional", - "setter": "setExplicitContent(boolean)", - "getter": "isExplicitContent()" - }, - "lastEngagementTimeMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLastEngagementTimeMillis(long)", - "getter": "getLastEngagementTimeMillis()" - } - } - }, - "MusicArtistEntity": { - "package": "com.google.android.engage.audio.datamodel.MusicArtistEntity", - "fields": { - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "name": { - "type": "String", - "requirement": "Required", - "setter": "setName(String)", - "getter": "getName()" - }, - "posterImages": { - "type": "List", - "requirement": "Required", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "description": { - "type": "String", - "requirement": "Optional", - "setter": "setDescription(String)", - "getter": "getDescription()" - }, - "infoPageUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setInfoPageUri(Uri)", - "getter": "getInfoPageUri()" - }, - "playBackUri": { - "type": "Uri", - "requirement": "Optional", - "setter": "setPlayBackUri(Uri)", - "getter": "getPlayBackUri()" - }, - "lastEngagementTimeMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLastEngagementTimeMillis(long)", - "getter": "getLastEngagementTimeMillis()" - } - } - }, - "PlaylistEntity": { - "package": "com.google.android.engage.audio.datamodel.PlaylistEntity", - "fields": { - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "name": { - "type": "String", - "requirement": "Required", - "setter": "setName(String)", - "getter": "getName()" - }, - "posterImages": { - "type": "List", - "requirement": "Required", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "description": { - "type": "String", - "requirement": "Optional", - "setter": "setDescription(String)", - "getter": "getDescription()" - }, - "playBackUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setPlayBackUri(Uri)", - "getter": "getPlayBackUri()" - }, - "songsCount": { - "type": "Integer", - "requirement": "Required", - "setter": "setSongsCount(int)", - "getter": "getSongsCount()" - }, - "durationMillis": { - "type": "Long", - "requirement": "Required", - "setter": "setDurationMillis(long)", - "getter": "getDurationMillis()" - }, - "infoPageUri": { - "type": "Uri", - "requirement": "Optional", - "setter": "setInfoPageUri(Uri)", - "getter": "getInfoPageUri()" - }, - "downloadedOnDevice": { - "type": "Boolean", - "requirement": "Optional", - "setter": "setDownloadedOnDevice(boolean)", - "getter": "isDownloadedOnDevice()" - }, - "explicitContent": { - "type": "Boolean", - "requirement": "Optional", - "setter": "setExplicitContent(boolean)", - "getter": "isExplicitContent()" - }, - "lastEngagementTimeMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLastEngagementTimeMillis(long)", - "getter": "getLastEngagementTimeMillis()" - }, - "progressPercentComplete": { - "type": "Integer", - "requirement": "Optional", - "setter": "setProgressPercentComplete(int)", - "getter": "getProgressPercentComplete()" - } - } - } - }, - "methods": { - "isServiceAvailable": "ServiceAvailabilityRequest", - "publishRecommendationClusters": "PublishRecommendationClustersRequest", - "publishFeaturedCluster": "PublishFeaturedClusterRequest", - "publishContinuationCluster": "PublishContinuationClusterRequest", - "publishSubscription": "PublishSubscriptionRequest", - "publishUserAccountManagementRequest": "PublishUserAccountManagementRequest", - "updatePublishStatus": "PublishStatusRequest", - "deleteRecommendationsClusters": "DeleteClustersRequest", - "deleteFeaturedCluster": "DeleteClustersRequest", - "deleteContinuationCluster": "DeleteClustersRequest", - "deleteSubscription": "DeleteClustersRequest", - "deleteUserManagementCluster": "DeleteClustersRequest" - }, - "intents": {} - } \ No newline at end of file diff --git a/play/engage-sdk-integration/references/schemas/other.md b/play/engage-sdk-integration/references/schemas/other.md deleted file mode 100644 index b55307c..0000000 --- a/play/engage-sdk-integration/references/schemas/other.md +++ /dev/null @@ -1,220 +0,0 @@ -This file defines the schema for the OTHER vertical in the Engage SDK. - - { - "client": "com.google.android.engage.service.AppEngagePublishClient", - "clusterTypes": [ - "TYPE_RECOMMENDATION", - "TYPE_FEATURED" - ], - "entities": { - "ArticleEntity": { - "package": "com.google.android.engage.common.datamodel.ArticleEntity", - "fields": { - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "actionLinkUri": { - "type": "Uri", - "requirement": "Required" - }, - "posterImages": { - "type": "List", - "requirement": "Required", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "title": { - "type": "String", - "requirement": "Optional", - "setter": "setTitle(String)", - "getter": "getTitle()" - }, - "description": { - "type": "String", - "requirement": "Optional", - "setter": "setDescription(String)", - "getter": "getDescription()" - }, - "subtitleList": { - "type": "List", - "requirement": "Optional" - }, - "badgeList": { - "type": "List", - "requirement": "Optional" - }, - "contentCategoryList": { - "type": "List", - "requirement": "Optional" - }, - "lastEngagementTime": { - "type": "Long", - "requirement": "Optional", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "displayTimeWindows": { - "type": "List", - "requirement": "Optional", - "adder": "addDisplayTimeWindow(DisplayTimeWindow)", - "getter": "getDisplayTimeWindows()" - }, - "actionUri": { - "requirement": "Required", - "setter": "setActionUri(Uri)", - "type": "Uri", - "getter": "getActionUri()" - }, - "subtitles": { - "requirement": "Optional", - "adder": "addSubtitle(String)", - "type": "List", - "adderAll": "addSubtitles(List)" - }, - "badges": { - "requirement": "Optional", - "adder": "addBadge(Badge)", - "type": "List", - "adderAll": "addBadges(List)" - }, - "contentCategories": { - "requirement": "Optional", - "adder": "addContentCategory(@EligibleContentCategory int)", - "type": "List<@EligibleContentCategory int>", - "adderAll": "addContentCategories(List)" - }, - "progressPercentage": { - "requirement": "Required", - "setter": "setProgressPercentage(int)", - "type": "Integer", - "getter": "getProgressPercentage()", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "lastEngagementTimestampMillis": { - "requirement": "Required", - "setter": "setLastEngagementTimestampMillis(long)", - "type": "Long", - "getter": "getLastEngagementTimestampMillis()" - }, - "source": { - "requirement": "Optional", - "setter": "setSource(Badge)", - "type": "Badge", - "getter": "getSource()" - }, - "lastContentPublishTimestampMillis": { - "requirement": "Optional", - "setter": "setLastContentPublishTimestampMillis(Long)", - "type": "Long", - "getter": "getLastContentPublishTimestampMillis()" - }, - "allDisplayTimeWindows": { - "requirement": "Optional", - "adder": "addAllDisplayTimeWindow(DisplayTimeWindow)", - "type": "List>", - "adderAll": "addAllDisplayTimeWindow(List)" - } - } - }, - "GenericFeaturedEntity": { - "package": "com.google.android.engage.common.datamodel.GenericFeaturedEntity", - "fields": { - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "actionUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setActionUri(Uri)", - "getter": "getActionUri()" - }, - "posterImages": { - "type": "List", - "requirement": "Required", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "title": { - "type": "String", - "requirement": "Optional", - "setter": "setTitle(String)", - "getter": "getTitle()" - }, - "description": { - "type": "String", - "requirement": "Optional", - "setter": "setDescription(String)", - "getter": "getDescription()" - }, - "subtitleList": { - "type": "List", - "requirement": "Optional" - }, - "badgeList": { - "type": "List", - "requirement": "Optional" - }, - "contentCategoryList": { - "type": "List", - "requirement": "Optional" - }, - "displayTimeWindows": { - "type": "List", - "requirement": "Optional", - "adder": "addDisplayTimeWindow(DisplayTimeWindow)", - "getter": "getDisplayTimeWindows()" - }, - "subtitles": { - "requirement": "Optional", - "adder": "addSubtitle(String)", - "type": "List", - "adderAll": "addSubtitles(List)" - }, - "badges": { - "requirement": "Optional", - "adder": "addBadge(Badge)", - "type": "List", - "adderAll": "addBadges(List)" - }, - "contentCategories": { - "requirement": "Optional", - "adder": "addContentCategory(@EligibleContentCategory int)", - "type": "List<@EligibleContentCategory int>", - "adderAll": "addContentCategories(List)" - }, - "allDisplayTimeWindows": { - "requirement": "Optional", - "adder": "addAllDisplayTimeWindow(DisplayTimeWindow)", - "type": "List>", - "adderAll": "addAllDisplayTimeWindow(List)" - } - } - } - }, - "methods": { - "isServiceAvailable": "ServiceAvailabilityRequest", - "publishRecommendationClusters": "PublishRecommendationClustersRequest", - "publishFeaturedCluster": "PublishFeaturedClusterRequest", - "publishContinuationCluster": "PublishContinuationClusterRequest", - "publishSubscription": "PublishSubscriptionRequest", - "publishUserAccountManagementRequest": "PublishUserAccountManagementRequest", - "updatePublishStatus": "PublishStatusRequest", - "deleteRecommendationsClusters": "DeleteClustersRequest", - "deleteFeaturedCluster": "DeleteClustersRequest", - "deleteContinuationCluster": "DeleteClustersRequest", - "deleteSubscription": "DeleteClustersRequest", - "deleteUserManagementCluster": "DeleteClustersRequest" - }, - "intents": {} - } \ No newline at end of file diff --git a/play/engage-sdk-integration/references/schemas/read.md b/play/engage-sdk-integration/references/schemas/read.md deleted file mode 100644 index bbaa2e3..0000000 --- a/play/engage-sdk-integration/references/schemas/read.md +++ /dev/null @@ -1,412 +0,0 @@ -This file defines the schema for the READ vertical in the Engage SDK. - - { - "client": "com.google.android.engage.service.AppEngagePublishClient", - "clusterTypes": [ - "TYPE_RECOMMENDATION", - "TYPE_FEATURED", - "TYPE_CONTINUATION" - ], - "entities": { - "EbookEntity": { - "package": "com.google.android.engage.books.datamodel.EbookEntity", - "fields": { - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "name": { - "type": "String", - "requirement": "Required", - "setter": "setName(String)", - "getter": "getName()" - }, - "posterImages": { - "type": "List", - "requirement": "Required", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "actionLinkUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setActionLinkUri(Uri)", - "getter": "getActionLinkUri()" - }, - "authors": { - "type": "List", - "requirement": "Required", - "adder": "addAuthor(String)", - "getter": "getAuthors()", - "adderAll": "addAuthors(List)" - }, - "publishDateEpochMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setPublishDateEpochMillis(long)", - "getter": "getPublishDateEpochMillis()" - }, - "description": { - "type": "String", - "requirement": "Optional", - "setter": "setDescription(String)", - "getter": "getDescription()" - }, - "pageCount": { - "type": "Integer", - "requirement": "Optional", - "setter": "setPageCount(int)", - "getter": "getPageCount()" - }, - "price": { - "type": "Price", - "requirement": "Optional", - "setter": "setPrice(Price)", - "getter": "getPrice()" - }, - "genres": { - "type": "List", - "requirement": "Optional", - "adder": "addGenre(String)", - "getter": "getGenres()", - "adderAll": "addGenres(List)" - }, - "seriesName": { - "type": "String", - "requirement": "Optional", - "setter": "setSeriesName(String)", - "getter": "getSeriesName()" - }, - "seriesUnitIndex": { - "type": "Integer", - "requirement": "Optional", - "setter": "setSeriesUnitIndex(Integer)", - "getter": "getSeriesUnitIndex()" - }, - "lastEngagementTimeMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLastEngagementTimeMillis(long)", - "getter": "getLastEngagementTimeMillis()", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "progressPercentComplete": { - "type": "Integer", - "requirement": "Optional", - "setter": "setProgressPercentComplete(int)", - "getter": "getProgressPercentComplete()", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "rating": { - "type": "Rating", - "requirement": "Optional", - "setter": "setRating(Rating)", - "getter": "getRating()" - }, - "availability": { - "type": "@ContentAvailability int", - "requirement": "Optional", - "setter": "setAvailability(@ContentAvailability int)", - "getter": "getAvailability()" - }, - "downloadedOnDevice": { - "type": "Boolean", - "requirement": "Optional", - "setter": "setDownloadedOnDevice(boolean)", - "getter": "isDownloadedOnDevice()" - }, - "displayTimeWindows": { - "type": "List", - "requirement": "Optional", - "adder": "addDisplayTimeWindow(DisplayTimeWindow)", - "getter": "getDisplayTimeWindows()" - }, - "allDisplayTimeWindows": { - "type": "List>", - "requirement": "Optional", - "adder": "addAllDisplayTimeWindow(DisplayTimeWindow)", - "adderAll": "addAllDisplayTimeWindow(List)" - }, - "continueBookType": { - "type": "Integer", - "requirement": "Optional", - "setter": "setContinueBookType(int)", - "getter": "getContinueBookType()", - "requiredFor": [ - "ContinuationCluster" - ] - } - } - }, - "BookSeriesEntity": { - "package": "com.google.android.engage.books.datamodel.BookSeriesEntity", - "fields": { - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "name": { - "type": "String", - "requirement": "Required", - "setter": "setName(String)", - "getter": "getName()" - }, - "posterImages": { - "type": "List", - "requirement": "Required", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "actionLinkUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setActionLinkUri(Uri)", - "getter": "getActionLinkUri()" - }, - "bookCount": { - "type": "Integer", - "requirement": "Required", - "setter": "setBookCount(int)", - "getter": "getBookCount()" - }, - "authors": { - "type": "List", - "requirement": "Required", - "adder": "addAuthor(String)", - "getter": "getAuthors()", - "adderAll": "addAuthors(List)" - }, - "description": { - "type": "String", - "requirement": "Optional", - "setter": "setDescription(String)", - "getter": "getDescription()" - }, - "genres": { - "type": "List>", - "requirement": "Optional", - "adder": "addGenre(String)", - "getter": "getGenres()", - "adderAll": "addGenres(List)" - }, - "lastEngagementTimeMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLastEngagementTimeMillis(long)", - "getter": "getLastEngagementTimeMillis()", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "progressPercentComplete": { - "type": "Integer", - "requirement": "Optional", - "setter": "setProgressPercentComplete(int)", - "getter": "getProgressPercentComplete()", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "rating": { - "type": "Rating", - "requirement": "Optional", - "setter": "setRating(Rating)", - "getter": "getRating()" - }, - "availability": { - "type": "@ContentAvailability int", - "requirement": "Optional", - "setter": "setAvailability(@ContentAvailability int)", - "getter": "getAvailability()" - }, - "downloadedOnDevice": { - "type": "Boolean", - "requirement": "Optional", - "setter": "setDownloadedOnDevice(boolean)", - "getter": "isDownloadedOnDevice()" - }, - "displayTimeWindows": { - "type": "List", - "requirement": "Optional", - "adder": "addDisplayTimeWindow(DisplayTimeWindow)", - "getter": "getDisplayTimeWindows()" - }, - "allDisplayTimeWindows": { - "type": "List>", - "requirement": "Optional", - "adder": "addAllDisplayTimeWindow(DisplayTimeWindow)", - "adderAll": "addAllDisplayTimeWindow(List)" - }, - "continueBookType": { - "type": "Integer", - "requirement": "Optional", - "setter": "setContinueBookType(int)", - "getter": "getContinueBookType()", - "requiredFor": [ - "ContinuationCluster" - ] - } - } - }, - "AudiobookEntity": { - "package": "com.google.android.engage.books.datamodel.AudiobookEntity", - "fields": { - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "name": { - "type": "String", - "requirement": "Required", - "setter": "setName(String)", - "getter": "getName()" - }, - "posterImages": { - "type": "List", - "requirement": "Required", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "description": { - "type": "String", - "requirement": "Optional", - "setter": "setDescription(String)", - "getter": "getDescription()" - }, - "actionLinkUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setActionLinkUri(Uri)", - "getter": "getActionLinkUri()" - }, - "rating": { - "type": "Rating", - "requirement": "Optional", - "setter": "setRating(Rating)", - "getter": "getRating()" - }, - "availability": { - "type": "@ContentAvailability int", - "requirement": "Optional", - "setter": "setAvailability(@ContentAvailability int)", - "getter": "getAvailability()" - }, - "downloadedOnDevice": { - "type": "Boolean", - "requirement": "Optional", - "setter": "setDownloadedOnDevice(boolean)", - "getter": "isDownloadedOnDevice()" - }, - "displayTimeWindows": { - "type": "List", - "requirement": "Optional", - "adder": "addDisplayTimeWindow(DisplayTimeWindow)", - "getter": "getDisplayTimeWindows()" - }, - "allDisplayTimeWindows": { - "type": "List>", - "requirement": "Optional", - "adder": "addAllDisplayTimeWindow(DisplayTimeWindow)", - "adderAll": "addAllDisplayTimeWindow(List)" - }, - "authors": { - "type": "List", - "requirement": "Required", - "adder": "addAuthor(String)", - "getter": "getAuthors()", - "adderAll": "addAuthors(List)" - }, - "narrators": { - "type": "List", - "requirement": "Required", - "adder": "addNarrator(String)", - "getter": "getNarrators()", - "adderAll": "addNarrators(List)" - }, - "publishDateEpochMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setPublishDateEpochMillis(long)", - "getter": "getPublishDateEpochMillis()" - }, - "durationMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setDurationMillis(long)", - "getter": "getDurationMillis()" - }, - "price": { - "type": "Price", - "requirement": "Optional", - "setter": "setPrice(Price)", - "getter": "getPrice()" - }, - "seriesName": { - "type": "String", - "requirement": "Optional", - "setter": "setSeriesName(String)", - "getter": "getSeriesName()" - }, - "seriesUnitIndex": { - "type": "Integer", - "requirement": "Optional", - "setter": "setSeriesUnitIndex(Integer)", - "getter": "getSeriesUnitIndex()" - }, - "genres": { - "type": "List>", - "requirement": "Optional", - "adder": "addGenre(String)", - "getter": "getGenres()", - "adderAll": "addGenres(List)" - }, - "lastEngagementTimeMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLastEngagementTimeMillis(long)", - "getter": "getLastEngagementTimeMillis()" - }, - "progressPercentComplete": { - "type": "Integer", - "requirement": "Optional", - "setter": "setProgressPercentComplete(int)", - "getter": "getProgressPercentComplete()" - }, - "continueBookType": { - "type": "Integer", - "requirement": "Optional", - "setter": "setContinueBookType(int)", - "getter": "getContinueBookType()" - } - } - } - }, - "methods": { - "isServiceAvailable": "ServiceAvailabilityRequest", - "publishRecommendationClusters": "PublishRecommendationClustersRequest", - "publishFeaturedCluster": "PublishFeaturedClusterRequest", - "publishContinuationCluster": "PublishContinuationClusterRequest", - "publishSubscription": "PublishSubscriptionRequest", - "publishUserAccountManagementRequest": "PublishUserAccountManagementRequest", - "updatePublishStatus": "PublishStatusRequest", - "deleteRecommendationsClusters": "DeleteClustersRequest", - "deleteFeaturedCluster": "DeleteClustersRequest", - "deleteContinuationCluster": "DeleteClustersRequest", - "deleteSubscription": "DeleteClustersRequest", - "deleteUserManagementCluster": "DeleteClustersRequest" - }, - "intents": {} - } \ No newline at end of file diff --git a/play/engage-sdk-integration/references/schemas/shopping.md b/play/engage-sdk-integration/references/schemas/shopping.md deleted file mode 100644 index 2b6f0d9..0000000 --- a/play/engage-sdk-integration/references/schemas/shopping.md +++ /dev/null @@ -1,357 +0,0 @@ -This file defines the schema for the SHOPPING vertical in the Engage SDK. - - { - "client": "com.google.android.engage.shopping.service.AppEngageShoppingClient", - "clusterTypes": [ - "TYPE_RECOMMENDATION", - "TYPE_FEATURED", - "TYPE_SHOPPING_CART", - "TYPE_SHOPPING_LIST", - "TYPE_SHOPPING_REORDER", - "TYPE_SHOPPING_ORDER_TRACKING" - ], - "entities": { - "ShoppingEntity": { - "package": "com.google.android.engage.shopping.datamodel.ShoppingEntity", - "fields": { - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "posterImages": { - "type": "List", - "requirement": "Required", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "displayTimeWindows": { - "type": "List", - "requirement": "Optional", - "adder": "addDisplayTimeWindow(DisplayTimeWindow)", - "getter": "getDisplayTimeWindows()" - }, - "allDisplayTimeWindows": { - "type": "List>", - "requirement": "Optional", - "adder": "addAllDisplayTimeWindow(DisplayTimeWindow)", - "adderAll": "addAllDisplayTimeWindow(List)" - }, - "actionLinkUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setActionLinkUri(Uri)", - "getter": "getActionLinkUri()" - }, - "title": { - "type": "String", - "requirement": "Optional", - "setter": "setTitle(String)", - "getter": "getTitle()" - }, - "callout": { - "type": "String", - "requirement": "Optional", - "setter": "setCallout(String)", - "getter": "getCallout()" - }, - "calloutFinePrint": { - "type": "String", - "requirement": "Optional", - "setter": "setCalloutFinePrint(String)", - "getter": "getCalloutFinePrint()" - }, - "price": { - "type": "Price", - "requirement": "Optional", - "setter": "setPrice(Price)", - "getter": "getPrice()", - "requiredFor": [ - "Required if strikethrough price is provided" - ] - }, - "rating": { - "type": "Rating", - "requirement": "Optional", - "setter": "setRating(Rating)", - "getter": "getRating()", - "requiredFor": [ - "Required if strikethrough price is provided", - "Mutually required with other rating fields" - ] - } - } - } - }, - "methods": { - "isServiceAvailable": "ServiceAvailabilityRequest", - "publishRecommendationClusters": "PublishRecommendationClustersRequest", - "publishFeaturedCluster": "PublishFeaturedClusterRequest", - "publishShoppingCart": "PublishShoppingCartClusterRequest", - "publishShoppingLists": "PublishShoppingListsRequest", - "publishShoppingReorderCluster": "PublishShoppingReorderClusterRequest", - "publishShoppingOrderTrackingCluster": "PublishShoppingOrderTrackingClusterRequest", - "publishUserAccountManagementRequest": "PublishUserAccountManagementRequest", - "updatePublishStatus": "PublishStatusRequest", - "deleteShoppingCartCluster": "DeleteClustersRequest", - "deleteShoppingListCluster": "DeleteClustersRequest", - "deleteReorderCluster": "DeleteClustersRequest", - "deleteShoppingOrderTrackingCluster": "DeleteClustersRequest", - "deleteRecommendationsClusters": "DeleteClustersRequest", - "deleteFeaturedCluster": "DeleteClustersRequest", - "deleteUserManagementCluster": "DeleteClustersRequest" - }, - "clusters": { - "ShoppingList": { - "package": "com.google.android.engage.shopping.datamodel.ShoppingList", - "fields": { - "title": { - "type": "String", - "requirement": "Optional", - "setter": "setTitle(String)", - "getter": "getTitle()" - }, - "actionText": { - "type": "String", - "requirement": "Optional", - "setter": "setActionText(String)", - "getter": "getActionText()" - }, - "posterImages": { - "type": "List", - "requirement": "Optional", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "itemLabels": { - "type": "List>", - "requirement": "Optional", - "adder": "addItemLabel(String)", - "getter": "getItemLabels()", - "adderAll": "addItemLabels(List)" - }, - "numberOfItems": { - "type": "Integer", - "requirement": "Optional", - "setter": "setNumberOfItems(int)", - "getter": "getNumberOfItems()" - }, - "actionLinkUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setActionLinkUri(Uri)", - "getter": "getActionLinkUri()" - }, - "lastUserInteractionTimestampMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLastUserInteractionTimestampMillis(long)", - "getter": "getLastUserInteractionTimestampMillis()" - } - } - }, - "ShoppingReorderCluster": { - "package": "com.google.android.engage.shopping.datamodel.ShoppingReorderCluster", - "fields": { - "title": { - "type": "String", - "requirement": "Optional", - "setter": "setTitle(String)", - "getter": "getTitle()" - }, - "actionText": { - "type": "String", - "requirement": "Optional", - "setter": "setActionText(String)", - "getter": "getActionText()" - }, - "posterImages": { - "type": "List", - "requirement": "Optional", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "itemLabels": { - "type": "List>", - "requirement": "Optional", - "adder": "addItemLabel(String)", - "getter": "getItemLabels()", - "adderAll": "addItemLabels(List)" - }, - "numberOfItems": { - "type": "Integer", - "requirement": "Optional", - "setter": "setNumberOfItems(int)", - "getter": "getNumberOfItems()" - }, - "actionLinkUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setActionLinkUri(Uri)", - "getter": "getActionLinkUri()" - }, - "displayTimeWindows": { - "type": "List", - "requirement": "Optional", - "adder": "addDisplayTimeWindow(DisplayTimeWindow)", - "getter": "getDisplayTimeWindows()" - }, - "allDisplayTimeWindows": { - "type": "List>", - "requirement": "Optional", - "adder": "addAllDisplayTimeWindow(DisplayTimeWindow)", - "adderAll": "addAllDisplayTimeWindow(List)" - } - } - }, - "ShoppingCart": { - "package": "com.google.android.engage.shopping.datamodel.ShoppingCart", - "fields": { - "title": { - "type": "String", - "requirement": "Optional", - "setter": "setTitle(String)", - "getter": "getTitle()" - }, - "actionText": { - "type": "String", - "requirement": "Optional", - "setter": "setActionText(String)", - "getter": "getActionText()" - }, - "posterImages": { - "type": "List", - "requirement": "Optional", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "itemLabels": { - "type": "List>", - "requirement": "Optional", - "adder": "addItemLabel(String)", - "getter": "getItemLabels()", - "adderAll": "addItemLabels(List)" - }, - "numberOfItems": { - "type": "Integer", - "requirement": "Optional", - "setter": "setNumberOfItems(int)", - "getter": "getNumberOfItems()" - }, - "actionLinkUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setActionLinkUri(Uri)", - "getter": "getActionLinkUri()" - }, - "displayTimeWindows": { - "type": "List", - "requirement": "Optional", - "adder": "addDisplayTimeWindow(DisplayTimeWindow)", - "getter": "getDisplayTimeWindows()" - }, - "allDisplayTimeWindows": { - "type": "List>", - "requirement": "Optional", - "adder": "addAllDisplayTimeWindow(DisplayTimeWindow)", - "adderAll": "addAllDisplayTimeWindow(List)" - }, - "lastUserInteractionTimestampMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLastUserInteractionTimestampMillis(long)", - "getter": "getLastUserInteractionTimestampMillis()" - } - } - }, - "ShoppingOrderTrackingCluster": { - "package": "com.google.android.engage.shopping.datamodel.ShoppingOrderTrackingCluster", - "fields": { - "title": { - "type": "String", - "requirement": "Required", - "setter": "setTitle(String)", - "getter": "getTitle()" - }, - "posterImages": { - "type": "List", - "requirement": "Optional", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "status": { - "type": "String", - "requirement": "Required", - "setter": "setStatus(String)", - "getter": "getStatus()" - }, - "orderTime": { - "type": "Long", - "requirement": "Required", - "setter": "setOrderTime(long)", - "getter": "getOrderTime()" - }, - "actionLinkUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setActionLinkUri(Uri)", - "getter": "getActionLinkUri()" - }, - "orderReadyTimeWindow": { - "type": "OrderReadyTimeWindow", - "requirement": "Optional", - "setter": "setOrderReadyTimeWindow(OrderReadyTimeWindow)", - "getter": "getOrderReadyTimeWindow()" - }, - "numberOfItems": { - "type": "Integer", - "requirement": "Optional", - "setter": "setNumberOfItems(Integer)", - "getter": "getNumberOfItems()" - }, - "orderDescription": { - "type": "String", - "requirement": "Optional", - "setter": "setOrderDescription(String)", - "getter": "getOrderDescription()" - }, - "subtitles": { - "type": "List", - "requirement": "Optional", - "adder": "addSubtitle(String)", - "adderAll": "addSubtitles(List)" - }, - "orderValue": { - "type": "Price", - "requirement": "Optional", - "setter": "setOrderValue(Price)", - "getter": "getOrderValue()" - }, - "shoppingOrderType": { - "type": "@ShoppingOrderType int", - "requirement": "Required", - "setter": "setShoppingOrderType(@ShoppingOrderType int)", - "getter": "getShoppingOrderType()" - }, - "trackingId": { - "type": "String", - "requirement": "Optional", - "setter": "setTrackingId(String)", - "getter": "getTrackingId()" - } - } - } - }, - "intents": { - "ACTION_PUBLISH_SHOPPING_CART": "com.google.android.engage.action.shopping.PUBLISH_SHOPPING_CART", - "ACTION_PUBLISH_SHOPPING_LIST": "com.google.android.engage.action.shopping.PUBLISH_SHOPPING_LIST", - "ACTION_PUBLISH_REORDER_CLUSTER": "com.google.android.engage.action.shopping.PUBLISH_REORDER_CLUSTER", - "ACTION_PUBLISH_ORDER_TRACKING_CLUSTER": "com.google.android.engage.action.shopping.PUBLISH_ORDER_TRACKING_CLUSTER" - } - } \ No newline at end of file diff --git a/play/engage-sdk-integration/references/schemas/social.md b/play/engage-sdk-integration/references/schemas/social.md deleted file mode 100644 index 5373317..0000000 --- a/play/engage-sdk-integration/references/schemas/social.md +++ /dev/null @@ -1,228 +0,0 @@ -This file defines the schema for the SOCIAL vertical in the Engage SDK. - - { - "client": "com.google.android.engage.social.service.AppEngageSocialClient", - "clusterTypes": [ - "TYPE_RECOMMENDATION", - "TYPE_FEATURED" - ], - "entities": { - "SocialPostEntity": { - "package": "com.google.android.engage.social.datamodel.SocialPostEntity", - "fields": { - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "actionLinkUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setActionLinkUri(Uri)", - "getter": "getActionLinkUri()" - }, - "displayTimeWindows": { - "type": "List", - "requirement": "Optional", - "adder": "addDisplayTimeWindow(DisplayTimeWindow)", - "getter": "getDisplayTimeWindows()" - }, - "allDisplayTimeWindows": { - "type": "List>", - "requirement": "Optional", - "adder": "addAllDisplayTimeWindow(DisplayTimeWindow)", - "adderAll": "addAllDisplayTimeWindow(List)" - }, - "genericPost": { - "type": "GenericPost", - "requirement": "Required", - "setter": "setGenericPost(GenericPost)", - "getter": "getGenericPost()" - }, - "profile": { - "type": "Profile", - "requirement": "Optional", - "setter": "setProfile(Profile)", - "getter": "getProfile()" - }, - "interactions": { - "type": "List", - "requirement": "Optional", - "adder": "addInteraction(Interaction)", - "getter": "getInteractions()" - }, - "allInteractions": { - "type": "List>", - "requirement": "Optional", - "adder": "addAllInteraction(Interaction)", - "adderAll": "addAllInteraction(List)" - } - } - }, - "PortraitMediaEntity": { - "package": "com.google.android.engage.social.datamodel.PortraitMediaEntity", - "fields": { - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "actionLinkUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setActionLinkUri(Uri)", - "getter": "getActionLinkUri()" - }, - "displayTimeWindows": { - "type": "List", - "requirement": "Optional", - "adder": "addDisplayTimeWindow(DisplayTimeWindow)", - "getter": "getDisplayTimeWindows()" - }, - "allDisplayTimeWindows": { - "type": "List>", - "requirement": "Optional", - "adder": "addAllDisplayTimeWindow(DisplayTimeWindow)", - "adderAll": "addAllDisplayTimeWindow(List)" - }, - "portraitMediaPost": { - "type": "PortraitMediaPost", - "requirement": "Required", - "setter": "setPortraitMediaPost(PortraitMediaPost)", - "getter": "getPortraitMediaPost()" - }, - "profile": { - "type": "Profile", - "requirement": "Optional", - "setter": "setProfile(Profile)", - "getter": "getProfile()" - }, - "interactions": { - "type": "List>", - "requirement": "Optional", - "adder": "addInteractions(Interaction)", - "getter": "getInteractions()", - "adderAll": "addInteractions(List)" - }, - "recommendationReason": { - "type": "@NonNull RecommendationReason", - "requirement": "Optional", - "setter": "setRecommendationReason(@NonNull RecommendationReason)", - "getter": "getRecommendationReason()" - }, - "platformSpecificPlaybackUris": { - "type": "List<@NonNull PlatformSpecificUri>", - "requirement": "Optional", - "adder": "addPlatformSpecificPlaybackUri(@NonNull PlatformSpecificUri)", - "getter": "getPlatformSpecificPlaybackUris()", - "adderAll": "addPlatformSpecificPlaybackUris(List)" - }, - "commentsSummary": { - "type": "@NonNull String", - "requirement": "Optional", - "setter": "setCommentsSummary(@NonNull String)", - "getter": "getCommentsSummary()" - }, - "interaction": { - "requirement": "Optional", - "setter": "setInteraction(Interaction)", - "type": "Interaction", - "getter": "getInteraction()" - } - } - }, - "PersonEntity": { - "package": "com.google.android.engage.social.datamodel.PersonEntity", - "fields": { - "actionLinkUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setActionLinkUri(Uri)", - "getter": "getActionLinkUri()" - }, - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "posterImages": { - "type": "List", - "requirement": "Required", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "profile": { - "type": "Profile", - "requirement": "Required", - "setter": "setProfile(Profile)", - "getter": "getProfile()" - }, - "headerImage": { - "type": "Image", - "requirement": "Optional", - "setter": "setHeaderImage(Image)", - "getter": "getHeaderImage()" - }, - "popularity": { - "type": "Popularity", - "requirement": "Optional", - "setter": "setPopularity(Popularity)", - "getter": "getPopularity()" - }, - "rating": { - "type": "Rating", - "requirement": "Optional", - "setter": "setRating(Rating)", - "getter": "getRating()" - }, - "locations": { - "type": "Address", - "requirement": "Optional" - }, - "badges": { - "type": "List", - "requirement": "Optional", - "adder": "addBadge(Badge)", - "adderAll": "addBadges(List)" - }, - "description": { - "type": "String", - "requirement": "Optional", - "setter": "setDescription(String)", - "getter": "getDescription()" - }, - "subtitles": { - "type": "List", - "requirement": "Optional", - "adder": "addSubtitle(String)", - "adderAll": "addSubtitles(List)" - }, - "contentCategories": { - "type": "List<@EligibleContentCategory int>", - "requirement": "Optional", - "adder": "addContentCategory(@EligibleContentCategory int)", - "adderAll": "addContentCategories(List)" - }, - "location": { - "requirement": "Optional", - "setter": "setLocation(Address)", - "type": "Address", - "getter": "getLocation()" - } - } - } - }, - "methods": { - "isServiceAvailable": "ServiceAvailabilityRequest", - "publishRecommendationClusters": "PublishRecommendationClustersRequest", - "publishUserAccountManagementRequest": "PublishUserAccountManagementRequest", - "updatePublishStatus": "PublishStatusRequest", - "deleteUserManagementCluster": "DeleteClustersRequest", - "deleteRecommendationsClusters": "DeleteClustersRequest" - }, - "intents": {} - } \ No newline at end of file diff --git a/play/engage-sdk-integration/references/schemas/travel.md b/play/engage-sdk-integration/references/schemas/travel.md deleted file mode 100644 index d4a0f95..0000000 --- a/play/engage-sdk-integration/references/schemas/travel.md +++ /dev/null @@ -1,923 +0,0 @@ -This file defines the schema for the TRAVEL vertical in the Engage SDK. - - { - "client": "com.google.android.engage.travel.service.AppEngageTravelClient", - "clusterTypes": [ - "TYPE_RECOMMENDATION", - "TYPE_FEATURED", - "TYPE_RESERVATION", - "TYPE_CONTINUE_SEARCH" - ], - "entities": { - "EventEntity": { - "package": "com.google.android.engage.travel.datamodel.EventEntity", - "fields": { - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "posterImages": { - "type": "List", - "requirement": "Required", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "actionLinkUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setActionLinkUri(Uri)", - "getter": "getActionLinkUri()" - }, - "title": { - "type": "@NonNull String", - "requirement": "Required", - "setter": "setTitle(@NonNull String)", - "getter": "getTitle()" - }, - "startTime": { - "type": "Long", - "requirement": "Required", - "setter": "setStartTime(Long)", - "getter": "getStartTime()" - }, - "localizedStartTime": { - "type": "LocalizedTimestamp", - "requirement": "Required", - "setter": "setLocalizedStartTime(LocalizedTimestamp)", - "getter": "getLocalizedStartTime()" - }, - "eventMode": { - "type": "@EventMode int", - "requirement": "Required", - "setter": "setEventMode(@EventMode int)", - "getter": "getEventMode()" - }, - "locations": { - "type": "Address", - "requirement": "Required" - }, - "endTime": { - "type": "Long", - "requirement": "Optional", - "setter": "setEndTime(Long)", - "getter": "getEndTime()" - }, - "localizedEndTime": { - "type": "LocalizedTimestamp", - "requirement": "Optional", - "setter": "setLocalizedEndTime(LocalizedTimestamp)", - "getter": "getLocalizedEndTime()" - }, - "description": { - "type": "String", - "requirement": "Optional", - "setter": "setDescription(String)", - "getter": "getDescription()" - }, - "subtitles": { - "type": "List", - "requirement": "Optional", - "adder": "addSubtitle(String)", - "adderAll": "addSubtitles(List)" - }, - "badges": { - "type": "List", - "requirement": "Optional", - "adder": "addBadge(Badge)", - "adderAll": "addBadges(List)" - }, - "price": { - "type": "Price", - "requirement": "Optional", - "setter": "setPrice(Price)", - "getter": "getPrice()", - "requiredFor": [ - "Required if strikethrough price is provided" - ] - }, - "priceCallout": { - "type": "String", - "requirement": "Optional", - "setter": "setPriceCallout(String)", - "getter": "getPriceCallout()" - }, - "contentCategories": { - "type": "List<@EligibleContentCategory int>", - "requirement": "Optional", - "adder": "addContentCategory(@EligibleContentCategory int)", - "adderAll": "addContentCategories(List)" - }, - "displayTimeWindows": { - "type": "List", - "requirement": "Optional", - "adder": "addDisplayTimeWindow(DisplayTimeWindow)", - "getter": "getDisplayTimeWindows()" - }, - "allDisplayTimeWindows": { - "type": "List>", - "requirement": "Optional", - "adder": "addAllDisplayTimeWindow(DisplayTimeWindow)", - "adderAll": "addAllDisplayTimeWindow(List)" - }, - "location": { - "requirement": "Required", - "setter": "setLocation(Address)", - "type": "Address", - "getter": "getLocation()", - "requiredFor": [ - "Required if strikethrough price is provided" - ] - } - } - }, - "LodgingEntity": { - "package": "com.google.android.engage.travel.datamodel.LodgingEntity", - "fields": { - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "posterImages": { - "type": "List", - "requirement": "Required", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "actionLinkUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setActionLinkUri(Uri)", - "getter": "getActionLinkUri()" - }, - "title": { - "type": "String", - "requirement": "Required", - "setter": "setTitle(String)", - "getter": "getTitle()" - }, - "locations": { - "type": "Address", - "requirement": "Required" - }, - "price": { - "type": "Price", - "requirement": "Optional", - "setter": "setPrice(Price)", - "getter": "getPrice()", - "requiredFor": [ - "Required if strikethrough price is provided" - ] - }, - "priceCallout": { - "type": "String", - "requirement": "Optional", - "setter": "setPriceCallout(String)", - "getter": "getPriceCallout()" - }, - "badges": { - "type": "List", - "requirement": "Optional", - "adder": "addBadge(Badge)", - "adderAll": "addBadges(List)" - }, - "description": { - "type": "String", - "requirement": "Optional", - "setter": "setDescription(String)", - "getter": "getDescription()" - }, - "subtitles": { - "type": "List", - "requirement": "Optional", - "adder": "addSubtitle(String)", - "adderAll": "addSubtitles(List)" - }, - "availabilityTimeWindow": { - "type": "AvailabilityTimeWindow", - "requirement": "Optional", - "setter": "setAvailabilityTimeWindow(AvailabilityTimeWindow)", - "getter": "getAvailabilityTimeWindow()" - }, - "rating": { - "type": "Rating", - "requirement": "Optional", - "setter": "setRating(Rating)", - "getter": "getRating()" - }, - "displayTimeWindows": { - "type": "List", - "requirement": "Optional", - "adder": "addDisplayTimeWindow(DisplayTimeWindow)", - "getter": "getDisplayTimeWindows()" - }, - "allDisplayTimeWindows": { - "type": "List>", - "requirement": "Optional", - "adder": "addAllDisplayTimeWindow(DisplayTimeWindow)", - "adderAll": "addAllDisplayTimeWindow(List)" - }, - "location": { - "requirement": "Required", - "setter": "setLocation(Address)", - "type": "Address", - "getter": "getLocation()", - "requiredFor": [ - "Required if strikethrough price is provided" - ] - } - } - }, - "PointOfInterestEntity": { - "package": "com.google.android.engage.travel.datamodel.PointOfInterestEntity", - "fields": { - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "posterImages": { - "type": "List", - "requirement": "Required", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "actionLinkUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setActionLinkUri(Uri)", - "getter": "getActionLinkUri()" - }, - "title": { - "type": "String", - "requirement": "Required", - "setter": "setTitle(String)", - "getter": "getTitle()" - }, - "locations": { - "type": "Address", - "requirement": "Required" - }, - "availabilityTimeWindow": { - "type": "AvailabilityTimeWindow", - "requirement": "Optional", - "setter": "setAvailabilityTimeWindow(AvailabilityTimeWindow)", - "getter": "getAvailabilityTimeWindow()" - }, - "badges": { - "type": "List", - "requirement": "Optional", - "adder": "addBadge(Badge)", - "adderAll": "addBadges(List)" - }, - "description": { - "type": "String", - "requirement": "Optional", - "setter": "setDescription(String)", - "getter": "getDescription()" - }, - "subtitles": { - "type": "List", - "requirement": "Optional", - "adder": "addSubtitle(String)", - "adderAll": "addSubtitles(List)" - }, - "rating": { - "type": "Rating", - "requirement": "Optional", - "setter": "setRating(Rating)", - "getter": "getRating()" - }, - "price": { - "type": "Price", - "requirement": "Optional", - "setter": "setPrice(Price)", - "getter": "getPrice()", - "requiredFor": [ - "Required if strikethrough price is provided" - ] - }, - "priceCallout": { - "type": "String", - "requirement": "Optional", - "setter": "setPriceCallout(String)", - "getter": "getPriceCallout()" - }, - "contentCategories": { - "type": "List<@EligibleContentCategory int>", - "requirement": "Optional", - "adder": "addContentCategory(@EligibleContentCategory int)", - "adderAll": "addContentCategories(List)" - }, - "lastEngagementTime": { - "type": "Instant", - "requirement": "Optional", - "setter": "setLastEngagementTime(Instant)", - "getter": "getLastEngagementTime()", - "requiredFor": [ - "ContinueSearchCluster" - ] - }, - "displayTimeWindows": { - "type": "List", - "requirement": "Optional", - "adder": "addDisplayTimeWindow(DisplayTimeWindow)", - "getter": "getDisplayTimeWindows()" - }, - "allDisplayTimeWindows": { - "type": "List>", - "requirement": "Optional", - "adder": "addAllDisplayTimeWindow(DisplayTimeWindow)", - "adderAll": "addAllDisplayTimeWindow(List)" - }, - "location": { - "requirement": "Required", - "setter": "setLocation(Address)", - "type": "Address", - "getter": "getLocation()", - "requiredFor": [ - "RecommendationCluster" - ] - } - } - }, - "LodgingReservationEntity": { - "package": "com.google.android.engage.travel.datamodel.LodgingReservationEntity", - "fields": { - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "posterImages": { - "type": "List", - "requirement": "Required", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "actionUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setActionUri(Uri)", - "getter": "getActionUri()" - }, - "title": { - "type": "String", - "requirement": "Required", - "setter": "setTitle(String)", - "getter": "getTitle()" - }, - "description": { - "type": "String", - "requirement": "Optional", - "setter": "setDescription(String)", - "getter": "getDescription()" - }, - "subtitles": { - "type": "List", - "requirement": "Optional", - "adder": "addSubtitle(String)", - "adderAll": "addSubtitles(List)" - }, - "address": { - "type": "Address", - "requirement": "Required", - "setter": "setAddress(Address)", - "getter": "getAddress()" - }, - "checkInTime": { - "type": "Long", - "requirement": "Required", - "setter": "setCheckInTime(long)", - "getter": "getCheckInTime()" - }, - "localizedCheckInTime": { - "type": "LocalizedTimestamp", - "requirement": "Required", - "setter": "setLocalizedCheckInTime(LocalizedTimestamp)", - "getter": "getLocalizedCheckInTime()" - }, - "checkOutTime": { - "type": "Long", - "requirement": "Required", - "setter": "setCheckOutTime(long)", - "getter": "getCheckOutTime()" - }, - "localizedCheckOutTime": { - "type": "LocalizedTimestamp", - "requirement": "Required", - "setter": "setLocalizedCheckOutTime(LocalizedTimestamp)", - "getter": "getLocalizedCheckOutTime()" - }, - "price": { - "type": "Price", - "requirement": "Optional", - "setter": "setPrice(Price)", - "getter": "getPrice()", - "requiredFor": [ - "Required if strikethrough price is provided" - ] - }, - "priceCallout": { - "type": "String", - "requirement": "Optional", - "setter": "setPriceCallout(String)", - "getter": "getPriceCallout()" - }, - "rating": { - "type": "Rating", - "requirement": "Optional", - "setter": "setRating(Rating)", - "getter": "getRating()", - "requiredFor": [ - "Required if strikethrough price is provided", - "Mutually required with other rating fields" - ] - }, - "reservationId": { - "type": "String", - "requirement": "Optional", - "setter": "setReservationId(String)", - "getter": "getReservationId()" - }, - "displayTimeWindows": { - "type": "List", - "requirement": "Optional", - "adder": "addDisplayTimeWindow(DisplayTimeWindow)", - "getter": "getDisplayTimeWindows()" - }, - "allDisplayTimeWindows": { - "type": "List>", - "requirement": "Optional", - "adder": "addAllDisplayTimeWindow(DisplayTimeWindow)", - "adderAll": "addAllDisplayTimeWindow(List)" - } - } - }, - "EventReservationEntity": { - "package": "com.google.android.engage.travel.datamodel.EventReservationEntity", - "fields": { - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "posterImages": { - "type": "List", - "requirement": "Required", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "actionUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setActionUri(Uri)", - "getter": "getActionUri()" - }, - "title": { - "type": "String", - "requirement": "Required", - "setter": "setTitle(String)", - "getter": "getTitle()" - }, - "description": { - "type": "String", - "requirement": "Optional", - "setter": "setDescription(String)", - "getter": "getDescription()" - }, - "subtitles": { - "type": "List", - "requirement": "Optional", - "adder": "addSubtitle(String)", - "adderAll": "addSubtitles(List)" - }, - "startTime": { - "type": "Long", - "requirement": "Required", - "setter": "setStartTime(Long)", - "getter": "getStartTime()" - }, - "localizedStartTime": { - "type": "LocalizedTimestamp", - "requirement": "Required", - "setter": "setLocalizedStartTime(LocalizedTimestamp)", - "getter": "getLocalizedStartTime()" - }, - "eventMode": { - "type": "@EventMode int", - "requirement": "Required", - "setter": "setEventMode(@EventMode int)", - "getter": "getEventMode()" - }, - "location": { - "type": "Address", - "requirement": "Required", - "setter": "setLocation(Address)", - "getter": "getLocation()" - }, - "endTime": { - "type": "Long", - "requirement": "Optional", - "setter": "setEndTime(Long)", - "getter": "getEndTime()" - }, - "localizedEndTime": { - "type": "LocalizedTimestamp", - "requirement": "Optional", - "setter": "setLocalizedEndTime(LocalizedTimestamp)", - "getter": "getLocalizedEndTime()" - }, - "serviceProvider": { - "type": "ServiceProvider", - "requirement": "Optional", - "setter": "setServiceProvider(ServiceProvider)", - "getter": "getServiceProvider()" - }, - "badges": { - "type": "List", - "requirement": "Optional", - "adder": "addBadge(Badge)", - "adderAll": "addBadges(List)" - }, - "price": { - "type": "Price", - "requirement": "Optional", - "setter": "setPrice(Price)", - "getter": "getPrice()", - "requiredFor": [ - "Required if strikethrough price is provided" - ] - }, - "priceCallout": { - "type": "String", - "requirement": "Optional", - "setter": "setPriceCallout(String)", - "getter": "getPriceCallout()" - }, - "rating": { - "type": "Rating", - "requirement": "Optional", - "setter": "setRating(Rating)", - "getter": "getRating()", - "requiredFor": [ - "Required if strikethrough price is provided" - ] - }, - "contentCategories": { - "type": "List<@EligibleContentCategory int>", - "requirement": "Optional", - "adder": "addContentCategory(@EligibleContentCategory int)", - "adderAll": "addContentCategories(List)" - }, - "reservationId": { - "type": "String", - "requirement": "Optional", - "setter": "setReservationId(String)", - "getter": "getReservationId()" - }, - "displayTimeWindows": { - "type": "List", - "requirement": "Optional", - "adder": "addDisplayTimeWindow(DisplayTimeWindow)", - "getter": "getDisplayTimeWindows()" - }, - "allDisplayTimeWindows": { - "type": "List>", - "requirement": "Optional", - "adder": "addAllDisplayTimeWindow(DisplayTimeWindow)", - "adderAll": "addAllDisplayTimeWindow(List)" - } - } - }, - "TransportationReservationEntity": { - "package": "com.google.android.engage.travel.datamodel.TransportationReservationEntity", - "fields": { - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "posterImages": { - "type": "List", - "requirement": "Required", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "actionUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setActionUri(Uri)", - "getter": "getActionUri()" - }, - "title": { - "type": "String", - "requirement": "Required", - "setter": "setTitle(String)", - "getter": "getTitle()" - }, - "description": { - "type": "String", - "requirement": "Optional", - "setter": "setDescription(String)", - "getter": "getDescription()" - }, - "subtitles": { - "type": "List", - "requirement": "Optional", - "adder": "addSubtitle(String)", - "adderAll": "addSubtitles(List)" - }, - "departureTime": { - "type": "Long", - "requirement": "Required", - "setter": "setDepartureTime(Long)", - "getter": "getDepartureTime()" - }, - "localizedDepartureTime": { - "type": "LocalizedTimestamp", - "requirement": "Required", - "setter": "setLocalizedDepartureTime(LocalizedTimestamp)", - "getter": "getLocalizedDepartureTime()" - }, - "arrivalTime": { - "type": "Long", - "requirement": "Required", - "setter": "setArrivalTime(Long)", - "getter": "getArrivalTime()" - }, - "localizedArrivalTime": { - "type": "LocalizedTimestamp", - "requirement": "Required", - "setter": "setLocalizedArrivalTime(LocalizedTimestamp)", - "getter": "getLocalizedArrivalTime()" - }, - "transportationType": { - "type": "@TransportationType int", - "requirement": "Required", - "setter": "setTransportationType(@TransportationType int)", - "getter": "getTransportationType()" - }, - "departureLocation": { - "type": "Address", - "requirement": "Optional", - "setter": "setDepartureLocation(Address)", - "getter": "getDepartureLocation()" - }, - "arrivalLocation": { - "type": "Address", - "requirement": "Optional", - "setter": "setArrivalLocation(Address)", - "getter": "getArrivalLocation()" - }, - "serviceProvider": { - "type": "ServiceProvider", - "requirement": "Optional", - "setter": "setServiceProvider(ServiceProvider)", - "getter": "getServiceProvider()" - }, - "price": { - "type": "Price", - "requirement": "Optional", - "setter": "setPrice(Price)", - "getter": "getPrice()", - "requiredFor": [ - "Required if strikethrough price is provided" - ] - }, - "priceCallout": { - "type": "String", - "requirement": "Optional", - "setter": "setPriceCallout(String)", - "getter": "getPriceCallout()" - }, - "transportationNumber": { - "type": "String", - "requirement": "Optional", - "setter": "setTransportationNumber(String)", - "getter": "getTransportationNumber()" - }, - "boardingTime": { - "type": "Long", - "requirement": "Optional", - "setter": "setBoardingTime(Long)", - "getter": "getBoardingTime()" - }, - "localizedBoardingTime": { - "type": "LocalizedTimestamp", - "requirement": "Required", - "setter": "setLocalizedBoardingTime(LocalizedTimestamp)", - "getter": "getLocalizedBoardingTime()" - }, - "reservationId": { - "type": "String", - "requirement": "Optional", - "setter": "setReservationId(String)", - "getter": "getReservationId()" - }, - "displayTimeWindows": { - "type": "List", - "requirement": "Optional", - "adder": "addDisplayTimeWindow(DisplayTimeWindow)", - "getter": "getDisplayTimeWindows()" - }, - "allDisplayTimeWindows": { - "type": "List>", - "requirement": "Optional", - "adder": "addAllDisplayTimeWindow(DisplayTimeWindow)", - "adderAll": "addAllDisplayTimeWindow(List)" - } - } - }, - "VehicleRentalReservationEntity": { - "package": "com.google.android.engage.travel.datamodel.VehicleRentalReservationEntity", - "fields": { - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "posterImages": { - "type": "List", - "requirement": "Required", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "actionUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setActionUri(Uri)", - "getter": "getActionUri()" - }, - "title": { - "type": "String", - "requirement": "Required", - "setter": "setTitle(String)", - "getter": "getTitle()" - }, - "description": { - "type": "String", - "requirement": "Optional", - "setter": "setDescription(String)", - "getter": "getDescription()" - }, - "subtitles": { - "type": "List", - "requirement": "Optional", - "adder": "addSubtitle(String)", - "adderAll": "addSubtitles(List)" - }, - "pickupTime": { - "type": "Long", - "requirement": "Required", - "setter": "setPickupTime(Long)", - "getter": "getPickupTime()" - }, - "localizedPickupTime": { - "type": "LocalizedTimestamp", - "requirement": "Required", - "setter": "setLocalizedPickupTime(LocalizedTimestamp)", - "getter": "getLocalizedPickupTime()" - }, - "returnTime": { - "type": "Long", - "requirement": "Optional", - "setter": "setReturnTime(Long)", - "getter": "getReturnTime()" - }, - "localizedReturnTime": { - "type": "LocalizedTimestamp", - "requirement": "Optional", - "setter": "setLocalizedReturnTime(LocalizedTimestamp)", - "getter": "getLocalizedReturnTime()" - }, - "pickupAddress": { - "type": "Address", - "requirement": "Optional", - "setter": "setPickupAddress(Address)", - "getter": "getPickupAddress()" - }, - "returnAddress": { - "type": "Address", - "requirement": "Optional", - "setter": "setReturnAddress(Address)", - "getter": "getReturnAddress()" - }, - "serviceProvider": { - "type": "ServiceProvider", - "requirement": "Optional", - "setter": "setServiceProvider(ServiceProvider)", - "getter": "getServiceProvider()" - }, - "price": { - "type": "Price", - "requirement": "Optional", - "setter": "setPrice(Price)", - "getter": "getPrice()", - "requiredFor": [ - "Required if strikethrough price is provided" - ] - }, - "priceCallout": { - "type": "String", - "requirement": "Optional", - "setter": "setPriceCallout(String)", - "getter": "getPriceCallout()" - }, - "confirmationId": { - "type": "String", - "requirement": "Optional", - "setter": "setConfirmationId(String)", - "getter": "getConfirmationId()" - }, - "displayTimeWindows": { - "type": "List", - "requirement": "Optional", - "adder": "addDisplayTimeWindow(DisplayTimeWindow)", - "getter": "getDisplayTimeWindows()" - }, - "allDisplayTimeWindows": { - "type": "List>", - "requirement": "Optional", - "adder": "addAllDisplayTimeWindow(DisplayTimeWindow)", - "adderAll": "addAllDisplayTimeWindow(List)" - } - } - } - }, - "methods": { - "isServiceAvailable": "ServiceAvailabilityRequest", - "publishRecommendationClusters": "PublishRecommendationClustersRequest", - "publishFeaturedCluster": "PublishFeaturedClusterRequest", - "publishUserAccountManagementRequest": "PublishUserAccountManagementRequest", - "updatePublishStatus": "PublishStatusRequest", - "publishContinueSearchCluster": "PublishContinueSearchClusterRequest", - "publishReservationCluster": "PublishReservationClusterRequest", - "deleteRecommendationsClusters": "DeleteClustersRequest", - "deleteFeaturedCluster": "DeleteClustersRequest", - "deleteUserManagementCluster": "DeleteClustersRequest", - "deleteContinueSearchCluster": "DeleteClustersRequest", - "deleteReservationCluster": "DeleteClustersRequest" - }, - "clusters": { - "ContinueSearchCluster": { - "package": "com.google.android.engage.travel.datamodel.ContinueSearchCluster", - "fields": { - "pointOfInterestEntities": { - "type": "List", - "requirement": "Required", - "adder": "addPointOfInterestEntity(PointOfInterestEntity)" - } - } - }, - "ReservationCluster": { - "package": "com.google.android.engage.travel.datamodel.ReservationCluster", - "fields": { - "lodgingReservationEntities": { - "type": "List", - "requirement": "Optional", - "adder": "addLodgingReservationEntity(LodgingReservationEntity)" - }, - "vehicleRentalReservationEntities": { - "type": "List", - "requirement": "Optional", - "adder": "addVehicleRentalReservationEntity(VehicleRentalReservationEntity)" - }, - "transportationReservationEntities": { - "type": "List", - "requirement": "Optional", - "adder": "addTransportationReservationEntity(TransportationReservationEntity)" - }, - "eventReservationEntities": { - "type": "List", - "requirement": "Optional", - "adder": "addEventReservationEntity(EventReservationEntity)" - }, - "restaurantReservationEntities": { - "type": "List", - "requirement": "Optional", - "adder": "addRestaurantReservationEntity(RestaurantReservationEntity)" - } - } - } - }, - "intents": { - "ACTION_PUBLISH_CONTINUE_SEARCH_CLUSTER": "com.google.android.engage.action.travel.PUBLISH_CONTINUE_SEARCH", - "ACTION_PUBLISH_RESERVATION_CLUSTER": "com.google.android.engage.action.travel.PUBLISH_RESERVATION" - } - } \ No newline at end of file diff --git a/play/engage-sdk-integration/references/schemas/tv.md b/play/engage-sdk-integration/references/schemas/tv.md deleted file mode 100644 index 7c5526f..0000000 --- a/play/engage-sdk-integration/references/schemas/tv.md +++ /dev/null @@ -1,1226 +0,0 @@ -This file defines the schema for the TV vertical in the Engage SDK. - - { - "client": "com.google.android.engage.service.AppEngagePublishClient", - "clusterTypes": [ - "TYPE_RECOMMENDATION", - "TYPE_FEATURED", - "TYPE_CONTINUATION", - "TYPE_SUBSCRIPTION", - "TYPE_ENGAGEMENT" - ], - "entities": { - "MovieEntity": { - "package": "com.google.android.engage.video.datamodel.MovieEntity", - "fields": { - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "posterImages": { - "type": "List", - "requirement": "Required", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "name": { - "type": "String", - "requirement": "Required", - "setter": "setName(String)", - "getter": "getName()" - }, - "lastEngagementTimeMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLastEngagementTimeMillis(long)", - "getter": "getLastEngagementTimeMillis()", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "watchNextType": { - "type": "@WatchNextType int", - "requirement": "Optional", - "setter": "setWatchNextType(@WatchNextType int)", - "getter": "getWatchNextType()", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "lastPlayBackPositionTimeMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLastPlayBackPositionTimeMillis(long)", - "getter": "getLastPlayBackPositionTimeMillis()", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "availabilityTimeWindows": { - "type": "List", - "requirement": "Optional", - "adder": "addAvailabilityTimeWindow(DisplayTimeWindow)", - "getter": "getAvailabilityTimeWindows()" - }, - "allAvailabilityTimeWindows": { - "type": "List>", - "requirement": "Optional", - "adder": "addAllAvailabilityTimeWindows(DisplayTimeWindow)", - "adderAll": "addAllAvailabilityTimeWindows(List)" - }, - "playBackUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setPlayBackUri(Uri)", - "getter": "getPlayBackUri()" - }, - "infoPageUri": { - "type": "Uri", - "requirement": "Optional", - "setter": "setInfoPageUri(Uri)", - "getter": "getInfoPageUri()" - }, - "releaseDateEpochMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setReleaseDateEpochMillis(long)", - "getter": "getReleaseDateEpochMillis()" - }, - "availability": { - "type": "@ContentAvailability int", - "requirement": "Required", - "setter": "setAvailability(@ContentAvailability int)", - "getter": "getAvailability()" - }, - "durationMillis": { - "type": "Long", - "requirement": "Required", - "setter": "setDurationMillis(long)", - "getter": "getDurationMillis()" - }, - "genres": { - "type": "List", - "requirement": "Optional", - "adder": "addGenre(String)", - "getter": "getGenres()", - "adderAll": "addGenres(List)" - }, - "contentRatings": { - "type": "List", - "requirement": "Required", - "adder": "addContentRating(RatingSystem)", - "getter": "getContentRatings()", - "adderAll": "addContentRatings(List)" - }, - "downloadedOnDevice": { - "type": "Boolean", - "requirement": "Optional", - "setter": "setDownloadedOnDevice(boolean)", - "getter": "isDownloadedOnDevice()" - }, - "price": { - "type": "Price", - "requirement": "Optional", - "setter": "setPrice(Price)", - "getter": "getPrice()" - }, - "platformSpecificPlaybackUris": { - "type": "List", - "requirement": "Optional", - "adder": "addPlatformSpecificPlaybackUri(PlatformSpecificUri)", - "getter": "getPlatformSpecificPlaybackUris()", - "adderAll": "addPlatformSpecificPlaybackUris(List)" - }, - "recommendationReason": { - "type": "@NonNull RecommendationReason", - "requirement": "Optional", - "setter": "setRecommendationReason(@NonNull RecommendationReason)", - "getter": "getRecommendationReason()" - }, - "description": { - "type": "@NonNull String", - "requirement": "Optional", - "setter": "setDescription(@NonNull String)", - "getter": "getDescription()" - }, - "contentRatingsLegacies": { - "requirement": "Required", - "adder": "addContentRatingsLegacy(String)", - "type": "List>", - "getter": "getContentRatingsLegacy()", - "adderAll": "addContentRatingsLegacy(List)" - }, - "callToActionText": { - "requirement": "Optional", - "setter": "setCallToActionText(@NonNull String)", - "type": "@NonNull String", - "getter": "getCallToActionText()" - }, - "tags": { - "requirement": "Optional", - "adder": "addTag(@NonNull String)", - "type": "List<@NonNull String>", - "adderAll": "addTags(List)", - "getter": "getTags()" - } - } - }, - "TvShowEntity": { - "package": "com.google.android.engage.video.datamodel.TvShowEntity", - "fields": { - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "posterImages": { - "type": "List", - "requirement": "Required", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "name": { - "type": "String", - "requirement": "Required", - "setter": "setName(String)", - "getter": "getName()" - }, - "lastEngagementTimeMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLastEngagementTimeMillis(long)", - "getter": "getLastEngagementTimeMillis()", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "watchNextType": { - "type": "@WatchNextType int", - "requirement": "Optional", - "setter": "setWatchNextType(@WatchNextType int)", - "getter": "getWatchNextType()", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "lastPlayBackPositionTimeMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLastPlayBackPositionTimeMillis(long)", - "getter": "getLastPlayBackPositionTimeMillis()", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "availabilityTimeWindows": { - "type": "List", - "requirement": "Optional", - "adder": "addAvailabilityTimeWindow(DisplayTimeWindow)", - "getter": "getAvailabilityTimeWindows()" - }, - "allAvailabilityTimeWindows": { - "type": "List>", - "requirement": "Optional", - "adder": "addAllAvailabilityTimeWindows(DisplayTimeWindow)", - "adderAll": "addAllAvailabilityTimeWindows(List)" - }, - "infoPageUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setInfoPageUri(Uri)", - "getter": "getInfoPageUri()" - }, - "playBackUri": { - "type": "Uri", - "requirement": "Optional", - "setter": "setPlayBackUri(Uri)", - "getter": "getPlayBackUri()" - }, - "firstEpisodeAirDateEpochMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setFirstEpisodeAirDateEpochMillis(long)", - "getter": "getFirstEpisodeAirDateEpochMillis()" - }, - "latestEpisodeAirDateEpochMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLatestEpisodeAirDateEpochMillis(long)", - "getter": "getLatestEpisodeAirDateEpochMillis()" - }, - "availability": { - "type": "@ContentAvailability int", - "requirement": "Required", - "setter": "setAvailability(@ContentAvailability int)", - "getter": "getAvailability()" - }, - "seasonCount": { - "type": "Integer", - "requirement": "Required", - "setter": "setSeasonCount(int)", - "getter": "getSeasonCount()" - }, - "genres": { - "type": "List", - "requirement": "Optional", - "adder": "addGenre(String)", - "getter": "getGenres()", - "adderAll": "addGenres(List)" - }, - "contentRatings": { - "type": "List", - "requirement": "Required", - "adder": "addContentRating(RatingSystem)", - "getter": "getContentRatings()", - "adderAll": "addContentRatings(List)" - }, - "price": { - "type": "Price", - "requirement": "Optional", - "setter": "setPrice(Price)", - "getter": "getPrice()" - }, - "platformSpecificPlaybackUris": { - "type": "List", - "requirement": "Required", - "adder": "addPlatformSpecificPlaybackUri(PlatformSpecificUri)", - "getter": "getPlatformSpecificPlaybackUris()", - "adderAll": "addPlatformSpecificPlaybackUris(List)" - }, - "recommendationReason": { - "type": "@NonNull RecommendationReason", - "requirement": "Optional", - "setter": "setRecommendationReason(@NonNull RecommendationReason)", - "getter": "getRecommendationReason()" - }, - "description": { - "type": "@NonNull String", - "requirement": "Optional", - "setter": "setDescription(@NonNull String)", - "getter": "getDescription()" - }, - "contentRatingsLegacies": { - "requirement": "Required", - "adder": "addContentRatingsLegacy(String)", - "type": "List>", - "getter": "getContentRatingsLegacy()", - "adderAll": "addContentRatingsLegacy(List)" - }, - "callToActionText": { - "requirement": "Optional", - "setter": "setCallToActionText(@NonNull String)", - "type": "@NonNull String", - "getter": "getCallToActionText()" - }, - "tags": { - "requirement": "Optional", - "adder": "addTag(@NonNull String)", - "type": "List<@NonNull String>", - "adderAll": "addTags(List)", - "getter": "getTags()" - } - } - }, - "TvSeasonEntity": { - "package": "com.google.android.engage.video.datamodel.TvSeasonEntity", - "fields": { - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "posterImages": { - "type": "List", - "requirement": "Required", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "name": { - "type": "String", - "requirement": "Required", - "setter": "setName(String)", - "getter": "getName()" - }, - "lastEngagementTimeMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLastEngagementTimeMillis(long)", - "getter": "getLastEngagementTimeMillis()", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "watchNextType": { - "type": "@WatchNextType int", - "requirement": "Optional", - "setter": "setWatchNextType(@WatchNextType int)", - "getter": "getWatchNextType()", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "lastPlayBackPositionTimeMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLastPlayBackPositionTimeMillis(long)", - "getter": "getLastPlayBackPositionTimeMillis()", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "availabilityTimeWindows": { - "type": "List", - "requirement": "Optional", - "adder": "addAvailabilityTimeWindow(DisplayTimeWindow)", - "getter": "getAvailabilityTimeWindows()" - }, - "allAvailabilityTimeWindows": { - "type": "List>", - "requirement": "Optional", - "adder": "addAllAvailabilityTimeWindows(DisplayTimeWindow)", - "adderAll": "addAllAvailabilityTimeWindows(List)" - }, - "infoPageUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setInfoPageUri(Uri)", - "getter": "getInfoPageUri()" - }, - "playBackUri": { - "type": "Uri", - "requirement": "Optional", - "setter": "setPlayBackUri(Uri)", - "getter": "getPlayBackUri()" - }, - "seasonNumber": { - "type": "Integer", - "requirement": "Required", - "setter": "setSeasonNumber(int)" - }, - "seasonDisplayNumber": { - "type": "String", - "requirement": "Required", - "setter": "setSeasonDisplayNumber(String)", - "getter": "getSeasonDisplayNumber()" - }, - "firstEpisodeAirDateEpochMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setFirstEpisodeAirDateEpochMillis(long)", - "getter": "getFirstEpisodeAirDateEpochMillis()" - }, - "latestEpisodeAirDateEpochMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLatestEpisodeAirDateEpochMillis(long)", - "getter": "getLatestEpisodeAirDateEpochMillis()" - }, - "availability": { - "type": "@ContentAvailability int", - "requirement": "Required", - "setter": "setAvailability(@ContentAvailability int)", - "getter": "getAvailability()" - }, - "episodeCount": { - "type": "Integer", - "requirement": "Required", - "setter": "setEpisodeCount(int)", - "getter": "getEpisodeCount()" - }, - "genres": { - "type": "List", - "requirement": "Optional", - "adder": "addGenre(String)", - "getter": "getGenres()", - "adderAll": "addGenres(List)" - }, - "contentRatings": { - "type": "List", - "requirement": "Required", - "adder": "addContentRating(RatingSystem)", - "getter": "getContentRatings()", - "adderAll": "addContentRatings(List)" - }, - "price": { - "type": "Price", - "requirement": "Optional", - "setter": "setPrice(Price)", - "getter": "getPrice()" - }, - "contentRatingsLegacies": { - "requirement": "Required", - "adder": "addContentRatingsLegacy(String)", - "type": "List>", - "getter": "getContentRatingsLegacy()", - "adderAll": "addContentRatingsLegacy(List)" - } - } - }, - "TvEpisodeEntity": { - "package": "com.google.android.engage.video.datamodel.TvEpisodeEntity", - "fields": { - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "posterImages": { - "type": "List", - "requirement": "Required", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "name": { - "type": "String", - "requirement": "Required", - "setter": "setName(String)", - "getter": "getName()" - }, - "lastEngagementTimeMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLastEngagementTimeMillis(long)", - "getter": "getLastEngagementTimeMillis()", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "watchNextType": { - "type": "@WatchNextType int", - "requirement": "Optional", - "setter": "setWatchNextType(@WatchNextType int)", - "getter": "getWatchNextType()", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "lastPlayBackPositionTimeMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLastPlayBackPositionTimeMillis(long)", - "getter": "getLastPlayBackPositionTimeMillis()", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "availabilityTimeWindows": { - "type": "List", - "requirement": "Optional", - "adder": "addAvailabilityTimeWindow(DisplayTimeWindow)", - "getter": "getAvailabilityTimeWindows()" - }, - "allAvailabilityTimeWindows": { - "type": "List>", - "requirement": "Optional", - "adder": "addAllAvailabilityTimeWindows(DisplayTimeWindow)", - "adderAll": "addAllAvailabilityTimeWindows(List)" - }, - "playBackUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setPlayBackUri(Uri)", - "getter": "getPlayBackUri()" - }, - "infoPageUri": { - "type": "Uri", - "requirement": "Optional", - "setter": "setInfoPageUri(Uri)", - "getter": "getInfoPageUri()" - }, - "episodeNumber": { - "type": "Integer", - "requirement": "Required", - "setter": "setEpisodeNumber(int)" - }, - "episodeDisplayNumber": { - "type": "String", - "requirement": "Required", - "setter": "setEpisodeDisplayNumber(String)", - "getter": "getEpisodeDisplayNumber()" - }, - "airDateEpochMillis": { - "type": "Long", - "requirement": "Required", - "setter": "setAirDateEpochMillis(long)", - "getter": "getAirDateEpochMillis()" - }, - "availability": { - "type": "@ContentAvailability int", - "requirement": "Required", - "setter": "setAvailability(@ContentAvailability int)", - "getter": "getAvailability()" - }, - "genres": { - "type": "List", - "requirement": "Optional", - "adder": "addGenre(String)", - "getter": "getGenres()", - "adderAll": "addGenres(List)" - }, - "contentRatings": { - "type": "List", - "requirement": "Required", - "adder": "addContentRating(RatingSystem)", - "getter": "getContentRatings()", - "adderAll": "addContentRatings(List)" - }, - "durationMillis": { - "type": "Long", - "requirement": "Required", - "setter": "setDurationMillis(long)", - "getter": "getDurationMillis()" - }, - "seasonNumber": { - "type": "String", - "requirement": "Optional", - "setter": "setSeasonNumber(String)", - "getter": "getSeasonNumber()" - }, - "seasonTitle": { - "type": "String", - "requirement": "Optional", - "setter": "setSeasonTitle(String)", - "getter": "getSeasonTitle()" - }, - "downloadedOnDevice": { - "type": "Boolean", - "requirement": "Optional", - "setter": "setDownloadedOnDevice(boolean)", - "getter": "isDownloadedOnDevice()" - }, - "price": { - "type": "Price", - "requirement": "Optional", - "setter": "setPrice(Price)", - "getter": "getPrice()" - }, - "showTitle": { - "type": "String", - "requirement": "Optional", - "setter": "setShowTitle(String)", - "getter": "getShowTitle()" - }, - "isNextEpisodeAvailable": { - "type": "Boolean", - "requirement": "Optional", - "setter": "setIsNextEpisodeAvailable(boolean)", - "getter": "getIsNextEpisodeAvailable()" - }, - "platformSpecificPlaybackUris": { - "type": "List", - "requirement": "Optional", - "adder": "addPlatformSpecificPlaybackUri(PlatformSpecificUri)", - "getter": "getPlatformSpecificPlaybackUris()", - "adderAll": "addPlatformSpecificPlaybackUris(List)" - }, - "contentRatingsLegacies": { - "requirement": "Required", - "adder": "addContentRatingsLegacy(String)", - "type": "List>", - "getter": "getContentRatingsLegacy()", - "adderAll": "addContentRatingsLegacy(List)" - }, - "callToActionText": { - "requirement": "Optional", - "setter": "setCallToActionText(@NonNull String)", - "type": "@NonNull String", - "getter": "getCallToActionText()" - }, - "tags": { - "requirement": "Optional", - "adder": "addTag(@NonNull String)", - "type": "List<@NonNull String>", - "adderAll": "addTags(List)", - "getter": "getTags()" - } - } - }, - "MediaActionFeedEntity": { - "package": "com.google.android.engage.video.datamodel.MediaActionFeedEntity", - "fields": { - "dataFeedElementId": { - "type": "@NonNull String", - "requirement": "Required", - "setter": "setDataFeedElementId(@NonNull String)", - "getter": "getDataFeedElementId()" - }, - "name": { - "type": "@NonNull String", - "requirement": "Optional", - "setter": "setName(@NonNull String)", - "getter": "getName()" - }, - "recommendationReason": { - "type": "@NonNull RecommendationReason", - "requirement": "Optional", - "setter": "setRecommendationReason(@NonNull RecommendationReason)", - "getter": "getRecommendationReason()" - }, - "description": { - "type": "@NonNull String", - "requirement": "Optional", - "setter": "setDescription(@NonNull String)", - "getter": "getDescription()" - }, - "posterImages": { - "type": "List>", - "requirement": "Optional", - "adder": "addPosterImage(@NonNull Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "callToActionText": { - "requirement": "Optional", - "setter": "setCallToActionText(@NonNull String)", - "type": "@NonNull String", - "getter": "getCallToActionText()" - }, - "tags": { - "requirement": "Optional", - "adder": "addTag(@NonNull String)", - "type": "List<@NonNull String>", - "adderAll": "addTags(List)", - "getter": "getTags()" - } - } - }, - "LiveTvProgramEntity": { - "package": "com.google.android.engage.video.datamodel.LiveTvProgramEntity", - "fields": { - "channelId": { - "type": "@NonNull String", - "requirement": "Optional", - "setter": "setChannelId(@NonNull String)", - "getter": "getChannelId()" - }, - "channelName": { - "type": "@NonNull String", - "requirement": "Optional", - "setter": "setChannelName(@NonNull String)", - "getter": "getChannelName()" - }, - "channelLogoImage": { - "type": "@NonNull Image", - "requirement": "Optional", - "setter": "setChannelLogoImage(@NonNull Image)", - "getter": "getChannelLogoImage()" - }, - "platformSpecificPlaybackUris": { - "type": "List<@NonNull PlatformSpecificUri>", - "requirement": "Optional", - "adder": "addPlatformSpecificPlaybackUri(@NonNull PlatformSpecificUri)", - "getter": "getPlatformSpecificPlaybackUris()", - "adderAll": "addPlatformSpecificPlaybackUris(List)" - }, - "contentRatings": { - "type": "List<@NonNull RatingSystem>", - "requirement": "Optional", - "adder": "addContentRating(@NonNull RatingSystem)", - "getter": "getContentRatings()", - "adderAll": "addContentRatings(List)" - }, - "genres": { - "type": "List<@NonNull String>", - "requirement": "Optional", - "adder": "addGenre(@NonNull String)", - "getter": "getGenres()", - "adderAll": "addGenres(List)" - }, - "availabilityTimeWindows": { - "type": "List<@NonNull DisplayTimeWindow>", - "requirement": "Optional", - "adder": "addAvailabilityTimeWindow(@NonNull DisplayTimeWindow)", - "getter": "getAvailabilityTimeWindows()", - "adderAll": "addAvailabilityTimeWindows(List)" - }, - "posterImages": { - "type": "List<@NonNull Image>", - "requirement": "Optional", - "adder": "addPosterImage(@NonNull Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "recommendationReason": { - "type": "@NonNull RecommendationReason", - "requirement": "Optional", - "setter": "setRecommendationReason(@NonNull RecommendationReason)", - "getter": "getRecommendationReason()" - }, - "description": { - "type": "@NonNull String", - "requirement": "Optional", - "setter": "setDescription(@NonNull String)", - "getter": "getDescription()" - }, - "entityId": { - "type": "@NonNull String", - "requirement": "Optional", - "setter": "setEntityId(@NonNull String)", - "getter": "getEntityId()" - }, - "name": { - "type": "@NonNull String", - "requirement": "Optional", - "setter": "setName(@NonNull String)", - "getter": "getName()" - }, - "callToActionText": { - "requirement": "Optional", - "setter": "setCallToActionText(@NonNull String)", - "type": "@NonNull String", - "getter": "getCallToActionText()" - }, - "tags": { - "requirement": "Optional", - "adder": "addTag(@NonNull String)", - "type": "List<@NonNull String>", - "adderAll": "addTags(List)", - "getter": "getTags()" - } - } - }, - "LiveStreamingVideoEntity": { - "package": "com.google.android.engage.video.datamodel.LiveStreamingVideoEntity", - "fields": { - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "posterImages": { - "type": "List", - "requirement": "Required", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "name": { - "type": "String", - "requirement": "Required", - "setter": "setName(String)", - "getter": "getName()" - }, - "lastEngagementTimeMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLastEngagementTimeMillis(long)", - "getter": "getLastEngagementTimeMillis()", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "watchNextType": { - "type": "@WatchNextType int", - "requirement": "Optional", - "setter": "setWatchNextType(@WatchNextType int)", - "getter": "getWatchNextType()", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "lastPlayBackPositionTimeMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLastPlayBackPositionTimeMillis(long)", - "getter": "getLastPlayBackPositionTimeMillis()", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "availabilityTimeWindows": { - "type": "List", - "requirement": "Optional", - "adder": "addAvailabilityTimeWindow(DisplayTimeWindow)", - "getter": "getAvailabilityTimeWindows()" - }, - "allAvailabilityTimeWindows": { - "type": "List>", - "requirement": "Optional", - "adder": "addAllAvailabilityTimeWindows(DisplayTimeWindow)", - "adderAll": "addAllAvailabilityTimeWindows(List)" - }, - "playBackUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setPlayBackUri(Uri)", - "getter": "getPlayBackUri()" - }, - "startTimeEpochMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setStartTimeEpochMillis(long)", - "getter": "getStartTimeEpochMillis()" - }, - "endTimeEpochMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setEndTimeEpochMillis(long)", - "getter": "getEndTimeEpochMillis()" - }, - "broadcaster": { - "type": "String", - "requirement": "Required", - "setter": "setBroadcaster(String)", - "getter": "getBroadcaster()" - }, - "viewCount": { - "type": "String", - "requirement": "Optional", - "setter": "setViewCount(String)", - "getter": "getViewCount()" - }, - "broadcasterIcon": { - "type": "Image", - "requirement": "Optional", - "setter": "setBroadcasterIcon(Image)", - "getter": "getBroadcasterIcon()" - }, - "platformSpecificPlaybackUris": { - "type": "List", - "requirement": "Optional", - "adder": "addPlatformSpecificPlaybackUri(PlatformSpecificUri)", - "getter": "getPlatformSpecificPlaybackUris()", - "adderAll": "addPlatformSpecificPlaybackUris(List)" - }, - "description": { - "type": "@NonNull String", - "requirement": "Optional", - "setter": "setDescription(@NonNull String)", - "getter": "getDescription()" - }, - "genres": { - "type": "List<@NonNull String>", - "requirement": "Optional", - "adder": "addGenre(@NonNull String)", - "getter": "getGenres()", - "adderAll": "addGenres(List)" - }, - "contentRatings": { - "type": "List<@NonNull RatingSystem>", - "requirement": "Optional", - "adder": "addContentRating(@NonNull RatingSystem)", - "getter": "getContentRatings()", - "adderAll": "addContentRatings(List)" - }, - "recommendationReason": { - "type": "@NonNull RecommendationReason", - "requirement": "Optional", - "setter": "setRecommendationReason(@NonNull RecommendationReason)", - "getter": "getRecommendationReason()" - }, - "callToActionText": { - "requirement": "Optional", - "setter": "setCallToActionText(@NonNull String)", - "type": "@NonNull String", - "getter": "getCallToActionText()" - }, - "tags": { - "requirement": "Optional", - "adder": "addTag(@NonNull String)", - "type": "List<@NonNull String>", - "adderAll": "addTags(List)", - "getter": "getTags()" - } - } - }, - "LiveTvChannelEntity": { - "package": "com.google.android.engage.video.datamodel.LiveTvChannelEntity", - "fields": { - "entityId": { - "type": "@NonNull String", - "requirement": "Optional", - "setter": "setEntityId(@NonNull String)", - "getter": "getEntityId()" - }, - "name": { - "type": "@NonNull String", - "requirement": "Optional", - "setter": "setName(@NonNull String)", - "getter": "getName()" - }, - "description": { - "type": "@NonNull String", - "requirement": "Optional", - "setter": "setDescription(@NonNull String)", - "getter": "getDescription()" - }, - "logoImage": { - "type": "@NonNull Image", - "requirement": "Optional", - "setter": "setLogoImage(@NonNull Image)", - "getter": "getLogoImage()" - }, - "contentRatings": { - "type": "List<@NonNull RatingSystem>", - "requirement": "Optional", - "adder": "addContentRating(@NonNull RatingSystem)", - "getter": "getContentRatings()", - "adderAll": "addContentRatings(List)" - }, - "platformSpecificPlaybackUris": { - "type": "List<@NonNull PlatformSpecificUri>", - "requirement": "Optional", - "adder": "addPlatformSpecificPlaybackUri(@NonNull PlatformSpecificUri)", - "getter": "getPlatformSpecificPlaybackUris()", - "adderAll": "addPlatformSpecificPlaybackUris(List)" - }, - "recommendationReason": { - "type": "@NonNull RecommendationReason", - "requirement": "Optional", - "setter": "setRecommendationReason(@NonNull RecommendationReason)", - "getter": "getRecommendationReason()" - }, - "callToActionText": { - "requirement": "Optional", - "setter": "setCallToActionText(@NonNull String)", - "type": "@NonNull String", - "getter": "getCallToActionText()" - }, - "tags": { - "requirement": "Optional", - "adder": "addTag(@NonNull String)", - "type": "List<@NonNull String>", - "adderAll": "addTags(List)", - "getter": "getTags()" - } - } - }, - "VideoClipEntity": { - "package": "com.google.android.engage.video.datamodel.VideoClipEntity", - "fields": { - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "posterImages": { - "type": "List", - "requirement": "Required", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "name": { - "type": "String", - "requirement": "Required", - "setter": "setName(String)", - "getter": "getName()" - }, - "lastEngagementTimeMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLastEngagementTimeMillis(long)", - "getter": "getLastEngagementTimeMillis()", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "watchNextType": { - "type": "@WatchNextType int", - "requirement": "Optional", - "setter": "setWatchNextType(@WatchNextType int)", - "getter": "getWatchNextType()", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "lastPlayBackPositionTimeMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLastPlayBackPositionTimeMillis(long)", - "getter": "getLastPlayBackPositionTimeMillis()", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "availabilityTimeWindows": { - "type": "List", - "requirement": "Optional", - "adder": "addAvailabilityTimeWindow(DisplayTimeWindow)", - "getter": "getAvailabilityTimeWindows()" - }, - "allAvailabilityTimeWindows": { - "type": "List>", - "requirement": "Optional", - "adder": "addAllAvailabilityTimeWindows(DisplayTimeWindow)", - "adderAll": "addAllAvailabilityTimeWindows(List)" - }, - "playBackUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setPlayBackUri(Uri)", - "getter": "getPlayBackUri()" - }, - "createdTimeEpochMillis": { - "type": "Long", - "requirement": "Required", - "setter": "setCreatedTimeEpochMillis(long)", - "getter": "getCreatedTimeEpochMillis()" - }, - "durationMillis": { - "type": "Long", - "requirement": "Required", - "setter": "setDurationMillis(long)", - "getter": "getDurationMillis()" - }, - "creator": { - "type": "String", - "requirement": "Required", - "setter": "setCreator(String)", - "getter": "getCreator()" - }, - "creatorImage": { - "type": "Image", - "requirement": "Optional", - "setter": "setCreatorImage(Image)", - "getter": "getCreatorImage()" - }, - "viewCount": { - "type": "String", - "requirement": "Optional", - "setter": "setViewCount(String)", - "getter": "getViewCount()" - }, - "downloadedOnDevice": { - "type": "Boolean", - "requirement": "Optional", - "setter": "setDownloadedOnDevice(boolean)", - "getter": "isDownloadedOnDevice()" - }, - "platformSpecificPlaybackUris": { - "type": "List", - "requirement": "Optional", - "adder": "addPlatformSpecificPlaybackUri(PlatformSpecificUri)", - "getter": "getPlatformSpecificPlaybackUris()", - "adderAll": "addPlatformSpecificPlaybackUris(List)" - }, - "callToActionText": { - "requirement": "Optional", - "setter": "setCallToActionText(@NonNull String)", - "type": "@NonNull String", - "getter": "getCallToActionText()" - }, - "tags": { - "requirement": "Optional", - "adder": "addTag(@NonNull String)", - "type": "List<@NonNull String>", - "adderAll": "addTags(List)", - "getter": "getTags()" - } - } - }, - "RatingSystem": { - "package": "com.google.android.engage.video.datamodel.RatingSystem", - "fields": { - "rating": { - "type": "@NonNull String", - "requirement": "Required", - "setter": "setRating(@NonNull String)", - "getter": "getRating()" - }, - "agencyName": { - "type": "@NonNull String", - "requirement": "Required", - "setter": "setAgencyName(@NonNull String)", - "getter": "getAgencyName()" - } - } - }, - "PlatformSpecificUri": { - "package": "com.google.android.engage.common.datamodel.PlatformSpecificUri", - "fields": { - "platformType": { - "type": "@PlatformType int", - "requirement": "Required", - "setter": "setPlatformType(@PlatformType int)", - "getter": "getPlatformType()" - }, - "actionUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setActionUri(Uri)", - "getter": "getActionUri()" - } - } - }, - "SubscriptionEntitlement": { - "package": "com.google.android.engage.common.datamodel.SubscriptionEntitlement", - "fields": { - "entitlementId": { - "type": "String", - "requirement": "Required", - "setter": "setEntitlementId(String)", - "getter": "getEntitlementId()" - }, - "displayName": { - "type": "String", - "requirement": "Optional", - "setter": "setDisplayName(String)", - "getter": "getDisplayName()" - }, - "expirationTimeMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setExpirationTimeMillis(long)", - "getter": "getExpirationTimeMillis()" - } - } - }, - "BundledSubscription": { - "package": "com.google.android.engage.common.datamodel.BundledSubscription", - "fields": { - "bundledSubscriptionProviderPackageName": { - "type": "String", - "requirement": "Optional", - "setter": "setBundledSubscriptionProviderPackageName(String)", - "getter": "getBundledSubscriptionProviderPackageName()" - }, - "subscriptionType": { - "type": "@SubscriptionType int", - "requirement": "Optional", - "setter": "setSubscriptionType(@SubscriptionType int)", - "getter": "getSubscriptionType()" - }, - "expirationTimeMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setExpirationTimeMillis(long)", - "getter": "getExpirationTimeMillis()" - }, - "entitlements": { - "type": "List", - "requirement": "Optional", - "adder": "addEntitlement(SubscriptionEntitlement)", - "getter": "getEntitlements()", - "adderAll": "addEntitlements(List)" - } - } - } - }, - "methods": { - "isServiceAvailable": "ServiceAvailabilityRequest", - "publishRecommendationClusters": "PublishRecommendationClustersRequest", - "publishFeaturedCluster": "PublishFeaturedClusterRequest", - "publishContinuationCluster": "PublishContinuationClusterRequest", - "publishSubscription": "PublishSubscriptionRequest", - "publishUserAccountManagementRequest": "PublishUserAccountManagementRequest", - "updatePublishStatus": "PublishStatusRequest", - "deleteRecommendationsClusters": "DeleteClustersRequest", - "deleteFeaturedCluster": "DeleteClustersRequest", - "deleteContinuationCluster": "DeleteClustersRequest", - "deleteSubscription": "DeleteClustersRequest", - "deleteUserManagementCluster": "DeleteClustersRequest" - }, - "intents": {} - } \ No newline at end of file diff --git a/play/engage-sdk-integration/references/schemas/watch.md b/play/engage-sdk-integration/references/schemas/watch.md deleted file mode 100644 index 381cab2..0000000 --- a/play/engage-sdk-integration/references/schemas/watch.md +++ /dev/null @@ -1,1137 +0,0 @@ -This file defines the schema for the WATCH vertical in the Engage SDK. - - { - "client": "com.google.android.engage.service.AppEngagePublishClient", - "clusterTypes": [ - "TYPE_RECOMMENDATION", - "TYPE_FEATURED", - "TYPE_CONTINUATION" - ], - "entities": { - "MovieEntity": { - "package": "com.google.android.engage.video.datamodel.MovieEntity", - "fields": { - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "posterImages": { - "type": "List", - "requirement": "Required", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "name": { - "type": "String", - "requirement": "Required", - "setter": "setName(String)", - "getter": "getName()" - }, - "lastEngagementTimeMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLastEngagementTimeMillis(long)", - "getter": "getLastEngagementTimeMillis()", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "watchNextType": { - "type": "@WatchNextType int", - "requirement": "Optional", - "setter": "setWatchNextType(@WatchNextType int)", - "getter": "getWatchNextType()", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "lastPlayBackPositionTimeMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLastPlayBackPositionTimeMillis(long)", - "getter": "getLastPlayBackPositionTimeMillis()", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "availabilityTimeWindows": { - "type": "List", - "requirement": "Optional", - "adder": "addAvailabilityTimeWindow(DisplayTimeWindow)", - "getter": "getAvailabilityTimeWindows()" - }, - "allAvailabilityTimeWindows": { - "type": "List>", - "requirement": "Optional", - "adder": "addAllAvailabilityTimeWindows(DisplayTimeWindow)", - "adderAll": "addAllAvailabilityTimeWindows(List)" - }, - "playBackUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setPlayBackUri(Uri)", - "getter": "getPlayBackUri()" - }, - "infoPageUri": { - "type": "Uri", - "requirement": "Optional", - "setter": "setInfoPageUri(Uri)", - "getter": "getInfoPageUri()" - }, - "releaseDateEpochMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setReleaseDateEpochMillis(long)", - "getter": "getReleaseDateEpochMillis()" - }, - "availability": { - "type": "@ContentAvailability int", - "requirement": "Required", - "setter": "setAvailability(@ContentAvailability int)", - "getter": "getAvailability()" - }, - "durationMillis": { - "type": "Long", - "requirement": "Required", - "setter": "setDurationMillis(long)", - "getter": "getDurationMillis()" - }, - "genres": { - "type": "List", - "requirement": "Optional", - "adder": "addGenre(String)", - "getter": "getGenres()", - "adderAll": "addGenres(List)" - }, - "contentRatings": { - "type": "List", - "requirement": "Required", - "adder": "addContentRating(RatingSystem)", - "getter": "getContentRatings()", - "adderAll": "addContentRatings(List)" - }, - "contentRatingsLegacies": { - "type": "List>", - "requirement": "Required", - "adder": "addContentRatingsLegacy(String)", - "getter": "getContentRatingsLegacy()", - "adderAll": "addContentRatingsLegacy(List)" - }, - "downloadedOnDevice": { - "type": "Boolean", - "requirement": "Optional", - "setter": "setDownloadedOnDevice(boolean)", - "getter": "isDownloadedOnDevice()" - }, - "price": { - "type": "Price", - "requirement": "Optional", - "setter": "setPrice(Price)", - "getter": "getPrice()" - }, - "platformSpecificPlaybackUris": { - "type": "List", - "requirement": "Optional", - "adder": "addPlatformSpecificPlaybackUri(PlatformSpecificUri)", - "getter": "getPlatformSpecificPlaybackUris()", - "adderAll": "addPlatformSpecificPlaybackUris(List)" - }, - "recommendationReason": { - "type": "@NonNull RecommendationReason", - "requirement": "Optional", - "setter": "setRecommendationReason(@NonNull RecommendationReason)", - "getter": "getRecommendationReason()" - }, - "description": { - "type": "@NonNull String", - "requirement": "Optional", - "setter": "setDescription(@NonNull String)", - "getter": "getDescription()" - }, - "callToActionText": { - "requirement": "Optional", - "setter": "setCallToActionText(@NonNull String)", - "type": "@NonNull String", - "getter": "getCallToActionText()" - }, - "tags": { - "requirement": "Optional", - "adder": "addTag(@NonNull String)", - "type": "List<@NonNull String>", - "adderAll": "addTags(List)", - "getter": "getTags()" - } - } - }, - "TvShowEntity": { - "package": "com.google.android.engage.video.datamodel.TvShowEntity", - "fields": { - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "posterImages": { - "type": "List", - "requirement": "Required", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "name": { - "type": "String", - "requirement": "Required", - "setter": "setName(String)", - "getter": "getName()" - }, - "lastEngagementTimeMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLastEngagementTimeMillis(long)", - "getter": "getLastEngagementTimeMillis()", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "watchNextType": { - "type": "@WatchNextType int", - "requirement": "Optional", - "setter": "setWatchNextType(@WatchNextType int)", - "getter": "getWatchNextType()", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "lastPlayBackPositionTimeMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLastPlayBackPositionTimeMillis(long)", - "getter": "getLastPlayBackPositionTimeMillis()", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "availabilityTimeWindows": { - "type": "List", - "requirement": "Optional", - "adder": "addAvailabilityTimeWindow(DisplayTimeWindow)", - "getter": "getAvailabilityTimeWindows()" - }, - "allAvailabilityTimeWindows": { - "type": "List>", - "requirement": "Optional", - "adder": "addAllAvailabilityTimeWindows(DisplayTimeWindow)", - "adderAll": "addAllAvailabilityTimeWindows(List)" - }, - "infoPageUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setInfoPageUri(Uri)", - "getter": "getInfoPageUri()" - }, - "playBackUri": { - "type": "Uri", - "requirement": "Optional", - "setter": "setPlayBackUri(Uri)", - "getter": "getPlayBackUri()" - }, - "firstEpisodeAirDateEpochMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setFirstEpisodeAirDateEpochMillis(long)", - "getter": "getFirstEpisodeAirDateEpochMillis()" - }, - "latestEpisodeAirDateEpochMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLatestEpisodeAirDateEpochMillis(long)", - "getter": "getLatestEpisodeAirDateEpochMillis()" - }, - "availability": { - "type": "@ContentAvailability int", - "requirement": "Required", - "setter": "setAvailability(@ContentAvailability int)", - "getter": "getAvailability()" - }, - "seasonCount": { - "type": "Integer", - "requirement": "Required", - "setter": "setSeasonCount(int)", - "getter": "getSeasonCount()" - }, - "genres": { - "type": "List", - "requirement": "Optional", - "adder": "addGenre(String)", - "getter": "getGenres()", - "adderAll": "addGenres(List)" - }, - "contentRatings": { - "type": "List", - "requirement": "Required", - "adder": "addContentRating(RatingSystem)", - "getter": "getContentRatings()", - "adderAll": "addContentRatings(List)" - }, - "contentRatingsLegacies": { - "type": "List>", - "requirement": "Required", - "adder": "addContentRatingsLegacy(String)", - "getter": "getContentRatingsLegacy()", - "adderAll": "addContentRatingsLegacy(List)" - }, - "price": { - "type": "Price", - "requirement": "Optional", - "setter": "setPrice(Price)", - "getter": "getPrice()" - }, - "platformSpecificPlaybackUris": { - "type": "List", - "requirement": "Required", - "adder": "addPlatformSpecificPlaybackUri(PlatformSpecificUri)", - "getter": "getPlatformSpecificPlaybackUris()", - "adderAll": "addPlatformSpecificPlaybackUris(List)" - }, - "recommendationReason": { - "type": "@NonNull RecommendationReason", - "requirement": "Optional", - "setter": "setRecommendationReason(@NonNull RecommendationReason)", - "getter": "getRecommendationReason()" - }, - "description": { - "type": "@NonNull String", - "requirement": "Optional", - "setter": "setDescription(@NonNull String)", - "getter": "getDescription()" - }, - "callToActionText": { - "requirement": "Optional", - "setter": "setCallToActionText(@NonNull String)", - "type": "@NonNull String", - "getter": "getCallToActionText()" - }, - "tags": { - "requirement": "Optional", - "adder": "addTag(@NonNull String)", - "type": "List<@NonNull String>", - "adderAll": "addTags(List)", - "getter": "getTags()" - } - } - }, - "TvSeasonEntity": { - "package": "com.google.android.engage.video.datamodel.TvSeasonEntity", - "fields": { - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "posterImages": { - "type": "List", - "requirement": "Required", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "name": { - "type": "String", - "requirement": "Required", - "setter": "setName(String)", - "getter": "getName()" - }, - "lastEngagementTimeMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLastEngagementTimeMillis(long)", - "getter": "getLastEngagementTimeMillis()", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "watchNextType": { - "type": "@WatchNextType int", - "requirement": "Optional", - "setter": "setWatchNextType(@WatchNextType int)", - "getter": "getWatchNextType()", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "lastPlayBackPositionTimeMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLastPlayBackPositionTimeMillis(long)", - "getter": "getLastPlayBackPositionTimeMillis()", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "availabilityTimeWindows": { - "type": "List", - "requirement": "Optional", - "adder": "addAvailabilityTimeWindow(DisplayTimeWindow)", - "getter": "getAvailabilityTimeWindows()" - }, - "allAvailabilityTimeWindows": { - "type": "List>", - "requirement": "Optional", - "adder": "addAllAvailabilityTimeWindows(DisplayTimeWindow)", - "adderAll": "addAllAvailabilityTimeWindows(List)" - }, - "infoPageUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setInfoPageUri(Uri)", - "getter": "getInfoPageUri()" - }, - "playBackUri": { - "type": "Uri", - "requirement": "Optional", - "setter": "setPlayBackUri(Uri)", - "getter": "getPlayBackUri()" - }, - "seasonNumber": { - "type": "Integer", - "requirement": "Required", - "setter": "setSeasonNumber(int)" - }, - "seasonDisplayNumber": { - "type": "String", - "requirement": "Required", - "setter": "setSeasonDisplayNumber(String)", - "getter": "getSeasonDisplayNumber()" - }, - "firstEpisodeAirDateEpochMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setFirstEpisodeAirDateEpochMillis(long)", - "getter": "getFirstEpisodeAirDateEpochMillis()" - }, - "latestEpisodeAirDateEpochMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLatestEpisodeAirDateEpochMillis(long)", - "getter": "getLatestEpisodeAirDateEpochMillis()" - }, - "availability": { - "type": "@ContentAvailability int", - "requirement": "Required", - "setter": "setAvailability(@ContentAvailability int)", - "getter": "getAvailability()" - }, - "episodeCount": { - "type": "Integer", - "requirement": "Required", - "setter": "setEpisodeCount(int)", - "getter": "getEpisodeCount()" - }, - "genres": { - "type": "List", - "requirement": "Optional", - "adder": "addGenre(String)", - "getter": "getGenres()", - "adderAll": "addGenres(List)" - }, - "contentRatings": { - "type": "List", - "requirement": "Required", - "adder": "addContentRating(RatingSystem)", - "getter": "getContentRatings()", - "adderAll": "addContentRatings(List)" - }, - "contentRatingsLegacies": { - "type": "List>", - "requirement": "Required", - "adder": "addContentRatingsLegacy(String)", - "getter": "getContentRatingsLegacy()", - "adderAll": "addContentRatingsLegacy(List)" - }, - "price": { - "type": "Price", - "requirement": "Optional", - "setter": "setPrice(Price)", - "getter": "getPrice()" - } - } - }, - "TvEpisodeEntity": { - "package": "com.google.android.engage.video.datamodel.TvEpisodeEntity", - "fields": { - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "posterImages": { - "type": "List", - "requirement": "Required", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "name": { - "type": "String", - "requirement": "Required", - "setter": "setName(String)", - "getter": "getName()" - }, - "lastEngagementTimeMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLastEngagementTimeMillis(long)", - "getter": "getLastEngagementTimeMillis()", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "watchNextType": { - "type": "@WatchNextType int", - "requirement": "Optional", - "setter": "setWatchNextType(@WatchNextType int)", - "getter": "getWatchNextType()", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "lastPlayBackPositionTimeMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLastPlayBackPositionTimeMillis(long)", - "getter": "getLastPlayBackPositionTimeMillis()", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "availabilityTimeWindows": { - "type": "List", - "requirement": "Optional", - "adder": "addAvailabilityTimeWindow(DisplayTimeWindow)", - "getter": "getAvailabilityTimeWindows()" - }, - "allAvailabilityTimeWindows": { - "type": "List>", - "requirement": "Optional", - "adder": "addAllAvailabilityTimeWindows(DisplayTimeWindow)", - "adderAll": "addAllAvailabilityTimeWindows(List)" - }, - "playBackUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setPlayBackUri(Uri)", - "getter": "getPlayBackUri()" - }, - "infoPageUri": { - "type": "Uri", - "requirement": "Optional", - "setter": "setInfoPageUri(Uri)", - "getter": "getInfoPageUri()" - }, - "episodeNumber": { - "type": "Integer", - "requirement": "Required", - "setter": "setEpisodeNumber(int)" - }, - "episodeDisplayNumber": { - "type": "String", - "requirement": "Required", - "setter": "setEpisodeDisplayNumber(String)", - "getter": "getEpisodeDisplayNumber()" - }, - "airDateEpochMillis": { - "type": "Long", - "requirement": "Required", - "setter": "setAirDateEpochMillis(long)", - "getter": "getAirDateEpochMillis()" - }, - "availability": { - "type": "@ContentAvailability int", - "requirement": "Required", - "setter": "setAvailability(@ContentAvailability int)", - "getter": "getAvailability()" - }, - "genres": { - "type": "List", - "requirement": "Optional", - "adder": "addGenre(String)", - "getter": "getGenres()", - "adderAll": "addGenres(List)" - }, - "contentRatings": { - "type": "List", - "requirement": "Required", - "adder": "addContentRating(RatingSystem)", - "getter": "getContentRatings()", - "adderAll": "addContentRatings(List)" - }, - "contentRatingsLegacies": { - "type": "List>", - "requirement": "Required", - "adder": "addContentRatingsLegacy(String)", - "getter": "getContentRatingsLegacy()", - "adderAll": "addContentRatingsLegacy(List)" - }, - "durationMillis": { - "type": "Long", - "requirement": "Required", - "setter": "setDurationMillis(long)", - "getter": "getDurationMillis()" - }, - "seasonNumber": { - "type": "String", - "requirement": "Optional", - "setter": "setSeasonNumber(String)", - "getter": "getSeasonNumber()" - }, - "seasonTitle": { - "type": "String", - "requirement": "Optional", - "setter": "setSeasonTitle(String)", - "getter": "getSeasonTitle()" - }, - "downloadedOnDevice": { - "type": "Boolean", - "requirement": "Optional", - "setter": "setDownloadedOnDevice(boolean)", - "getter": "isDownloadedOnDevice()" - }, - "price": { - "type": "Price", - "requirement": "Optional", - "setter": "setPrice(Price)", - "getter": "getPrice()" - }, - "showTitle": { - "type": "String", - "requirement": "Optional", - "setter": "setShowTitle(String)", - "getter": "getShowTitle()" - }, - "isNextEpisodeAvailable": { - "type": "Boolean", - "requirement": "Optional", - "setter": "setIsNextEpisodeAvailable(boolean)", - "getter": "getIsNextEpisodeAvailable()" - }, - "platformSpecificPlaybackUris": { - "type": "List", - "requirement": "Optional", - "adder": "addPlatformSpecificPlaybackUri(PlatformSpecificUri)", - "getter": "getPlatformSpecificPlaybackUris()", - "adderAll": "addPlatformSpecificPlaybackUris(List)" - }, - "callToActionText": { - "requirement": "Optional", - "setter": "setCallToActionText(@NonNull String)", - "type": "@NonNull String", - "getter": "getCallToActionText()" - }, - "tags": { - "requirement": "Optional", - "adder": "addTag(@NonNull String)", - "type": "List<@NonNull String>", - "adderAll": "addTags(List)", - "getter": "getTags()" - } - } - }, - "MediaActionFeedEntity": { - "package": "com.google.android.engage.video.datamodel.MediaActionFeedEntity", - "fields": { - "dataFeedElementId": { - "type": "@NonNull String", - "requirement": "Required", - "setter": "setDataFeedElementId(@NonNull String)", - "getter": "getDataFeedElementId()" - }, - "name": { - "type": "@NonNull String", - "requirement": "Optional", - "setter": "setName(@NonNull String)", - "getter": "getName()" - }, - "recommendationReason": { - "type": "@NonNull RecommendationReason", - "requirement": "Optional", - "setter": "setRecommendationReason(@NonNull RecommendationReason)", - "getter": "getRecommendationReason()" - }, - "description": { - "type": "@NonNull String", - "requirement": "Optional", - "setter": "setDescription(@NonNull String)", - "getter": "getDescription()" - }, - "posterImages": { - "type": "List>", - "requirement": "Optional", - "adder": "addPosterImage(@NonNull Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "callToActionText": { - "requirement": "Optional", - "setter": "setCallToActionText(@NonNull String)", - "type": "@NonNull String", - "getter": "getCallToActionText()" - }, - "tags": { - "requirement": "Optional", - "adder": "addTag(@NonNull String)", - "type": "List<@NonNull String>", - "adderAll": "addTags(List)", - "getter": "getTags()" - } - } - }, - "LiveTvProgramEntity": { - "package": "com.google.android.engage.video.datamodel.LiveTvProgramEntity", - "fields": { - "channelId": { - "type": "@NonNull String", - "requirement": "Optional", - "setter": "setChannelId(@NonNull String)", - "getter": "getChannelId()" - }, - "channelName": { - "type": "@NonNull String", - "requirement": "Optional", - "setter": "setChannelName(@NonNull String)", - "getter": "getChannelName()" - }, - "channelLogoImage": { - "type": "@NonNull Image", - "requirement": "Optional", - "setter": "setChannelLogoImage(@NonNull Image)", - "getter": "getChannelLogoImage()" - }, - "platformSpecificPlaybackUris": { - "type": "List<@NonNull PlatformSpecificUri>", - "requirement": "Optional", - "adder": "addPlatformSpecificPlaybackUri(@NonNull PlatformSpecificUri)", - "getter": "getPlatformSpecificPlaybackUris()", - "adderAll": "addPlatformSpecificPlaybackUris(List)" - }, - "contentRatings": { - "type": "List<@NonNull RatingSystem>", - "requirement": "Optional", - "adder": "addContentRating(@NonNull RatingSystem)", - "getter": "getContentRatings()", - "adderAll": "addContentRatings(List)" - }, - "genres": { - "type": "List<@NonNull String>", - "requirement": "Optional", - "adder": "addGenre(@NonNull String)", - "getter": "getGenres()", - "adderAll": "addGenres(List)" - }, - "availabilityTimeWindows": { - "type": "List<@NonNull DisplayTimeWindow>", - "requirement": "Optional", - "adder": "addAvailabilityTimeWindow(@NonNull DisplayTimeWindow)", - "getter": "getAvailabilityTimeWindows()", - "adderAll": "addAvailabilityTimeWindows(List)" - }, - "posterImages": { - "type": "List<@NonNull Image>", - "requirement": "Optional", - "adder": "addPosterImage(@NonNull Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "recommendationReason": { - "type": "@NonNull RecommendationReason", - "requirement": "Optional", - "setter": "setRecommendationReason(@NonNull RecommendationReason)", - "getter": "getRecommendationReason()" - }, - "description": { - "type": "@NonNull String", - "requirement": "Optional", - "setter": "setDescription(@NonNull String)", - "getter": "getDescription()" - }, - "entityId": { - "type": "@NonNull String", - "requirement": "Optional", - "setter": "setEntityId(@NonNull String)", - "getter": "getEntityId()" - }, - "name": { - "type": "@NonNull String", - "requirement": "Optional", - "setter": "setName(@NonNull String)", - "getter": "getName()" - }, - "callToActionText": { - "requirement": "Optional", - "setter": "setCallToActionText(@NonNull String)", - "type": "@NonNull String", - "getter": "getCallToActionText()" - }, - "tags": { - "requirement": "Optional", - "adder": "addTag(@NonNull String)", - "type": "List<@NonNull String>", - "adderAll": "addTags(List)", - "getter": "getTags()" - } - } - }, - "LiveStreamingVideoEntity": { - "package": "com.google.android.engage.video.datamodel.LiveStreamingVideoEntity", - "fields": { - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "posterImages": { - "type": "List", - "requirement": "Required", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "name": { - "type": "String", - "requirement": "Required", - "setter": "setName(String)", - "getter": "getName()" - }, - "lastEngagementTimeMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLastEngagementTimeMillis(long)", - "getter": "getLastEngagementTimeMillis()", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "watchNextType": { - "type": "@WatchNextType int", - "requirement": "Optional", - "setter": "setWatchNextType(@WatchNextType int)", - "getter": "getWatchNextType()", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "lastPlayBackPositionTimeMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLastPlayBackPositionTimeMillis(long)", - "getter": "getLastPlayBackPositionTimeMillis()", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "availabilityTimeWindows": { - "type": "List", - "requirement": "Optional", - "adder": "addAvailabilityTimeWindow(DisplayTimeWindow)", - "getter": "getAvailabilityTimeWindows()" - }, - "allAvailabilityTimeWindows": { - "type": "List>", - "requirement": "Optional", - "adder": "addAllAvailabilityTimeWindows(DisplayTimeWindow)", - "adderAll": "addAllAvailabilityTimeWindows(List)" - }, - "playBackUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setPlayBackUri(Uri)", - "getter": "getPlayBackUri()" - }, - "startTimeEpochMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setStartTimeEpochMillis(long)", - "getter": "getStartTimeEpochMillis()" - }, - "endTimeEpochMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setEndTimeEpochMillis(long)", - "getter": "getEndTimeEpochMillis()" - }, - "broadcaster": { - "type": "String", - "requirement": "Required", - "setter": "setBroadcaster(String)", - "getter": "getBroadcaster()" - }, - "viewCount": { - "type": "String", - "requirement": "Optional", - "setter": "setViewCount(String)", - "getter": "getViewCount()" - }, - "broadcasterIcon": { - "type": "Image", - "requirement": "Optional", - "setter": "setBroadcasterIcon(Image)", - "getter": "getBroadcasterIcon()" - }, - "platformSpecificPlaybackUris": { - "type": "List", - "requirement": "Optional", - "adder": "addPlatformSpecificPlaybackUri(PlatformSpecificUri)", - "getter": "getPlatformSpecificPlaybackUris()", - "adderAll": "addPlatformSpecificPlaybackUris(List)" - }, - "description": { - "type": "@NonNull String", - "requirement": "Optional", - "setter": "setDescription(@NonNull String)", - "getter": "getDescription()" - }, - "genres": { - "type": "List<@NonNull String>", - "requirement": "Optional", - "adder": "addGenre(@NonNull String)", - "getter": "getGenres()", - "adderAll": "addGenres(List)" - }, - "contentRatings": { - "type": "List<@NonNull RatingSystem>", - "requirement": "Optional", - "adder": "addContentRating(@NonNull RatingSystem)", - "getter": "getContentRatings()", - "adderAll": "addContentRatings(List)" - }, - "recommendationReason": { - "type": "@NonNull RecommendationReason", - "requirement": "Optional", - "setter": "setRecommendationReason(@NonNull RecommendationReason)", - "getter": "getRecommendationReason()" - }, - "callToActionText": { - "requirement": "Optional", - "setter": "setCallToActionText(@NonNull String)", - "type": "@NonNull String", - "getter": "getCallToActionText()" - }, - "tags": { - "requirement": "Optional", - "adder": "addTag(@NonNull String)", - "type": "List<@NonNull String>", - "adderAll": "addTags(List)", - "getter": "getTags()" - } - } - }, - "LiveTvChannelEntity": { - "package": "com.google.android.engage.video.datamodel.LiveTvChannelEntity", - "fields": { - "entityId": { - "type": "@NonNull String", - "requirement": "Optional", - "setter": "setEntityId(@NonNull String)", - "getter": "getEntityId()" - }, - "name": { - "type": "@NonNull String", - "requirement": "Optional", - "setter": "setName(@NonNull String)", - "getter": "getName()" - }, - "description": { - "type": "@NonNull String", - "requirement": "Optional", - "setter": "setDescription(@NonNull String)", - "getter": "getDescription()" - }, - "logoImage": { - "type": "@NonNull Image", - "requirement": "Optional", - "setter": "setLogoImage(@NonNull Image)", - "getter": "getLogoImage()" - }, - "contentRatings": { - "type": "List<@NonNull RatingSystem>", - "requirement": "Optional", - "adder": "addContentRating(@NonNull RatingSystem)", - "getter": "getContentRatings()", - "adderAll": "addContentRatings(List)" - }, - "platformSpecificPlaybackUris": { - "type": "List<@NonNull PlatformSpecificUri>", - "requirement": "Optional", - "adder": "addPlatformSpecificPlaybackUri(@NonNull PlatformSpecificUri)", - "getter": "getPlatformSpecificPlaybackUris()", - "adderAll": "addPlatformSpecificPlaybackUris(List)" - }, - "recommendationReason": { - "type": "@NonNull RecommendationReason", - "requirement": "Optional", - "setter": "setRecommendationReason(@NonNull RecommendationReason)", - "getter": "getRecommendationReason()" - }, - "callToActionText": { - "requirement": "Optional", - "setter": "setCallToActionText(@NonNull String)", - "type": "@NonNull String", - "getter": "getCallToActionText()" - }, - "tags": { - "requirement": "Optional", - "adder": "addTag(@NonNull String)", - "type": "List<@NonNull String>", - "adderAll": "addTags(List)", - "getter": "getTags()" - } - } - }, - "VideoClipEntity": { - "package": "com.google.android.engage.video.datamodel.VideoClipEntity", - "fields": { - "entityId": { - "type": "String", - "requirement": "Optional", - "setter": "setEntityId(String)", - "getter": "getEntityId()" - }, - "posterImages": { - "type": "List", - "requirement": "Required", - "adder": "addPosterImage(Image)", - "getter": "getPosterImages()", - "adderAll": "addPosterImages(List)" - }, - "name": { - "type": "String", - "requirement": "Required", - "setter": "setName(String)", - "getter": "getName()" - }, - "lastEngagementTimeMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLastEngagementTimeMillis(long)", - "getter": "getLastEngagementTimeMillis()", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "watchNextType": { - "type": "@WatchNextType int", - "requirement": "Optional", - "setter": "setWatchNextType(@WatchNextType int)", - "getter": "getWatchNextType()", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "lastPlayBackPositionTimeMillis": { - "type": "Long", - "requirement": "Optional", - "setter": "setLastPlayBackPositionTimeMillis(long)", - "getter": "getLastPlayBackPositionTimeMillis()", - "requiredFor": [ - "ContinuationCluster" - ] - }, - "availabilityTimeWindows": { - "type": "List", - "requirement": "Optional", - "adder": "addAvailabilityTimeWindow(DisplayTimeWindow)", - "getter": "getAvailabilityTimeWindows()" - }, - "allAvailabilityTimeWindows": { - "type": "List>", - "requirement": "Optional", - "adder": "addAllAvailabilityTimeWindows(DisplayTimeWindow)", - "adderAll": "addAllAvailabilityTimeWindows(List)" - }, - "playBackUri": { - "type": "Uri", - "requirement": "Required", - "setter": "setPlayBackUri(Uri)", - "getter": "getPlayBackUri()" - }, - "createdTimeEpochMillis": { - "type": "Long", - "requirement": "Required", - "setter": "setCreatedTimeEpochMillis(long)", - "getter": "getCreatedTimeEpochMillis()" - }, - "durationMillis": { - "type": "Long", - "requirement": "Required", - "setter": "setDurationMillis(long)", - "getter": "getDurationMillis()" - }, - "creator": { - "type": "String", - "requirement": "Required", - "setter": "setCreator(String)", - "getter": "getCreator()" - }, - "creatorImage": { - "type": "Image", - "requirement": "Optional", - "setter": "setCreatorImage(Image)", - "getter": "getCreatorImage()" - }, - "viewCount": { - "type": "String", - "requirement": "Optional", - "setter": "setViewCount(String)", - "getter": "getViewCount()" - }, - "downloadedOnDevice": { - "type": "Boolean", - "requirement": "Optional", - "setter": "setDownloadedOnDevice(boolean)", - "getter": "isDownloadedOnDevice()" - }, - "platformSpecificPlaybackUris": { - "type": "List", - "requirement": "Optional", - "adder": "addPlatformSpecificPlaybackUri(PlatformSpecificUri)", - "getter": "getPlatformSpecificPlaybackUris()", - "adderAll": "addPlatformSpecificPlaybackUris(List)" - }, - "callToActionText": { - "requirement": "Optional", - "setter": "setCallToActionText(@NonNull String)", - "type": "@NonNull String", - "getter": "getCallToActionText()" - }, - "tags": { - "requirement": "Optional", - "adder": "addTag(@NonNull String)", - "type": "List<@NonNull String>", - "adderAll": "addTags(List)", - "getter": "getTags()" - } - } - } - }, - "methods": { - "isServiceAvailable": "ServiceAvailabilityRequest", - "publishRecommendationClusters": "PublishRecommendationClustersRequest", - "publishFeaturedCluster": "PublishFeaturedClusterRequest", - "publishContinuationCluster": "PublishContinuationClusterRequest", - "publishSubscription": "PublishSubscriptionRequest", - "publishUserAccountManagementRequest": "PublishUserAccountManagementRequest", - "updatePublishStatus": "PublishStatusRequest", - "deleteRecommendationsClusters": "DeleteClustersRequest", - "deleteFeaturedCluster": "DeleteClustersRequest", - "deleteContinuationCluster": "DeleteClustersRequest", - "deleteSubscription": "DeleteClustersRequest", - "deleteUserManagementCluster": "DeleteClustersRequest" - }, - "intents": {} - } \ No newline at end of file diff --git a/play/engage-sdk-integration/references/troubleshooting.md b/play/engage-sdk-integration/references/troubleshooting.md deleted file mode 100644 index c47d02b..0000000 --- a/play/engage-sdk-integration/references/troubleshooting.md +++ /dev/null @@ -1,39 +0,0 @@ -This guide helps resolve common compilation, import, and sandbox-related errors encountered during the Play Engage SDK integration. - -## 1. Ensure sandbox compilation safety for agents - -When executing compilations in sandboxed or ephemeral workspaces, such as evaluation environments or automated presubmits, you must follow these rules to prevent container crashes and lockups: - -- **Use full compilation** : Run `./gradlew compileDebugUnitTestSources --no-daemon` or `./gradlew assembleDebug --no-daemon`. -- **Don't use compilation shortcuts** : Avoid commands like `./gradlew :app:compileDebugKotlin` or other compile-only tasks. These shortcuts bypass manifest merging and resource packaging, which can hide critical errors like namespace conflicts or XML syntax errors. -- **Don't run full tests or builds** : Don't run `./gradlew test` or `./gradlew build`. These tasks trigger heavy testing environments (requiring emulators) and run full builds, which easily crash the FUSE filesystem mounts and exhaust sandbox CPU and RAM. -- **Always use --no-daemon** : Always append `--no-daemon` to prevent background JVM processes from leaking and locking files in the sandbox. - -## 2. Resolve missing imports - -If you're upgrading the SDK version, for example, to 1.6.0, or if you get `Unresolved reference` errors during the initial compilation, make sure you've added the correct import statements: - -| Class name | Package or import statement | Note | -|---|---|---| -| `ImageTheme` | `import com.google.android.engage.common.datamodel.ImageTheme` | | -| `PlatformSpecificUri` | `import com.google.android.engage.common.datamodel.PlatformSpecificUri` | | -| `DisplayTimeWindow` | `import com.google.android.engage.common.datamodel.DisplayTimeWindow` | | -| `RatingSystem` | `import com.google.android.engage.video.datamodel.RatingSystem` | Note: It's under `video.datamodel`, not `common.datamodel`. | - -## 3. Resolve namespace conflicts - -You might receive an error message like this one: - - Namespace 'com.google.android.engage' is used in multiple modules and/or libraries: com.google.android.engage:engage-core:1.6.0, com.google.android.engage:engage-tv:1.1.0 - -This conflict occurs because both packages declare the same namespace. -\* **Resolution** : Remove the explicit `engage-core` dependency from your `build.gradle` or `build.gradle.kts` and keep *only* `engage-tv`. The `engage-tv` library transitively pulls in the compatible version of `engage-core` automatically. - -## 4. Resolve sandbox-specific build and test failures - -When compiling or running tests in the evaluation sandbox (especially for large projects like Now in Android), you might encounter environment-specific failures: - -- **Jlink error with Android SDK 36** : If the build fails during Java compilation with a `jlink` error in `JdkImageTransform` for `android-36`, force Gradle to use JDK 17 by prepending `JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64` to the Gradle command. -- **Missing ANDROID_HOME** : If lint or test tasks fail complaining about missing SDK, explicitly set the environment variable `ANDROID_HOME=/usr/local/google/home/shashvatgupta/Android/Sdk` (or the corresponding path on the workstation). -- **Roborazzi screenshot mismatches** : Headless or sandbox container rendering might differ from local golden images, causing Roborazzi screenshot test assertions to fail. Bypass these visual checks by appending `-Proborazzi.test.verify=false` to your test command. -- **Robolectric SDK 36 unsupported** : If screenshot tests fail with `java.lang.UnsupportedOperationException` in `DefaultSdkProvider` due to experimental SDK 36, update the `@Config` annotation in the failing test files to pin the SDK to 34 (e.g., `@Config(..., sdk = [34])`). \ No newline at end of file diff --git a/play/play-billing-library-version-upgrade/SKILL.md b/play/play-billing-library-version-upgrade/SKILL.md index efe3611..ac34277 100644 --- a/play/play-billing-library-version-upgrade/SKILL.md +++ b/play/play-billing-library-version-upgrade/SKILL.md @@ -6,7 +6,6 @@ description: Use this skill when upgrading or migrating an Android project from license: Complete terms in LICENSE.txt metadata: author: Google LLC - last-updated: '2026-10-06' keywords: - android - play billing @@ -33,7 +32,7 @@ upgrade Play Billing Library to the latest version." Version"** ---defined as the version where those specific APIs were **last available**, not when they were introduced. - **Example** : If `SkuDetails` is present, treat the baseline as **PBL v7** or earlier (regardless of the version string in `build.gradle`). -4. **Identify Target \& Path** : Access the version tool or release notes to find the latest stable version and calculate a \[Direct/Stepped\] migration path based on the **Effective Version** baseline. +4. **Identify Target \& Path:** Access the version tool or release notes to find the latest stable version and calculate a \[Direct/Stepped\] migration path based on the **Effective Version** baseline. - **Calculate Migration Path** : - If the **Effective Version** is within 2 major versions of the target: Plan a **Direct Migration**. @@ -59,7 +58,7 @@ accomplished afterwards.* ### Step 1: SDK \& Environment Alignment -- **Action** : Update `build.gradle` to meet SDK requirements (e.g., "PBL 9 requires `compileSdk` 35"). +- **Action** : Update `build.gradle` to meet SDK requirements (e.g., "PBL 8 requires `compileSdk` 35"). - **Gradle Version**: Verify if the new library requires a newer Android Gradle Plugin (AGP) or Kotlin version. ### Step 2: Intent-based Refactoring @@ -78,7 +77,7 @@ string replacement. ### Step 4: Final Validation Checklist -1. **Smart Checklist Verification**: +1. **Smart Checklist Verification:** 2. Open [references/version-checklist.md](references/version-checklist.md) and locate the **Smart Version-Specific Checklist**. 3. **Action**: For every version between your \[Detected Effective Version\] and \[Detected New Version\], verify that every item has been addressed in the code using "Find in Files" or structural analysis. 4. **Tests** : Run all unit and implementation tests (`./gradlew test`). @@ -90,4 +89,4 @@ Explain the "Why" to the developer: - "I updated your SDK to \[Version\] because PBL \[Version\] requires it for \[Reason from docs\]." - "I removed your custom `retryConnection()` logic because it is now handled natively by the library using `enableAutoServiceReconnection()`." -- "Successfully upgraded from PBL \[Old\] to PBL \[New\] and verified with unit tests. Based on an analysis of features in the latest library and this application's current feature set, I suggest exploring \[New Feature\] (e.g., Prepaid Plans or Installments) from the latest release because it is now available but not yet implemented." +- "Successfully upgraded from PBL \[Old\] to PBL \[New\] and verified with unit tests. Based on an analysis of features in the latest library and this application's current feature set, I suggest exploring \[New Feature\] (e.g., Prepaid Plans or Installments) from the latest release because it is now available but not yet implemented." \ No newline at end of file diff --git a/play/play-billing-library-version-upgrade/references/android/google/play/billing/release-notes.md b/play/play-billing-library-version-upgrade/references/android/google/play/billing/release-notes.md index 02c4114..4a1dd9a 100644 --- a/play/play-billing-library-version-upgrade/references/android/google/play/billing/release-notes.md +++ b/play/play-billing-library-version-upgrade/references/android/google/play/billing/release-notes.md @@ -1,74 +1,5 @@ This document contains release notes for the Google Play Billing Library. - -## Android skills - -[View on GitHub](https://github.com/android/skills/tree/main/play/play-billing-library-version-upgrade) - -### Upgrade Play Billing Library - -Use the Play Billing Library [Android skill](https://developer.android.com/tools/agents/android-skills) to automate your upgrade to the latest version. To install the skill from the [Android CLI](https://developer.android.com/tools/agents/android-cli), run: - - android skills add play-billing-library-version-upgrade - -To activate the skill, try the following prompt: - - Help me upgrade my Play Billing Library implementation. - -
- -## Google Play Billing Library 9.1.0 Release (2026-06-18) - -Version 9.1.0 of the Google Play Billing Library and Kotlin extensions are now available. - -### Summary of changes - -- New APIs for [Billing Choice](https://developer.android.com/google/play/billing/billingchoice): - - Added [`BillingClient.getBillingChoiceInfoAsync()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#getBillingChoiceInfoAsync(com.android.billingclient.api.GetBillingChoiceInfoParams,com.android.billingclient.api.BillingChoiceInfoResponseListener)) to retrieve information about billing choices available to the user. - - Added [`BillingChoiceInfo`](https://developer.android.com/reference/com/android/billingclient/api/BillingChoiceInfo) class which contains the response details, including image URLs and loyalty information. - - Added [`GetBillingChoiceInfoParams`](https://developer.android.com/reference/com/android/billingclient/api/GetBillingChoiceInfoParams) to configure the request. - - Added [`BillingChoiceInfoResponseListener`](https://developer.android.com/reference/com/android/billingclient/api/BillingChoiceInfoResponseListener) to receive the callback. - - Added Kotlin extension `BillingClient.getBillingChoiceInfo()` suspend function. - - Added [`BillingClient.showBillingProgramInformationDialog()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#showBillingProgramInformationDialog(android.app.Activity,com.android.billingclient.api.BillingProgramInformationDialogParams,com.android.billingclient.api.BillingProgramInformationDialogListener)) to show an information dialog for a billing program. - - Added [`BillingProgramInformationDialogParams`](https://developer.android.com/reference/com/android/billingclient/api/BillingProgramInformationDialogParams) to configure the dialog. - - Added [`BillingProgramInformationDialogListener`](https://developer.android.com/reference/com/android/billingclient/api/BillingProgramInformationDialogListener) to receive the callback. - - Added [`BillingProgramAvailabilityDetails.BillingChoiceAvailabilityDetails`](https://developer.android.com/reference/com/android/billingclient/api/BillingProgramAvailabilityDetails.BillingChoiceAvailabilityDetails) to provide details about billing choice availability. - - Added [`ChoiceScreenType`](https://developer.android.com/reference/com/android/billingclient/api/BillingProgramAvailabilityDetails.BillingChoiceAvailabilityDetails.ChoiceScreenType) to specify the type of choice screen. - -## Google Play Billing Library 9.0.0 Release (2026-05-19) - -Version 9.0.0 of the Google Play Billing Library and Kotlin extensions are -now available. See the [PBL 9 migration guide](https://developer.android.com/google/play/billing/migrate-pbl-latest) if you want to migrate from -the previous versions of PBL. - -### Summary of changes - -- **Updated error codes for blocked Play Store activity** : Error codes for - blocked Play Store apps have been updated. For instances where - the Play Store app is blocked by the system - (for example, in OEM-customized kids mode), the response code has - changed from `ERROR` to `BILLING_UNAVAILABLE`. Additionally, the - `BillingResult` for such cases now provides a *Play Store is blocked* - debug message. - - > [!NOTE] - > **Note:** For this feature to work, you need [AndroidX.core library](https://developer.android.com/jetpack/androidx/releases/core#1.9.0) version 1.9 or later. - -- **Nullability update for developer-provided billing** : The - [`DeveloperProvidedBillingDetails.getLinkUri()`](https://developer.android.com/reference/com/android/billingclient/api/DeveloperProvidedBillingDetails#getLinkUri()) method has - been updated to be `@Nullable`. This change supports scenarios where - the direct link URI for external payments is unavailable during the - payment selection stage. - - To handle this change safely, ensure your integration code handles both - `null` and empty string (`""`) values from the - [`DeveloperProvidedBillingDetails.getLinkUri()`](https://developer.android.com/reference/com/android/billingclient/api/DeveloperProvidedBillingDetails#getLinkUri()) method before parsing - or launching browser intents. -- Updated `targetSdkVersion` to 35. - -- You can now use [in-app messaging](https://developer.android.com/google/play/billing/subscriptions#in-app-messaging) to notify users - of an upcoming opt-in price increase. This lets users confirm the price increase without leaving the app. The message for an outstanding opt-in price increase is shown starting on the first day the user can accept the price increase, and the message is shown a maximum of once every 7 days. - ## Google Play Billing Library 8.3.0 Release (2025-12-23) Version 8.3.0 of the Google Play Billing Library and Kotlin extensions are now available. @@ -78,16 +9,13 @@ Version 8.3.0 of the Google Play Billing Library and Kotlin extensions are now a - New APIs for [external payments](https://developer.android.com/google/play/billing/externalpaymentlinks): - Added classes to support external payments flow: - - [`BillingProgram.EXTERNAL_PAYMENTS`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.BillingProgram#EXTERNAL_PAYMENTS()) + - [`BillingProgram.EXTERNAL_PAYMENTS`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.BillingProgram#EXTERNAL_PAYMENTS) - [`EnableBillingProgramParams`](https://developer.android.com/reference/com/android/billingclient/api/EnableBillingProgramParams) - [`DeveloperBillingOptionParams`](https://developer.android.com/reference/com/android/billingclient/api/DeveloperBillingOptionParams) - [`DeveloperProvidedBillingDetails`](https://developer.android.com/reference/com/android/billingclient/api/DeveloperProvidedBillingDetails) - [`DeveloperProvidedBillingListener`](https://developer.android.com/reference/com/android/billingclient/api/DeveloperProvidedBillingListener) - Added [`enableBillingProgram(EnableBillingProgramParams)`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.Builder#enableBillingProgram(com.android.billingclient.api.EnableBillingProgramParams)) to enable external payments. - Added [`BillingFlowParams.Builder.enableDeveloperBillingOption`](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder#enableDeveloperBillingOption(com.android.billingclient.api.DeveloperBillingOptionParams)) to launch the external payments flow. -- Improved error categorization for external payment flow drop-offs: - - - If a user navigates away from the purchase flow to complete payment on an external app (such as an e-wallet) and abandons the transaction without returning, the response code returned to the client is now `BillingResponseCode.USER_CANCELED` (1) with debug message `BILLING_ACTIVITY_PAUSED` (previously defaulted to `BillingResponseCode.ERROR` (6)). ## Google Play Billing Library 8.2.1 Release (2025-12-15) @@ -184,7 +112,7 @@ available. 8.0.0, unfetched products are returned with a new product-level status code that provides information about unfetched products. Note that there is a change in the signature of the - [`ProductDetailsResponseListener.onProductDetailsResponse()`](https://developer.android.com/reference/com/android/billingclient/api/ProductDetailsResponseListener#onProductDetailsResponse(com.android.billingclient.api.BillingResult,com.android.billingclient.api.QueryProductDetailsResult)) which + [`ProductDetailsResponseListener.onProductDetailsResponse()`](https://developer.android.com/google/play/billing/integrate#automatic-service-reconnection) which requires changes in your app. For more information, see [process the result](https://developer.android.com/google/play/billing/integrate#process-the-result). - Automatic service reconnection. @@ -260,7 +188,7 @@ available. - Added [`ProductDetails.InstallmentPlanDetails`](https://developer.android.com/reference/com/android/billingclient/api/ProductDetails.InstallmentPlanDetails) for installment base plans that users are eligible to purchase. This API helps your app identify the installment plan and its commitment setup to provide related information to the user. To learn more, see our [subscription installments guide](https://developer.android.com/google/play/billing/subscriptions#installments). - Added [`PendingPurchasesParams`](https://developer.android.com/reference/com/android/billingclient/api/PendingPurchasesParams) and - [`BillingClient.Builder.enablePendingPurchases(PendingPurchaseParams)`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.Builder#enablePendingPurchases(com.android.billingclient.api.PendingPurchasesParams)) + [`BillingClient.Builder.enablePendingPurchases(PendingPurchaseParams)`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.Builder#enablePendingPurchases(PendingPurchaseParams)) to replace [`BillingClient.Builder.enablePendingPurchases()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.Builder#enablePendingPurchases()), which has been deprecated in this release. @@ -268,9 +196,9 @@ available. - The deprecated `enablePendingPurchases()` is functionally equivalent to `enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build())`. - Added APIs to support pending transactions for subscription prepaid plans: - - Use [`PendingPurchasesParams.Builder.enablePrepaidPlans()`](https://developer.android.com/reference/com/android/billingclient/api/PendingPurchasesParams.Builder#enablePrepaidPlans()) along with [`BillingClient.Builder.enablePendingPurchases(PendingPurchaseParams)`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.Builder#enablePendingPurchases(com.android.billingclient.api.PendingPurchasesParams)) to enable pending transactions for subscription prepaid plans. When adding support, be sure that your app also correctly manages subscription lifecycles. To learn more see our [pending purchases + - Use [`PendingPurchasesParams.Builder.enablePrepaidPlans()`](https://developer.android.com/reference/com/android/billingclient/api/PendingPurchasesParams.Builder#enablePrepaidPlans()) along with [`BillingClient.Builder.enablePendingPurchases(PendingPurchaseParams)`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.Builder#enablePendingPurchases(PendingPurchaseParams)) to enable pending transactions for subscription prepaid plans. When adding support, be sure that your app also correctly manages subscription lifecycles. To learn more see our [pending purchases guide](https://developer.android.com/google/play/billing/subscriptions#pending). - - Added [`Purchase.PendingPurchaseUpdate`](https://developer.android.com/reference/com/android/billingclient/api/Purchase.PendingPurchaseUpdate) and [`Purchase.getPendingPurchaseUpdate()`](https://developer.android.com/reference/com/android/billingclient/api/Purchase#getPendingPurchaseUpdate()) for retrieving the pending top-up or upgrade or downgrade to an existing subscription. + - Added [`Purchase.PendingPurchaseUpdate`](https://developer.android.com/reference/com/android/billingclient/api/Purchase.PendingPurchaseUpdate) and [`Purchase.getPendingPurchaseUpdate()`](https://developer.android.com/reference/com/android/billingclient/api/Purchase.getPendingPurchaseUpdate()) for retrieving the pending top-up or upgrade or downgrade to an existing subscription. - Removed [`BillingClient.Builder.enableAlternativeBilling()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.Builder#enableAlternativeBilling(com.android.billingclient.api.AlternativeBillingListener)), [`AlternativeBillingListener`](https://developer.android.com/reference/com/android/billingclient/api/AlternativeBillingListener), and @@ -362,14 +290,14 @@ available. Note that `ProrationMode` is still available for backward compatibility reasons. -- Removed order ID for [`PENDING`](https://developer.android.com/reference/com/android/billingclient/api/Purchase.PurchaseState#PENDING()) +- Removed order ID for [`PENDING`](https://developer.android.com/reference/com/android/billingclient/api/Purchase.PurchaseState#PENDING) purchases. Previously, the order ID would always be created even if the purchase was pending. Starting with version 6.0.0, an order ID will not be created for pending purchases, and for these purchases, the order ID will be populated after the purchase is moved to the - [`PURCHASED`](https://developer.android.com/reference/com/android/billingclient/api/Purchase.PurchaseState#PURCHASED()) + [`PURCHASED`](https://developer.android.com/reference/com/android/billingclient/api/Purchase.PurchaseState#PURCHASED) state. - Removed `queryPurchases` and `launchPriceConfirmationFlow` methods. @@ -382,20 +310,20 @@ available. - Added new network error response code. A new network error response code, - [`NETWORK_ERROR`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.BillingResponseCode#NETWORK_ERROR()), + [`NETWORK_ERROR`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.BillingResponseCode#NETWORK_ERROR), has been added starting with PBL version 6.0.0. This code is returned when an error occurs due to a network connection issue. These network connection errors were previously reported as `SERVICE_UNAVAILABLE`. -- Updated [`SERVICE_UNAVAILABLE`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.BillingResponseCode#SERVICE_UNAVAILABLE()) +- Updated [`SERVICE_UNAVAILABLE`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.BillingResponseCode#SERVICE_UNAVAILABLE) and - [`SERVICE_TIMEOUT`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.BillingResponseCode#SERVICE_TIMEOUT()). + [`SERVICE_TIMEOUT`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.BillingResponseCode#SERVICE_TIMEOUT). Starting with PBL version 6.0.0, errors due to timeout in processing will be returned as `SERVICE_UNAVAILABLE` instead of the current `SERVICE_TIMEOUT`. The behavior does not change in earlier versions of PBL. - Removed - [`SERVICE_TIMEOUT`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.BillingResponseCode#SERVICE_TIMEOUT()). + [`SERVICE_TIMEOUT`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.BillingResponseCode#SERVICE_TIMEOUT). Starting with PBL version 6.0.0, `SERVICE_TIMEOUT` will no longer be returned. Previous versions of PBL will still return this code. @@ -457,7 +385,7 @@ This version contains the following changes. price](https://developer.android.com/google/play/billing/integrate#personalized-price). - Removed `queryPurchases()`, which was previously deprecated and replaced by queryPurchasesAsync introduced in Google Play Billing Library 4.0.0. - `launchPriceChangeFlow` has been deprecated and will be removed in a future release. To learn more about alternatives, see [Launch a price change - confirmation flow](https://developer.android.com/google/play/billing/price-changes). + confirmation flow](https://developer.android.com/google/play/billing/subscriptions#price-change-launch). - Removed [`setVrPurchaseFlow()`](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder#setVrPurchaseFlow(boolean)), which was previously used when instantiating a purchase flow. In previous versions, this method redirected the user to complete the purchase on their Android-powered device. Once you remove this method, users will complete the purchase through the standard purchase flow. ## Google Play Billing Library 4.1 release (2022-02-23) @@ -486,7 +414,7 @@ available. which will be removed in a future release. - Added new subscription replacement mode - [`IMMEDIATE_AND_CHARGE_FULL_PRICE`](https://developer.android.com/google/play/billing/subscriptions#replacement-modes). + [`IMMEDIATE_AND_CHARGE_FULL_PRICE`](https://developer.android.com/google/play/billing/subscriptions#change). - Added [`BillingClient.getConnectionState()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#getConnectionState()) @@ -607,7 +535,9 @@ Unity](https://developer.android.com/google/play/billing/unity). - In `BillingFlowParams`, added [`setObfuscatedProfileId()`](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder#setobfuscatedprofileid) which works similarly to [`setObfuscatedAccountId()`](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder#setObfuscatedAccountId(java.lang.String)). For more information, see [Developer payload updates and alternatives](https://developer.android.com/google/play/billing/developer-payload). - In [`Purchase`](https://developer.android.com/reference/com/android/billingclient/api/Purchase), added the [`getAccountIdentifiers()`](https://developer.android.com/reference/com/android/billingclient/api/Purchase#getAccountIdentifiers()) method to return the obfuscated account identifiers set in `BillingFlowParams`. - - In [`BillingClient`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient), the [`loadRewardedSku()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#loadRewardedSku(com.android.billingclient.api.RewardLoadParams,%20com.android.billingclient.api.RewardResponseListener)) method has been marked deprecated as part of deprecating rewarded SKUs. + - In [`BillingClient`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient), the [`loadRewardedSku()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#loadRewardedSku(com.android.billingclient.api.RewardLoadParams,%20com.android.billingclient.api.RewardResponseListener)) method has been marked deprecated as part of deprecating rewarded SKUs. You can find more information about the deprecation in the [Play Console + Help + Center](https://support.google.com/googleplay/android-developer/answer/9155268). ## Google Play Billing Library 2.1.0 Release and Kotlin Extension 2.1.0 Release (2019-12-10) @@ -918,14 +848,14 @@ billingClient.launchPriceChangeConfirmationFlow(activity, The price change confirmation flow displays a dialog containing the new pricing information, asking users to accept the new price. This flow returns a response code of type -[`BillingClient.BillingResponse`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.BillingResponseCode). +[`BillingClient.BillingResponse`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.BillingResponse). #### New proration mode When upgrading or downgrading a user's subscription, you can use a new proration mode, `DEFERRED`. This mode updates the user's subscription when it next renews. To learn more about how to set this proration mode, see [Set proration -mode](https://developer.android.com/google/play/billing/subscriptions#replacement-modes). +mode](https://developer.android.com/google/play/billing/billing_subscriptions#set-proration-mode). #### New method for setting SKU details @@ -1006,7 +936,7 @@ contains the following changes. - Added support to specify a proration mode in [`BillingFlowParams`](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams) when upgrading/downgrading an existing subscription. - The `replaceSkusProration` boolean flag in [`BillingFlowParams`](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams) is no longer supported. Use `replaceSkusProrationMode` instead. -- [`launchBillingFlow()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#launchBillingFlow(android.app.Activity,com.android.billingclient.api.BillingFlowParams)) now triggers a callback for failed responses. +- [`launchBillingFlow()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.html#launchBillingFlow(android.app.Activity,%0Acom.android.billingclient.api.BillingFlowParams)) now triggers a callback for failed responses. ### Behavior changes @@ -1065,16 +995,16 @@ longer supported. The Billing Library will always trigger the [`PurhcasesUpdatedListener`](https://developer.android.com/reference/com/android/billingclient/api/PurchasesUpdatedListener) callback and return a -[`BillingResponse`](https://developer.android.com/reference/com/android/billingclient/api/BillingResult) +[`BillingResponse`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.BillingResponse) asynchronously. The synchronous return value of -[`BillingResponse`](https://developer.android.com/reference/com/android/billingclient/api/BillingResult) +[`BillingResponse`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.BillingResponse) is kept as well. ### Bug fixes - Properly exits early in async methods when service is disconnected. - [`Builder`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.Builder) param objects no longer mutates built objects. -- Issue [68087141](https://issuetracker.google.com/issues/68087141): [`launchBillingFlow()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#launchBillingFlow(android.app.Activity,com.android.billingclient.api.BillingFlowParams)) now trigger callback for failed responses. +- Issue [68087141](https://issuetracker.google.com/issues/68087141): [`launchBillingFlow()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.html#launchBillingFlow(android.app.Activity,%0Acom.android.billingclient.api.BillingFlowParams)) now trigger callback for failed responses. ## Google Play Billing Library 1.0 Release (2017-09-19, [Announcement](https://android-developers.googleblog.com/2017/09/google-play-billing-library-10-released.html)) @@ -1113,7 +1043,7 @@ billingClient = BillingClient.newBuilder(context).setListener(this).build(); #### launchBillingFlow method is now called using a BillingFlowParams class To initiate the billing flow for a purchase or subscription, the -[`launchBillingFlow()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#launchBillingFlow(android.app.Activity,com.android.billingclient.api.BillingFlowParams)) method receives a +[`launchBillingFlow()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.html#launchBillingFlow(android.app.Activity,%0Acom.android.billingclient.api.BillingFlowParams)) method receives a [`BillingFlowParams`](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.html) instance initialized with parameters specific to the request: @@ -1188,7 +1118,7 @@ public void onSkuDetailsResponse(@BillingResponse int responseCode, List=10.0.0, you **MUST** synthesize a new checklist for each new version header found in the [Release Notes](https://developer.android.com/google/play/billing/release-notes). +- \[ \] **Dynamic Checklist Generation** : For any version \>=9.0.0, you **MUST** synthesize a new checklist for each new version header found in the [Release Notes](https://developer.android.com/google/play/billing/release-notes). - \[ \] **Identify Version Delta** : Review "Breaking Changes" and "Removed APIs" for the new version and create a list of terms to `grep`. \ No newline at end of file diff --git a/play/play-policy-insights/SKILL.md b/play/play-policy-insights/SKILL.md deleted file mode 100644 index 8eeb9d1..0000000 --- a/play/play-policy-insights/SKILL.md +++ /dev/null @@ -1,169 +0,0 @@ ---- -name: play-policy-insights -description: Automated auditor designed to verify Android applications against Google Play Policy domains. It cross-references static code analysis with Play Store declarations to generate deterministic compliance reports, identifying undeclared data collection, architectural risks, and missing disclosures across Permissions and APIs Hygiene, User Account and Identity, and Data Safety and Privacy domains. -license: Complete terms in LICENSE.txt -metadata: - author: Google LLC - last-updated: '2026-07-13' - keywords: - - account deletion - - accessibility api - - all files access - - audio recording - - audit - - compliance - - contacts access - - data disclosure - - data safety - - data safety label - - data transmission - - demo credentials - - exact alarm - - foreground services - - location access - - login credentials - - manifest hygiene - - package visibility - - permissions hygiene - - photo and video access - - photopicker - - play policy - - pre-submission audit - - privacy policy - - prominent disclosure - - restricted permissions - - scoped storage - - sms and call log - - static analysis - - target sdk - - user consent ---- - -# Play Policy Insights: data safety, login credentials, and restricted permissions - -You must audit Android apps for three specific policy domains. You must check -data safety, demo login credentials, and restricted permissions. - -## Path Resolution - -* **repo_root**: Absolute path to the directory containing this `SKILL.md`. -* **app_dir**:: Absolute path to the directory containing app's code. -* **temp_dir**: Absolute path to the scratch directory at the workspace root. - It is located at `.scratch/play_policy_insights_`. **Containment - Mandate**: You must confine all file system writes, intermediate artifacts, - and logs strictly to this directory. This ensures the skill remains portable - and safe across diverse execution environments, including local harnesses - and CI/CD pipelines, by avoiding reliance on system-level temporary paths or - user home directories. - -## Critical mandates - -- **Execution Mode Awareness** Before starting Phase 2, evaluate if your - execution environment provides a tool to spawn or delegate tasks to - general-purpose sub-agents (e.g., tools often named `invoke_agent`, - `delegate_task`, or `spawn_worker`, using generic agent profiles like - 'generalist' or 'coding_agent'). - -- If **YES**, you MUST use **Mode A (Delegation)**. - -- If **NO**, use **Mode B (Sequential Self-Execution)**. You must read the - prompt files intended for the subagents, follow their instructions, and - write the expected output files to disk. - -- **Sub-agents orchestration:** - - - If you use "Mode A (Delegation)", wait for "SUCCESS" confirmation from - sub-agents to know when they are done. - - - **Idempotency & Timeout Safeguard**: If a sub-agent fails or times out, - you MUST verify the presence and integrity of its target output file - (e.g., `/worker_.json`) before retrying. If the - file exists and contains valid JSON, treat the execution as **SUCCESS** - and proceed. Otherwise, retry up to three times. - -- **Fail-fast mandate:** The automated audit in Phase 1 is the source of - truth. If `orchestrator.py` fails, you must stop immediately with an - explanation of failure. Do not use manual auditing as a fallback. - -## The two-phase protocol - -### Phase 1: Fact gathering and triage - -1. **Initialize and triage**: - - Run `python3 /scripts/orchestrator.py init `. - - This will create the scratch environment, perform static analysis, map - the codebase, identify audit goals, and produce prompts for subagents - for each audit goal and prompts for designated critic and aggregator - subagents. - - You must wait (up to 5 minutes) for the script to finish. -2. **Capture environment**: Note values of the `temp_dir`, and - `activated_goals` from the JSON output. You will need them in Phase 2. -3. **Evaluate goals**: If `activated_goals` is empty, skip to step 3 of Phase 2 - (Aggregation). Otherwise, proceed to step 1 of Phase 2 (Detailed analysis). - -### Phase 2: Goal-oriented audit - -Determine your execution capabilities and proceed with either Mode A OR Mode B. - -#### Mode A: Orchestrator WITH Delegation Capabilities (Parallel) - -1. **Detailed analysis**: For each goal in `activated_goals` (e.g., - `permissions_and_apis`, `data_safety_part_1`, `data_safety_part_2`), - delegate to a sub-agent. **Concurrency Limit:** You must not spawn more than - 3 sub-agents simultaneously. Spawn the first batch of up to 3, wait for - their completions, and then spawn the next batch. Repeat until all goals are - complete. Pass the prompt: `"Read your instructions from - /prompt_worker_.md and execute. MANDATORY: You must - use your file-writing capabilities to save your final JSON findings directly - to the file system at /worker_.json. You are strictly - forbidden from outputting the JSON in your chat response. To minimize - context usage, your final response must be exactly 'SUCCESS' and nothing - else."` **Validate**: Confirm every - `/worker_.json` exists and contains valid JSON. If a - sub-agent fails or times out, but the valid JSON output file is already - present on disk, do NOT retry; proceed normally. Only retry the - corresponding worker (up to three times) if the file is missing or invalid. -2. **Aggregate Findings**: Execute the python aggregation command: - `python3 /scripts/orchestrator.py aggregate `. This - produces `aggregated_findings.json` and returns a JSON object containing - `critic_chunks` representing the number of chunks to verify (e.g., - `{"temp_dir": "...", "critic_chunks": 2}`). -3. **Parallel Critic review**: For each chunk index `i` from 1 to - `critic_chunks`, delegate to a sub-agent. **Concurrency Limit:** You must not - spawn more than 3 critic sub-agents simultaneously. Batch them in groups of 3 - as above. Pass the prompt: - `"Read your instructions from /prompt_critic_.md and execute. MANDATORY: You must use your file-writing capabilities to save your final JSON findings directly to the file system at /critic_output_.json. You are strictly forbidden from outputting the JSON in your chat response. To minimize context usage, your final response must be exactly 'SUCCESS' and nothing else."` - **Validate**: Confirm each `/critic_output_.json` exists and - contains valid JSON before proceeding. If it failed or timed out, but the - valid JSON file is present, proceed normally. Otherwise, retry that specific - critic chunk. -4. **Proceed to Finalization** (Step 4 below) - -#### Mode B: Orchestrator WITHOUT Delegation Capabilities (Sequential) - -1. **Detailed Analysis**: For each goal in `activated_goals`, sequentially: - - Read the contents of `/prompt_worker_.md`. - - Execute the instructions contained within that file yourself. - - **CRITICAL**: You MUST format your findings exactly as requested in the - prompt and save them to `/worker_.json`. **Do not** - summarize findings in your thoughts or chat; move to the next task. - - **Validate**: Confirm `/worker_.json` exists before - moving to the next goal. -2. **Aggregate Findings**: Execute the python aggregation command: - `python3 /scripts/orchestrator.py --aggregate `. - This produces `aggregated_findings.json` and returns a JSON object containing - `critic_chunks` representing the number of chunks to verify. -3. **Sequential Critic review**: For each chunk index `i` from 1 to - `critic_chunks`, sequentially: - - Read the contents of `/prompt_critic_.md`. - - Execute the steps yourself and save your findings to - `/critic_output_.json`. - - **Validate**: Confirm `/critic_output_.json` exists before - moving to the next chunk. -4. **Proceed to Finalization** (Step 4 below) - -#### Finalization (Both Modes) - -4. **Present findings**: Run `python3 /scripts/generate_report.py `. - It will produce `/compliance_report.md`. Present this output file to user. -5. **STOP**: The audit is complete. Await further instructions. diff --git a/play/play-policy-insights/resources/common_mandates.md b/play/play-policy-insights/resources/common_mandates.md deleted file mode 100644 index 87857e1..0000000 --- a/play/play-policy-insights/resources/common_mandates.md +++ /dev/null @@ -1,100 +0,0 @@ -# Execution Mandates - -### Technical Rules - -1. **Absolute Paths Only**: Always resolve and use absolute paths. -2. **Containment**: Write all artifacts strictly within `{{TEMP_DIR}}`. -3. **Fail-fast**: If any required input file is missing, stop immediately and - report the failure. - -### Surgical Input Protocol & Efficient Search (MANDATORY) - -- **Direct Evidence First**: Prioritize files listed in the **Context & - Evidence** sections. Use the provided file/line evidence (e.g., from Data - Sources or Sinks) to jump directly to the relevant code. Do not perform - broad workspace searches if these surgical starting points are available. -- **Path Filtering Over File Crawling**: Locate target files by name, path, or - extension *first* using directory/file listing tools before performing any - text/content-based searches. Restrict searches and file reads strictly to - the target `{{APP_DIR}}`. -- **Strict Exclusions (The Noise Wall)**: Configure search, glob, and find - tools to ignore build, cache, dependencies, and testing folders. You MUST - exclude matches from: `**/build/**`, `**/.gradle/**`, `**/.scratch/**`, - `**/androidTest/**`, `**/test/**`, `**/node_modules/**`. -- **Targeted Extensions**: Restrict content searches and file reads strictly - to source and configuration files: `.java`, `.kt`, `.xml`, `.gradle`, `.kts` - (and `.js`, `.ts`, `.jsx`, `.tsx`, `.dart` if a hybrid/cross-platform - environment is analyzed). Never search or read inside compiled `.class` - files, binary resources, or output assets. -- **Surgical Queries & Limiters**: Use highly specific search patterns (e.g., - search for `getLastKnownLocation` or `deleteAccount` instead of general - words like `location` or `delete`). If search tools support limits or - pagination, cap results at a maximum of 50 matches. Do not load unlimited - search results into your context window. -- **Parallel Reading Required (Turn Efficiency)**: You are operating under a - strict maximum turn limit. To prevent timeouts, you MUST request to read - multiple target files concurrently in a single response. Do not read the - evidence files sequentially one-by-one. Issue all of your file-reading tool - calls simultaneously whenever possible. - -### Evidentiary Standard & Guardrails (CRITICAL) - -To prevent over-auditing, false positives, and speculative "prosecution" of -compliant code during extrapolation: - -1. **Presumption of Compliance**: Treat code as compliant unless there is - *definitive, visible evidence* in the provided files of a policy violation. - If code is ambiguous, or if network/database logic is hidden behind - abstractions (e.g., calling an interface or repository method like - `clearSession()`), you must assume standard compliant behavior. Do NOT guess - or speculate about what happens behind interfaces. -2. **Benefit of the Doubt**: When compliance cannot be strictly verified due to - code abstractions or missing source file contexts, you must downgrade your - finding: - - Never flag a `🔴 Critical` or `🟡 Important` finding based on suspicion or - lack of context. - - Instead, output a `🔵 Suggestion` (informational) to advise the developer - on what to double-check in their backend or configuration. -3. **Exclusion of Local State**: Local-only processing (e.g., caching theme - settings, user-selected visual configurations, or on-device-only database - operations) is explicitly exempt from Data Safety collection or Account - Deletion mandates. -4. **Concrete Attributions**: Every `🔴 Critical` or `🟡 Important` finding must - cite the exact file, line number, or configuration block containing the - direct violation. If you cannot cite the exact line of code containing the - violation, you cannot flag it as a violation. -5. **Empty-List Discipline**: If no policy violations, discrepancies, or review - items are identified during your audit, you MUST represent this as an empty - array `[]` for that field (e.g., `"findings": []`, `"verified_findings": - []`, or `"manual_verification_required": []`). **DO NOT** populate arrays - with "dummy" objects, placeholder strings, or `"N/A"` / `"None"` values. -6. **Heuristics & Extrapolation Boundaries**: Whenever applying specific - heuristics defined in your goal (e.g., searching for implicit logger leaks - or - SDK siphoning), you must strictly bound them to the provided evidence and - their immediate callers. You are strictly forbidden from initiating broad, - unbounded searches for custom paths or variables across the wider codebase. - Base your extrapolation only within the specific files already provided to - you in the prompt. - -### Finalization & Output Mandates (CRITICAL) - -- **Iterative Saving**: If your investigation requires multiple steps, save - partial or intermediate JSON states to disk as you progress. Do not hold all - data in memory until the very end to prevent data loss upon interruption. -- **Strict File Output (NO TRIPLE BACKTICKS)**: You MUST save your final JSON - output to disk at the exact path specified in the goal schema using your - file-writing capabilities. - - **CRITICAL: The content written to the file MUST be pure, raw JSON. DO - NOT wrap the contents inside the JSON file with Markdown code blocks - (such as triple backticks `json ...`). Writing markdown blocks into the - file makes the JSON unparseable by the compiler.** -- **NO Chat Summaries**: **MANDATORY: DO NOT summarize your findings, explain - your reasoning, or output JSON in your final chat response.** Your chat - output wastes context and is ignored by the orchestrator. -- **Verification Before Termination**: You MUST only terminate and return the - "SUCCESS" string *after* you have explicitly verified that your JSON file - successfully wrote to disk and contains valid JSON (e.g., by reading the - file back or checking the directory contents). -- **Final response**: Your final response MUST be exactly the word: "SUCCESS" - and nothing else. diff --git a/play/play-policy-insights/resources/compliance_report_template.md b/play/play-policy-insights/resources/compliance_report_template.md deleted file mode 100644 index e045394..0000000 --- a/play/play-policy-insights/resources/compliance_report_template.md +++ /dev/null @@ -1,59 +0,0 @@ -# Play Policy Compliance Report: {{app_name_id}} - -**Status:** {{overall_compliance}} **Scan Date:** {{current_date}} - -> **Disclaimer:** This report is generated by an AI automated scanner and is for - **informational and advisory purposes only**. It does NOT constitute legal - advice, a guarantee of Play Store approval, or a definitive compliance check. - The Google Play Review team has final authority. Undiscovered issues may still - exist. The developer retains sole responsibility and liability for the apps’ - security, data privacy compliance, and final Play Store submission. - ---- - -## Policy Risks & Recommendations - -*Review these identified risks and follow the suggested recommendation steps.* - -{{findings_detail}} - ---- - -## Data Safety Discrepancies - -Review these discrepancies between detected behavior and Play Store -declarations. Discrepancies may stem from data collection/sharing practices or -new code features not yet reflected in your Data Safety label. - -{{data_safety_table}} - -{{suggested_declaration_section}} - -{{local_access_section}} - ---- - -## Remediation Checklist - -Follow these personalized steps to resolve the identified risks. - -{{personalized_checklist}} - ---- - -### Play Store Compliance Status Legend - -| Overall Status | Meaning & Rejection Risk | Required Action | -| :--- | :--- | :--- | -| **🟢 Compliant** | The application code and metadata appear fully compliant with analyzed Play Store policies. No active policy violations or high-risk discrepancies were identified. | **No issues detected**: No active policy violations were identified by this automatic scan. | -| **🟡 Needs Review** | No critical violations were found in the code, but the app implements features requiring manual Play Console configurations (e.g., submitting **Reviewer Demo Credentials** for login screens) or requests restricted, high-risk permissions (e.g., Exact Alarms, All Files Access, or Background Location) requiring Play Console declaration forms. | **Must Review**: Review the findings. You must either **migrate to modern scoped alternatives (highly recommended)** to bypass declaration scrutiny, OR **manually verify that your Play Console settings (like Reviewer Credentials and Declaration Forms) are completely and carefully configured** before submitting. | -| **🔴 Non-Compliant** | Severe, direct policy violations (e.g., silent background data tracking, default-only permission abuse) or active Data Safety declaration mismatches were identified. | **Must Fix**: Immediate rejection or account-level policy suspension risk. You must remediate these issues before submitting to the Play Store. | - -
- -| Finding Severity | Description | Action Required | -| :--- | :--- | :--- | -| **🔴 Critical** | Direct, verified policy violation (e.g., unauthorized data collection/sharing). | Critical action recommended. | -| **🟡 Important** | Deprecated permission use or API discrepancy where scoped pathways are mandated. | **Should Fix**: Highly recommended to migrate to prevent manual reviewer flags. | -| **🔵 Suggestion** | Standard advisory reminder or administrative checklist item (fully compliant). | **Good to Know**: Clean-path confirmation or helper checklist. | - diff --git a/play/play-policy-insights/resources/critic.md b/play/play-policy-insights/resources/critic.md deleted file mode 100644 index f39d7b5..0000000 --- a/play/play-policy-insights/resources/critic.md +++ /dev/null @@ -1,86 +0,0 @@ -# Review of specialized policy findings (Chunked Critic) - -Review a specific chunk of identified policy findings to identify potential -false positives, exaggerated claims, or inaccuracies due to lack of evidence. - -### Provided context files - -The prompt provides absolute paths to files and directories: -- `{{TEMP_DIR}}`: The temporary scratch directory for this audit. -- `input_file`: `{{TEMP_DIR}}/input_critic_{{CHUNK_INDEX}}.json` contains the - chunk of findings to verify. - -### Instructions - -1. **Read input chunk**: Read the JSON object in - `{{TEMP_DIR}}/input_critic_{{CHUNK_INDEX}}.json`. The keys (e.g. `"1"`, - `"2"`, `"3"`) correspond to the finding IDs. -2. **Verify each finding**: - - For each finding, analyze its evidence against the source files in - `{{APP_DIR}}`. - - Determine the verdict: - - `"VERIFIED"`: True Positive. The codebase confirms the policy - violation. - - `"MANUAL_REVIEW"`: Ambiguous code or abstract logic where automatic - verification is impossible. - - `"PRUNED"`: False Positive. The codebase is compliant, or the - finding is not supported by actual evidence. - - Provide your verification details in the output JSON. -3. **Optional Editorial Overrides**: If (and only if) you need to edit, refine, - or moderate the worker finding's text, you may include one or more of these - optional keys to your decision object. **If you agree with the worker's text, - you MUST omit these keys entirely from your JSON.** - - `"issue_summary"`: Write a more accurate, tailored summary. - - `"severity"`: Set to `"CRITICAL"`, `"IMPORTANT"`, or `"SUGGESTION"` to - override. - - `"recommendation"`: Write a tailored, codebase-specific remediation - step. -4. **Save Results**: Save your final "Thin JSON" mapping to - `{{TEMP_DIR}}/critic_output_{{CHUNK_INDEX}}.json`. - -### Audit principles for false positive detection - -- **Environment detection**: **Do not** flag emulator or root detection as - violations unless evidence shows malicious intent or review evasion. -- **Speculative collection**: Prune claims based solely on permission - presence. Require evidence of actual data access and transfer (whether - off-device network egress or on-device sharing to a third-party app). -- **Standard patterns**: **Do not** flag standard Android architectural - patterns unless used maliciously. -- **Surgical Evidence Standard**: Prune or downgrade any finding where the - worker has speculated on a transmission pathway that cannot be directly - verified in the immediate source files, or where the finding fails to cite a - concrete file and line number containing the direct policy violation. -- **Data Safety Flag Verification**: If a finding includes Data Safety flags - (e.g., `user_initiated`, `is_third_party`), strictly verify them. If the - worker claims `user_initiated: true`, ensure there is undeniable evidence of - explicit user interaction triggering the transfer. If the worker claims - `is_third_party: true`, verify the sink is definitively outside the - developer's control (e.g., Android Share Sheet, Social Media SDK). If evidence - is lacking, downgrade the finding or use `"issue_summary"` to correct the - claim. -- **Gatekeeper Validation**: If a worker claims a finding is compliant because - a disclosure exists (`prominent_disclosure_status: "DISCLOSED"`), you MUST - verify that the UI acts as a strict gatekeeper. If data collection begins - before the user taps "Accept", or if they can dismiss it and continue, - override the `"severity"` to `"CRITICAL"` and explicitly state the gatekeeper - is invalid. - -### Output JSON format - -**Important**: The output must be a pure JSON object mapping the sequential -finding IDs from your input file to their decisions. If there are no findings in -the chunk, return `{}`. - -```json -{ - "1": { - "action": "VERIFIED | MANUAL_REVIEW | PRUNED", - "confidence": "High | Medium | Low", - "critic_justification": "A concise, technical explanation of your decision based on codebase evidence.", - "issue_summary": "OPTIONAL: Overridden issue summary text", - "severity": "OPTIONAL: CRITICAL | IMPORTANT | SUGGESTION", - "recommendation": "OPTIONAL: Overridden recommendation text" - } -} -``` diff --git a/play/play-policy-insights/resources/goal_data_safety.md b/play/play-policy-insights/resources/goal_data_safety.md deleted file mode 100644 index 59a9337..0000000 --- a/play/play-policy-insights/resources/goal_data_safety.md +++ /dev/null @@ -1,212 +0,0 @@ -{{#IF_ANY data_sources, disclosure}} -{{ACTIVATE_GOAL}} -{{/IF_ANY}} -## Data Safety and Privacy Audit - -### Policies to Verify - -- **The Policy Spirit**: Users deserve complete transparency regarding what - personal or sensitive information leaves their devices. Under Play Store - rules, any off-device transmission of user data must be explicitly declared in - the Play Store Data Safety form, and sensitive or non-obvious data collection - requires prior prominent in-app disclosure and affirmative user consent. - -- **Common Evaluation Matrix**: - | Case | Technical Observation | `user_initiated` | `is_third_party` | Disclosure Status | Severity | Issue Summary | - | :--- | :--- | :--- | :--- | :--- | :--- | :--- | - | **1** | **Not Transferred** (Local only) | N/A | N/A | `EXEMPT` | `SUGGESTION` | "Data Safety Compliant (Local-Only)" | - | **2A** | **Transferred** + **Background** + **No Disclosure** | `false` | (Any) | `MISSING` | `CRITICAL` | "**Silent Background Transfer**": Policy violation. Data is sent without prior disclosure. | - | **2B** | **Transferred** + **Background** + **Disclosure Found** | `false` | (Any) | `DISCLOSED` | `IMPORTANT` | "**Background Transfer (Disclosure Claimed)**": Evidence found; requires Critic verification. | - | **3** | **Transferred** + **User-Initiated** + **First Party** | `true` | `false` | `EXEMPT` | `IMPORTANT` | "**User-Initiated Collection**": Disclosure exempt, but **must be declared** in Play Store form. | - | **4** | **Transferred** + **User-Initiated** + **Third Party** | `true` | `true` | `EXEMPT` | `SUGGESTION` | "**Manual Sharing**": **Policy Exempt**. User triggers transfer to 3P; no disclosure or declaration needed. | - -- **Exemptions Reference**: - - **Anonymous Data**: Fully anonymized data not linked to a user is exempt. - - **End-to-End Encryption (E2EE)**: Data E2EE where the developer cannot read - it is exempt. - - **Open Web WebView**: Data entered into a WebView navigating the open web is - exempt. - - *If any of these apply, downgrade severity to `SUGGESTION` and mark - `EXEMPT`.* - ---- - -### The Audit Protocol - -#### Step 0: Facts orientation - -- **Identify Third-Party SDKs**: If you observe network, crash-reporting, or - tracking telemetry, check the project's build files (e.g., build.gradle, - build.gradle.kts, libs.versions.toml) on-demand using your file-reading tools - to verify which SDKs (Analytics, Ads, etc.) are integrated. -- **Understand Off-Device Sinks**: Use your native knowledge of standard Android - framework components to identify transmission destinations. Focus on active - egress vectors such as standard HTTP/REST libraries (e.g., OkHttp, Retrofit, - Ktor, HttpURLConnection), WebViews passing data off-device, background syncing - tasks, or analytic/telemetry frameworks (e.g., Firebase, Mixpanel, Adjust, - AppsFlyer). -- **Identify Disclosures**: Review `disclosure` below. Use these as starting - points for Step 2. - -#### Step 1: Verify behavioral transmission (Forward Trace) - -For each source in `data_sources` listed below, trace the technical signal from -the exact location provided to its transmission endpoint. -- **Mandatory Starting Point**: Navigate to the file and line number specified - in the `data_sources` list. -- **Loop Discipline**: You must analyze the transmission path for *every* piece - of evidence listed to verify if it reaches an off-device sink. -- **Evidence**: Provide the file/line where the data is passed to the sink and a - brief justification of the sink's known transmission behavior (e.g., network - upload, telemetry payload). -- **Semantic Grounding**: Use the provided `*Description*` to verify that the - code evidence actually matches the semantic scope of the data type. If the - variable or logic refers to unrelated data (e.g., a `fileName` that does not - identify a user `NAME`), treat it as a false positive and follow the - **Evidentiary Standard** to downgrade or prune the finding. -- **Sink Categorization**: Determine the destination: - 1. **First Party / Service Provider (`is_third_party: false`)**: - Developer-controlled servers, Firebase, custom APIs, etc. - 2. **Third Party (`is_third_party: true`)**: Social SDKs, ad networks, or - user-selected apps via Android Share Sheet (`Intent.ACTION_SEND`). -- **Ambiguity Mandate**: If the destination is ambiguous (e.g., generic URL or - obfuscated SDK), you MUST default to `is_third_party: false` (erring on the - side of Collection). -- **On-Device Transfer**: Treat silent data sharing to another app via - Intents/ContentProviders as a transfer (`is_transferred: true`), even if it - doesn't use the network. - -#### Step 2: Semantic Search for Protection (UI Disclosure) - -**Conditional Execution**: Execute this step **ONLY IF** `is_transferred` is -`true` AND `user_initiated` is `false`. -- **Rationale**: User-initiated actions (Case 3 and 4) are exempt from Prominent - Disclosure. Do not search for disclosures if `user_initiated` is true. -- **Semantic Search**: Review the `disclosure` list below. -- **Gatekeeper Check**: Check if the Activity/Fragment displaying that consent - acts as a gatekeeper (i.e., gates the initialization of the data-collection - logic). Do not attempt to trace backwards from a low-level repository up to - the UI. -- **Content Check**: The UI must state *what* is collected and *how* it is used. -- **Evidence**: Provide the file/layout name and the specific text found. - -#### Step 3: Synthesis - -- **`findings` (CRITICAL)**: You must construct and output exactly **one** - object inside the `"findings"` array for **every** data type found in the - `data_sources` list below, regardless of whether it is compliant or a - violation. -- **Evaluating Keys**: For each data type object in `"findings"`, evaluate and - populate: - 1. **psl_constant**: Set this exactly to the uppercase **PSL Constant** of - the data type (listed as the **Data Type** heading in the **Data Sources to - Trace** section below, e.g., `"PRECISE_LOCATION"`, `"CONTACTS"`). This is - critical for downstream matching. - 2. **policy_id**: Set this exactly to either `"prominent_disclosure_policy"` - or `"data_safety_section"` depending on the check being performed. - 3. **is_transferred**: `true` (JSON Boolean) if the data is transferred - off-device or on-device to a third party. `false` (JSON Boolean) if local - only. - 4. **user_initiated**: `true` (JSON Boolean) if the user explicitly triggers - the transfer. - 5. **is_third_party**: `true` (JSON Boolean) if the destination is a Third - Party (e.g., Share Sheet, Social SDK). `false` (JSON Boolean) if it's a - First Party/Service Provider (e.g., your own backend). - 6. **prominent_disclosure_status**: Set strictly to one of these three - uppercase enums: `"DISCLOSED"` (visible disclosure and user consent found), - `"MISSING"` (no disclosure found, or is not an affirmative gatekeeper), or - `"EXEMPT"` (exempt from prominent disclosure under policies). - 7. **purpose**: Categorize why the data is being collected (e.g., "App - functionality", "Analytics", "Local functionality only"). - 8. **linked_to_user**: `true` (JSON Boolean) if the data is tied to an - identity, email, or device ID, otherwise `false` (JSON Boolean). - -- **Map findings strictly to the 5 Cases:** Use the Case matrix in "Policies to - Verify" to set the appropriate `severity` and `issue_summary` based on the - combination of `is_transferred`, `user_initiated`, `is_third_party`, and - `prominent_disclosure_status`. - ---- - -### Domain-Specific Heuristics - -Apply the following heuristics while strictly adhering to the Heuristics & -Extrapolation Boundaries defined in your Execution Mandates: -1. **Implicit Data Leaks (The Logger Loop)**: Inspect if any custom logging - frameworks (e.g., Crashlytics, custom error loggers, or telemetry SDKs) - receive sensitive variables as part of diagnostic payloads. If a user - identifier (e.g., email, account ID) or precise location is passed to a - logger that uploads payloads off-device, this counts as **transferred** - (`is_transferred`: true). -2. **Third-Party SDK Siphoning**: Look at the project's build files on-demand - (e.g., build.gradle or libs.versions.toml). If SDKs like Google Ads, or - Firebase are initialized and have access to the context, evaluate if - they are siphoning advertiser IDs or device identifiers automatically. If - those libraries are loaded and the manifest requests broad network - permissions, treat those device identifiers as **transferred** - (`is_transferred`: true) for analytics/marketing purposes. -3. **Indirect Consent**: If you find an `AlertDialog` or disclosure, verify if - it is an actual gatekeeper. If the app begins tracking user data *prior* to - the user tapping "Accept", or if the user can close the dialog and continue - using the app while data tracking remains active, flag this as a `CRITICAL` - violation under `prominent_disclosure_policy`. - ---- - -### Codebase Context & Evidence - -{{#IF semantic_files.LEGAL}} -**Semantic Triage Starting Points (Files of Interest)**: -The following files likely contain privacy or consent logic: -- **LEGAL** related: - {{#EACH semantic_files.LEGAL}} - - `{{ITEM}}` - {{/EACH}} -{{/IF}} - -{{#IF data_sources}} -**Data Sources to Trace**: -{{#EACH data_sources}} -- **Data Type**: {{KEY}} - *Description: {{VALUE.description}}* - {{#EACH VALUE.findings}} - - `{{ITEM}}` - {{/EACH}} -{{/EACH}} -{{/IF}} - -{{#IF disclosure}} -**UI Disclosure Starting Points**: -{{#EACH disclosure}} -- `{{ITEM}}` -{{/EACH}} -{{/IF}} - -## Output schema - -Save final JSON output to `{{TEMP_DIR}}/worker_{{GOAL_NAME}}.json`. - -```json -{ - "domain": "Data Safety and Privacy", - "findings": [ - { - "psl_constant": "STRING_VALUE (The exact PSL Constant, e.g., USER_ACCOUNT)", - "policy_id": "prominent_disclosure_policy | data_safety_section", - "issue_summary": "STRING_VALUE", - "severity": "CRITICAL | IMPORTANT | SUGGESTION", - "files_involved": ["STRING_VALUE"], - "evidence": "STRING_VALUE", - "recommendation": "STRING_VALUE", - - # UNIFIED DATA SAFETY METADATA LOOKUP KEYS: - "is_transferred": true | false, - "user_initiated": true | false, - "is_third_party": true | false, - "prominent_disclosure_status": "DISCLOSED | MISSING | EXEMPT", - "purpose": "STRING_VALUE", - "linked_to_user": true | false - } - ] -} -``` diff --git a/play/play-policy-insights/resources/goal_permissions_and_apis.md b/play/play-policy-insights/resources/goal_permissions_and_apis.md deleted file mode 100644 index 126d95e..0000000 --- a/play/play-policy-insights/resources/goal_permissions_and_apis.md +++ /dev/null @@ -1,459 +0,0 @@ -{{#IF_ALL requested_permissions.exact_alarm, exact_alarm}}{{ACTIVATE_GOAL}}{{/IF_ALL}} -{{#IF_ALL requested_permissions.foreground_service, foreground_service}}{{ACTIVATE_GOAL}}{{/IF_ALL}} -{{#IF_ALL requested_permissions.accessibility, accessibility}}{{ACTIVATE_GOAL}}{{/IF_ALL}} -{{#IF_ALL requested_permissions.APPS_ON_DEVICE, data_sources.APPS_ON_DEVICE}}{{ACTIVATE_GOAL}}{{/IF_ALL}} -{{#IF_ALL requested_permissions.SMS_CALL_LOG, data_sources.SMS_CALL_LOG}}{{ACTIVATE_GOAL}}{{/IF_ALL}} -{{#IF_ALL requested_permissions.MEDIA, data_sources.MEDIA}}{{ACTIVATE_GOAL}}{{/IF_ALL}} -{{#IF_ALL requested_permissions.AUDIO, data_sources.AUDIO}}{{ACTIVATE_GOAL}}{{/IF_ALL}} -{{#IF_ALL requested_permissions.ALL_FILES, data_sources.FILES_AND_DOCS}}{{ACTIVATE_GOAL}}{{/IF_ALL}} -{{#IF_ALL requested_permissions.LOCATION, data_sources.PRECISE_LOCATION}}{{ACTIVATE_GOAL}}{{/IF_ALL}} -{{#IF_ALL requested_permissions.LOCATION, data_sources.APPROX_LOCATION}}{{ACTIVATE_GOAL}}{{/IF_ALL}} -{{#IF_ALL requested_permissions.CONTACTS, data_sources.CONTACTS}}{{ACTIVATE_GOAL}}{{/IF_ALL}} - -## Permissions and APIs Audit - -### Hint: Deducing Core Functionality - -Since you must determine if certain permissions are justified by the app's "core -purpose", use these fast heuristics: -1. **The "Broken" Test**: Is the feature essential to the app's primary purpose? - If the app would still be functional and useful without the feature, it is - NOT core functionality. -2. **Manifest Intent**: Review `AndroidManifest.xml`. The name of the `LAUNCHER` - Activity and specialized `` declarations (like default SMS - handlers) strongly indicate the app's main purpose. -3. **Naming**: The package name (`{{PACKAGE_NAME}}`) and app label - (`{{APP_NAME}}`) often describe the app's purpose explicitly. -4. **Execution Context**: Usage in classes like `BackupManager` suggest core - functionality, whereas usage in `AdHelper`, `CrashReporter`, or - `AnalyticsManager` indicates secondary features. -5. **Mandatory Rule**: Secondary features like **advertising, analytics, or - social sharing never justify** restricted permissions like Background - Location, All Files Access, or Broad Media Access. - ---- - -### Policies to Verify - -{{#IF_ALL requested_permissions.MEDIA, data_sources.MEDIA}} -#### Photo and Video Access Policy (Policy ID: photo_video_access_policy) - -- **Goal**: Evaluate if the app's core functionality justifies broad access to - photos or if it should migrate to the Android Photo Picker. -- **The Policy Spirit**: User privacy is paramount. Apps should only request - broad media storage permissions if they are dedicated media managers (like - Gallery or Backup apps). For standard tasks like profile picture uploads, - custom sharing, or attaching media, developers must use scoped APIs to prevent - security risks. -- **Evidence**: - {{#EACH data_sources.MEDIA}} - - `{{ITEM}}` - {{/EACH}} - **Relevant Permissions Requested**: - {{#EACH requested_permissions.MEDIA}} - - `{{ITEM}}` - {{/EACH}} - {{#IF TARGET_SDK}} - **Target SDK**: `{{TARGET_SDK}}` - {{/IF}} -- **Common Evaluation Matrix**: - | Target SDK | Broad Media Permission Requested? | Condition / Context Checked | Severity | Direct Actionable Recommendation | - | :--- | :--- | :--- | :--- | :--- | - | **33 or higher** | Yes | App requests broad media access (e.g. `READ_MEDIA_IMAGES`), but features only require user-selected media. | `IMPORTANT` | Migrate to the **Android Photo Picker** (`MediaStore.ACTION_PICK_IMAGES`) which does not require any permission prompt. | - | **Any** | Yes | App requests broad storage/media permissions but is not a dedicated media manager (e.g., a social or utility app). | `IMPORTANT` | Migrate to the **Android Photo Picker** for single-item or multi-item media selection. | - -- **Domain-Specific Heuristics (Strictly Bounded)**: - Look beyond standard native file pickers. Extrapolate using this heuristic: - 1. **User-Selected Media Heuristic**: Analyze where the code handles selected - files. If images or videos are loaded strictly via a user-facing button - click (e.g., "Upload Avatar", "Share Photo", "Attach File") and processed - one-at-a-time, broad filesystem media permissions are structurally - unnecessary. Flag an `IMPORTANT` violation and recommend Photo Picker - migration. -{{/IF_ALL}} - -{{#IF_ALL requested_permissions.ALL_FILES, data_sources.FILES_AND_DOCS}} -#### All Files Access Policy (Policy ID: all_files_access_policy) - -- **Goal**: Evaluate if the app's core purpose justifies the high-risk - `MANAGE_EXTERNAL_STORAGE` permission. -- **The Policy Spirit**: Full filesystem access is a restricted privilege. The - Play Store strictly limits `MANAGE_EXTERNAL_STORAGE` to apps where full disk - reads/writes are critical to the core purpose (e.g., file managers, antivirus - scanner, backup tools). Non-compliant apps must utilize Scoped Storage or - Storage Access Framework (SAF). -- **Evidence**: - {{#EACH data_sources.FILES_AND_DOCS}} - - `{{ITEM}}` - {{/EACH}} - **Relevant Permissions Requested**: - {{#EACH requested_permissions.ALL_FILES}} - - `{{ITEM}}` - {{/EACH}} -- **Common Evaluation Matrix**: - | Core App Purpose | Is Permission Justified? | Severity | Direct Actionable Recommendation | - | :--- | :--- | :--- | :--- | - | **File Manager, Antivirus, Backup/Restore, or Document Manager** | Yes (Compliant) | None | No action needed; core purpose justifies the restricted permission. | - | **Standard Utility, Social Media, Game, or Productivity App** | No (Violation) | `CRITICAL` | Remove `MANAGE_EXTERNAL_STORAGE` from the Manifest. For document picking or local file saving, migrate to the **Storage Access Framework (SAF)**. | - -- **Domain-Specific Heuristics (Strictly Bounded)**: - Surgically audit how the filesystem is queried: - 1. **Scoped Storage Sufficiency**: Check the files using the storage APIs. If - the app is using filesystem pathways solely to log diagnostics, store - custom caches, or save simple download artifacts, broad access is not - justified. Recommend using app-specific directories - (`Context.getExternalFilesDir()`) which require zero permissions. -{{/IF_ALL}} - -{{#IF requested_permissions.LOCATION}} -{{#IF_ANY data_sources.PRECISE_LOCATION, data_sources.APPROX_LOCATION}} -#### Location Access Policy (Policy ID: location_access_policy) - -- **Goal**: Verify that location access is essential, uses minimum scope, and is - properly disclosed. -- **The Policy Spirit**: User tracking is extremely sensitive. Apps must collect - the minimum scope of location required (approximate vs precise), provide clear - prominent disclosures, and must never utilize background tracking unless it is - essential for safety, navigation, or physical fitness features. -- **Evidence**: - {{#IF data_sources.PRECISE_LOCATION}} - - **Precise Location usage**: - {{#EACH data_sources.PRECISE_LOCATION}} - - `{{ITEM}}` - {{/EACH}} - {{/IF}} - {{#IF data_sources.APPROX_LOCATION}} - - **Approximate Location usage**: - {{#EACH data_sources.APPROX_LOCATION}} - - `{{ITEM}}` - {{/EACH}} - {{/IF}} - **Relevant Permissions Requested**: - {{#EACH requested_permissions.LOCATION}} - - `{{ITEM}}` - {{/EACH}} - {{#IF TARGET_SDK}} - **Target SDK**: `{{TARGET_SDK}}` - {{/IF}} -- **Common Evaluation Matrix**: - | Scope | Target SDK | Finding / Condition Checked | Severity | Direct Actionable Recommendation | - | :--- | :--- | :--- | :--- | :--- | - | **Foreground** | Any | Requests `ACCESS_FINE_LOCATION` but features only require city-level or approximate weather/search features. | `IMPORTANT` | Downgrade Manifest to `ACCESS_COARSE_LOCATION` to respect the minimum scope mandate. | - | **Foreground** | Any | App purpose (`{{APP_NAME}}`) does not imply location, yet foreground tracking is used, and prominent disclosure alert code is missing. | `IMPORTANT` | Implement a **Prominent In-App Disclosure** dialog explaining what location data is collected *before* requesting foreground permission. | - | **Background** | Any | Requests `ACCESS_BACKGROUND_LOCATION` solely for advertising, marketing, or general analytics. | `CRITICAL` | **High-Risk Violation**: Completely remove background location collection from the codebase. | - | **Background** | Any | Requests background location, but features could operate with foreground location access. | `IMPORTANT` | Downgrade the feature to foreground-only location tracking and remove background permission. | - | **Background** | Any | Background location is legitimate, but prominent disclosure does not mention "location" and "when the app is closed or not in use." | `IMPORTANT` | Update the Prominent Disclosure text to explicitly state "location" and "when closed or not in use". | - | **Foreground** | **37 or higher** | Precise location is requested on Android 17+. | `SUGGESTION` | Migrate to the **Location Button** API as the minimum scope mechanism for precise foreground location. | - -- **Domain-Specific Heuristics (Strictly Bounded)**: - Actively trace custom background workers and disclosures: - 1. **The Foreground Sufficiency Test**: Analyze background threads, - `WorkManager` tasks, or background services triggering location updates. If - the background process performs syncing or location calculations that could - be deferred to when the user is actively viewing the app, flag an - `IMPORTANT` violation. - 2. **The Reasonable Expectation Test**: If the app label (`{{APP_NAME}}`) or - packages suggest a utility that shouldn't logically track location, - evaluate any indirect geo-tracking (such as geo-lookup of network IP - addresses, or sending local Wi-Fi SSID logs off-device). If found, flag an - `IMPORTANT` missing prominent disclosure. -{{/IF_ANY}} -{{/IF}} - -{{#IF_ALL requested_permissions.CONTACTS, data_sources.CONTACTS}} -#### Contacts Access Policy (Policy ID: contacts_access_policy) - -- **Goal**: Evaluate if broad contacts access (`READ_CONTACTS`) is justified or - if the Android Contact Picker should be used. -- **The Policy Spirit**: Address books contain sensitive personal details. The - Play Store mandates that apps use scoped contact access unless broad, - continuous contacts synchronization is critical (such as in social network - friend-matching or full contact managers). -- **Evidence**: - {{#EACH data_sources.CONTACTS}} - - `{{ITEM}}` - {{/EACH}} - **Relevant Permissions Requested**: - {{#EACH requested_permissions.CONTACTS}} - - `{{ITEM}}` - {{/EACH}} - {{#IF TARGET_SDK}} - **Target SDK**: `{{TARGET_SDK}}` - {{/IF}} -- **Common Evaluation Matrix**: - | Target SDK | Access Model | Condition / Finding Checked | Severity | Direct Actionable Recommendation | - | :--- | :--- | :--- | :--- | :--- | - | **Any** | Broad (`READ_CONTACTS`) | Broad access is requested for one-time transactions, file sharing, referrals, or simple forms. | `IMPORTANT` | Migrate to the **Android Contact Picker** (`Intent.ACTION_PICK_CONTACTS`) to query contacts securely. | - | **37 or higher** | Broad (`READ_CONTACTS`) | Target SDK is 37+ (effective Oct 2026), and broad access is used for secondary or standard tasks. | `IMPORTANT` | Migrate to the **Android Contact Picker** as broad contacts access is restricted. | - -- **Domain-Specific Heuristics (Strictly Bounded)**: - Evaluate the depth of contact queries: - 1. **Single-Item Verification**: Search for database cursors reading contact - tables (`ContactsContract.CommonDataKinds`). If the cursors are used solely - to let a user select a single friend, mobile phone number, or email - address, broad contacts access is a violation. Recommend Contact Picker. -{{/IF_ALL}} - -{{#IF_ALL requested_permissions.exact_alarm, exact_alarm}} -#### Exact Alarm Policy (Policy ID: exact_alarm_policy) - -- **Goal**: Evaluate if the app's core functionality justifies the - `USE_EXACT_ALARM` permission. -- **The Policy Spirit**: Exact alarms degrade system performance and battery - life. The Play Store strictly limits the high-risk `USE_EXACT_ALARM` - permission to alarm clocks, timers, and calendar apps where precise, - down-to-the-second timing is critical. -- **Evidence**: - {{#EACH exact_alarm}} - - `{{ITEM}}` - {{/EACH}} - **Relevant Permissions Requested**: - {{#EACH requested_permissions.exact_alarm}} - - `{{ITEM}}` - {{/EACH}} -- **Common Evaluation Matrix**: - | Core App Purpose | Permission Requested | Justified? | Severity | Direct Actionable Recommendation | - | :--- | :--- | :--- | :--- | :--- | - | **Alarm Clock, Timer, or Calendar App** | `USE_EXACT_ALARM` | Yes (Compliant) | None | No action needed. | - | **Standard Utility, Game, Sync, or Productivity App** | `USE_EXACT_ALARM` | No (Violation) | `IMPORTANT` | Switch to `SCHEDULE_EXACT_ALARM` which respects system battery constraints, or use standard `AlarmManager` inexact scheduling. | - -- **Domain-Specific Heuristics (Strictly Bounded)**: - Examine alarm trigger targets: - 1. **Time-Insensitive Syncing**: Verify the files scheduling alarms. If alarms - are used to fetch network updates, clean cache files, trigger analytics - uploads, or post local daily notifications, exact timing is not justified. - Recommend using `WorkManager` for background tasks instead of - `AlarmManager`. -{{/IF_ALL}} - -{{#IF_ALL requested_permissions.foreground_service, foreground_service}} -#### Foreground Services (Policy ID: foreground_services_policy) - -- **Goal**: Verify the declaration and justification of foreground services. -- **The Policy Spirit**: Foreground services keep processes alive in the - background and must be highly visible to users. Every declared service must - have an appropriate `foregroundServiceType` defined in the Manifest, and - special types like `specialUse` require specific tag property justifications. -- **Evidence**: - {{#EACH foreground_service}} - - `{{ITEM}}` - {{/EACH}} - **Relevant Permissions Requested**: - {{#EACH requested_permissions.foreground_service}} - - `{{ITEM}}` - {{/EACH}} - {{#IF TARGET_SDK}} - **Target SDK**: `{{TARGET_SDK}}` - {{/IF}} -- **Common Evaluation Matrix**: - | Service Configuration | Justification Check | Severity | Direct Actionable Recommendation | - | :--- | :--- | :--- | :--- | - | **Missing type tag** | Foreground service is declared but lacks a `foregroundServiceType` attribute. | `CRITICAL` | Add the appropriate `android:foregroundServiceType` attribute to the service declaration in the Manifest. | - | **Lacks specialUse property** | Service type is `specialUse`, but Manifest lacks the required `` tag. | `CRITICAL` | Add the `` tag inside the service block with a valid subtype string. | - | **Type Misalignment** | Declared FGS type does not logically align with the app's core purpose. | `IMPORTANT` | Re-align FGS type to match app features, or migrate background operations to **WorkManager** if user-visible foreground presence is not justified. | - | **Declaration Reminder** | Foreground service is declared (even if type is correct). | `SUGGESTION` | **Play Console Declaration Required**: For apps targeting Android 14+, you must complete a Foreground Service declaration in the Play Console (App content section) for each type used, providing a functional description, user impact video, and a specific use case selection. | - -- **Domain-Specific Heuristics (Strictly Bounded)**: - Critique specialUse justifications and service behavior: - 1. **Justification String Audit**: Read the text of the `` tag for - `specialUse`. If the text contains weak, boilerplate, or placeholder - justifications (e.g., "requires background process for app to run"), flag - an `IMPORTANT` violation warning the developer that Google Play reviewers - will reject this service. - 2. **Notification Integrity**: Verify if the FGS implementation creates a - valid user-facing notification. If no `startForeground()` or notification - builder logic is associated with the service initiation, flag an - `IMPORTANT` violation. - 3. **Play Console Declaration Confirmation**: If any foreground service is - used, flag a `SUGGESTION` to remind the developer that a specialized - declaration form in the Play Console is mandatory, requiring a video - demonstration of the feature. -{{/IF_ALL}} - -{{#IF_ALL requested_permissions.accessibility, accessibility}} -#### Accessibility API Policy (Policy ID: accessibility_api_policy) - -- **Goal**: Verify the configuration and justification of Accessibility - Services. -- **The Policy Spirit**: Accessibility APIs provide deep system access to assist - users with disabilities. Using these APIs for non-accessibility tasks (like UI - automation, screen scraping, background monitoring, or ad blocking) is - strictly prohibited by Google Play and causes immediate app rejection. -- **Evidence**: - {{#EACH accessibility}} - - `{{ITEM}}` - {{/EACH}} - **Relevant Permissions Requested**: - {{#EACH requested_permissions.accessibility}} - - `{{ITEM}}` - {{/EACH}} -- **Common Evaluation Matrix**: - | Service Configuration | Real Code Usage | Justified? | Severity | Direct Actionable Recommendation | - | :--- | :--- | :--- | :--- | :--- | - | **`isAccessibilityTool="true"`** | Code performs screen scraping, ad blocking, or automated click routines for standard users. | No (Violation) | `CRITICAL` | **High Risk of Play Store Rejection**: Remove accessibility helper configs. Migrate UI automation to standard Android testing libraries. | - | **`isAccessibilityTool` false/missing** | Code implements an accessibility listener but is not a dedicated helper app. | No prominent disclosure | `CRITICAL` | **High Risk of Play Store Rejection**: Because you request accessibility permission for a standard utility, you MUST implement an in-app **Prominent Disclosure and Affirmative Consent screen** before asking the user to enable the service, otherwise your app will be rejected. | - -- **Domain-Specific Heuristics (Strictly Bounded)**: - Trace the ingestion of accessibility events: - 1. **Telemetry Siphoning**: Scan the Accessibility Service methods - (`onAccessibilityEvent`). If the service captures on-screen text, - notifications, or keystrokes and routes them to local caches, shared - preferences, or off-device network logging endpoints, flag a `CRITICAL` - violation under the Accessibility API policy. -{{/IF_ALL}} - -{{#IF_ALL requested_permissions.APPS_ON_DEVICE, data_sources.APPS_ON_DEVICE}} -#### Package Visibility (Policy ID: package_visibility_policy) - -- **Goal**: Evaluate if the app's core functionality justifies broad visibility - into installed apps (`QUERY_ALL_PACKAGES` or related package APIs). -- **The Policy Spirit**: The list of installed apps reveals sensitive user - habits. The Play Store strictly restricts the `QUERY_ALL_PACKAGES` permission - to apps that directly manage device safety (Antivirus, File Managers, Device - Search). -- **Evidence**: - {{#EACH data_sources.APPS_ON_DEVICE}} - - `{{ITEM}}` - {{/EACH}} - **Relevant Permissions Requested**: - {{#EACH requested_permissions.APPS_ON_DEVICE}} - - `{{ITEM}}` - {{/EACH}} -- **Common Evaluation Matrix**: - | Core App Purpose | Permission Requested | Justified? | Severity | Direct Actionable Recommendation | - | :--- | :--- | :--- | :--- | :--- | - | **Antivirus, File Manager, or Device Search** | `QUERY_ALL_PACKAGES` | Yes (Compliant) | None | No action needed. | - | **Any App** | `QUERY_ALL_PACKAGES` | No, used for ads, analytics, or secondary marketing. | `IMPORTANT` | **High-Risk Violation**: Remove the permission from the Manifest. Use specific `` intent declarations if package checks are strictly required for sharing/integration. | - -- **Domain-Specific Heuristics (Strictly Bounded)**: - Examine indirect queries: - 1. **Indirect Package Searching**: Check if the code queries package details - dynamically using `PackageManager.getInstalledPackages()` or checks intents - in loops. If the intent lists are used to deduce if competitor apps or - advertising profiles exist, flag an `IMPORTANT` violation of package - visibility guidelines. -{{/IF_ALL}} - -{{#IF_ALL requested_permissions.SMS_CALL_LOG, data_sources.SMS_CALL_LOG}} -#### SMS and Call Log Permissions (Policy ID: sms_call_log_policy) - -- **Goal**: Evaluate if the app's core functionality justifies access to - sensitive SMS or Call Log data. -- **The Policy Spirit**: SMS and Call details are high-risk. Google Play - restricts access to default phone handlers and default SMS handlers. Standard - utility or shopping apps must use non-privileged APIs for SMS-based - verification. -- **Evidence**: - {{#EACH data_sources.SMS_CALL_LOG}} - - `{{ITEM}}` - {{/EACH}} - **Relevant Permissions Requested**: - {{#EACH requested_permissions.SMS_CALL_LOG}} - - `{{ITEM}}` - {{/EACH}} -- **Common Evaluation Matrix**: - | Code Context | Finding / Trigger Checked | Severity | Direct Actionable Recommendation | - | :--- | :--- | :--- | :--- | - | **OTP/Account Verification** | App requests SMS permissions to automatically read login verification codes (OTPs). | `IMPORTANT` | Migrate to the **SMS Retriever API** or **SMS User Consent API** which require zero permissions. | - | **Utility or Secondary Feature** | App requests SMS/Call log access for non-core dashboard or notification features. | `CRITICAL` | **High-Risk Violation**: Completely remove SMS and Call Log permissions from the Manifest. | - | **Default SMS Handler** | App appears to be a legitimate handler (e.g. implements required intent filters). | `SUGGESTION` | **Play Console Declaration**: You must submit a **Permissions Declaration Form** in the Play Console and may be required to provide a video demonstration of this core functionality. | - -- **Domain-Specific Heuristics (Strictly Bounded)**: - Audit background receivers: - 1. **Background Incoming SMS Listeners**: Check if a `BroadcastReceiver` - listens to `android.provider.Telephony.SMS_RECEIVED`. If this receiver - parses text in the background without being a designated default SMS - handler, flag a `CRITICAL` violation. - 2. **Default Handler Confirmation**: If the app requests SMS/Call Log - permissions and correctly implements the system-mandated intent filters for - a Default Handler, flag a `SUGGESTION` to remind the developer about the - mandatory Play Console declaration form. -{{/IF_ALL}} - -{{#IF_ALL requested_permissions.AUDIO, data_sources.AUDIO}} -#### Audio Recording Policy (Policy ID: audio_recording_policy) - -- **Goal**: Evaluate if the app's core functionality justifies broad access to - audio recording. -- **The Policy Spirit**: Unprompted audio recording is a severe privacy breach. - Apps should request microphone access strictly for user-visible, time-bounded - actions. Target SDK 34+ encourages using the system-managed Microphone Button - for temporary needs. -- **Evidence**: - {{#EACH data_sources.AUDIO}} - - `{{ITEM}}` - {{/EACH}} - **Relevant Permissions Requested**: - {{#EACH requested_permissions.AUDIO}} - - `{{ITEM}}` - {{/EACH}} - {{#IF TARGET_SDK}} - **Target SDK**: `{{TARGET_SDK}}` - {{/IF}} -- **Common Evaluation Matrix**: - | Target SDK | Audio Recording Trigger Context | Severity | Direct Actionable Recommendation | - | :--- | :--- | :--- | :--- | - | **34 or higher** | App requests broad `RECORD_AUDIO` permission for occasional, user-initiated vocal input or short recording. | `IMPORTANT` | Migrate to the **Android Microphone Button** API to process temporary audio securely. | - | **Any** | App captures audio for secondary features (analytics, user-agent details, etc.) without explicit user control. | `IMPORTANT` | Remove microphone permissions. For general searches, integrate standard Android Speech Recognizer intents. | - -- **Domain-Specific Heuristics (Strictly Bounded)**: - Verify recording indicators and threads: - 1. **Continuous Capture**: Scan for active recorder threads (`AudioRecord` or - `MediaRecorder`). If recording loops can be active when the app is - minimized or without a visible user-facing indicator, flag this as a - `CRITICAL` violation. -{{/IF_ALL}} - -{{#IF_ALL requested_permissions.FILES_AND_DOCS, data_sources.FILES_AND_DOCS}} -#### Files and Docs Access Policy (Policy ID: files_and_docs_policy) - -- **Goal**: Evaluate if broad file access (non-media) is justified or if the - Storage Access Framework should be used. -- **The Policy Spirit**: Storage isolation (Scoped Storage) is mandatory on - modern Android versions. Broad access to shared files is heavily restricted. - Standard files, documents, and download folders should be navigated using - scoped contracts to prevent global filesystem snooping. -- **Evidence**: - {{#EACH data_sources.FILES_AND_DOCS}} - - `{{ITEM}}` - {{/EACH}} - **Relevant Permissions Requested**: - {{#EACH requested_permissions.FILES_AND_DOCS}} - - `{{ITEM}}` - {{/EACH}} - {{#IF TARGET_SDK}} - **Target SDK**: `{{TARGET_SDK}}` - {{/IF}} -- **Common Evaluation Matrix**: - | Target SDK | Storage Configuration | Justified? | Severity | Direct Actionable Recommendation | - | :--- | :--- | :--- | :--- | :--- | - | **30 or higher** | App requests broad `READ_EXTERNAL_STORAGE` or `WRITE_EXTERNAL_STORAGE` for simple document selection. | No (Violation) | `IMPORTANT` | **Scoped Storage Mandate**: Migrate your document/file selections to the **Storage Access Framework (SAF)** (`Intent.ACTION_OPEN_DOCUMENT`). | - -- **Domain-Specific Heuristics (Strictly Bounded)**: - Critique external directory creation: - 1. **Manual File Sync Heuristic**: Check if the code creates custom root-level - folders on external storage (e.g. - `Environment.getExternalStorageDirectory() + "/my_folder"`). If directories - are created for standard document outputs or logging, flag an `IMPORTANT` - violation. Direct the developer to utilize scoped storage paths. -{{/IF_ALL}} - -## Output schema - -Save final JSON output to `{{TEMP_DIR}}/worker_{{GOAL_NAME}}.json`. - -```json -{ - "domain": "Permissions and APIs", - "findings": [ - { - "policy_id": "STRING_VALUE (The exact Policy ID, e.g., photo_video_access_policy)", - "issue_summary": "STRING_VALUE", - "severity": "CRITICAL | IMPORTANT | SUGGESTION", - "files_involved": ["STRING_VALUE"], - "evidence": "STRING_VALUE", - "recommendation": "STRING_VALUE" - } - ] -} -``` diff --git a/play/play-policy-insights/resources/goal_user_account.md b/play/play-policy-insights/resources/goal_user_account.md deleted file mode 100644 index 336e58d..0000000 --- a/play/play-policy-insights/resources/goal_user_account.md +++ /dev/null @@ -1,106 +0,0 @@ -{{#IF_ANY data_sources.USER_ACCOUNT, data_sources.ACCOUNT_DELETION}} -{{ACTIVATE_GOAL}} -{{/IF_ANY}} -## User Account and Identity Audit - -### Hint: Deducing Account Presence - -To determine if an app handles user accounts (even if it uses third-party -providers like Google Sign-In or Firebase Auth), look for: -1. **Login/Auth Screens**: Semantic files or layouts named `login`, `auth`, - `signin`, or `signup`. -2. **Account Management**: APIs like `AccountManager`, `CredentialManager`, or - `Firebase.auth`. -3. **User Profile**: UI strings or data models referencing `profile`, - `my account`, or `user settings`. - -### Policies to Verify - -{{#IF_ANY data_sources.USER_ACCOUNT, semantic_files.USER_ACCOUNT}} -#### Play Console Requirements (Policy ID: login_credentials) - -- **Goal**: Identify if the app implements a login wall or authentication - screen. -- **The Policy Spirit**: To ensure Play Store reviewers can successfully test - and audit apps, developers must submit functional, non-expiring credentials in - Play Console if the app's features are gated behind a login screen. -- **Evidence**: - {{#IF semantic_files.USER_ACCOUNT}} - - **Account Files**: - `{{#EACH semantic_files.USER_ACCOUNT}}{{ITEM}}, {{/EACH}}` - {{/IF}} - {{#IF data_sources.USER_ACCOUNT}} - - **Account Signals**: - `{{#EACH data_sources.USER_ACCOUNT}}{{ITEM}}, {{/EACH}}` - {{/IF}} -- **Common Evaluation Matrix**: - | App State | Finding / Condition | Severity | Actionable Recommendation | - | :--- | :--- | :--- | :--- | - | **Login Screen Detected** | App displays or contains a login screen, registration wall, or authentication interface. | `IMPORTANT` | **Administrative Console Requirements**: Because your app implements a login flow, you MUST complete two manual setup steps in the Play Console dashboard to pass review:
1. **Reviewer Demo Credentials**: Submit active, non-expiring test credentials so Google Play reviewers can access your gated features.
2. **Account Deletion Link**: Submit a public-facing web link for account deletion to satisfy Google Play's data deletion policies. | - -- **Domain-Specific Heuristics (Strictly Bounded)**: - Look beyond standard native login buttons. Extrapolate using this heuristic: - 1. **Hidden Gatekeepers**: Analyze if certain critical features (e.g., - synchronizing local database, checkout forms, or member-only dashboards) - require authentication even if the app opens directly to a main page. If - you deduce that functional workflows require a login, treat it as a login - gate and output the `IMPORTANT` console credentials and deletion link - reminders. -{{/IF_ANY}} - -{{#IF_ANY data_sources.USER_ACCOUNT, data_sources.ACCOUNT_DELETION}} -#### Account Deletion Requirement (Policy ID: account_deletion) - -- **Goal**: If the app handles user accounts, it must provide a discoverable - in-app account deletion mechanism. -- **The Policy Spirit**: Users have a fundamental right to request data erasure. - If they can create an account in-app, they must be able to delete it in-app. - Deletion must wipe remote database records, not just sign out. -- **Evidence**: - {{#IF data_sources.USER_ACCOUNT}} - - **Account signals (Presence)**: - `{{#EACH data_sources.USER_ACCOUNT}}{{ITEM}}, {{/EACH}}` - {{/IF}} - {{#IF data_sources.ACCOUNT_DELETION}} - - **Deletion signals (Mechanism)**: - `{{#EACH data_sources.ACCOUNT_DELETION}}{{ITEM}}, {{/EACH}}` - {{/IF}} -- **Common Evaluation Matrix**: - | App Account Status | Finding / Deletion Evidence | Severity | Actionable Recommendation | - | :--- | :--- | :--- | :--- | - | **Handles User Accounts** | App manages user accounts, but NO code, layout, or string suggests an in-app deletion button or process. | `IMPORTANT` | **Implement in-app deletion**: Create a highly discoverable path (e.g., under User Profile/Account Settings) to let users initiate account deletion directly in the app. | - -- **Domain-Specific Heuristics (Strictly Bounded)**: - Do not limit your analysis to basic "Delete" buttons. Actively analyze custom - and grey-area implementations: - 1. **The Partial Deletion Trap**: Read the implementation of any found - deletion mechanisms. If the code merely calls `clearPreferences()`, clears - a local cookie, or triggers a standard `logout()` without sending a remote - delete/purge network API call to clean up backend user records, flag this - as a `IMPORTANT` violation of the deletion mandate. - 2. **Indirect User Accounts**: If the app uses third-party sign-in bridges - (e.g., Google Sign-In, Firebase) but does not store an - explicit account profile on its own server, it still handles user account - details if any user preferences or device identifiers are cached remotely. - If so, a delete link/button is still required. -{{/IF_ANY}} - -## Output schema - -Save final JSON output to `{{TEMP_DIR}}/worker_{{GOAL_NAME}}.json`. - -```json -{ - "domain": "User Account and Identity", - "findings": [ - { - "policy_id": "STRING_VALUE (The exact Policy ID, e.g., account_deletion)", - "issue_summary": "STRING_VALUE", - "severity": "CRITICAL | IMPORTANT | SUGGESTION", - "files_involved": ["STRING_VALUE"], - "evidence": "STRING_VALUE", - "recommendation": "STRING_VALUE" - } - ] -} -``` diff --git a/play/play-policy-insights/resources/policies.json b/play/play-policy-insights/resources/policies.json deleted file mode 100644 index d069a83..0000000 --- a/play/play-policy-insights/resources/policies.json +++ /dev/null @@ -1,321 +0,0 @@ -{ - "data_safety_section": { - "name": "Data safety section", - "category": "User Data", - "urls": [ - "https://support.google.com/googleplay/android-developer/answer/10144311#safetysection", - "https://developer.android.com/privacy-and-security/declare-data-use", - "https://support.google.com/googleplay/android-developer/answer/10787469" - ], - "taxonomy": { - "PRECISE_LOCATION": { - "category": "Location", - "data_type": "Precise location", - "description": "User or device physical location within an area less than 3 square kilometers, such as location provided by Android’s ACCESS_FINE_LOCATION permission." - }, - "APPROX_LOCATION": { - "category": "Location", - "data_type": "Approximate location", - "description": "User or device physical location to an area greater than or equal to 3 square kilometers, such as the city a user is in, or location provided by Android’s ACCESS_COARSE_LOCATION permission." - }, - "USER_ACCOUNT": { - "category": "Personal info", - "data_type": "User IDs", - "description": "Identifiers that relate to an identifiable person. For example, an account ID, account number, or account name." - }, - "NAME": { - "category": "Personal info", - "data_type": "Name", - "description": "How a user refers to themselves, such as their first or last name, or nickname." - }, - "EMAIL": { - "category": "Personal info", - "data_type": "Email address", - "description": "A user’s email address." - }, - "ADDRESS": { - "category": "Personal info", - "data_type": "Address", - "description": "A user’s address, such as a mailing or home address." - }, - "PHONE": { - "category": "Personal info", - "data_type": "Phone number", - "description": "A user’s phone number." - }, - "RACE_ETHNICITY": { - "category": "Personal info", - "data_type": "Race and ethnicity", - "description": "Information about a user’s race or ethnicity." - }, - "POLITICAL_RELIGIOUS_BELIEFS": { - "category": "Personal info", - "data_type": "Political or religious beliefs", - "description": "Information about a user’s political or religious beliefs." - }, - "SEXUAL_ORIENTATION": { - "category": "Personal info", - "data_type": "Sexual orientation", - "description": "Information about a user’s sexual orientation." - }, - "PERSONAL_INFO_OTHER": { - "category": "Personal info", - "data_type": "Other info", - "description": "Any other personal information such as date of birth, gender identity, veteran status, etc." - }, - "CREDIT_DEBIT_BANK_ACCOUNT_NUMBER": { - "category": "Financial info", - "data_type": "User payment info", - "description": "Information about a user’s financial accounts such as credit card number." - }, - "PURCHASE_HISTORY": { - "category": "Financial info", - "data_type": "Purchase history", - "description": "Information about purchases or transactions a user has made." - }, - "CREDIT_SCORE": { - "category": "Financial info", - "data_type": "Credit score", - "description": "Information about a user’s credit score." - }, - "FINANCIAL_INFO_OTHER": { - "category": "Financial info", - "data_type": "Other financial info", - "description": "Any other financial information such as user salary or debts." - }, - "HEALTH": { - "category": "Health and fitness", - "data_type": "Health info", - "description": "Information about a user's health, such as medical records or symptoms." - }, - "FITNESS": { - "category": "Health and fitness", - "data_type": "Fitness info", - "description": "Information about a user's fitness, such as exercise or other physical activity." - }, - "EMAILS": { - "category": "Messages", - "data_type": "Emails", - "description": "A user’s emails including the email subject line, sender, recipients, and the content of the email." - }, - "SMS_CALL_LOG": { - "category": "Messages", - "data_type": "SMS or MMS", - "description": "A user’s text messages including the sender, recipients, and the content of the message." - }, - "OTHER_MESSAGES": { - "category": "Messages", - "data_type": "Other in-app messages", - "description": "Any other types of messages. For example, instant messages or chat content." - }, - "PHOTOS": { - "category": "Photos and videos", - "data_type": "Photos", - "description": "A user’s photos." - }, - "VIDEOS": { - "category": "Photos and videos", - "data_type": "Videos", - "description": "A user’s videos." - }, - "AUDIO": { - "category": "Audio files", - "data_type": "Voice or sound recordings", - "description": "A user’s voice such as a voicemail or a sound recording." - }, - "MUSIC": { - "category": "Audio files", - "data_type": "Music files", - "description": "A user’s music files." - }, - "OTHER_AUDIO": { - "category": "Audio files", - "data_type": "Other audio files", - "description": "Any other user-created or user-provided audio files." - }, - "FILES_AND_DOCS": { - "category": "Files and docs", - "data_type": "Files and docs", - "description": "A user’s files or documents, or information about their files or documents such as file names." - }, - "CALENDAR": { - "category": "Calendar", - "data_type": "Calendar events", - "description": "Information from a user’s calendar such as events, event notes, and attendees." - }, - "CONTACTS": { - "category": "Contacts", - "data_type": "Contacts", - "description": "Information about the user’s contacts such as contact names, message history, and social graph information like usernames, contact recency, contact frequency, interaction duration and call history." - }, - "APP_INTERACTIONS": { - "category": "App activity", - "data_type": "App interactions", - "description": "Information about how a user interacts with the app. For example, the number of times they visit a page or sections they tap on." - }, - "IN_APP_SEARCH_HISTORY": { - "category": "App activity", - "data_type": "In-app search history", - "description": "Information about what a user has searched for in your app." - }, - "APPS_ON_DEVICE": { - "category": "App activity", - "data_type": "Installed apps", - "description": "Information about the apps installed on a user's device." - }, - "USER_GENERATED_CONTENT": { - "category": "App activity", - "data_type": "Other user-generated content", - "description": "Any other user-generated content not listed here, or in any other section. For example, user bios, notes, or open-ended responses." - }, - "OTHER_ACTIONS": { - "category": "App activity", - "data_type": "Other actions", - "description": "Any other user activity or actions in-app not listed here such as gameplay, likes, and dialog options." - }, - "WEB_BROWSING_HISTORY": { - "category": "Web browsing", - "data_type": "Web browsing history", - "description": "Information about the websites a user has visited." - }, - "CRASH_LOGS": { - "category": "App info and performance", - "data_type": "Crash logs", - "description": "Crash log data from your app. For example, the number of times your app has crashed, stack traces, or other information directly related to a crash." - }, - "PERFORMANCE_DIAGNOSTICS": { - "category": "App info and performance", - "data_type": "Diagnostics", - "description": "Information about the performance of your app. For example battery life, loading time, latency, framerate, or any technical diagnostics." - }, - "OTHER_APP_PERFORMANCE": { - "category": "App info and performance", - "data_type": "Other app performance data", - "description": "Any other app performance data excluding crash log data (e.g., stack traces) and diagnostics (e.g., battery life, loading time, latency, or framerate)." - }, - "DEVICE_ID": { - "category": "Device or other IDs", - "data_type": "Device or other IDs", - "description": "Identifiers that relate to an individual device, browser or app. For example, an IMEI number, MAC address, Widevine Device ID, Firebase installation ID, or advertising identifier." - } - } - }, - "login_credentials": { - "name": "Login credentials", - "category": "Play Console Requirements", - "urls": [ - "https://support.google.com/googleplay/android-developer/answer/15748846" - ] - }, - "sms_call_log_policy": { - "name": "SMS and Call Log Permissions", - "category": "Permissions", - "urls": [ - "https://support.google.com/googleplay/android-developer/answer/16558241#sms-call-permissions", - "https://support.google.com/googleplay/android-developer/answer/10208820" - ] - }, - "package_visibility_policy": { - "name": "Package Visibility", - "category": "Permissions", - "urls": [ - "https://support.google.com/googleplay/android-developer/answer/16558241#package-vis", - "https://support.google.com/googleplay/android-developer/answer/10158779" - ] - }, - "all_files_access_policy": { - "name": "All Files Access", - "category": "Permissions", - "urls": [ - "https://support.google.com/googleplay/android-developer/answer/16558241#all-files-access", - "https://developer.android.com/training/data-storage/manage-all-files", - "https://support.google.com/googleplay/android-developer/answer/10467955" - ] - }, - "photo_video_access_policy": { - "name": "Photo and Video Permissions", - "category": "Permissions", - "urls": [ - "https://support.google.com/googleplay/android-developer/answer/16558241#photo-and-video-permissions", - "https://support.google.com/googleplay/android-developer/answer/16935362", - "https://developer.android.com/training/data-storage/shared/media" - ] - }, - "location_access_policy": { - "name": "Location Permissions", - "category": "Permissions", - "urls": [ - "https://support.google.com/googleplay/android-developer/answer/16558241", - "https://support.google.com/googleplay/android-developer/answer/9799150" - ] - }, - "contacts_access_policy": { - "name": "Contacts Permissions", - "category": "Permissions", - "urls": [ - "https://support.google.com/googleplay/android-developer/answer/16909972#contacts-permissions", - "https://support.google.com/googleplay/android-developer/answer/16935362" - ] - }, - "audio_recording_policy": { - "name": "Audio Recording Policy", - "category": "Permissions", - "urls": [ - "https://support.google.com/googleplay/android-developer/answer/10144311", - "https://support.google.com/googleplay/android-developer/answer/16558241" - ] - }, - "files_and_docs_policy": { - "name": "Files and Docs Access Policy", - "category": "Permissions", - "urls": [ - "https://support.google.com/googleplay/android-developer/answer/10467955", - "https://support.google.com/googleplay/android-developer/answer/16558241" - ] - }, - "prominent_disclosure_policy": { - "name": "Prominent Disclosure Policy", - "category": "User Data", - "urls": [ - "https://support.google.com/googleplay/android-developer/answer/10144311" - ] - }, - "account_deletion": { - "name": "Account Deletion Requirement", - "category": "User Data", - "urls": [ - "https://support.google.com/googleplay/android-developer/answer/10144311#account_deletion", - "https://support.google.com/googleplay/android-developer/answer/13327111" - ] - }, - "target_api_level": { - "name": "Target API Level Requirements", - "category": "Privacy, Deception and Device Abuse", - "urls": [ - "https://support.google.com/googleplay/android-developer/answer/16561298", - "https://support.google.com/googleplay/android-developer/answer/11926878" - ] - }, - "accessibility_api_policy": { - "name": "Accessibility API Policy", - "category": "Permissions", - "urls": [ - "https://support.google.com/googleplay/android-developer/answer/16558241#accessibility" - ] - }, - "exact_alarm_policy": { - "name": "Exact Alarm Policy", - "category": "Permissions", - "urls": [ - "https://support.google.com/googleplay/android-developer/answer/16558241#exact_alarm" - ] - }, - "foreground_services_policy": { - "name": "Foreground Services", - "category": "Privacy, Deception and Device Abuse", - "urls": [ - "https://support.google.com/googleplay/android-developer/answer/13392821", - "https://support.google.com/googleplay/android-developer/answer/16559646#foreground_service" - ] - } -} diff --git a/play/play-policy-insights/resources/scanner_config.json b/play/play-policy-insights/resources/scanner_config.json deleted file mode 100644 index 79ae497..0000000 --- a/play/play-policy-insights/resources/scanner_config.json +++ /dev/null @@ -1,843 +0,0 @@ -{ - "signal_categories": { - "data_sources": { - "PRECISE_LOCATION": [ - "ACCESS_FINE_LOCATION", - "FusedLocationProviderClient", - "requestSingleUpdate", - "getLastKnownLocation", - "navigator.geolocation", - "Geolocator", - "location.getLocation()", - "latitude", - "longitude", - "gps_coords", - "watchPosition", - "getCurrentPosition", - "getPositionStream", - "LocationAccuracy.high", - "react-native-geolocation-service", - "Expo.Location", - "Geolocation.GetLocationAsync", - "GeolocationRequest", - "GeolocationAccuracy", - "Microsoft.Maui.Devices.Sensors" - ], - "APPROX_LOCATION": [ - "ACCESS_COARSE_LOCATION", - "ACCESS_BACKGROUND_LOCATION", - "LocationManager", - "coarse_location", - "ip_location", - "ip_country", - "LocationAccuracy.low", - "LocationAccuracy.balanced", - "GeolocationAccuracy.Low", - "GeolocationAccuracy.Lowest" - ], - "USER_ACCOUNT": [ - "AccountManager", - "registerUser", - "login", - "signIn", - "signUp", - "createAccount", - "GET_ACCOUNTS", - "isLoggedIn", - "sign in", - "firebaseAuth", - "CredentialManager", - "credentials", - "auth_token", - "session_token", - "user_id", - "uid", - "FirebaseAuth", - "GoogleSignIn", - "SignInWithApple", - "flutter_facebook_auth", - "Auth0", - "@react-native-firebase/auth", - "react-native-google-signin", - "react-native-app-auth", - "WebAuthenticator", - "IPublicClientApplication", - "AcquireTokenInteractive", - "SecureStorage.SetAsync", - "Microsoft.Identity.Client" - ], - "ACCOUNT_DELETION": [ - "deleteAccount", - "purgeUserData", - "closeAccount", - "removeUser", - "deactivate", - "requestDelete", - "delete_profile", - "destroy_account" - ], - "NAME": [ - "textPersonName", - "user_name", - "firstName", - "lastName", - "display_name", - "full_name", - "fullName", - "real_name", - "realName", - "family_name", - "familyName", - "given_name", - "contact_name", - "profile_name", - "NAME_DATA_EXTRA", - "middleName", - "nickname", - "TextInputType.name" - ], - "EMAIL": [ - "textEmailAddress", - "email_address", - "user_email", - ".email", - "emailAddress", - "userEmail", - "accountEmail", - "senderEmail", - "recipientEmail", - "mailAddress", - "getEmail(", - "setEmail(", - "email_id", - "primary_email", - "keyboardType=\"email-address\"", - "TextInputType.emailAddress", - "Email.ComposeAsync", - "EmailMessage" - ], - "ADDRESS": [ - "textPostalAddress", - "postal_address", - "user_address", - "street_address", - "billing_address", - "shipping_address", - "postal_code", - "zip_code", - "country_code", - "autoComplete=\"postal-address\"", - "TextInputType.streetAddress" - ], - "PHONE": [ - "textPhone", - "phone_number", - "user_phone", - "getLine1Number", - "READ_PHONE_NUMBERS", - "mobile_phone", - "telephone", - "phone_id", - "textContentType=\"telephoneNumber\"", - "TextInputType.phone", - "PhoneDialer.Open", - "PhoneDialer.Default" - ], - "RACE_ETHNICITY": [ - "race", - "ethnicity", - "tribal", - "demographics" - ], - "POLITICAL_RELIGIOUS_BELIEFS": [ - "religion", - "religious", - "political_affiliation", - "political_party", - "political_beliefs" - ], - "SEXUAL_ORIENTATION": [ - "sexual_orientation", - "gender_identity", - "gender_preference" - ], - "PERSONAL_INFO_OTHER": [ - "date_of_birth", - "dob", - "birth_date", - "gender", - "sex", - "salutation" - ], - "CREDIT_DEBIT_BANK_ACCOUNT_NUMBER": [ - "cardNumber", - "card_number", - "cvv", - "expiry_date", - "stripe", - "paypal", - "bank_account", - "iban", - "swift_code", - "credit_card", - "card_holder", - "routing_number", - "flutter_stripe", - "stripe-react-native", - "initPaymentSheet" - ], - "PURCHASE_HISTORY": [ - "purchaseHistory", - "transactionHistory", - "orderHistory", - "orderId", - "purchaseToken", - "queryPurchases", - "queryPurchasesAsync", - "startPurchaseFlow", - "PurchasesUpdatedListener", - "BillingClient", - "receipt", - "invoice", - "payment_history", - "in_app_purchase", - "react-native-iap", - "RNIap" - ], - "CREDIT_SCORE": [ - "credit_score", - "fico", - "credit_rating", - "equifax", - "experian", - "transunion" - ], - "FINANCIAL_INFO_OTHER": [ - "salary", - "income", - "net_worth", - "outstanding_debt", - "user_debt" - ], - "HEALTH": [ - "BODY_SENSORS", - "BODY_SENSORS_BACKGROUND", - "SensorManager", - "Health Connect", - "medical_record", - "symptoms", - "blood_pressure", - "glucose_level", - "HealthFactory", - "HealthDataType", - "getHealthDataFromTypes", - "HealthConnectClient", - "AppleHealthKit" - ], - "FITNESS": [ - "ACTIVITY_RECOGNITION", - "Google Fit", - "HealthConnect", - "Sleep API", - "step_counter", - "HeartRate", - "Heart_Rate", - "Cadence", - "PowerMeter", - "CyclingPower", - "CaloriesBurned", - "Workout", - "PhysicalActivity", - "GPX", - "ANT+", - "react-native-google-fit", - "GoogleFit", - "fitbit" - ], - "EMAILS": [ - "javax.mail", - "smtp", - "imap", - "pop3", - "message/rfc822", - "mailserver", - "email_body", - "sendMail", - "mail_subject", - "email_attachment" - ], - "SMS_CALL_LOG": [ - "READ_SMS", - "RECEIVE_SMS", - "SEND_SMS", - "READ_CALL_LOG", - "WRITE_CALL_LOG", - "SmsRetriever", - "Sms.ComposeAsync", - "SmsMessage" - ], - "OTHER_MESSAGES": [ - "chat_message", - "instant_message", - "xmpp", - "matrix_sdk", - "chat_history", - "direct_message", - "send_message", - "websocket_chat" - ], - "MEDIA": [ - "READ_MEDIA_IMAGES", - "READ_MEDIA_VIDEO", - "PhotoPicker", - "MediaStore", - "ACTION_GET_CONTENT", - "ACTION_OPEN_DOCUMENT", - "image/*", - "video/*" - ], - "PHOTOS": [ - "READ_MEDIA_IMAGES", - "READ_MEDIA_VISUAL_USER_SELECTED", - "PhotoPicker", - "PickVisualMedia", - "MediaStore.Images", - "ACTION_PICK", - "ACTION_GET_CONTENT", - "image/*", - "TakePicture", - "ImageCapture", - "CAMERA", - "image_picker", - "launchImageLibrary", - "launchCamera", - "react-native-image-picker", - "expo-image-picker", - "wechat_assets_picker", - "MediaPicker.PickPhotoAsync", - "MediaPicker.CapturePhotoAsync" - ], - "VIDEOS": [ - "READ_MEDIA_VIDEO", - "READ_MEDIA_VISUAL_USER_SELECTED", - "MediaStore.Video", - "video/*", - "PickVisualMedia.VideoOnly", - "ACTION_VIDEO_CAPTURE", - "VideoCapture", - "MediaRecorder", - "ExoPlayer", - "MediaController", - "pickVideo", - "react-native-vision-camera", - "CameraRoll", - "expo-image-picker", - "MediaPicker.PickVideoAsync", - "MediaPicker.CaptureVideoAsync" - ], - "AUDIO": [ - "AudioRecord", - "MediaRecorder", - "RECORD_AUDIO", - "CAPTURE_AUDIO_OUTPUT", - "SpeechRecognizer", - "RecognitionListener", - "VoiceInteractionService", - "audio/*", - "audio/amr", - "audio/3gpp", - "voice_note", - "voice_memo", - "voicemail", - "dictation", - "voice_recorder", - "ambient_sound", - "record_button", - "mic_icon", - "record", - "Record.hasPermission", - "flutter_sound", - "FlutterSoundRecorder", - "react-native-audio-recorder-player", - "expo-av", - "Permissions.Microphone", - "SpeechToText", - "AudioRecorder" - ], - "MUSIC": [ - "MediaPlayer", - "ExoPlayer", - "SimpleExoPlayer", - "AudioTrack", - "MediaController", - "MediaSession", - "PlaybackState", - "IS_MUSIC", - "MediaStore.Audio.Media", - "MediaStore.Audio.Playlists", - "MediaStore.Audio.Albums", - "MediaStore.Audio.Artists", - "audio/mpeg", - "audio/mp3", - "audio/flac", - "audio/x-wav", - "music", - "playlist", - "artist", - "album", - "track", - "song", - "mp3", - "flac", - "m4a", - "wma", - "audioplayers", - "assets_audio_player", - "react-native-track-player", - "react-native-sound" - ], - "OTHER_AUDIO": [ - "RingtoneManager", - "SoundPool", - "ACTUAL_DEFAULT_RINGTONE_URI", - "ACTION_RINGTONE_PICKER", - "IS_RINGTONE", - "IS_NOTIFICATION", - "IS_ALARM", - "IS_PODCAST", - "audio/ogg", - "audio/aac", - "audio/webm", - "ringtone", - "notification_sound", - "alarm_sound", - "podcast", - "sound_effect", - "audio_effect", - "sfx", - "soundboard" - ], - "FILES_AND_DOCS": [ - "READ_EXTERNAL_STORAGE", - "WRITE_EXTERNAL_STORAGE", - "MANAGE_EXTERNAL_STORAGE", - "*/*", - "localStorage", - "sessionStorage", - "AsyncStorage", - "shared_preferences", - "flutter_secure_storage", - "Hive", - "sqflite", - "application/pdf", - "application/msword", - "text/plain", - "path_provider", - "getApplicationDocumentsDirectory", - "getExternalStorageDirectory", - "getTemporaryDirectory", - "react-native-fs", - "RNFS", - "expo-file-system", - "MMKV", - "react-native-mmkv", - "Sembast", - "RxDB", - "Realm", - "FilePicker.PickAsync", - "FilePicker.PickMultipleAsync" - ], - "CALENDAR": [ - "READ_CALENDAR", - "WRITE_CALENDAR", - "CalendarContract", - "device_calendar", - "retrieveCalendars", - "react-native-calendar-events", - "RNCalendarEvents", - "Permissions.CalendarRead", - "Permissions.CalendarWrite" - ], - "CONTACTS": [ - "READ_CONTACTS", - "WRITE_CONTACTS", - "ContactsContract", - "contacts_service", - "ContactsService.getContacts", - "flutter_contacts", - "FlutterContacts", - "react-native-contacts", - "Contacts.getAll", - "Contacts.PickContactAsync", - "Contacts.GetAllAsync", - "Microsoft.Maui.ApplicationModel.Communication.Contacts" - ], - "APP_INTERACTIONS": [ - "logEvent", - "trackScreen", - "onItemClick", - "onClickListener", - "firebase.analytics()", - "Mixpanel", - "Amplitude", - "FirebaseAnalytics", - "AmplitudeFlutter", - "user_session", - "event_params", - "page_view", - "FirebaseAnalytics.instance", - "facebook_app_events", - "amplitude_flutter", - "mixpanel_flutter", - "@react-native-firebase/analytics", - "react-native-fbsdk-next", - "mixpanel-react-native", - "AppEventsLogger" - ], - "IN_APP_SEARCH_HISTORY": [ - "searchHistory", - "search_query", - "SearchRecentSuggestions", - "search_term", - "query_string" - ], - "APPS_ON_DEVICE": [ - "QUERY_ALL_PACKAGES", - "getPackageInfo", - "getInstalledPackages", - "queryIntentActivities" - ], - "USER_GENERATED_CONTENT": [ - "textMultiLine", - "edit_text", - "user_note", - "user_bio", - "user_feedback", - "comment_text" - ], - "WEB_BROWSING_HISTORY": [ - "WebView", - "loadUrl", - "WebSettings", - "setJavaScriptEnabled", - "browser_history", - "visited_url" - ], - "CRASH_LOGS": [ - "ApplicationErrorReport", - "Crashlytics", - "ACRA", - "Bugsnag", - "Sentry", - "uncaughtException", - "recordFlutterError", - "recordError", - "SentryFlutter", - "crashlytics()", - "Microsoft.AppCenter.Crashes", - "SentrySdk" - ], - "PERFORMANCE_DIAGNOSTICS": [ - "ActivityManager", - "BatteryManager", - "Choreographer", - "Debug", - "PowerManager", - "StrictMode", - "FirebasePerf", - "trace", - "api_latency", - "write_latency", - "query_time", - "elapsed_time", - "execution_time" - ], - "OTHER_APP_PERFORMANCE": [ - "WorkInfo", - "sync_status", - "sync_failed", - "api_error", - "request_timeout", - "status_code", - "getDatabasePath", - "db_size", - "database_size", - "Macrobenchmark", - "benchmark", - "telemetry", - "cpu_usage", - "memory_usage", - "heap_usage", - "bytes_sent", - "bytes_received", - "payload_size", - "heartbeat", - "foreground_duration", - "background_duration", - "cache_hit_rate", - "cache_size", - "queue_depth" - ], - "DEVICE_ID": [ - "TelephonyManager", - "getDeviceId", - "getSubscriberId", - "IMEI", - "IMSI", - "AndroidId", - "Settings.Secure.ANDROID_ID", - "AdvertisingIdClient", - "instanceId", - "firebase_token", - "push_token", - "fcm_token", - "device_info_plus", - "DeviceInfoPlugin", - "AndroidDeviceInfo", - "react-native-device-info", - "DeviceInfo", - "getUniqueId", - "getAndroidId", - "getAdvertisingId", - "DeviceInfo.Current", - "Microsoft.Maui.Devices.DeviceInfo" - ], - "OTHER_ACTIONS": [ - "gameplay", - "button_click", - "option_selected", - "user_action" - ] - }, - "network_transmission": { - "-": [ - "fetch", - "axios", - "XMLHttpRequest", - "WebSocket", - "http.get", - "http.post", - "Dio", - "HttpClient", - "HttpRequestMessage", - "GetStringAsync", - "RestSharp", - "Refit", - "Flurl" - ] - }, - "exact_alarm": { - "-": [ - "USE_EXACT_ALARM", - "SCHEDULE_EXACT_ALARM", - "setExact", - "setExactAndAllowWhileIdle", - "AlarmManager" - ] - }, - "accessibility": { - "-": [ - "AccessibilityService", - "AccessibilityEvent", - "BIND_ACCESSIBILITY_SERVICE" - ] - }, - "foreground_service": { - "-": [ - "FOREGROUND_SERVICE", - "startForeground", - "foregroundServiceType", - "ServiceInfo.FOREGROUND_SERVICE_TYPE", - "FOREGROUND_SERVICE_SPECIAL_USE", - "PROPERTY_SPECIAL_USE_FGS_SUBTYPE", - "specialUse" - ] - }, - "disclosure": { - "-": [ - "AlertDialog", - "Dialog", - "MaterialAlertDialogBuilder", - "Consent", - "Disclosure", - "Privacy Policy", - "Accept", - "Agree", - "collect", - "share", - "transmit", - "data safety", - "privacy" - ] - } - }, - "file_patterns": { - "USER_ACCOUNT": [ - "account", - "login", - "auth" - ], - "SUPPORT": [ - "support", - "help" - ], - "LEGAL": [ - "privacy", - "terms", - "legal" - ], - "CONFIG": [ - "config" - ], - "AUDIO": [ - "record", - "voice", - "mic" - ], - "MUSIC": [ - "music", - "playlist", - "song", - "player" - ], - "OTHER_AUDIO": [ - "ringtone", - "sound", - "effect", - "podcast", - "sfx" - ], - "OTHER_APP_PERFORMANCE": [ - "telemetry", - "benchmark", - "sync", - "performance" - ], - "PHOTOS": [ - "photo", - "image", - "picture", - "camera", - "avatar", - "gallery" - ], - "VIDEOS": [ - "video", - "movie", - "playback", - "camcorder", - "recording" - ] - }, - "permission_groups": { - "LOCATION": [ - "android.permission.ACCESS_FINE_LOCATION", - "android.permission.ACCESS_COARSE_LOCATION", - "android.permission.ACCESS_BACKGROUND_LOCATION" - ], - "CONTACTS": [ - "android.permission.READ_CONTACTS", - "android.permission.WRITE_CONTACTS" - ], - "SMS_CALL_LOG": [ - "android.permission.READ_SMS", - "android.permission.RECEIVE_SMS", - "android.permission.SEND_SMS", - "android.permission.RECEIVE_MMS", - "android.permission.RECEIVE_WAP_PUSH", - "android.permission.READ_CALL_LOG", - "android.permission.WRITE_CALL_LOG", - "android.permission.PROCESS_OUTGOING_CALLS" - ], - "MEDIA": [ - "android.permission.READ_MEDIA_IMAGES", - "android.permission.READ_MEDIA_VIDEO", - "android.permission.READ_EXTERNAL_STORAGE", - "android.permission.WRITE_EXTERNAL_STORAGE" - ], - "PHOTOS": [ - "android.permission.READ_MEDIA_IMAGES", - "android.permission.READ_MEDIA_VISUAL_USER_SELECTED", - "android.permission.READ_EXTERNAL_STORAGE", - "android.permission.WRITE_EXTERNAL_STORAGE", - "android.permission.CAMERA" - ], - "VIDEOS": [ - "android.permission.READ_MEDIA_VIDEO", - "android.permission.READ_MEDIA_VISUAL_USER_SELECTED", - "android.permission.READ_EXTERNAL_STORAGE", - "android.permission.WRITE_EXTERNAL_STORAGE", - "android.permission.CAMERA", - "android.permission.RECORD_AUDIO" - ], - "ALL_FILES": [ - "android.permission.MANAGE_EXTERNAL_STORAGE" - ], - "AUDIO": [ - "android.permission.RECORD_AUDIO", - "android.permission.READ_MEDIA_AUDIO", - "android.permission.READ_EXTERNAL_STORAGE", - "android.permission.WRITE_EXTERNAL_STORAGE" - ], - "MUSIC": [ - "android.permission.READ_MEDIA_AUDIO", - "android.permission.READ_EXTERNAL_STORAGE", - "android.permission.WRITE_EXTERNAL_STORAGE" - ], - "OTHER_AUDIO": [ - "android.permission.READ_MEDIA_AUDIO", - "android.permission.READ_EXTERNAL_STORAGE", - "android.permission.WRITE_EXTERNAL_STORAGE" - ], - "FILES_AND_DOCS": [ - "android.permission.READ_EXTERNAL_STORAGE", - "android.permission.WRITE_EXTERNAL_STORAGE" - ], - "APPS_ON_DEVICE": [ - "android.permission.QUERY_ALL_PACKAGES" - ], - "exact_alarm": [ - "android.permission.USE_EXACT_ALARM", - "android.permission.SCHEDULE_EXACT_ALARM" - ], - "foreground_service": [ - "android.permission.FOREGROUND_SERVICE" - ], - "accessibility": [ - "android.permission.BIND_ACCESSIBILITY_SERVICE" - ] - }, - "ignored_directories": [ - ".git", - ".scratch", - "build", - ".gradle", - ".idea", - "test", - "androidTest", - "tests", - "testFixtures", - "node_modules", - ".pub-cache", - "ios", - "web", - "macos", - "windows", - "linux", - "bin", - "obj" - ], - "supported_extensions": [ - ".java", - ".kt", - ".js", - ".ts", - ".jsx", - ".tsx", - ".dart", - ".cs", - ".vue", - ".csproj", - ".xaml" - ] -} diff --git a/play/play-policy-insights/scripts/generate_report.py b/play/play-policy-insights/scripts/generate_report.py deleted file mode 100755 index cb4112b..0000000 --- a/play/play-policy-insights/scripts/generate_report.py +++ /dev/null @@ -1,876 +0,0 @@ -#!/usr/bin/env python3 - -# Copyright 2026 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 -# -# https://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. - -"""Generates a comprehensive compliance report from synthesized findings.""" - -import argparse -from datetime import datetime -import glob -import json -import os -import re -import sys -import play_store_scraper -from template_engine import render_template - -# Precompiled regex patterns at module scope for performance optimizations -EMOJI_PATTERN = re.compile( - r"[\U00010000-\U0010ffff\u2600-\u27bf\u2300-\u23ff\u2b50]+", - flags=re.UNICODE, -) -CODE_LINK_PATTERN = re.compile(r"^(.*?)(?:[:\s]+L?(\d+))?$") -SEVERITY_CLEAN_PATTERN = re.compile(r"['\"`*🔴🟡🔵\s]+") - - -def run_scraper(package_name, output_dir): - """Invokes the Play Store scraper directly.""" - try: - print(f"Auditing {package_name} from Play Store...", file=sys.stderr) - result = play_store_scraper.scrape_app_details(package_name) - - # Save to temp for consistency with existing report logic if needed - os.makedirs(output_dir, exist_ok=True) - out_path = os.path.join(output_dir, "play_store_declaration.json") - with open(out_path, "w", encoding="utf-8") as f: - json.dump(result, f, indent=4, sort_keys=True) - - return result - except Exception as e: - print(f"Warning: Play Store scrape failed: {e}", file=sys.stderr) - return {} - - -def strip_emojis(text): - """Removes emoji characters from a string.""" - if not isinstance(text, str): - return text - return EMOJI_PATTERN.sub(r"", text) - - -def format_code_link(file_val): - """Formats file paths and line numbers into Markdown links.""" - if not file_val or file_val == "N/A": - return "N/A" - files = [] - if isinstance(file_val, str): - if "," in file_val: - files = [f.strip() for f in file_val.split(",")] - else: - files = [file_val.strip()] - elif isinstance(file_val, list): - files = [str(f).strip() for f in file_val] - formatted_links = [] - for f in files: - f_str = str(f) - if f_str.startswith("["): - formatted_links.append(f_str) - continue - match = CODE_LINK_PATTERN.search(f_str) - if match: - path = match.group(1) - line = match.group(2) - basename = os.path.basename(path) - if line: - link = f"[{basename}:L{line}](file://{path}#L{line})" - else: - link = f"[{basename}](file://{path})" - formatted_links.append(link) - else: - formatted_links.append(f_str) - return ", ".join(formatted_links) - - -def render_table( - items, columns, column_names, empty_message="No items identified." -): - """Renders a Markdown table from a list of dictionaries.""" - if not items: - return f"\n* {empty_message}\n" - - widths = [len(name) for name in column_names] - rows = [] - for item in items: - row = [] - for col in columns: - val = str(item.get(col, "N/A")).replace("|", "\\|").replace("\n", "
") - row.append(val) - rows.append(row) - for i, val in enumerate(row): - widths[i] = max(widths[i], len(val)) - - def format_row(vals): - return ( - "| " - + " | ".join(val.ljust(widths[i]) for i, val in enumerate(vals)) - + " |" - ) - - header = format_row(column_names) - separator = ( - "| " + " | ".join("-" * widths[i] for i in range(len(widths))) + " |" - ) - body = "\n".join(format_row(row) for row in rows) - - return f"\n{header}\n{separator}\n{body}\n" - - -def parse_boolean(val): - """Safely converts boolean or sloppy string yes/no/true/false.""" - if isinstance(val, bool): - return val - if isinstance(val, str): - v_clean = val.strip().lower() - return v_clean in ("yes", "true", "1") - return False - - -def parse_disclosure_status(val): - """Safely parses disclosure enums or visual yes/no.""" - if not val: - return "No" - - val_clean = str(val).strip().upper() - if "EXEMPT" in val_clean: - return "Exempt (Obvious)" - if "DISCLOSED" in val_clean or "YES" in val_clean or "TRUE" in val_clean: - return "Yes" - - return "No" - - -def clean_severity(sev_str): - """Robustly cleans severity strings by removing quotes, asterisks, - emojis, and whitespace. - """ - if not isinstance(sev_str, str): - return "SUGGESTION" - - # Convert to uppercase and strip formatting characters - cleaned = sev_str.upper() - cleaned = SEVERITY_CLEAN_PATTERN.sub("", cleaned) - - if "CRITICAL" in cleaned: - return "CRITICAL" - if "IMPORTANT" in cleaned: - return "IMPORTANT" - if "SUGGESTION" in cleaned: - return "SUGGESTION" - - return "SUGGESTION" - - -def render_finding_card(finding, policy_refs=None): - """Renders a detailed finding card in Markdown.""" - severity = clean_severity(finding.get("severity")) - severity_map = { - "CRITICAL": "🔴 Critical", - "IMPORTANT": "🟡 Important", - "SUGGESTION": "🔵 Suggestion", - } - visual_severity = severity_map.get(severity, "🔵 Suggestion") - - title = strip_emojis( - finding.get("issue") or finding.get("issue_summary") or "Policy Risk" - ) - p_id = ( - finding.get("policy_id") - or finding.get("policy_reference") - or "Unknown Policy" - ) - policy_link = f"**{p_id}**" - - policy_info = policy_refs.get(p_id) if policy_refs else None - - policy_link = f"**{p_id}**" - policy_urls = [] - if p_id and policy_info: - policy_link = f"**{policy_info.get('name', p_id)}**" - policy_urls = policy_info.get("urls", []) - - files = finding.get("files_involved") - has_local_override = False - local_override_file = None - - if files and isinstance(files, list): - for f in files: - if "play_store_declaration.json" in f: - has_local_override = True - local_override_file = f - break - - files_formatted = format_code_link(files) - recommendation = finding.get("recommendation", "Review and remediate.") - - if has_local_override: - recommendation = ( - "Update the local Play Store declaration file at " - f"{format_code_link(local_override_file)}." - ) - - card = f"\n#### {title}\n" - card += f"- **Policy**: {policy_link}\n" - card += f"- **Severity**: {visual_severity}\n" - card += f"- **Files**: {files_formatted}\n" - card += f"- **Evidence**: {finding.get('evidence', 'N/A')}\n" - card += f"- **Recommendation**: {recommendation}\n" - - if policy_urls: - card += "- **References**:\n" - for url in policy_urls: - card += f" - {url}\n" - - return card - - -def load_json(file_path): - """Loads JSON from a file if it exists.""" - if os.path.exists(file_path): - try: - with open(file_path, "r", encoding="utf-8", errors="ignore") as f: - return json.load(f) - except Exception as e: - print(f"Warning: Failed to load {file_path}: {e}", file=sys.stderr) - return None - - -def aggregate_findings(temp_dir, taxonomy): - """Consolidates audit findings from workers. - - Produces a unified report data object. - """ - play_store_info = ( - load_json(os.path.join(temp_dir, "play_store_info.json")) or {} - ) - manifest_details = ( - load_json(os.path.join(temp_dir, "manifest_details.json")) or {} - ) - - aggregated_findings_path = os.path.join(temp_dir, "aggregated_findings.json") - - raw_findings = [] - critic_decisions = {} - - if os.path.exists(aggregated_findings_path): - master_data = load_json(aggregated_findings_path) or {} - raw_findings = master_data.get("findings", []) - - # Load all chunked critic outputs: critic_output_*.json - critic_files = sorted( - glob.glob(os.path.join(temp_dir, "critic_output_*.json")) - ) - for c_file in critic_files: - c_data = load_json(c_file) or {} - for fid, dec in c_data.items(): - critic_decisions[fid] = dec - - # 2. Process Findings and Apply Decoupled Critic Verdicts - identified_risks = [] - manual_review_needed = [] - data_safety_inventory = [] - - # Sort raw findings by finding_id numerically for stable processing order - def finding_sort_key(f): - fid = f.get("finding_id", "0") - try: - return int(fid) - except (ValueError, TypeError): - return 0 - - sorted_raw_findings = sorted(raw_findings, key=finding_sort_key) - - for finding in sorted_raw_findings: - p_id = finding.get("policy_id", "Unknown") - psl = finding.get("psl_constant") - fid = finding.get("finding_id") - wf = finding.get("worker_file") - - # Retrieve decision - decision = critic_decisions.get(str(fid)) - if not decision: - # Automatically approve/verify findings that bypassed the Critic - # (like SUGGESTIONs) - decision = { - "action": "VERIFIED", - "confidence": "High", - "critic_justification": ( - "Automatically verified (bypassed critic review)." - ), - } - - action = str(decision.get("action", "VERIFIED")).upper().strip() - confidence = decision.get("confidence", "Medium") - justification = decision.get("critic_justification", "Verified by Critic.") - - # Apply Critic's editorial overrides if specified - severity = decision.get("severity") or finding.get("severity", "SUGGESTION") - severity = clean_severity(severity) - - issue_summary = decision.get("issue_summary") or finding.get( - "issue_summary", "Unknown Issue" - ) - recommendation = decision.get("recommendation") or finding.get( - "recommendation", "" - ) - - files_involved = finding.get("files_involved", []) - if isinstance(files_involved, list): - files_involved = sorted([str(f) for f in files_involved]) - - evidence = finding.get("evidence", "") - - reconstructed_finding = { - "policy_id": p_id, - "finding_id": fid or f"{wf}_{p_id}", - "issue_summary": issue_summary, - "severity": severity, - "files_involved": files_involved, - "evidence": ( - f"{evidence}\n\n**Critic Verification**: {justification}" - f" (Confidence: {confidence})" - ), - "recommendation": recommendation, - } - - # Programmatically evaluate if this is a compliant Data Safety finding - # to avoid card clutter - is_compliant_ds = False - if "is_transferred" in finding: - is_transferred = parse_boolean(finding.get("is_transferred")) - user_initiated = parse_boolean(finding.get("user_initiated")) - is_third_party = parse_boolean(finding.get("is_third_party")) - disc_status = ( - str(finding.get("prominent_disclosure_status", "MISSING")) - .upper() - .strip() - ) - - # Compliant if either local-only OR transmitted but disclosed/exempt - # OR user initiated - if not is_transferred: - is_compliant_ds = True - elif user_initiated: - is_compliant_ds = True - elif "DISCLOSED" in disc_status or "EXEMPT" in disc_status: - is_compliant_ds = True - - # Route the finding based on Critic's action - if action == "VERIFIED": - if not is_compliant_ds: - identified_risks.append(reconstructed_finding) - elif action == "MANUAL_REVIEW": - if not is_compliant_ds: - manual_review_needed.append(reconstructed_finding) - - # 3. Process Data Safety Inventory Programmatically (If it's a DS Finding) - if "is_transferred" in finding: - # Create a localized copy of the data safety keys (psl_constant is - # natively preserved) - inv_item = { - "psl_constant": psl, - "is_transferred": parse_boolean(finding.get("is_transferred")), - "user_initiated": parse_boolean(finding.get("user_initiated")), - "is_third_party": parse_boolean(finding.get("is_third_party")), - "prominent_disclosure_status": ( - str(finding.get("prominent_disclosure_status", "MISSING")) - .upper() - .strip() - ), - "purpose": finding.get("purpose", "N/A"), - "linked_to_user": parse_boolean(finding.get("linked_to_user")), - "behavioral_proof": evidence, - "disclosure_proof": recommendation, - } - - # Programmatically align/vet inventory based on Critic's verdict - if action == "PRUNED": - inv_item["is_transferred"] = False - inv_item["purpose"] = "Local functionality only" - inv_item["behavioral_proof"] = f"Pruned by Critic: {justification}" - - data_safety_inventory.append(inv_item) - - # 4. Consolidate and Deduplicate Data Safety Inventory by psl_constant - merged_inventory = {} - for item in data_safety_inventory: - psl_id = item.get("psl_constant") - if not psl_id: - continue - - if psl_id not in merged_inventory: - merged_inventory[psl_id] = { - "psl_constant": psl_id, - "is_transferred": False, - "user_initiated": True, # Default to True for AND logic - "is_third_party": False, - "linked_to_user": False, - "prominent_disclosure_status": "EXEMPT", - "purposes": set(), - "behavioral_proofs": set(), - "disclosure_proofs": set(), - } - - current = merged_inventory[psl_id] - - # Transmission (True if any is True) - if item.get("is_transferred"): - current["is_transferred"] = True - - # User Initiated (True only if ALL are True) - if not item.get("user_initiated"): - current["user_initiated"] = False - - # Third Party (True if any is True) - if item.get("is_third_party"): - current["is_third_party"] = True - - # Linked (True if any is True) - if item.get("linked_to_user"): - current["linked_to_user"] = True - - # Disclosure logic precedence: MISSING > DISCLOSED > EXEMPT - new_disc = ( - str(item.get("prominent_disclosure_status", "EXEMPT")).upper().strip() - ) - curr_disc = current["prominent_disclosure_status"] - - if "MISSING" in curr_disc or "MISSING" in new_disc: - current["prominent_disclosure_status"] = "MISSING" - elif "DISCLOSED" in curr_disc or "DISCLOSED" in new_disc: - current["prominent_disclosure_status"] = "DISCLOSED" - else: - current["prominent_disclosure_status"] = "EXEMPT" - - if item.get("purpose") and item.get("purpose") != "N/A": - current["purposes"].add(str(item["purpose"]).strip()) - if item.get("behavioral_proof") and item.get("behavioral_proof") != "N/A": - current["behavioral_proofs"].add(str(item["behavioral_proof"]).strip()) - if item.get("disclosure_proof") and item.get("disclosure_proof") != "N/A": - current["disclosure_proofs"].add(str(item["disclosure_proof"]).strip()) - - # Reconstruct the list with joined sets - normalized_inventory = [] - sorted_psl_ids = sorted(merged_inventory.keys()) - for psl_id in sorted_psl_ids: - item = merged_inventory[psl_id] - reconstructed = { - "psl_constant": psl_id, - "is_transferred": "Yes" if item["is_transferred"] else "No", - "user_initiated": "Yes" if item.get("user_initiated") else "No", - "is_third_party": "Yes" if item.get("is_third_party") else "No", - "linked_to_user": "Yes" if item["linked_to_user"] else "No", - "prominent_disclosure_status": parse_disclosure_status( - item["prominent_disclosure_status"] - ), - "purpose": ", ".join(sorted(list(item["purposes"]))) or "N/A", - "behavioral_proof": ( - ", ".join(sorted(list(item["behavioral_proofs"]))) or "N/A" - ), - "disclosure_proof": ( - ", ".join(sorted(list(item["disclosure_proofs"]))) or "N/A" - ), - } - normalized_inventory.append(reconstructed) - - # 5. Decorate and Filter Data Safety Inventory - decorated_inventory = [] - local_access_only = [] - - for item in normalized_inventory: - psl_id = item.get("psl_constant") - tax_info = taxonomy.get(psl_id, {"category": "Other", "data_type": "Other"}) - item["category"] = tax_info["category"] - item["data_type"] = tax_info["data_type"] - - is_local = ( - item.get("is_transferred") == "No" - or item.get("purpose") == "Local functionality only" - ) - if is_local: - local_access_only.append(item) - else: - decorated_inventory.append(item) - - # Cross-reference with Play Store - matches = [] - mismatches = [] - is_published = play_store_info.get("is_published", False) - - if is_published: - play_declarations = play_store_info.get("data_safety", {}).get( - "data_collected", [] - ) - play_data_types = set() - for category_dict in play_declarations: - for type_dict in category_dict.get("types", []): - play_data_types.add(type_dict.get("type")) - - for item in decorated_inventory: - dt = item["data_type"] - if dt in play_data_types: - matches.append({"data_type": dt, "status": "Declared and detected"}) - else: - is_obvious = "Exempt" in item.get( - "prominent_disclosure_status", "" - ) or "Obvious" in item.get("prominent_disclosure_status", "") - mismatches.append({ - "data_type": dt, - "local_view": ( - f"Detected in code (Evidence: {item.get('behavioral_proof')})" - ), - "play_view": "Not declared in Play Store", - "status": ( - "Exempt: Obvious core functionality" - if is_obvious - else "Discrepancy" - ), - }) - - # Check for unjustified declarations (in Play Store but not in code) - detected_data_types = { - item["data_type"] for item in decorated_inventory + local_access_only - } - for category_dict in play_declarations: - for type_dict in category_dict.get("types", []): - dt = type_dict.get("type") - if dt not in detected_data_types: - mismatches.append({ - "data_type": dt, - "local_view": "Not detected in code", - "play_view": "Declared in Play Store", - "status": "Discrepancy", - }) - - # 7. Determine Compliance - overall_compliance = "Compliant" - critical_risks = any( - clean_severity(r.get("severity")) == "CRITICAL" for r in identified_risks - ) - active_mismatches = any(m.get("status") == "Discrepancy" for m in mismatches) - - if critical_risks or active_mismatches: - overall_compliance = "Non-compliant" - elif manual_review_needed or any( - clean_severity(r.get("severity")) == "IMPORTANT" for r in identified_risks - ): - overall_compliance = "Needs review" - - # 8. Sort result lists for deterministic output - def report_finding_sort_key(f): - pid = f.get("policy_id", "") - fid = f.get("finding_id", "0") - try: - num_fid = int(fid) - except (ValueError, TypeError): - num_fid = 0 - return (pid, num_fid) - - identified_risks.sort(key=report_finding_sort_key) - manual_review_needed.sort(key=lambda x: x.get("issue_summary", "")) - matches.sort(key=lambda x: x.get("data_type", "")) - mismatches.sort(key=lambda x: x.get("data_type", "")) - - # 9. Generate Summary - - risk_count = len(identified_risks) - mismatch_count = len( - [m for m in mismatches if m.get("status") == "Discrepancy"] - ) - exempt_count = len([ - m - for m in mismatches - if m.get("status") == "Exempt: Obvious core functionality" - ]) - - summary_parts = [] - summary_parts.append( - "The automated audit of" - f" {play_store_info.get('title', 'the application')} is complete." - ) - summary_parts.append( - f"Identified {risk_count} potential policy risks and {mismatch_count}" - " active Data Safety discrepancies." - ) - if exempt_count > 0: - summary_parts.append( - f"{exempt_count} detections were flagged as 'Obvious' and exempt from" - " prominent disclosure." - ) - - if overall_compliance == "Non-compliant": - summary_parts.append( - "Immediate remediation is required for critical findings and" - " declaration mismatches." - ) - elif overall_compliance == "Needs review": - summary_parts.append( - "Manual review is recommended for several ambiguous findings." - ) - else: - summary_parts.append( - "The application appears broadly compliant with analyzed policies." - ) - - # 9. Final Report Construction - pkg_name = ( - play_store_info.get("package_name") - or manifest_details.get("package_name") - ) - if not pkg_name or pkg_name == "unknown": - pkg_name = "unknown" - - report = { - "overall_compliance": overall_compliance, - "summary": " ".join(summary_parts), - "package_name": pkg_name, - "is_published": is_published, - "identified_risks": identified_risks, - "data_safety_comparison": {"matches": matches, "mismatches": mismatches}, - "local_data_access": [ - { - "data_type": item["data_type"], - "category": item["category"], - "evidence": item.get("behavioral_proof"), - } - for item in local_access_only - ], - "manual_review_needed": [ - {"issue_summary": r.get("issue_summary")} - for r in manual_review_needed - ], - "suggested_data_safety_declaration": { - "collected_data": [ - { - "data_type": item["data_type"], - "purpose": item.get("purpose"), - "linked_to_user": item.get("linked_to_user") == "Yes", - } - for item in decorated_inventory - ] - }, - } - - return report - - -def main(): - """Main entry point for report generation.""" - parser = argparse.ArgumentParser(description="Compliance report generator.") - parser.add_argument( - "temp_dir", - help="Path to the temporary scratch directory containing audit findings.", - ) - - args = parser.parse_args() - temp_dir = os.path.abspath(args.temp_dir) - repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - - policy_refs = ( - load_json(os.path.join(repo_root, "resources", "policies.json")) - ) - if not policy_refs: - policy_refs = {} - taxonomy = policy_refs.get("data_safety_section", {}).get("taxonomy", {}) - - manifest_details = ( - load_json(os.path.join(temp_dir, "manifest_details.json")) or {} - ) - package_name = manifest_details.get("package_name") - - # Load Play Store info (potentially ingested by orchestrator) - play_store_info_path = os.path.join(temp_dir, "play_store_info.json") - play_store_info = {} - if os.path.exists(play_store_info_path): - play_store_info = load_json(play_store_info_path) or {} - - if not play_store_info and package_name: - play_store_info = run_scraper(package_name, temp_dir) - # Cache it for report reuse - with open(play_store_info_path, "w", encoding="utf-8") as f: - json.dump(play_store_info, f, indent=4, sort_keys=True) - - report_data = aggregate_findings(temp_dir, taxonomy) - - template_path = os.path.join( - repo_root, "resources", "compliance_report_template.md" - ) - if not os.path.exists(template_path): - print(f"Error: Template not found at {template_path}", file=sys.stderr) - sys.exit(1) - - try: - with open(template_path, "r", encoding="utf-8") as f: - content = f.read() - except Exception as e: - print(f"Error reading template: {e}", file=sys.stderr) - sys.exit(1) - - output_path = os.path.join(temp_dir, "compliance_report.md") - - violations = report_data.get("identified_risks", []) - mismatches = report_data.get("data_safety_comparison", {}).get( - "mismatches", [] - ) - manual = report_data.get("manual_review_needed", []) - - compliance_raw = report_data.get("overall_compliance") or "Needs review" - status_map = { - "Non-compliant": "🔴 Non-Compliant", - "Needs review": "🟡 Needs Review", - "Compliant": "🟢 Compliant", - } - compliance_ui = status_map.get(compliance_raw, "🟡 Needs Review") - - app_id = report_data.get("package_name") - if not app_id or app_id == "unknown": - app_id = ( - play_store_info.get("package_name") - or manifest_details.get("package_name") - or "Unknown App" - ) - - findings_content = "" - if not violations: - findings_content = "\n* No policy risks identified in this scan.\n" - else: - grouped_findings = {} - for v in violations: - p_id = v.get("policy_id") - macro_cat = "Other Policies" - if p_id and p_id in policy_refs: - macro_cat = policy_refs[p_id].get("category", "Other Policies") - - if macro_cat not in grouped_findings: - grouped_findings[macro_cat] = [] - grouped_findings[macro_cat].append(v) - - ordered_cats = [ - "Restricted Content", - "Privacy, Deception and Device Abuse", - "Monetization and Ads", - "Store Listing and Promotion", - "Developer Account Management", - ] - - all_cats = ordered_cats + [ - cat for cat in grouped_findings if cat not in ordered_cats - ] - - for cat in all_cats: - if cat in grouped_findings: - findings_content += f"\n### {cat}\n" - for v in grouped_findings[cat]: - findings_content += render_finding_card(v, policy_refs) + "\n" - - is_not_published = report_data.get("is_published") is False - - d_table_msg = "Code detection matches Play Store declarations." - if is_not_published: - d_table_msg = ( - "N/A - App is not yet published. No declarations found for comparison." - ) - - d_table = render_table( - mismatches, - ["data_type", "local_view", "play_view", "status"], - ["Data Type", "Code Detection", "Play Store Declaration", "Status"], - d_table_msg, - ) - - # Local Access Section - local_access = report_data.get("local_data_access", []) - la_content = "" - if local_access: - la_table = render_table( - local_access, - ["data_type", "category", "evidence"], - ["Data Type", "Category", "Access Evidence"], - "No local-only access detected.", - ) - la_content = ( - "## Local Data Access (No Transmission)\nThe following data types are" - " accessed by the code but no evidence of network transmission or" - " exfiltration was detected. These typically do not require a" - f" 'Collection' declaration in the Data Safety section.\n{la_table}" - ) - - # Suggested Declaration (Conditional Section) - suggested_dec = report_data.get("suggested_data_safety_declaration", {}) - dec_content = "" - if suggested_dec and any(suggested_dec.values()): - dec_content = ( - "### Suggested Data Safety Declaration" - f" Updates\n```json\n{json.dumps(suggested_dec, indent=4)}\n```" - ) - - checklist_items = [] - for m in manual: - m_issue = strip_emojis( - m.get("issue") or m.get("issue_summary") or "Review item" - ) - checklist_items.append(f"- [ ] {m_issue}") - - risk_categories = set( - [v.get("category") for v in violations if v.get("category")] - ) - mapping = { - "Undeclared Collection": "Update Data Safety section in Play Console.", - "Permissions": "Review and minimize requested permissions.", - } - for cat in risk_categories: - if cat in mapping: - checklist_items.append(f"- [ ] {mapping[cat]}") - if not checklist_items: - checklist_items.append("- [ ] Review all identified policy risks.") - - template_context = { - "overall_compliance": compliance_ui, - "current_date": datetime.now().strftime("%Y-%m-%d"), - "app_name_id": app_id, - "findings_detail": findings_content, - "data_safety_table": d_table, - "local_access_section": la_content, - "suggested_declaration_section": dec_content, - "personalized_checklist": "\n".join(checklist_items), - } - - content = render_template(content, template_context) - - with open(output_path, "w", encoding="utf-8") as f: - f.write(content) - - json_output_path = output_path.replace(".md", ".json") - with open(json_output_path, "w", encoding="utf-8") as f: - json.dump( - { - "metadata": { - "app_id": app_id, - "scan_date": datetime.now().isoformat(), - "overall_compliance": compliance_raw, - }, - "findings": violations, - "data_safety_mismatches": mismatches, - "manual_review": manual, - "suggested_declaration": suggested_dec, - }, - f, - indent=4, - sort_keys=True, - ) - print(f"Reports generated successfully at {output_path}") - - -if __name__ == "__main__": - main() diff --git a/play/play-policy-insights/scripts/orchestrator.py b/play/play-policy-insights/scripts/orchestrator.py deleted file mode 100755 index e7da787..0000000 --- a/play/play-policy-insights/scripts/orchestrator.py +++ /dev/null @@ -1,1341 +0,0 @@ -#!/usr/bin/env python3 - -# Copyright 2026 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 -# -# https://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. - -"""Coordinates the end-to-end Play Policy audit flow. - -This manages everything from triage to analysis aggregation. -""" - -import argparse -import concurrent.futures -import glob -import json -import os -import re -import sys -import uuid -import xml.etree.ElementTree as ET - -import scanner -from template_engine import render_template - -# Constants for Gradle parsing -GRADLE_APP_ID_PATTERN = ( - r'applicationId\s*(?:[:=]|\.set\(?)\s*[\'"]?([a-zA-Z0-9._]+)[\'"]?\)?' -) -GRADLE_APP_ID_PROP_PATTERN = ( - r'applicationId\s*[:=]?\s*project\.property\(\s*[\'"]([^\'"]+)[\'"]\s*\)' -) -GRADLE_NAMESPACE_PATTERN = ( - r'namespace\s*(?:[:=]|\.set\(?)\s*[\'"]?([a-zA-Z0-9._]+)[\'"]?\)?' -) -GRADLE_NAMESPACE_PROP_PATTERN = ( - r'namespace\s*[:=]?\s*project\.property\(\s*[\'"]([^\'"]+)[\'"]\s*\)' -) -GRADLE_GROUP_PATTERN = ( - r'group(?:Id)?\s*(?:[:=]|\.set\(?)\s*[\'"]?([a-zA-Z0-9._]+)[\'"]?\)?' -) -GRADLE_GROUP_PROP_PATTERN = ( - r'group(?:Id)?\s*[:=]?\s*project\.property\(\s*[\'"]([^\'"]+)[\'"]\s*\)' -) -GRADLE_TARGET_SDK_PATTERN = ( - r"targetSdk(?:Version)?(?:\.set\(?)?\s*[:=" - r" (]*\s*([\'\"]?[\w\d_\.]+[\'\"]?)\)?" -) - -# Well-known framework defaults for unresolved dynamic variables (e.g. Flutter) -FRAMEWORK_DEFAULTS = { - "flutter.targetSdkVersion": 35, - "flutter.compileSdkVersion": 35, - "flutter.minSdkVersion": 21, - "flutter.ndkVersion": "25.1.8937393", -} - -# .NET MAUI / .NET for Android express the target SDK through the target framework -# moniker rather than a manifest or Gradle file. An explicit platform level wins -# (net9.0-android35.0 -> 35); an unqualified moniker (net9.0-android) resolves to -# the highest API the installed workload supports, which is fixed per .NET release. -DOTNET_ANDROID_TARGET_SDK = { - "8.0": 34, - "9.0": 35, - "10.0": 36, -} - -# net10.0-android, net10.0-android36.0, net8.0-android34.0 ... -DOTNET_TFM_PATTERN = re.compile( - r"net(\d+\.\d+)-android(\d+)?(?:\.\d+)?", re.IGNORECASE -) - -# Patterns for diverse assignment styles (Groovy & Kotlin DSL) -# 1. Standard: targetSdkVersion = 35, ext.targetSdkVersion = 35, etc. -# 2. Delegated: val targetSdkVersion by extra(35) -# 3. Functional: set("targetSdkVersion", 35) or -# extra.set("targetSdkVersion", 35) -# 4. Indexer: extra["targetSdkVersion"] = 35 -GRADLE_ASSIGNMENT_PATTERNS = [ - re.compile( - r"(?:\b(?:val|var|def|const|static|internal|private|" - r"public|protected|ext|final)\s+)*" - r"([\w\d_\.]+)(?:\s*:\s*[\w\d_\.]+)?\s*=\s*['\"]?" - r"([^\x27\"\s\(\)<>\{\}]+)['\"]?" - ), - re.compile( - r"val\s+([\w\d_\.]+)(?:\s*:\s*[\w\d_\.]+)?\s+by\s+" - r"extra\s*\(\s*['\"]?([^\x27\"\s\(\)]+)['\"]?\s*\)" - ), - re.compile( - r"(?:extra\.)?set\s*\(\s*['\"]([\w\d_\.]+)['\"]\s*,\s*" - r"['\"]?([^\x27\"\s\(\)]+)['\"]?\s*\)" - ), - re.compile( - r"extra\s*\[\s*['\"]([\w\d_\.]+)['\"]\s*\]\s*=\s*" - r"['\"]?([^\x27\"\s]+)['\"]?" - ), -] - - -def _strip_inline_comment(line, support_quotes=True): - """Strips inline comments starting with '#' while preserving quotes.""" - in_double = False - in_single = False - for i, char in enumerate(line): - if support_quotes: - if char == '"' and not in_single: - in_double = not in_double - elif char == "'" and not in_double: - in_single = not in_single - - if char == "#" and not in_double and not in_single: - return line[:i] - return line - - -def load_gradle_properties(target_dir): - """Loads properties from gradle.properties and local.properties.""" - properties = {} - for filename in ["gradle.properties", "local.properties"]: - path = os.path.join(target_dir, filename) - if os.path.exists(path): - try: - with open(path, "r", encoding="utf-8", errors="ignore") as f: - for line in f: - line = _strip_inline_comment(line, support_quotes=False).strip() - if line and not line.startswith("#") and "=" in line: - key, val = line.split("=", 1) - properties[key.strip()] = val.strip() - except Exception as e: # pylint: disable=broad-exception-caught - print( - f"Warning: Failed to load properties from {path}: {e}", - file=sys.stderr, - ) - return properties - - -def load_version_catalog(target_dir): - """Loads versions from gradle/libs.versions.toml if it exists.""" - catalog = {} - toml_path = os.path.join(target_dir, "gradle", "libs.versions.toml") - if os.path.exists(toml_path): - try: - with open(toml_path, "r", encoding="utf-8", errors="ignore") as f: - current_section = None - for line in f: - line = _strip_inline_comment(line).strip() - if not line or line.startswith("#"): - continue - if line.startswith("[") and line.endswith("]"): - current_section = line[1:-1].strip() - continue - if current_section == "versions" and "=" in line: - key, val = line.split("=", 1) - val = val.strip().strip('"').strip("'") - catalog[key.strip()] = val.strip() - except Exception as e: # pylint: disable=broad-exception-caught - print( - f"Warning: Failed to load version catalog from {toml_path}: {e}", - file=sys.stderr, - ) - return catalog - - -def load_custom_gradle_versions(target_dir, file_inventory=None): - """Loads versions from shared Gradle files and project configuration.""" - versions = {} - if not target_dir or not os.path.isdir(target_dir): - return versions - - # 1. Identify Candidate Files (Root files are the primary targets) - candidate_files = [] - - # Leaking Strategy: If target_dir is a sub-module, leak up to find the root - # e.g. /path/to/project/app -> check /path/to/project for root build files - search_roots = [target_dir] - parent = os.path.dirname(os.path.normpath(target_dir)) - if parent and parent != target_dir: - search_roots.append(parent) - - for s_root in search_roots: - for f in ["build.gradle", "build.gradle.kts"]: - root_file = os.path.normpath(os.path.join(s_root, f)) - if os.path.exists(root_file) and root_file not in candidate_files: - candidate_files.append(root_file) - - # Use inventory if provided to avoid redundant file walk - if file_inventory: - search_lists = [ - file_inventory.get("gradles", []), - file_inventory.get("source_code", []), - ] - for file_list in search_lists: - for path in file_list: - norm_path = os.path.normpath(path) - if norm_path not in candidate_files: - file_lower = os.path.basename(norm_path).lower() - if any( - name in file_lower - for name in [ - "versions.gradle", - "dependencies.gradle", - "variables.gradle", - ] - ): - candidate_files.append(norm_path) - elif any( - d in norm_path.lower() for d in ["build-logic", "buildsrc"] - ) and file_lower.endswith((".gradle", ".gradle.kts", ".kt")): - candidate_files.append(norm_path) - else: - # Fallback to walk if no inventory provided - config = scanner.get_scanner_config() - ignored_dirs = set(config.get("ignored_directories", [])) - for root, dirs, files in os.walk(target_dir): - dirs[:] = [d for d in dirs if d not in ignored_dirs] - for f in files: - if f.lower().endswith((".gradle", ".gradle.kts", ".kt")): - candidate_files.append(os.path.join(root, f)) - - # 2. Parse candidate files for assignments - for f_path in candidate_files: - try: - with open(f_path, "r", encoding="utf-8", errors="ignore") as f: - content = f.read() - for pattern in GRADLE_ASSIGNMENT_PATTERNS: - for key, val in pattern.findall(content): - # Clean up the key (remove 'ext.' or 'project.' prefixes) - clean_key = key.split(".")[-1] - if clean_key not in versions: - versions[clean_key] = val.strip().strip('"').strip("'") - except Exception as e: # pylint: disable=broad-exception-caught - print( - f"Warning: Failed to parse Gradle file {f_path}: {e}", file=sys.stderr - ) - - return versions - - -def _load_json_file(path): - """Safely loads a JSON file.""" - if os.path.exists(path): - try: - with open(path, "r", encoding="utf-8") as f: - return json.load(f) - except Exception as e: # pylint: disable=broad-exception-caught - print(f"Warning: Failed to load JSON from {path}: {e}", file=sys.stderr) - return {} - - -def _read_text_file(path): - """Safely reads a text file.""" - if os.path.exists(path): - try: - with open(path, "r", encoding="utf-8") as f: - return f.read() - except Exception as e: # pylint: disable=broad-exception-caught - print(f"Warning: Failed to read file {path}: {e}", file=sys.stderr) - return "" - - -def _write_json_file(path, data): - """Safely writes a JSON file.""" - try: - with open(path, "w", encoding="utf-8") as f: - json.dump(data, f, indent=4, sort_keys=True) - except Exception as e: # pylint: disable=broad-exception-caught - print(f"Warning: Failed to write JSON to {path}: {e}", file=sys.stderr) - - -def _write_text_file(path, content): - """Safely writes a text file.""" - try: - with open(path, "w", encoding="utf-8") as f: - f.write(content) - except Exception as e: # pylint: disable=broad-exception-caught - print(f"Warning: Failed to write file {path}: {e}", file=sys.stderr) - - -def _filter_by_flavor(data, detected_flavors, prioritized_flavors): - """Recursively filters lists and dicts based on flavor path segments.""" - if isinstance(data, dict): - filtered_dict = {} - for k, v in data.items(): - filtered_val = _filter_by_flavor(v, detected_flavors, prioritized_flavors) - if isinstance(filtered_val, (dict, list)) and not filtered_val: - continue - filtered_dict[k] = filtered_val - return filtered_dict - - elif isinstance(data, list): - filtered_list = [] - for entry in data: - # Handle tuple entries (like in codebase_map.all_files) - entry_str = ( - entry[1] - if isinstance(entry, tuple) and len(entry) > 1 - else str(entry) - ) - - is_excluded = any( - f not in prioritized_flavors and f"src/{f}/" in entry_str - for f in detected_flavors - ) - if not is_excluded: - filtered_list.append(entry) - return filtered_list - - return data # Fallback for primitive types - - -def parse_application_modules( - gradle_files, - manifest_files, - gradle_properties, - version_catalog, - custom_gradle_versions=None, -): - """Identifies distinct application modules in the project.""" - modules = [] - lookups = { - "properties": gradle_properties, - "catalog": version_catalog, - "custom": custom_gradle_versions or {}, - } - - # First, check Gradle files for the application plugin - for g_file in gradle_files: - try: - with open(g_file, "r", encoding="utf-8", errors="ignore") as f: - content = f.read() - if re.search(GRADLE_APP_PLUGIN_PATTERN, content): - # Found an app module! - mod_dir = os.path.dirname(g_file) - mod_name = os.path.basename(mod_dir) - - # Try to extract details from this Gradle file - app_id = None - target_sdk = None - app_label = None - - id_match = re.search(GRADLE_APP_ID_PATTERN, content) - if id_match: - app_id = id_match.group(1) - else: - prop_match = re.search(GRADLE_APP_ID_PROP_PATTERN, content) - if prop_match: - app_id = lookups["properties"].get(prop_match.group(1)) - - sdk_match = re.search(GRADLE_TARGET_SDK_PATTERN, content) - if sdk_match: - target_sdk = _resolve_to_int(sdk_match.group(1), lookups) - - # Fallback to namespace if applicationId is missing - if not app_id: - ns_match = re.search(GRADLE_NAMESPACE_PATTERN, content) - if ns_match: - app_id = ns_match.group(1) - else: - ns_prop_match = re.search(GRADLE_NAMESPACE_PROP_PATTERN, content) - if ns_prop_match: - app_id = lookups["properties"].get(ns_prop_match.group(1)) - - # Fallback to group / groupId if applicationId and namespace are missing - if not app_id: - group_match = re.search(GRADLE_GROUP_PATTERN, content) - if group_match: - app_id = group_match.group(1) - else: - group_prop_match = re.search(GRADLE_GROUP_PROP_PATTERN, content) - if group_prop_match: - app_id = lookups["properties"].get(group_prop_match.group(1)) - - modules.append({ - "name": mod_name, - "path": mod_dir, - "application_id": app_id, - "target_sdk": target_sdk, - "app_label": app_label, - }) - except Exception as e: # pylint: disable=broad-exception-caught - print( - f"Warning: Failed to parse Gradle file {g_file}: {e}", file=sys.stderr - ) - - return modules - - -def determine_primary_identity( - app_modules, - gradle_files, - manifest_files, - gradle_properties, - version_catalog, - custom_gradle_versions, - app_dir, -): - """Heuristically determines the primary app ID and target SDK.""" - primary_id = None - primary_sdk = None - primary_label = None - - # 1. Prefer explicitly defined app modules - for mod in app_modules: - if mod.get("application_id"): - primary_id = mod["application_id"] - primary_sdk = mod["target_sdk"] - primary_label = mod["app_label"] - break - - # 2. Fallback to global Gradle properties - if not primary_id: - primary_id = ( - gradle_properties.get("applicationId") - or gradle_properties.get("namespace") - or gradle_properties.get("group") - or gradle_properties.get("groupId") - or gradle_properties.get("GROUP") - ) - if not primary_sdk: - primary_sdk = _resolve_to_int( - gradle_properties.get("targetSdkVersion") - or gradle_properties.get("targetSdk"), - {"properties": gradle_properties, "catalog": version_catalog}, - ) - - # 3. Fallback to scanning manifests if still missing - if not primary_id or not primary_sdk: - for m_file in manifest_files: - try: - with open(m_file, "r", encoding="utf-8-sig") as f: - content = f.read() - details = extract_manifest_details(content) - if not primary_id: - primary_id = details.get("package_name") - if not primary_sdk: - primary_sdk = details.get("target_sdk") - if not primary_label: - primary_label = details.get("app_label") - except Exception: # pylint: disable=broad-exception-caught - pass - - # 4. Cross-Platform Framework Detection - if not primary_id or not primary_sdk: - # Check for Flutter (pubspec.yaml) - pubspec_path = os.path.join(app_dir, "pubspec.yaml") - if os.path.exists(pubspec_path): - try: - with open(pubspec_path, "r", encoding="utf-8") as f: - for line in f: - if line.startswith("name:"): - primary_id = line.split(":")[1].strip() - break - primary_sdk = FRAMEWORK_DEFAULTS.get("flutter.targetSdkVersion") - except Exception: # pylint: disable=broad-exception-caught - pass - - # Check for React Native (package.json) - pkg_json_path = os.path.join(app_dir, "package.json") - if os.path.exists(pkg_json_path): - try: - with open(pkg_json_path, "r", encoding="utf-8") as f: - data = json.load(f) - primary_id = data.get("name") - except Exception: # pylint: disable=broad-exception-caught - pass - - # Check for .NET MAUI / .NET for Android (*.csproj) - if not primary_id or not primary_sdk: - dotnet_id, dotnet_sdk, dotnet_label = extract_dotnet_android_details( - app_dir - ) - primary_id = primary_id or dotnet_id - primary_sdk = primary_sdk or dotnet_sdk - primary_label = primary_label or dotnet_label - - # 5. Last-resort directory fallback if primary_id is still missing - if not primary_id: - dir_name = os.path.basename(os.path.normpath(app_dir)) - if dir_name and dir_name not in (".", "/", "\\"): - primary_id = dir_name - - return primary_id, primary_sdk, primary_label - - -def _msbuild_property(content, tag): - """Reads a single MSBuild property value out of a project file.""" - match = re.search( - rf"<{tag}[^>]*>([^<]+)", content, re.IGNORECASE - ) - return match.group(1).strip() if match else None - - -def extract_dotnet_android_details(app_dir): - """Finds the .NET MAUI / .NET for Android project and reads its identity. - - A .NET for Android app has no Gradle files, and its AndroidManifest.xml carries - neither the package name nor uses-sdk -- both are MSBuild properties merged into - the manifest at build time. Without this the audit sees an app with no id and no - target SDK and reports nothing, which reads as a clean bill of health. - - Args: - app_dir: Absolute path to the directory containing the app's code. - - Returns: - A tuple of (application_id, target_sdk, application_title). Any element is - None when it could not be determined. - """ - app_id = None - target_sdk = None - label = None - - config = scanner.get_scanner_config() - ignored_dirs = set(config.get("ignored_directories", [])) - - candidates = [] - for root, dirs, files in os.walk(app_dir): - dirs[:] = [d for d in dirs if d not in ignored_dirs] - for f in files: - if f.lower().endswith(".csproj"): - candidates.append(os.path.join(root, f)) - - for csproj in sorted(candidates): - try: - with open(csproj, "r", encoding="utf-8", errors="ignore") as f: - content = f.read() - except Exception: # pylint: disable=broad-exception-caught - continue - - content = re.sub(r"", "", content, flags=re.DOTALL) - - tfm_match = DOTNET_TFM_PATTERN.search(content) - if not tfm_match: - # Not an Android head project (class libraries, test projects). - continue - - app_id = app_id or _msbuild_property(content, "ApplicationId") - label = label or _msbuild_property(content, "ApplicationTitle") - - if target_sdk is None: - # Explicit override wins, then the moniker's platform level, then the - # workload default for that .NET release. - explicit = _msbuild_property(content, "AndroidTargetSdkVersion") - if explicit and explicit.isdigit(): - target_sdk = int(explicit) - elif tfm_match.group(2): - target_sdk = int(tfm_match.group(2)) - else: - target_sdk = DOTNET_ANDROID_TARGET_SDK.get(tfm_match.group(1)) - - if app_id and target_sdk and label: - break - - return app_id, target_sdk, label - - -def extract_manifest_details(xml_content): - """Parses AndroidManifest.xml for key policy signals.""" - details = { - "package_name": None, - "target_sdk": None, - "app_label": None, - "permissions": [], - "foreground_services": [], - } - - try: - # Strip a UTF-8 BOM before parsing. Visual Studio writes one into - # AndroidManifest.xml by default, and ElementTree rejects it as an invalid - # token at line 1. The permission scan below is regex-based and survives, so - # the failure is silent: the manifest still yields permissions while quietly - # losing the package name, target SDK and app label. - root = ET.fromstring(xml_content.lstrip("\ufeff")) - ns = {"android": "http://schemas.android.com/apk/res/android"} - - details["package_name"] = root.get("package") - - # Target SDK - uses_sdk = root.find("uses-sdk", ns) - if uses_sdk is not None: - details["target_sdk"] = uses_sdk.get( - "{http://schemas.android.com/apk/res/android}targetSdkVersion" - ) - - # App Label - application = root.find("application", ns) - if application is not None: - label = application.get( - "{http://schemas.android.com/apk/res/android}label" - ) - if label and label.startswith("@string/"): - details["app_label"] = label[8:] - else: - details["app_label"] = label - - # Permissions - for tag in ["uses-permission", "uses-permission-sdk-23"]: - for perm in root.findall(tag, ns): - name = perm.get("{http://schemas.android.com/apk/res/android}name") - if name: - details["permissions"].append(name) - - # Permissions from component declarations - for tag in [ - "service", - "receiver", - "activity", - "provider", - "activity-alias", - ]: - for component in root.findall(f".//{tag}", ns): - perm = component.get( - "{http://schemas.android.com/apk/res/android}permission" - ) - if perm: - details["permissions"].append(perm) - - if tag == "provider": - read_perm = component.get( - "{http://schemas.android.com/apk/res/android}readPermission" - ) - if read_perm: - details["permissions"].append(read_perm) - write_perm = component.get( - "{http://schemas.android.com/apk/res/android}writePermission" - ) - if write_perm: - details["permissions"].append(write_perm) - - # Foreground Services - for service in root.findall(".//service", ns): - # Check for foregroundServiceType - fgs_type = service.get( - "{http://schemas.android.com/apk/res/android}foregroundServiceType" - ) - if fgs_type: - details["foreground_services"].append({ - "name": service.get( - "{http://schemas.android.com/apk/res/android}name" - ), - "type": fgs_type, - }) - except Exception as e: # pylint: disable=broad-exception-caught - print(f"Warning: Failed to parse XML: {e}", file=sys.stderr) - - return details - - -def generate_triage_summary(manifest_details, data_safety_scan): - """Generates a high-level summary of the audit landscape.""" - summary = [] - - package = manifest_details.get("package_name") or "Unknown" - summary.append(f"Audit Target: {package}") - - sdk = manifest_details.get("target_sdk") or "Unknown" - summary.append(f"Target SDK: {sdk}") - - perms_count = len(manifest_details.get("permissions", [])) - summary.append(f"Permissions Requested: {perms_count}") - - signals_count = 0 - if isinstance(data_safety_scan, dict): - inner_scan = data_safety_scan.get("data_safety_scan", {}) - if "data_sources" in inner_scan: - signals_count = len(inner_scan["data_sources"]) - summary.append(f"Sensitive Data Signals Found: {signals_count}") - - return "\n".join(summary) - - -def run_scanner_direct(target_dir, file_inventory, output_file): - """Invokes the scanner directly using the provided inventory.""" - try: - # This calls the internal scan logic from scanner.py - results = scanner.perform_scan(target_dir, file_inventory) - _write_json_file(output_file, results) - return results - except Exception as e: # pylint: disable=broad-exception-caught - print(f"Error during scan: {e}", file=sys.stderr) - return {} - - -def write_agent_prompts(env_data, goal_map, output_data): - """Generates surgical prompt files for worker agents and infers activation.""" - repo_root = env_data.get("repo_root") - temp_dir = env_data.get("temp_dir") - app_dir = env_data.get("app_dir") - activated_goals = [] - - # 1. Load Shared Resources - policies_path = os.path.join(repo_root, "resources", "policies.json") - policies_data = _load_json_file(policies_path) - taxonomy = policies_data.get("data_safety_section", {}).get("taxonomy", {}) - taxonomy_keys = set(taxonomy.keys()) - - common_mandates_path = os.path.join( - repo_root, "resources", "common_mandates.md" - ) - common_mandates = _read_text_file(common_mandates_path) - - config = scanner.get_scanner_config() - permission_groups = config.get("permission_groups", {}) - - # 2. Write Slices - if output_data: - for slice_name in ["manifest_details", "codebase_map"]: - slice_path = os.path.join(temp_dir, f"{slice_name}.json") - _write_json_file(slice_path, output_data.get(slice_name, {})) - - # 3. Build Base Context - scan_results = output_data.get("data_safety_scan", {}) - base_context = scan_results.get("data_safety_scan", {}).copy() - - # Also ensure other metadata is included - base_context["found_urls"] = scan_results.get("found_urls", []) - base_context["semantic_files"] = scan_results.get("semantic_files", {}) - - # Flatten anonymous categories (those with only a "-" key) - for cat in [ - "exact_alarm", - "accessibility", - "foreground_service", - "disclosure", - ]: - if ( - cat in base_context - and isinstance(base_context[cat], dict) - and "-" in base_context[cat] - ): - base_context[cat] = base_context[cat]["-"] - - manifest_details = output_data.get("manifest_details", {}) - base_context["TARGET_SDK"] = manifest_details.get("target_sdk") - base_context["APP_NAME"] = output_data.get("app_name") - base_context["PACKAGE_NAME"] = output_data.get("package_name") - manifest_perms = manifest_details.get("permissions", []) - - # Populate requested_permissions dictionary based on groups - requested_perms_dict = {} - for group_name, perms_to_check in permission_groups.items(): - matched = [p for p in perms_to_check if p in manifest_perms] - if matched: - requested_perms_dict[group_name] = matched - - base_context["requested_permissions"] = requested_perms_dict - base_context["TEMP_DIR"] = temp_dir - base_context["APP_DIR"] = app_dir - base_context["REPO_ROOT"] = repo_root - - # 4. Smart Filtering for data_sources (Noise Reduction) - if "data_sources" in base_context: - filtered_sources = {} - for data_type, findings in base_context["data_sources"].items(): - # Deduplicate by File (max 2 per unique file) - file_counts = {} - diverse_findings = [] - for f in findings: - file_path = f.split(":L")[0] if ":L" in f else f - if file_counts.get(file_path, 0) < 2: - diverse_findings.append(f) - file_counts[file_path] = file_counts.get(file_path, 0) + 1 - - # Global Cap (max 3 per data type) - filtered_sources[data_type] = diverse_findings[:3] - base_context["data_sources"] = filtered_sources - - max_evidence_per_worker = 6 - - # Helper to evaluate and save a goal instance - def _save_goal_instance(instance_name, context, template_content): - full_template = template_content + "\n\n" + common_mandates - final_prompt = render_template(full_template, context) - - if "{{ACTIVATE_GOAL}}" in final_prompt: - final_prompt = final_prompt.replace("{{ACTIVATE_GOAL}}", "") - - goal_input_file = os.path.join( - temp_dir, f"input_worker_{instance_name}.json" - ) - _write_json_file(goal_input_file, context) - - output_path = os.path.join(temp_dir, f"prompt_worker_{instance_name}.md") - _write_text_file(output_path, final_prompt) - return True - return False - - # 5. Process Goals Sequentially (Removes Concurrency GIL Overhead & State - # Copies) - for goal_name, goal_conf in goal_map.items(): - template_path = os.path.join( - repo_root, "resources", goal_conf["prompt_file"] - ) - template_content = _read_text_file(template_path) - if not template_content: - continue - - # Identify chunks for this goal - chunks = [] - - if goal_name == "data_safety" and "data_sources" in base_context: - all_evidence = [] - for data_type, findings in base_context["data_sources"].items(): - if not taxonomy_keys or data_type in taxonomy_keys: - for finding in findings: - all_evidence.append((data_type, finding)) - - for i in range(0, len(all_evidence), max_evidence_per_worker): - chunk = all_evidence[i : i + max_evidence_per_worker] - chunk_dict = {} - for dt, finding in chunk: - if dt not in chunk_dict: - chunk_dict[dt] = { - "description": taxonomy.get(dt, {}).get( - "description", "User data type." - ), - "findings": [], - } - chunk_dict[dt]["findings"].append(finding) - chunks.append(chunk_dict) - - if not chunks: - # Single pass for small goals - if _save_goal_instance(goal_name, base_context, template_content): - activated_goals.append(goal_name) - else: - # Multipass for large data safety sets - for idx, chunk_data in enumerate(chunks, 1): - instance_name = f"{goal_name}_part_{idx}" - chunk_context = base_context.copy() - chunk_context["data_sources"] = chunk_data - chunk_context["GOAL_NAME"] = instance_name - if _save_goal_instance(instance_name, chunk_context, template_content): - activated_goals.append(instance_name) - - return activated_goals - - -def run_aggregation(temp_dir, repo_root): - """Aggregates and chunks findings for the Critic.""" - worker_pattern = os.path.join(temp_dir, "worker_*.json") - worker_files = sorted(glob.glob(worker_pattern)) - - all_findings = [] - finding_id_counter = 1 - - for w_file in worker_files: - basename = os.path.basename(w_file) - try: - with open(w_file, "r", encoding="utf-8") as f: - w_data = json.load(f) - findings_list = w_data.get("findings", []) - if isinstance(findings_list, list): - for finding in findings_list: - # Store the source filename so generate_report can associate it - # correctly - finding["worker_file"] = basename - # Assign sequential ID - finding["finding_id"] = str(finding_id_counter) - finding_id_counter += 1 - all_findings.append(finding) - except Exception as e: # pylint: disable=broad-exception-caught - print( - f"Warning: Failed to load worker file {w_file}: {e}", file=sys.stderr - ) - - # Save the master list of all findings - aggregated_path = os.path.join(temp_dir, "aggregated_findings.json") - with open(aggregated_path, "w", encoding="utf-8") as f: - json.dump({"findings": all_findings}, f, indent=4) - - # Filter findings that need Critic evaluation: skip only SUGGESTION findings - critic_findings = [] - for f in all_findings: - severity = str(f.get("severity", "SUGGESTION")).upper().strip() - if severity == "SUGGESTION": - continue - critic_findings.append(f) - - # Chunk the findings for the Critic - chunk_size = 3 - chunks = [ - critic_findings[i : i + chunk_size] - for i in range(0, len(critic_findings), chunk_size) - ] - - # Render Critic template for each chunk - critic_template_path = os.path.join(repo_root, "resources", "critic.md") - critic_template = "" - if os.path.exists(critic_template_path): - with open(critic_template_path, "r", encoding="utf-8") as f: - critic_template = f.read() - - common_mandates_path = os.path.join( - repo_root, "resources", "common_mandates.md" - ) - common_mandates = "" - if os.path.exists(common_mandates_path): - with open(common_mandates_path, "r", encoding="utf-8") as f: - common_mandates = f.read() - - full_template = critic_template + "\n\n" + common_mandates - - # Load contextual values from manifest_details.json - manifest_path = os.path.join(temp_dir, "manifest_details.json") - base_context = {} - if os.path.exists(manifest_path): - try: - with open(manifest_path, "r", encoding="utf-8", errors="ignore") as f: - m_details = json.load(f) - base_context["APP_DIR"] = m_details.get("app_dir") - except Exception: # pylint: disable=broad-exception-caught - pass - - if not repo_root: - repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - - base_context["TEMP_DIR"] = temp_dir - base_context["REPO_ROOT"] = repo_root - - for idx, chunk in enumerate(chunks, 1): - # Create input_critic_.json - chunk_dict = {f["finding_id"]: f for f in chunk} - chunk_input_path = os.path.join(temp_dir, f"input_critic_{idx}.json") - with open(chunk_input_path, "w", encoding="utf-8") as f: - json.dump(chunk_dict, f, indent=4) - - # Render prompt_critic_.md - chunk_context = base_context.copy() - chunk_context["CHUNK_INDEX"] = str(idx) - - final_prompt = render_template(full_template, chunk_context) - chunk_prompt_path = os.path.join(temp_dir, f"prompt_critic_{idx}.md") - with open(chunk_prompt_path, "w", encoding="utf-8") as f: - f.write(final_prompt) - - return len(chunks) - - -GRADLE_APP_PLUGIN_PATTERN = ( - r"(?:id\s*\(?\s*[\x27\x22]com\.android\.application[\x27\x22]\s*\)?|" - r"alias\s*\([^)]*android\.application[^)]*\)|" - r"alias\s*\([^)]*android\.app[^)]*\)|" - r"apply\s*\(?\s*(?:plugin:\s*)?[\x27\x22]com\.android\.application" - r"[\x27\x22]\s*\)?)(?!\s*(?:\.?apply\s*\(?\s*false\s*\)?|apply\s*=\s*false))" -) - - -def _resolve_to_int(val, lookups, visited=None): - """Recursively resolves a variable name to an integer value.""" - if visited is None: - visited = set() - - if not val: - return None - - # Clean and normalize the value - val_str = str(val).strip().strip('"').strip("'") - - # Support project.property("key") syntax - prop_match = re.search( - r'project\.property\s*\(\s*[\'"](.*?)[\'"]\s*\)', val_str - ) - if prop_match: - val_str = prop_match.group(1).strip() - - # Robust resolution for Gradle Version Catalogs and dynamic getters - # Strip common accessors and indirection prefixes - val_str = re.sub( - r"\.(?:get|toInt|getOrElse|provider)(?:\s*\([^)]*\))?", "", val_str - ) - if val_str.startswith("libs.versions."): - val_str = val_str[14:] - elif val_str.startswith("libs."): - val_str = val_str[5:] - elif val_str.startswith("versions."): - val_str = val_str[9:] - - if val_str.isdigit(): - return int(val_str) - - if val_str in visited: - return None # Circular reference protection - visited.add(val_str) - - # 1. Direct and Namespaced Lookups - search_keys = [val_str] - if "." in val_str: - # If A.B.C fails, try B.C, then C - parts = val_str.split(".") - for i in range(1, len(parts)): - search_keys.append(".".join(parts[i:])) - - for key in search_keys: - # Try custom versions first (usually has highest signal) - if key in lookups.get("custom", {}): - res = _resolve_to_int(lookups["custom"][key], lookups, visited) - if res is not None: - return res - - # Try gradle properties - if key in lookups.get("properties", {}): - res = _resolve_to_int(lookups["properties"][key], lookups, visited) - if res is not None: - return res - - # Try version catalog - if key in lookups.get("catalog", {}): - res = _resolve_to_int(lookups["catalog"][key], lookups, visited) - if res is not None: - return res - - return None - - -def main(): - """Deterministic context-gathering tool for the Reviewer skill.""" - parser = argparse.ArgumentParser( - description="Deterministic context-gathering and state-query tool." - ) - subparsers = parser.add_subparsers(dest="command", required=True) - - # Init subcommand - init_parser = subparsers.add_parser( - "init", help="Initialize scratch environment." - ) - init_parser.add_argument("app_dir", help="Path to the Android application.") - - # Aggregate subcommand - agg_parser = subparsers.add_parser("aggregate", help="Aggregate findings.") - agg_parser.add_argument( - "temp_dir", help="Path to the temporary scratch directory." - ) - - args = parser.parse_args() - - repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - - if args.command == "aggregate": - temp_dir = os.path.abspath(args.temp_dir) - if not os.path.isdir(temp_dir): - print( - f"Error: Specified temporary directory does not exist: {temp_dir}", - file=sys.stderr, - ) - sys.exit(1) - - chunk_count = run_aggregation(temp_dir, repo_root) - print(json.dumps({"temp_dir": temp_dir, "critic_chunks": chunk_count})) - sys.exit(0) - - # Initialization Mode - app_dir = os.path.abspath(args.app_dir) - if not os.path.isdir(app_dir): - print(f"Error: {app_dir} is not a valid directory.", file=sys.stderr) - sys.exit(1) - - scratch_id = str(uuid.uuid4()) - workspace_root = os.getcwd() - temp_dir = os.path.join( - workspace_root, ".scratch", f"play_policy_insights_{scratch_id}" - ) - os.makedirs(temp_dir, exist_ok=True) - - # Perform a single master filesystem walk using the scanner - file_inventory = scanner.collect_target_files(app_dir) - - gradle_properties = load_gradle_properties(app_dir) - version_catalog = load_version_catalog(app_dir) - # Pass inventory to avoid redundant walk - custom_gradle_versions = load_custom_gradle_versions(app_dir, file_inventory) - - codebase_map = { - "key_files": { - "manifests": file_inventory["manifests"], - "gradles": [ - g - for g in file_inventory["gradles"] - if g.endswith((".gradle", ".gradle.kts")) - ], - } - } - - # 1. Detect and Filter Flavors (Play Store Prioritization) - detected_flavors = set() - src_dirs = [] - - config = scanner.get_scanner_config() - ignored_dirs = set(config.get("ignored_directories", [])) - - # Find all 'src' directories - for root, dirs, _ in os.walk(app_dir): - dirs[:] = [d for d in dirs if d not in ignored_dirs] - if "src" in dirs: - src_dirs.append(os.path.join(root, "src")) - dirs.remove("src") # Don't recurse deeper for 'src' search - - for s_dir in src_dirs: - for flavor in os.listdir(s_dir): - if ( - os.path.isdir(os.path.join(s_dir, flavor)) - and flavor not in ignored_dirs - ): - detected_flavors.add(flavor) - - prioritized_flavors = [] - if "play" in detected_flavors: - prioritized_flavors = ["main", "play"] - - if prioritized_flavors: - # Filter codebase_map to only include prioritized flavors - print( - f"Prioritizing flavors: {', '.join(prioritized_flavors)}", - file=sys.stderr, - ) - - filtered_files = {} - for key, paths in codebase_map.get("key_files", {}).items(): - filtered_paths = [] - for p in paths: - # Check if path contains any non-prioritized flavor - is_excluded = False - for f in detected_flavors: - if f not in prioritized_flavors and f"src/{f}/" in p: - is_excluded = True - break - if not is_excluded: - filtered_paths.append(p) - filtered_files[key] = filtered_paths - codebase_map["key_files"] = filtered_files - - with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: - # 1. Start the Data Safety Scanner immediately (independent task) - data_safety_scan_file = os.path.join(temp_dir, "data_safety_scan.json") - scanner_future = executor.submit( - run_scanner_direct, app_dir, file_inventory, data_safety_scan_file - ) - - # 2. Extract Application Modules and Primary Identity - app_modules = parse_application_modules( - codebase_map.get("key_files", {}).get("gradles", []), - codebase_map.get("key_files", {}).get("manifests", []), - gradle_properties, - version_catalog, - custom_gradle_versions=custom_gradle_versions, - ) - primary_app_id, primary_target_sdk, primary_app_label = ( - determine_primary_identity( - app_modules, - codebase_map.get("key_files", {}).get("gradles", []), - codebase_map.get("key_files", {}).get("manifests", []), - gradle_properties, - version_catalog, - custom_gradle_versions, - app_dir, - ) - ) - - manifest_content = "" - manifest_details = { - "package_name": primary_app_id, - "app_label": primary_app_label, - "target_sdk": primary_target_sdk, - "app_dir": app_dir, - "permissions": [], - "foreground_services": [], - } - - if codebase_map.get("key_files", {}).get("manifests"): - for _, manifest_path in enumerate(codebase_map["key_files"]["manifests"]): - # Filter Test-Bleed based on relative path - rel_path = os.path.relpath(manifest_path, app_dir) - normalized_rel_path = "/" + rel_path.replace("\\", "/") - if any( - x in normalized_rel_path - for x in [ - "/debug/", - "/test/", - "/androidTest/", - "/testFixtures/", - ] - ): - continue - - if os.path.exists(manifest_path): - try: - # Manifests are UTF-8. Without an explicit encoding this falls back - # to the platform default, which on Windows is the locale codepage: - # the file arrives mojibake and every downstream parse fails. - with open(manifest_path, "r", encoding="utf-8-sig") as f: - content = f.read() - if not manifest_content: - manifest_content = content - - details = extract_manifest_details(content) - - # Identity Fallbacks - if details.get("package_name") and not manifest_details.get( - "package_name" - ): - manifest_details["package_name"] = details["package_name"] - - if details.get("target_sdk") and not manifest_details.get( - "target_sdk" - ): - manifest_details["target_sdk"] = details["target_sdk"] - - if details.get("app_label") and not manifest_details.get( - "app_label" - ): - manifest_details["app_label"] = details["app_label"] - - manifest_details["permissions"].extend( - details.get("permissions", []) - ) - manifest_details["foreground_services"].extend( - details.get("foreground_services", []) - ) - except Exception as e: # pylint: disable=broad-exception-caught - print(f"Warning: Failed to read manifest: {e}", file=sys.stderr) - - manifest_details["permissions"] = sorted( - list(set(manifest_details["permissions"])) - ) - - # Deduplicate and sort foreground services - unique_services = [] - seen_services = set() - for svc in manifest_details["foreground_services"]: - svc_key = (svc.get("name"), svc.get("type")) - if svc_key not in seen_services: - seen_services.add(svc_key) - unique_services.append(svc) - - # Final stable sort - manifest_details["foreground_services"] = sorted( - unique_services, - key=lambda x: (x.get("name") or "", x.get("type") or ""), - ) - - # 3. Gather results - data_safety_scan = scanner_future.result() - - # 5. Post-Scan Flavor Filtering - if prioritized_flavors: - data_safety_scan = _filter_by_flavor( - data_safety_scan, detected_flavors, prioritized_flavors - ) - - # 6. Ingest local Play Store info - local_info_path = os.path.join( - app_dir, "play_store_assets", "play_store_info.json" - ) - if os.path.exists(local_info_path): - try: - with open(local_info_path, "r", encoding="utf-8") as f: - play_store_info = json.load(f) - if "is_published" not in play_store_info: - play_store_info["is_published"] = True - if "store_url" not in play_store_info: - play_store_info["store_url"] = f"file://{local_info_path}" - - with open( - os.path.join(temp_dir, "play_store_info.json"), - "w", - encoding="utf-8", - ) as out_f: - json.dump(play_store_info, out_f, indent=4) - print( - f"Ingested local Play Store info from {local_info_path}", - file=sys.stderr, - ) - except Exception as e: # pylint: disable=broad-exception-caught - print( - f"Warning: Failed to ingest local Play Store info: {e}", - file=sys.stderr, - ) - - reasoning = [] - goal_map = {} - resources_dir = os.path.join(repo_root, "resources") - if os.path.exists(resources_dir): - for f in os.listdir(resources_dir): - if f.startswith("goal_") and f.endswith(".md"): - goal_name = f[5:-3] - goal_map[goal_name] = {"prompt_file": f} - - if prioritized_flavors: - reasoning.append( - f"Prioritizing Play Store flavors: {', '.join(prioritized_flavors)}" - ) - reasoning.append("Activated via template evaluation") - - output_data = { - "app_name": ( - manifest_details.get("app_label") - or os.path.basename(os.path.normpath(app_dir)) - ), - "package_name": manifest_details.get("package_name"), - "target_dir": app_dir, - "codebase_map": codebase_map, - "manifest_content": manifest_content, - "manifest_details": manifest_details, - "application_modules": app_modules, - "data_safety_scan": data_safety_scan, - "triage_summary": generate_triage_summary( - manifest_details, data_safety_scan - ), - "triage_reasoning": "\n".join(reasoning), - "detected_flavors": sorted(list(detected_flavors)), - "prioritized_flavors": prioritized_flavors, - } - - try: - activated_goals = write_agent_prompts( - {"repo_root": repo_root, "temp_dir": temp_dir, "app_dir": app_dir}, - goal_map, - output_data, - ) - print(f"Prompts generated in {temp_dir}", file=sys.stderr) - except Exception as e: # pylint: disable=broad-exception-caught - print(f"Warning: Failed to generate prompts: {e}", file=sys.stderr) - - print( - json.dumps( - {"temp_dir": temp_dir, "activated_goals": activated_goals}, indent=4 - ) - ) - - -if __name__ == "__main__": - main() diff --git a/play/play-policy-insights/scripts/play_store_scraper.py b/play/play-policy-insights/scripts/play_store_scraper.py deleted file mode 100755 index 35ce028..0000000 --- a/play/play-policy-insights/scripts/play_store_scraper.py +++ /dev/null @@ -1,410 +0,0 @@ -#!/usr/bin/env python3 - -# Copyright 2026 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 -# -# https://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. - -"""Scrapes and parses app metadata and safety info from Play Store.""" - -import html.parser -import json -import os -import ssl -import sys -import urllib.error -import urllib.request - - -def fetch_html(url, verify_ssl=True): - """Fetches HTML content from a URL with a realistic User-Agent.""" - headers = { - "User-Agent": ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/119.0.0.0 Safari/537.36" - ), - "Accept-Language": "en-US,en;q=0.9", - } - req = urllib.request.Request(url, headers=headers) - if verify_ssl: - context = ssl.create_default_context() - else: - context = ssl._create_unverified_context() - try: - with urllib.request.urlopen(req, timeout=10, context=context) as resp: - return resp.read().decode("utf-8") - except urllib.error.HTTPError as e: - if e.code == 404: - print(f"App not found on Play Store (404): {url}", file=sys.stderr) - else: - print(f"HTTP Error: {e.code} {e.reason}", file=sys.stderr) - return None - except Exception as e: - print(f"Error fetching {url}: {e}", file=sys.stderr) - return None - - -class PlayStoreMetadataParser(html.parser.HTMLParser): - """Parses core app metadata from Play Store HTML.""" - - def __init__(self): - super().__init__() - self.ld_json = None - self.meta_category = None - self.meta_content_rating = None - self.meta_description = None - self.title_text = None - self.developer_name = None - self.privacy_policy_url = None - - self.in_ld_json = False - self.json_data = [] - self.in_title = False - self.in_dev_link = False - - self.in_a_tag = False - self.current_a_href = None - - def handle_starttag(self, tag, attrs): - attrs_dict = dict(attrs) - - if tag == "script" and attrs_dict.get("type") == "application/ld+json": - self.in_ld_json = True - self.json_data = [] - - if tag == "meta": - itemprop = attrs_dict.get("itemprop") - if itemprop == "applicationCategory": - self.meta_category = attrs_dict.get("content") - elif itemprop == "contentRating": - self.meta_content_rating = attrs_dict.get("content") - - name = attrs_dict.get("name") - prop = attrs_dict.get("property") - if prop == "og:description" or name == "description": - if not self.meta_description: - self.meta_description = attrs_dict.get("content") - - if tag == "h1": - self.in_title = True - - if tag == "a": - href = attrs_dict.get("href", "") - if "/store/apps/dev" in href: - self.in_dev_link = True - - self.in_a_tag = True - self.current_a_href = href - - def handle_endtag(self, tag): - if tag == "script" and self.in_ld_json: - self.in_ld_json = False - try: - self.ld_json = json.loads("".join(self.json_data)) - if isinstance(self.ld_json, list): - self.ld_json = self.ld_json[0] - except (json.JSONDecodeError, TypeError, IndexError): - pass - if tag == "h1": - self.in_title = False - if tag == "a": - self.in_dev_link = False - self.in_a_tag = False - self.current_a_href = None - - def handle_data(self, data): - if self.in_ld_json: - self.json_data.append(data) - elif self.in_title and not self.title_text: - self.title_text = data.strip() - elif self.in_dev_link and not self.developer_name: - self.developer_name = data.strip() - elif self.in_a_tag and self.current_a_href: - if "privacy policy" in data.lower(): - if "google.com" not in self.current_a_href.lower(): - if not self.privacy_policy_url: - self.privacy_policy_url = self.current_a_href - - -class PlayStoreDataSafetyParser(html.parser.HTMLParser): - """Extracts visible text content from HTML structurally.""" - - def __init__(self): - super().__init__() - self.text_parts = [] - self.ignore_tags = {"script", "style", "noscript", "svg", "path"} - self.in_ignored_tag = 0 - - def handle_starttag(self, tag, attrs): - if tag.lower() in self.ignore_tags: - self.in_ignored_tag += 1 - - def handle_endtag(self, tag): - if tag.lower() in self.ignore_tags: - self.in_ignored_tag = max(0, self.in_ignored_tag - 1) - - def handle_data(self, data): - if self.in_ignored_tag == 0: - cleaned = data.strip() - if cleaned: - self.text_parts.append(cleaned) - - -def load_taxonomy(): - """Loads the valid data safety taxonomy from policies.json.""" - repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - policy_path = os.path.join(repo_root, "resources", "policies.json") - try: - with open(policy_path, "r", encoding="utf-8", errors="ignore") as f: - data = json.load(f) - taxonomy = data.get("data_safety_section", {}).get("taxonomy", {}) - return { - item["data_type"].lower() - for item in taxonomy.values() - if "data_type" in item - } - except Exception as e: - print( - f"Warning: Failed to load taxonomy from {policy_path}: {e}", - file=sys.stderr, - ) - return set() - - -# Official Play Store Data Safety Taxonomy for validation -VALID_TAXONOMY = load_taxonomy() - - -def extract_metadata(html_content): - """Extracts all metadata into a clean dictionary.""" - metadata = { - "title": "Unknown App", - "developer": "Unknown Developer", - "category": None, - "content_rating": None, - "description": None, - "privacy_policy_url": None, - } - - if not html_content: - return metadata - - parser = PlayStoreMetadataParser() - parser.feed(html_content) - - # 1. Prioritize ld+json payload - if parser.ld_json: - metadata["title"] = parser.ld_json.get("name", metadata["title"]) - - author = parser.ld_json.get("author", {}) - if isinstance(author, dict): - metadata["developer"] = author.get("name", metadata["developer"]) - elif isinstance(author, str): - metadata["developer"] = author - - metadata["category"] = parser.ld_json.get( - "applicationCategory", metadata["category"] - ) - metadata["content_rating"] = parser.ld_json.get( - "contentRating", metadata["content_rating"] - ) - metadata["description"] = parser.ld_json.get( - "description", metadata["description"] - ) - - # 2. Fall back to HTML tags if json was missing elements - if metadata["title"] == "Unknown App" and parser.title_text: - metadata["title"] = parser.title_text - if metadata["developer"] == "Unknown Developer" and parser.developer_name: - metadata["developer"] = parser.developer_name - if not metadata["category"] and parser.meta_category: - metadata["category"] = parser.meta_category - if not metadata["content_rating"] and parser.meta_content_rating: - metadata["content_rating"] = parser.meta_content_rating - if not metadata["description"] and parser.meta_description: - metadata["description"] = parser.meta_description - - # Privacy Policy is only found in tags - metadata["privacy_policy_url"] = parser.privacy_policy_url - - return metadata - - -def extract_data_safety(html_content): - """Extracts Data Safety information structurally.""" - if not html_content: - return {"data_collected": [], "data_shared": []} - - parser = PlayStoreDataSafetyParser() - parser.feed(html_content) - parts = parser.text_parts - - result = {"data_collected": [], "data_shared": []} - - purposes_keywords = [ - "App functionality", - "Analytics", - "Fraud prevention, security, and compliance", - "Personalization", - "Account management", - "Advertising or marketing", - "Developer communications", - ] - - known_categories = { - "Location", - "Personal info", - "Financial info", - "Health and fitness", - "Messages", - "Photos and videos", - "Audio files", - "Files and docs", - "Calendar", - "Contacts", - "App activity", - "Web browsing", - "App info and performance", - "Device or other IDs", - } - - # Robust parsing ignoring UI icons - cur_sec = None - cur_cat = None - cur_type = None - - for p in parts: - p_lower = p.lower() - - # Section Transitions - if ( - p_lower == "data shared" - or p_lower - == "here's more information the developer has provided about the kinds" - " of data this app may share" - ): - cur_sec = "data_shared" - cur_cat = None - continue - elif ( - p_lower == "data collected" - or p_lower - == "here's more information the developer has provided about the kinds" - " of data this app may collect" - ): - cur_sec = "data_collected" - cur_cat = None - continue - elif p_lower == "security practices" or p_lower == "data safety": - if cur_sec: - break - - if not cur_sec: - continue - - # Ignore UI artifacts - if p_lower in [ - "expand_more", - "expand_less", - "info", - "data collected and for what purpose", - "data shared and for what purpose", - ]: - continue - - # If it's a known purpose keyword, attach it to the current type - matched_purposes = [kw for kw in purposes_keywords if kw.lower() in p_lower] - if matched_purposes: - if cur_type: - cur_type["purposes"].extend(matched_purposes) - cur_type["purposes"] = list(set(cur_type["purposes"])) - continue - - if p_lower == "· optional": - if cur_type: - cur_type["optional"] = True - continue - - # If it's not a purpose or an artifact, it's either a Category or a - # Data Type - if len(p) > 50: - continue - - if p in known_categories: - if not cur_cat or cur_cat["category"] != p: - cur_cat = {"category": p, "types": []} - result[cur_sec].append(cur_cat) - cur_type = None - continue - - if cur_cat: - if p == cur_cat["category"]: - continue - - cur_type = {"type": p, "purposes": [], "optional": False} - cur_cat["types"].append(cur_type) - - # Post-process to remove summary headers (UI groupings) - for cat in result["data_collected"] + result["data_shared"]: - filtered_types = [] - cat_name = cat["category"].lower() - - for t in cat["types"]: - t_name_lower = t["type"].lower() - - # Indicators that this is a summary header rather than a declaration: - # 1. It has no associated purposes (headers are never assigned purposes) - # 2. AND it either: - # a. Matches the category name exactly (redundant UI artifact) - # b. Is not a valid item in the official taxonomy (summary string) - if not t["purposes"]: - if ( - t_name_lower == cat_name - or t_name_lower not in VALID_TAXONOMY - ): - continue - - filtered_types.append(t) - - cat["types"] = filtered_types - - return result - - -def scrape_app_details(pkg_name, verify_ssl=True): - """Scrapes app metadata and Data Safety info for a given package.""" - details_url = ( - f"https://play.google.com/store/apps/details?id={pkg_name}&hl=en&gl=US" - ) - ds_url = ( - f"https://play.google.com/store/apps/datasafety?id={pkg_name}&hl=en&gl=US" - ) - - details_html = fetch_html(details_url, verify_ssl=verify_ssl) - metadata = extract_metadata(details_html) - - ds_html = fetch_html(ds_url, verify_ssl=verify_ssl) - data_safety = extract_data_safety(ds_html) - - return { - "package_name": pkg_name, - "is_published": bool(details_html), - "store_url": details_url, - "title": metadata["title"], - "developer": metadata["developer"], - "description": metadata["description"], - "category": metadata["category"], - "app_info": {"content_rating": metadata["content_rating"]}, - "developer_links": {"privacy_policy_url": metadata["privacy_policy_url"]}, - "data_safety": data_safety, - } diff --git a/play/play-policy-insights/scripts/scanner.py b/play/play-policy-insights/scripts/scanner.py deleted file mode 100755 index 60ad66b..0000000 --- a/play/play-policy-insights/scripts/scanner.py +++ /dev/null @@ -1,261 +0,0 @@ -#!/usr/bin/env python3 - -# Copyright 2026 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 -# -# https://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. - -"""Scans Android codebase for data safety signals and sensitive API usage.""" - -import concurrent.futures -import functools -import json -import os -import re -import sys - -# Pre-compiled regex patterns at module scope for thread-safe performance -URL_PATTERN = re.compile(r'"(https?://[^"]+)"') -XML_COMMENT_PATTERN = re.compile(r"", flags=re.DOTALL) -COMMENT_STRIP_PATTERN = re.compile( - r'("(?:\\.|[^"\\])*")|//[^\n]*|/\*.*?\*/', - flags=re.DOTALL, -) - - -@functools.lru_cache(maxsize=1) -def get_scanner_config(): - """Loads the scanner configuration from scanner_config.json with caching.""" - config_path = os.path.join( - os.path.dirname(os.path.dirname(os.path.abspath(__file__))), - "resources", - "scanner_config.json", - ) - try: - with open(config_path, "r", encoding="utf-8") as f: - return json.load(f) - except Exception as e: - print( - f"Warning: Failed to load scanner config from {config_path}: {e}", - file=sys.stderr, - ) - return {} - - -@functools.lru_cache(maxsize=1) -def get_supported_extensions(): - """Returns a tuple of supported file extensions from config.""" - return tuple(get_scanner_config().get("supported_extensions", [])) - - -def collect_target_files(target_dir): - """Performs a single master walk to categorize files.""" - categorized = { - "manifests": [], - "gradles": [], - "source_code": [], - "all_files": [], - } - - normalized_target = os.path.abspath(target_dir) - hybrid_root = normalized_target - target_parent = os.path.dirname(normalized_target) - - for potential_root in [normalized_target, target_parent]: - if os.path.exists( - os.path.join(potential_root, "package.json") - ) or os.path.exists(os.path.join(potential_root, "pubspec.yaml")): - hybrid_root = potential_root - print( - f"Hybrid app detected. Expanding scan root to: {hybrid_root}", - file=sys.stderr, - ) - break - - config = get_scanner_config() - ignored_dirs = set(config.get("ignored_directories", [])) - supported_exts = get_supported_extensions() - - for root, dirs, files in os.walk(hybrid_root): - dirs[:] = sorted([d for d in dirs if d not in ignored_dirs]) - for file in sorted(files): - file_path = os.path.join(root, file) - categorized["all_files"].append((file, file_path, root)) - - # Surgical filter for XMLs to avoid scanning thousands of low-signal - # assets. - if file.endswith(".xml"): - # Exclude test-bleed XMLs - if not any( - x in file_path.lower() - for x in ["/debug/", "/test/", "/androidtest/", "/testfixtures/"] - ): - if file == "AndroidManifest.xml": - categorized["manifests"].append(file_path) - else: - path_parts = file_path.split(os.sep) - is_valuable_xml = False - for i, part in enumerate(path_parts): - if part in ["res", "resources"] and i + 1 < len(path_parts): - next_part = path_parts[i + 1] - if any( - next_part.startswith(d) - for d in ["layout", "values", "xml", "navigation"] - ): - is_valuable_xml = True - break - if is_valuable_xml: - categorized["source_code"].append(file_path) - - elif file.lower().endswith((".gradle", ".gradle.kts")): - categorized["gradles"].append(file_path) - - elif file.lower().endswith(supported_exts) or ( - file.lower() == "pubspec.yaml" or file.lower() == "package.json" - ): - # Filter (Gap 3 - OS-Specific Path Separators and Test-Bleed based on - # filename/path) - file_lower = file.lower() - is_test_or_debug = ( - any( - x in file_path.lower() - for x in [ - "/debug/", - "/test/", - "/androidtest/", - "/testfixtures/", - ".spec.js", - ".spec.ts", - ".spec.jsx", - ".spec.tsx", - ] - ) - or file_lower.endswith("_test.dart") - ) - if not is_test_or_debug: - categorized["source_code"].append(file_path) - - return categorized - - -def _identify_semantic_files(all_files): - """Maps files to semantic categories based on filename patterns.""" - semantic_files = {} - file_patterns = get_scanner_config().get("file_patterns", {}) - supported_exts = get_supported_extensions() - - for file, file_path, root in all_files: - file_lower = file.lower() - # Check if it's a relevant source/config file - if file_lower.endswith(supported_exts + (".xml",)): - rel_path = os.path.relpath(file_path, root) - for category, patterns in file_patterns.items(): - if any(p in file_lower for p in patterns): - if category not in semantic_files: - semantic_files[category] = [] - semantic_files[category].append(rel_path) - - return semantic_files - - -def _scan_single_file(file_path, all_signal_categories, target_dir): - """Internal worker to scan a single file for all signal categories.""" - local_api_usage = {} - found_urls = [] - supported_exts = get_supported_extensions() - - try: - with open(file_path, "r", encoding="utf-8", errors="ignore") as f: - content = f.read() - - # URLs - with quick check for policy relatedness - policy_url_keywords = ["privacy", "support", "delete", "terms", "policy"] - for match in URL_PATTERN.finditer(content): - url = match.group(1) - if any(kw in url.lower() for kw in policy_url_keywords): - found_urls.append(url) - - # Cleanup comments based on file type. .csproj and .xaml are XML documents - # despite their extensions, so they use XML comment syntax rather than the - # C-style stripping applied to source files. - file_path_lower = file_path.lower() - if file_path_lower.endswith((".xml", ".csproj", ".xaml")): - content = XML_COMMENT_PATTERN.sub("", content) - elif file_path_lower.endswith(supported_exts): - content = COMMENT_STRIP_PATTERN.sub( - lambda m: m.group(1) if m.group(1) else "", content - ) - - # Signal Patterns - for cat_name, signals in all_signal_categories.items(): - for signal_id, patterns in signals.items(): - for p in patterns: - if p in content: - if cat_name not in local_api_usage: - local_api_usage[cat_name] = {} - if signal_id not in local_api_usage[cat_name]: - local_api_usage[cat_name][signal_id] = [] - - rel_path = os.path.relpath(file_path, target_dir) - local_api_usage[cat_name][signal_id].append( - f"{rel_path} (Pattern: {p})" - ) - break # Move to next signal once pattern found in file - - except Exception: # pylint: disable=broad-exception-caught - pass - - return {"api_usage": local_api_usage, "urls": found_urls} - - -def perform_scan(target_dir, file_inventory): - """Orchestrates the parallel scanning of identified files.""" - api_usage = {} - all_urls = [] - all_signal_categories = get_scanner_config().get("signal_categories", {}) - - with concurrent.futures.ThreadPoolExecutor(max_workers=8) as executor: - futures = [ - executor.submit( - _scan_single_file, f, all_signal_categories, target_dir - ) - for f in file_inventory.get("source_code", []) - ] - - for future in concurrent.futures.as_completed(futures): - res = future.result() - for cat_name, cat_findings in res["api_usage"].items(): - if cat_name not in api_usage: - api_usage[cat_name] = {} - for signal_id, findings in cat_findings.items(): - if signal_id not in api_usage[cat_name]: - api_usage[cat_name][signal_id] = [] - api_usage[cat_name][signal_id].extend(findings) - - for u in res["urls"]: - all_urls.append(u) - - # Final cleanup and formatting - for cat_name in api_usage: - for signal_id in api_usage[cat_name]: - api_usage[cat_name][signal_id] = sorted( - list(set(api_usage[cat_name][signal_id])) - ) - - return { - "data_safety_scan": api_usage, - "found_urls": sorted(list(set(all_urls))), - "semantic_files": _identify_semantic_files( - file_inventory.get("all_files", []) - ), - "codebase_map": file_inventory, - } diff --git a/play/play-policy-insights/scripts/template_engine.py b/play/play-policy-insights/scripts/template_engine.py deleted file mode 100644 index 1c4fccb..0000000 --- a/play/play-policy-insights/scripts/template_engine.py +++ /dev/null @@ -1,243 +0,0 @@ -#!/usr/bin/env python3 - -# Copyright 2026 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 -# -# https://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. - -"""Small logic-less template engine for Markdown generation.""" - -import re - -# Precompiled regex patterns at module scope for linear-time optimizations -TAG_PATTERN = re.compile(r"\{\{(#|/)?(IF_ANY|IF_ALL|IF|EACH)?\s*(.*?)\}\}") -NEWLINE_COLLAPSE_PATTERN = re.compile(r"\n{3,}") -VAR_VALID_PATTERN = re.compile(r"^[A-Za-z0-9_.]+$") - - -def get_nested_val(ctx, key): - """Resolves nested dot-notation keys from context (e.g., app.info.package).""" - parts = key.split(".") - val = ctx - for p in parts: - if isinstance(val, dict): - val = val.get(p) - else: - return None - return val - - -class Node: - """Base class for all Abstract Syntax Tree (AST) node elements.""" - - def render(self, ctx, buffer): - raise NotImplementedError - - -class TextNode(Node): - """Represents raw static text blocks.""" - - def __init__(self, text): - self.text = text - - def render(self, ctx, buffer): - buffer.append(self.text) - - -class VarNode(Node): - """Represents dynamic variable interpolation with literal fallbacks.""" - - def __init__(self, key, raw_text): - self.key = key - self.raw_text = raw_text - - def render(self, ctx, buffer): - val = get_nested_val(ctx, self.key) - if val is not None: - buffer.append(str(val)) - else: - # Preserve the unresolved tag exactly like the original engine - buffer.append(self.raw_text) - - -class IfNode(Node): - """Represents conditional block logic (IF, IF_ALL, IF_ANY).""" - - def __init__(self, keys_str, mode, children): - self.keys = [k.strip() for k in keys_str.split(",")] - self.mode = mode - self.children = children - - def render(self, ctx, buffer): - condition_met = False - if self.mode == "IF": - condition_met = bool(get_nested_val(ctx, self.keys[0])) - elif self.mode == "IF_ANY": - condition_met = any(get_nested_val(ctx, k) for k in self.keys) - elif self.mode == "IF_ALL": - condition_met = all(get_nested_val(ctx, k) for k in self.keys) - - if condition_met: - for child in self.children: - child.render(ctx, buffer) - - -def strip_nodes(nodes): - """Helper to strip leading/trailing whitespace from first/last TextNodes.""" - if not nodes: - return nodes - - # Strip leading whitespace from first TextNode - if isinstance(nodes[0], TextNode): - nodes[0].text = nodes[0].text.lstrip() - if not nodes[0].text: - nodes.pop(0) - - # Strip trailing whitespace from last TextNode - if nodes and isinstance(nodes[-1], TextNode): - nodes[-1].text = nodes[-1].text.rstrip() - if not nodes[-1].text: - nodes.pop() - - return nodes - - -class EachNode(Node): - """Represents standard iteration logic {{#EACH}} over lists/dicts.""" - - def __init__(self, key, children): - self.key = key - self.children = strip_nodes(children) - - def _render_children(self, local_ctx): - """Helper to render nested children under a specific loop context.""" - sub_buffer = [] - for child in self.children: - child.render(local_ctx, sub_buffer) - return "".join(sub_buffer) - - def render(self, ctx, buffer): - val = get_nested_val(ctx, self.key) - if not val: - return - - rendered_items = [] - if isinstance(val, dict): - for k, v in val.items(): - if k == "-": - continue - new_ctx = ctx.copy() - new_ctx["KEY"] = str(k) - new_ctx["VALUE"] = v - rendered_items.append(self._render_children(new_ctx)) - - elif isinstance(val, list): - for item in val: - new_ctx = ctx.copy() - new_ctx["ITEM"] = item - rendered_items.append(self._render_children(new_ctx)) - - # Join iterations together with single newline - buffer.append("\n".join(rendered_items)) - - -class _ParserFrame: - """Helper class representing an active stack frame in the compiler.""" - - def __init__(self, tag_type, children, match): - self.tag_type = tag_type - self.children = children - self.match = match - - -def compile_template(template_str): - """Parses template_str forward-only, building the AST Node tree.""" - root_children = [] - stack = [_ParserFrame(None, root_children, None)] - - pos = 0 - for match in TAG_PATTERN.finditer(template_str): - start, end = match.span() - - # 1. Capture static text prior to the tag - if start > pos: - stack[-1].children.append(TextNode(template_str[pos:start])) - - prefix = match.group(1) - tag_type = match.group(2) - arg = match.group(3).strip() if match.group(3) else "" - raw_tag = match.group(0) - - if prefix == "#": - if tag_type: - # Pushing a new nested frame block onto the stack - new_children = [] - stack.append(_ParserFrame(tag_type, new_children, match)) - else: - # Invalid tag starting with #, treat as normal text block - stack[-1].children.append(TextNode(raw_tag)) - - elif prefix == "/": - if tag_type: - # Block closer tag - if len(stack) > 1 and stack[-1].tag_type == tag_type: - frame = stack.pop() - open_arg = frame.match.group(3).strip() - - if frame.tag_type == "EACH": - node = EachNode(open_arg, frame.children) - else: - node = IfNode(open_arg, frame.tag_type, frame.children) - - stack[-1].children.append(node) - else: - # Mismatched closer, treat as static text - stack[-1].children.append(TextNode(raw_tag)) - else: - # Invalid closer, treat as static text - stack[-1].children.append(TextNode(raw_tag)) - - else: - # Variable tag or unknown braces - if VAR_VALID_PATTERN.match(arg): - stack[-1].children.append(VarNode(arg, raw_tag)) - else: - stack[-1].children.append(TextNode(raw_tag)) - - pos = end - - # Capture any trailing text blocks - if pos < len(template_str): - stack[-1].children.append(TextNode(template_str[pos:])) - - # Graceful recovery for unclosed tags to prevent runtime crashes - while len(stack) > 1: - frame = stack.pop() - # Re-inject the unclosed opening tag as plain text, followed by its children - stack[-1].children.append(TextNode(frame.match.group(0))) - stack[-1].children.extend(frame.children) - - return root_children - - -def render_template(template_str, context_dict): - """Renders a markdown template with high performance and zero dependencies.""" - nodes = compile_template(template_str) - - buffer = [] - for node in nodes: - node.render(context_dict, buffer) - - final_string = "".join(buffer) - - # Collapse 3 or more consecutive newlines into exactly 2 (preserves layout) - return NEWLINE_COLLAPSE_PATTERN.sub("\n\n", final_string) diff --git a/profilers/android-profiler/SKILL.md b/profilers/android-profiler/SKILL.md deleted file mode 100644 index f0f0724..0000000 --- a/profilers/android-profiler/SKILL.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -name: android-profiler -description: > - Manages Android performance profiling and debugging. Triggers when the user - asks to record or analyze Android performance data, such as system traces, - heap dumps, method recordings, callstack samples, memory allocations, or - investigate bottlenecks, jank, memory leaks, and app startup issues on - Android, or when the user asks to write, debug, or execute ad-hoc SQL - queries. Applies to both user and system apps or services. -license: Complete terms in LICENSE.txt -metadata: - author: Google LLC - last-updated: '2026-08-06' - keywords: - - Android performance - - debugging - - profiling - - recording - - trace analysis - - memory leaks - - bottleneck - - jank - - SQL ---- - -# Android Profiler Orchestrator - -Your primary role is **Intent Disambiguation and Routing**; route the user to -the correct workflow or prepare an execution plan for the user. Work with the -user to finalize the plan and then proceed with the plan execution, addressing -singular as well as composite needs. - -## Prerequisites and Setup - -Before executing any workflows, read -[`references/env_setup.md`](references/env_setup.md) (it sits next to this file -in the skill root). It defines what to set `$SKILL_ROOT` to - the anchor every -other path in this skill is written against. - -## Intent Disambiguation - -Do not guess the user's intent. If the user request is not clear, **ask the -user** what they want to do before proceeding. - -## Recording - -Route all recording requests through -`$SKILL_ROOT/recording/recording_orchestrator.md`. This defines guidelines and -pre-flight checks or dependency checks that apply to all recording workflows, -and ensures you have the necessary setup to proceed. Read the orchestrator and -execute the plan it describes based on what the user wants to record (for -example, a system trace or a heap dump). - -## Analysis - -Route all analysis requests through -`$SKILL_ROOT/analysis/analysis_orchestrator.md`. \ No newline at end of file diff --git a/profilers/android-profiler/analysis/analysis_orchestrator.md b/profilers/android-profiler/analysis/analysis_orchestrator.md deleted file mode 100644 index 750b301..0000000 --- a/profilers/android-profiler/analysis/analysis_orchestrator.md +++ /dev/null @@ -1,32 +0,0 @@ -# Analysis Orchestrator - -Use the guidelines below to find the right analysis workflow to execute based -on the user intent (for example, analyzing a system trace or a heap dump). - -Ensure you can answer the following: "User intends to -[analyze/query/investigate] ______". For example, -"User intends to analyze a heap dump to investigate a memory leak". - -## Handling Composite Requests - -If the user's request involves multiple distinct analysis goals (for example, -analyzing a trace for jank AND checking for memory leaks or GPU issues), -do not execute them simultaneously. - -1. Break down the request and propose a sequential execution plan to the user. -2. Ask the user for confirmation to start the first step. -3. Do not proceed to the next workflow until the current one is completed. - -## Workflow Discovery and Routing - -This skill supports multiple specialized analysis workflows. To determine the -right workflows to use: - -1. Use your file search tools (for example, `grep_search`) to recursively scan - the `$SKILL_ROOT/analysis/workflows/` directory for workflow entrypoints - (markdown files defining a top-level `name:` key in their frontmatter, - ignoring internal `references/` subdirectories). -2. Compare the user's request and intent against the `name:`, `description:`, - and `keywords:` fields to identify matching workflows. If multiple are - found, present them as options to the user and proceed with the - user's selection. \ No newline at end of file diff --git a/profilers/android-profiler/analysis/workflows/perfetto-sql/perfetto_sql.md b/profilers/android-profiler/analysis/workflows/perfetto-sql/perfetto_sql.md deleted file mode 100644 index a0c042b..0000000 --- a/profilers/android-profiler/analysis/workflows/perfetto-sql/perfetto_sql.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -name: perfetto-sql -description: > - Translates natural language data intents into syntactically valid PerfettoSQL - queries and/or executes them against a trace file, if provided. Use this - workflow to draft, debug, or execute queries extracting slice, thread, or - memory data from Android Perfetto traces using trace_processor. -keywords: - - Perfetto SQL - - SQL Guidelines - - SQL Best Practices - - Ad-hoc Query - - Trace Processor - - SPAN_JOIN - - Idempotency ---- - -# Ad-Hoc PerfettoSQL Querying - -Use this workflow to write, debug, or execute PerfettoSQL queries: - -- **Executing against a trace:** If a trace file path or URL is provided, verify - environment prerequisites in `$SKILL_ROOT/references/perfetto/setup.md` and - follow the session execution workflow in - `$SKILL_ROOT/references/perfetto/sql.md`. -- **Static query authoring or debugging:** If no trace is attached, draft, - optimize, or debug the queries using the syntax, schema guidelines, and - standard library best practices in `$SKILL_ROOT/references/perfetto/sql.md`. diff --git a/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/perfetto_trace_analysis.md b/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/perfetto_trace_analysis.md deleted file mode 100644 index cd7514a..0000000 --- a/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/perfetto_trace_analysis.md +++ /dev/null @@ -1,121 +0,0 @@ ---- -name: perfetto-trace-analysis -description: > - Analyzes Perfetto traces to find the root cause of performance issues in - user or system Android apps (for example, janks, app startup, memory, or - latency stalls). -keywords: - - perfetto - - trace - - jank - - startup - - latency - - stall - - thread - - bottleneck ---- - -# Perfetto Trace Analysis - -Use this workflow to diagnose general Android performance issues: jank, app -startup, latency, stall, or a thread-blocking problem. - -Follow these steps in order: - -## Step 1: Identify what to investigate first - -Work with the user (for example, ask questions, present options) to -understand the following: - -- _What is the symptom_: Do they want to investigate frame drops, jank, - startup issues, app crash, system crash, ANR? -- _Who is the victim:_ Where did they observe the symptom? For example, "a - frame drop in `com.example.sample`". - -> **Input Trace Required:** Confirm that the user has provided a trace file -> path or URL (for example, `.pftrace`, `.perfetto-trace`, or a Perfetto UI -> link). If none is provided, pause and ask the user to provide one before -> proceeding. - -> If you do not know the symptom, pause and ask the user for clarification. - -> If you know the symptom but not the victim, proceed to Step 2 (Triage) to -> discover candidates. Present the 3-5 most severe instances to the user and -> proceed with the user selection. - -> **A/B Trace Comparison:** If the user provides multiple traces (for -> example, a baseline and an issue trace), explicitly clarify which is the -> baseline. You will pass both paths to the subagents in Step 3 so they can -> use the baseline trace to establish expected behavior. - -## Step 2: Run a system-wide triage - -Spin off a subagent/task and instruct it to follow instructions from -`$SKILL_ROOT/analysis/workflows/perfetto-trace-analysis/references/triage.md` -to run a system-wide triage. (Do not read this file yourself. Pass this path -in the sub-agent prompt so it loads the file and performs the triage steps). - -Note the output produced by the subagent to identify the filtered list of -**candidates** that we need to investigate. - -> **Note:** -> - If no candidates are found, ask the user for symptom clarification and -> specific timestamps. -> - If the triage reveals multiple candidates, select the top 2 or 3 most -> severe, representative candidates and ask the user before proceeding -> which ones to investigate. The user may choose one or more. - -**Expected outcome:** Confirmation on which candidate(s) to investigate -before proceeding. - -## Step 3: Investigate each branch in parallel - -For **every** candidate (one or more) identified in the previous step, spin -off a task/subagent to investigate these tracks _in parallel_. - -Construct a prompt for each candidate containing: -```markdown -Trace path: [path] -Baseline Trace Path: [path, if provided] -Candidate Info: [`utid`, `upid`, thread_name, process_name, `render_thread_utid` (if applicable)] -Symptom Window: [start_ts, end_ts, duration] -System Vitals: [payload from Step 2] -Budget: [expected duration, if applicable] -Execution Protocol: Read -`$SKILL_ROOT/analysis/workflows/perfetto-trace-analysis/references/per_candidate_analysis.md` -and follow its instructions end-to-end. -``` - -SPAWN the subagent(s) in parallel and await completion of all subagents before -proceeding to Step 4. - -## Step 4: Final report and consolidation - -Read the output from every spawned subagent and generate a consolidated "Trace -Analysis Report" adhering to these instructions: - -1. **Summary and Root Cause:** State the findings in simple, clear language - and classify root causes (hardware, software/code, scheduling policy, - system exhaustion, or external dependency). -2. **Dependency Chain:** Map the full path from symptom to root cause. - Include thread names, `UTID`s, blocking states, and exact timestamps at - every hop. -3. **Evidence Tags:** Tag every single claim with `[SQL]` (backed by data), - `[INFERRED]` (logical deduction), or `[GAP]` (partial or missing data), - and briefly define these tags at the start. -4. **Platform Context:** Explain Android system behaviors _only_ by citing - specific retrieved slice names, values, and timestamps. - **What's bad:** "SurfaceFlinger does X around vsync" - **What's a good explanation:** "SurfaceFlinger's composition slice at - `ts=142.3ms` ran `4ms` after the vsync signal at `ts=138.1ms`, consistent - with X". -5. **Partial Suspects:** List all branches that were investigated but did not - reach a terminal root cause, ranked by evidence strength. Include what - was found and where verification fell short. A partial suspect can be the - real contributor - report it even if a terminal root cause was found - elsewhere. - - > If evidence is evenly split between multiple potential causes, report each - > suspicion with its supporting data and evidence so that engineers can - > evaluate probabilities without false certainty. Do not arbitrarily pick - > a winner. \ No newline at end of file diff --git a/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/guiding_principles.md b/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/guiding_principles.md deleted file mode 100644 index 6627bf8..0000000 --- a/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/guiding_principles.md +++ /dev/null @@ -1,51 +0,0 @@ -# Guiding Principles for Trace Analysis - -Whether you are augmented with domain-specific knowledge or doing a standard -workflow analysis, follow these rules. - -**Why:** Performance analysis is complex. It is entirely possible to end up -identifying a root cause while the reality is different (for example, a hardware -bottleneck causing a cascade of failures). The principles below keep your -analysis grounded in truth and ensure that you investigate the entire causal -chain to discover the true bottleneck. - -1. **Schema Validation via Intrinsic Discovery:** - Read `$SKILL_ROOT/references/perfetto/sql.md` and query table schemas - using `LIMIT 0` before drafting queries. - - **Why:** Trace processor schemas evolve across versions; discovering - schema directly prevents invalid assumptions and syntax failures. -2. **Empirical Data Grounding:** Support every claim with tool output or - queried timestamps, slice durations or counter values (`[SQL]`). - - **Why:** General Android heuristics cannot substitute for ground-truth - trace metrics. When queries return empty results, broaden search - constraints using fuzzy matching or wider time windows. -3. **Causation vs. Correlation:** Verify that the blocker's active execution - overlaps with the victim's wait interval. - - **Why:** Concurrent anomalies are only causally linked if their - execution lifetimes intersect. For example, just because thread A was busy - while thread B was waiting does not necessarily mean thread A caused the - wait. -4. **Follow Evidence:** Follow dependency chains across thread, process and - kernel boundaries to the terminal root cause. Keep going as long as - **each hop meaningfully explains the symptom**. - - **Why:** Halting blocker traversal prematurely reports intermediate - symptoms rather than the true origin. -5. **Explicit Uncertainty Reporting:** Categorize unverified execution paths - as `[GAP]` or partial suspects. - - **Why:** Transparent reporting of missing data enables engineers to evaluate - probabilities without false certainty. -6. **Evidence-First Explanation:** Cite concrete retrieved metrics and slice - timestamps before asserting platform behavioral context. - - **Why:** Contextual explanations are only reliable when anchored in - empirical trace observations. -7. **Systemic Confound Sweeps:** Before attributing a bottleneck to - application software, verify that thermal throttling, CPU capping - (`cpufreq`), scheduling (`sched_slice`), or LMKD pressure isn't uniformly - degrading the system. Report such confounds as root cause modifiers. - > To uncover short-lived anomalies that get mathematically missed by simple - > averages or aggregate queries, isolate and look around the symptom window - > and query for individual event spikes or percentiles. -8. **Systematic Step Adherence:** At every step of your investigation, strictly - follow the defined investigation steps. - - **Why:** Structured verification prevents analytical blind spots and - premature conclusions on obvious but non-critical anomalies. diff --git a/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/domain_hints_template.md b/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/domain_hints_template.md deleted file mode 100644 index f746c67..0000000 --- a/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/domain_hints_template.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -domain: The specific domain or product. -description: Description of the domain/subsystem for effective routing -keywords: - - List of symptoms and keywords where these hints are applicable, - for example, 'jank', 'surfaceflinger', 'vulkan'. ---- diff --git a/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/subsystems/hints_cpu.md b/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/subsystems/hints_cpu.md deleted file mode 100644 index cf7dfe8..0000000 --- a/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/subsystems/hints_cpu.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -subsystem: cpu -description: > - Expert hints for CPU scheduling, thread states, CPU contention, and - frequency scaling. -keywords: - - cpu - - scheduling - - latency - - runnable - - running - - thread state - - governor - - core - - frequency ---- - -# CPU & Scheduling Expert Hints - -- When debugging a long slice: Examine its thread states to understand what the - thread was doing (running, sleeping, blocked on I/O). -- When debugging a long slice for latency issues: Check if its duration is - caused by one or more long-running child slices. Apply recursively. -- When a thread is woken up but there is a delay before it runs, check the - "IRQ" track for the corresponding CPU to see if an interrupt is the cause. -- Check if kernel threads associated with hardware are running with real-time - priority; if not, they can be preempted. -- When analysis of the primary application package does not reveal root cause, - expand to all threads and processes. Search for other runnable threads on the - same CPU or high-priority kernel threads. -- When investigating app startup, use an SQL query to aggregate reasons for - uninterruptible sleep on the main thread. -- Check the "cpu_frequency" counter for CPU cores that ran the main process. - Missing frequency data or stuck frequencies indicate a kernel-level bug in - the governor. -- Query raw "ftrace" events for logs related to the governor thread (for - example, "su_gov"). -- To find concurrency issues, search for critical threads (for example, - 'RenderThread') in a blocked state (`thread_state.state = 'D'`) and join with - scheduling data to find the waker. -- Compare time spent in userspace functions vs kernel (slices with [k] prefix). -- To detect a 'catch-up storm', look for threads with long gaps in - thread_state/cpu_slice activity immediately followed by a high-density burst. -- Quantify scheduler contention by calculating scheduling latency (measure - duration of preceding 'Runnable' state using - `preceding_sched_slice_for_thread`). Search for maximums and high percentiles - (p95/p99). -- If a task exhibits high scheduling latency, check if other CPUs were idle - (running swapper or idle thread). -- Check the `cpu_id` for key threads; if consistently scheduled on slower - cores, it signals a potential performance gain by allowing them on big cores. -- For a struggling thread, analyze 'Runnable' vs 'Running' state time. A large - 'Runnable' time indicates CPU contention. -- If a slice's wall duration increases but the percentage of 'Running' time is - unchanged, it strongly suggests a lower CPU frequency. Check `sched_switch` - to focus on the correct cores. diff --git a/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/subsystems/hints_graphics.md b/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/subsystems/hints_graphics.md deleted file mode 100644 index e122032..0000000 --- a/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/subsystems/hints_graphics.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -subsystem: graphics -description: > - Expert hints for graphics performance, UI jank, GPU memory, and frame - rendering. -keywords: - - graphics - - ui jank - - gpu - - frame - - vsync - - render - - surfaceflinger - - texture - - bitmap ---- - -# Graphics & UI Jank Expert Hints - -- When investigating UI jank, check for long-running slices on the main - thread; if a slice like "ConstraintLayout.onMeasure" is taking a long time - (for example, >8ms), it is a likely cause of the jank. -- When a `bitmap_write_to_parcel` slice takes milliseconds instead of - microseconds, investigate its children slices to see how time is spent. Long - durations often point to suboptimal kernel operations like memory mapping - (`mmap`) or unnecessary data zeroing. -- For a high-level overview of graphics memory usage, track `gpu_mem_total` - counter for a specific process (`upid`). -- To find the largest graphics allocations, query the `android_graphics_allocs` - table and sort by `size_bytes` in descending order. Compare width and height - against the device's display resolution. -- To detect the "double memory cost" of an image existing on both CPU and GPU, - look for a large buffer in `android_graphics_allocs` and a simultaneous CPU - memory allocation of a similar size (`rss_anon_bytes` or `heap_graph`). -- To find potentially costly intermediate render targets, look for large - buffers in `android_graphics_allocs` where `usage_bits` lack a - `COMPOSER_OVERLAY` flag. -- To check if an allocation is actually presented on screen, correlate - `buffer_id` with SurfaceFlinger events. -- To see if high graphics memory is causing performance issues, check the frame - duration in `actual_frame_timeline_slice`. Durations over vsync (for - example, 16.6ms) correlating with `gpu_mem_total` spikes suggest memory - pressure jank. -- Within the main thread of a janky graphics application, look for frequent or - long-running `texture_upload` slices. -- Analyze the duration of `eglSwapBuffersWithDamageKHR` or similar buffer-swap - slices in the graphics rendering thread. Consistently long durations suggest - a large "damage area" being redrawn every frame. -- To identify UI jank, compare `actual_frame_timeline` against the - `expected_frame_timeline` on the main process; a significant deviation - indicates missed frames. diff --git a/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/subsystems/hints_io.md b/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/subsystems/hints_io.md deleted file mode 100644 index d7a9f7b..0000000 --- a/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/subsystems/hints_io.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -subsystem: io -description: > - Expert hints for I/O and storage bottlenecks, uninterruptible sleep, and - page cache. -keywords: - - i/o - - storage - - disk - - uninterruptible sleep - - d state - - page fault - - dm-verity - - file - - syscall ---- - -# I/O & Storage Expert Hints - -- When debugging a long, uninterruptible sleep state related to I/O: Check for - overlapping slices with "verity" in their name (dm-verity). -- When a thread is stuck in an uninterruptible sleep with no `blocked_function`, - look for other threads that might be holding the memory lock (for example, - "jit-thread-pool", memory mapping ops). -- When analyzing a long uninterruptible sleep, check the `blocked_function` in - the thread state details (from `sched_blocked_reason` ftrace event). -- A lot of time spent in `do_page_fault` during app startup is a strong - indicator of I/O contention. -- For file integrity mechanisms like DM-Verity, search for events like - `dm_verity_fec_prefetch`. -- To find the specific kernel dependency of a stalled app thread, locate the - thread in state 'D', then look for kworker or kernel threads that become - runnable immediately after. -- For app stalls caused by I/O, analyze the scheduling latency of the relevant - kworker threads handling the request. -- To find inefficient file I/O, query the syscall table for a high frequency - of small, sequential `read()` or `pread()` syscalls on a single fd. -- If a thread spends significant time in 'Uninterruptible Sleep', check if - `blocked_function` is `page_cache_readahead`. Correlate waking timestamps - with `filemap_add_to_page_cache` ftrace events. -- Aggregate counts of `filemap_add_to_page_cache` grouping by `inode` to find - the specific file causing I/O pressure. -- Inspect `nr_sector` in `block_rq_issue` ftrace events to understand file - read-ahead size. -- If an I/O issue disappears on subsequent launches, it is a 'cold start' - problem (populating page cache). diff --git a/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/subsystems/hints_ipc.md b/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/subsystems/hints_ipc.md deleted file mode 100644 index 0fad644..0000000 --- a/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/subsystems/hints_ipc.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -subsystem: ipc -description: > - Expert hints for IPC, binder transactions, and cross-process dependencies. -keywords: - - ipc - - binder - - transaction - - cross-process - - bottleneck - - service - - system_server ---- - -# IPC & Binder Expert Hints - -- Look for multiple outbound binder transactions from the same process - (`system_server`) that carry similar data to different destinations in a - short time frame. This "binder storm" indicates a lack of multiplexing. -- To trace data across processes, correlate slices using flow events by - linking a slice's ID to `flow.source_slice_id` or `flow.dest_slice_id`. -- To detect binder spam, query the `binder_transaction` table and group by - thread ID (`tid`), `service_name`, and `method_name` to find high numbers of - identical calls. -- When high binder concurrency is found, identify the bottleneck server - process by grouping transactions by `server_upid`. -- To analyze latency of a slow binder transaction, calculate time spent - outside the server by subtracting `server_dur` from total `dur` in the - `binder_transaction` table. -- When a thread is suspected of binder spam, correlate its `tid` with the - `cpu_slice` table to check for high CPU consumption. -- To find code responsible for binder spam, get the `utid` of the problematic - thread and use it to query `stack_profile_callsite`. -- To find callers of a problematic function, filter `stack_profile_callsite` - for frames mapping to it, then trace upwards using `parent_id`. -- A long-running slice on one thread causally linked to a slice on another - thread (for example, binder from `system_server` to SystemUI) indicates a - scheduling dependency bottleneck. -- To find asynchronous operations that might cause UI jank, look for a binder - transaction from a controlling process that returns quickly, followed by a - long-running slice in the receiving process. diff --git a/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/subsystems/hints_memory.md b/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/subsystems/hints_memory.md deleted file mode 100644 index ebd67ad..0000000 --- a/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/subsystems/hints_memory.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -subsystem: memory -description: > - Expert hints for memory pressure, Low Memory Killer (LMK), and allocations. -keywords: - - memory - - lmk - - lmkd - - oom - - rss - - swap - - kswapd - - thrashing - - pressure - - allocation ---- - -# Memory & LMK Expert Hints - -- To investigate low memory kills, look for the `lmk_kill_occurred` ftrace - event; a high count indicates severe memory pressure. -- When you see a burst of `lmk_kill_occurred` events, query for processes with - high CPU `wall_duration` in the `sched_slice` table during the same time - window. Runaway processes consuming CPU exacerbate memory pressure. -- To understand a process's memory impact, inspect its `anon_rss` (Anonymous - Resident Set Size) from memory counters like `mem.info`. -- Check system-wide memory stats for a high or rapidly increasing `swap_used` - value. -- To confirm memory thrashing, look for high CPU usage by the `kswapd` kernel - thread. -- To find the direct trigger for LMKD, look for `memory_pressure` trace events - from Pressure Stall Information (PSI). -- To detect 'lost' memory for processes using hardware accelerators (like a - TPU), query the `counter` table for `RssFile` values. If it drops - significantly while hardware is active, check for `kswapd0` scheduling - slices. -- When analyzing memory shared with hardware, query `dma_heap_stat` and - `dmabuf_total_size` counters (or `ion_total_size` for older devices) for a - more accurate picture than RSS. -- Be aware that `RssFile` can over-report memory if the same physical page is - mapped multiple times. -- When investigating OOMs, establish a baseline by querying process memory - counters (for example, `mem.rss`) and compare the median and 95th percentile - against its history. -- If bitmaps are a major memory consumer, check for outliers in bitmap count by - querying `android.graphics.Bitmap` instances across traces. -- To find what is holding onto an object (like a bitmap), trace its retainer - path back to a GC root by querying `heap_graph_reference`. Pay close - attention to custom application classes. -- Query the `android_bitmaps` table to check the width and height properties of - bitmaps. -- Check for the presence of software bitmaps, which consume memory on both the - app heap and in graphics memory. -- When analyzing bitmaps, look for duplicates by checking for multiple - `android.graphics.Bitmap` objects with identical properties. diff --git a/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/subsystems/hints_power.md b/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/subsystems/hints_power.md deleted file mode 100644 index a74cdc5..0000000 --- a/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/subsystems/hints_power.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -subsystem: power -description: > - Expert hints for battery drain, power rails, network traffic, and wake locks. -keywords: - - power - - battery - - wake lock - - suspend - - network - - modem - - bluetooth - - drain ---- - -# Power & Network Expert Hints - -- When investigating overall battery drain, start by querying `power_rails` - track and summing energy consumed (`power_ma * duration`) for each rail. -- To check if a device is sleeping correctly during screen-off periods, query - the `suspend_state` track; a lack of time in "suspended" state indicates a - wakefulness problem. -- To find the root cause of the device failing to suspend, query the - `kernel_wakelock` track and aggregate total duration for each wake lock. -- If a top kernel wake lock name contains "bt_" or "bcm" (for example, - `bt_host_wake`), cross-reference its timing with events in the - `bluetooth_scan_results` track. -- If the modem rail in `power_rails` shows high consumption, query the - `network_packets` table and aggregate traffic volume by UID to identify app. -- When analyzing network traffic from a shared UID, use the `socket_tag` - associated with network packets for granular attribution. -- Convert impact into a common energy unit like milliwatt-hours (mWh) to - compare the severity of different issues. diff --git a/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/per_candidate_analysis.md b/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/per_candidate_analysis.md deleted file mode 100644 index d9518a8..0000000 --- a/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/per_candidate_analysis.md +++ /dev/null @@ -1,172 +0,0 @@ -# Candidate Investigation Protocol - -This is the execution protocol for a deep-dive subagent during trace analysis. - -## Context - -Execute the protocol below using the candidate details (Trace Paths, Symptom -Window, UTID, UPID, System Vitals) provided in your initial prompt. - -## Investigation Protocol - -**High-Level Algorithm:** - -1. Prerequisites (Step 1) -2. Calculate time distribution and identify state buckets (Step 2) -3. Domain and System hints discovery and selection (Step 3) -4. Run exhaustive investigation for each significant state bucket sequentially - (Steps 4 and 5), starting with the largest bucket. Output total time - explained so far after investigating each. -5. Output the result (Step 6) - -### Step 1: Prerequisites - -- Follow `$SKILL_ROOT/references/perfetto/sql.md` for session-based query execution. -- Read - `$SKILL_ROOT/analysis/workflows/perfetto-trace-analysis/references/guiding_principles.md` - to identify best practices, ensure data-driven analysis, and avoid pitfalls. - -### Step 2: Calculate the time distribution - -1. **Establish a baseline if possible:** For example, if investigating a jank - candidate, find a _non-janky_ instance of the **same** operation first to - establish expected behavior and rule out red herrings. _If no healthy - instance exists in the trace, note this as a `[GAP]` and proceed with the - next steps_. -2. **Calculate time spent in each state:** Sum the time spent in `Running`, - `R`, `S` and `D` states. Investigate all substantial buckets. Also look for - composite bottlenecks (for example, "40% CPU-starved + 35% IO-blocked"). -3. **Run checks to rule out red herrings:** For example: - - A thread in `S` state could be normal behavior -> investigate only if - the sleep actually overlaps with a pending obligation (for example, a - pending binder reply). - - A thread in `Running/Runnable`: When a thread spends high duration in - `Running/Runnable`, inspect CPU frequency, throttling counters, and core - migrations before attributing latency to code inefficiency, because - hardware throttling inflates wall time without increasing instruction - overhead: - - Verify first that an identified code path is actually doing - disproportionate work. - - Query the trace to find what the thread was doing during this window. - Query the `slice` table for the longest duration slices during this - window. - - Beyond blocking slices, query and identify repetitive micro-operations - or gaps that collectively exhaust the budget to prevent tunnel vision. - - Perform a system-wide check to identify if there was CPU throttling or - core migrations around the symptom window (see Principle 7). -4. **Drill down:** For every significant bucket revealed from the time - distribution analysis during the symptom window, identify what the thread - was doing at the transition point. Investigate every significant bucket (for - example, >= 20% of the time window) to avoid missing real bottlenecks or - composite issues. -> **Action:** Tag missing data as `[GAP]`. Do not guess. - -### Step 3: Domain and Hints Discovery - -Even if you find a major bottleneck, continue searching for other bottlenecks -using expert-vetted domain and system hints and techniques. Follow these steps -in order: - -- **Domain discovery:** Use your file search tools (for example, `grep_search`) - to scan frontmatter (`domain:`, `description:`, `keywords:`) in - `$SKILL_ROOT/analysis/workflows/perfetto-trace-analysis/references/hints/domains/` - (if it exists) and identify matching domain hint files based on collective - investigation state (victim, intermediate processes). -- **Subsystem hints:** Use your file search tools to scan frontmatter - (`subsystem:`, `description:`, `keywords:`) in - `$SKILL_ROOT/analysis/workflows/perfetto-trace-analysis/references/hints/subsystems/` - for relevant tracks (for example, keywords for sleep states, memory, IPC). -- **Hint Selection and Application:** Apply all the matched subsystem and - domain hint files during the Step 4 investigation, and list them under - `Applied Hints` in your Step 6 output. - -### Step 4: Exhaustive Investigation (Do Not Give Up Early) - -- **Follow the dependency chain:** - - If the victim thread was waiting or blocked by another thread, follow the - chain to the leaf. Find out what it is waiting _for_. Cross process - boundaries if necessary. Do not conclude without following the entire - blocker chain (See Principle 4 in `guiding_principles.md`). - - **Dynamic hint injection:** If your dependency chain leads to a new - subsystem or process you haven't researched yet, do a single search in - `$SKILL_ROOT/analysis/workflows/perfetto-trace-analysis/references/hints/` - for relevant hints before proceeding. Do not fall into infinite loops. - - Every state bucket investigation should end in one of three states: - **Terminal root cause**, **Blocked by another thread**, or - **Partial suspect**. - - > A finding is a "terminal root cause" if: - > - > - You can trace it down to a physical bottleneck (thermal throttle, GPU, - > storage). **Require Specificity:** Do not conclude with generic labels. - > Specify the _what_ clearly. - > - A specific function or code path is identified as doing disproportionate - > work relative to its purpose (for example, synchronous disk IO on main - > thread, unnecessary object allocation triggering GC). - > - A scheduling policy or resource limit is identified as artificially - > constraining the thread (for example, background CPU cap, foreground - > service restriction). - > - The bottleneck is identified in a different process/service that the - > investigated process cannot control (for example, `system_server` lock - > contention, `SurfaceFlinger` throttling). - -- **Systemic sweep before concluding:** Discovering an application-layer - bottleneck (software root cause) does not terminate the investigation of a - state bucket. Before concluding any state bucket as a terminal root cause, - check relevant system hints for that state (`CPU`, `IO`, `Memory`, `IPC`) - and verify whether platform-level confounds - such as CPU scaling, memory - pressure, thermal throttling, or I/O saturation - simultaneously degraded - performance **around the symptom window.** Report discovered systemic - confounds as **co-root causes** or duration modifiers (see Principle 7 in - `guiding_principles.md`). - -**Why:** Software execution duration is not an absolute constant; it is -modulated by platform state (frequency scaling, thermals, memory reclamation). -Always check system-wide confounds before reporting an inefficient code path as -the sole root cause - to avoid concluding inflated software duration as the -sole root cause while missing the underlying kernel or hardware anomaly that -magnified it. Back findings with empirical proof (`[SQL]`) or mark as `[GAP]` -if inconclusive. - -### Step 5: Contextualize the Workload - -Once the mechanical bottleneck is identified in Step 4, query the trace to -identify the high-level user feature, UI operation, or exact workload that -triggered it. Explain the _why_, not just the _what_. This workload context -ensures that we can provide actionable next steps for the user instead of -leaving them confused. - -### Step 6: Output - -Output the following investigation result: - -```markdown -# Investigation Result - -Candidate: -Symptom Window: [Start TS] and [End TS] -Symptom Duration: [Duration] -Budget (Expected Duration): [If applicable] -Applied Hints: [List of matched subsystem and domain hint files used] - -## Primary Finding - -- **Classification:** [Hardware | Software/Code | System Exhaustion | Scheduling Policy | External Dependency] -- **Details and Reasoning:** `___` -- **Explained Duration:** `___` -- **Evidence SQL or backing data:** `___` -- **Dependency chain:** [Root to Leaf Dependency Chain] -- **Root cause conclusion:** `___` - -## Other Findings - -- **Status:** [Partial Suspect | Low Confidence Suspicion | Terminal Root Cause] -- _(same fields)_ - -## Verification Checklist - -- All claims tagged (`[SQL]`/`[GAP]`/`[INFERRED]`): [yes/no] -- Total explained time accounts for most of the symptom window: [yes/no] -- Unexplored state buckets: [none/list with reason why] -- Workload contextualized (for example, specific slices/layers involved): [yes/no] -``` diff --git a/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/triage.md b/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/triage.md deleted file mode 100644 index 4ff97d2..0000000 --- a/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/triage.md +++ /dev/null @@ -1,94 +0,0 @@ -# System-Wide Triage - -Follow these steps in order: - -1. **Verify data integrity:** Query `stats` for CPU and data loss indicators. - If found, warn the user that findings may be incomplete and proceed with - `[GAP]` awareness. - -2. **Run metrics:** Systemic issues (like thermal throttling, LMKs, binder - contention) affect all threads. - - > **Prerequisite:** Verify that `trace_processor` is available in your - > environment before executing commands (see - > `$SKILL_ROOT/references/perfetto/setup.md`). - - Run the following query based on the user's intent to understand the - high-level picture: - - ```sh - trace_processor [trace_file] --run-metrics [comma_separated_metrics] - ``` - -3. **Define target and symptom window:** Find the specific issue's start - (`ts`), duration (`dur`) and end timestamp (`ts + dur`). Verify the trace - actually covers the full window. - - > After identifying the specific instance to investigate and the symptom - > window (for example, janky frame at `ts = 5.1s`), check whether the first - > blocking event was caused by a stall that _started before_ the symptom - > window. Expanding the window upstream is essential because stalls (for - > example, in binder servers, memory reclamation or kernel locks) often - > originate hundreds of milliseconds before the user-visible frame drop or - > latency spike occurs. - > - > - Find the victim's first non-Running state transition within the symptom - > window. - > - Follow the waker chain for that transition. Check waker timestamps - - > if the blocking event began significantly before the symptom window, - > expand the investigation window upstream to include that origin. - > - If it cascades down to an origin in another process (for example, a - > binder server that stalled `500ms` before the jank), **note** the - > expanded window and include the upstream stall as a co-candidate to - > investigate further. - -4. **Output** the triage summary using the format defined below. - -## Quick reference for triaging - -Key available metrics for `--run-metrics`: -`android_startup`, `android_cpu`, `android_mem`, `android_lmk`, -`android_binder`, `android_surfaceflinger`, `android_gpu` - -Quick lookup table based on symptom: - -| Symptom/Issue | What to check | Useful Perfetto tables | -| :--------------- | :------------------------------------ | :---------------------------------------------- | -| App startup | Main thread | `android.startup.startups` (`android_startups`) | -| App jank | Main, render threads | `actual_frame_timeline_slice` | -| System jank | SurfaceFlinger | `actual_frame_timeline_slice` | -| App/system crash | `Process crashed` or `tombstoned` | `slice` | -| ANR | Main thread, `system_server` watchdog | `thread_state`, `slice` | -| Frame issues | `DrawFrame` or `doFrame` slices | `slice` | - -## Final Output Format - -Output the triage summary in the following format: - -```markdown -## Trace Metadata - -- **Device Model:** `[String, e.g., Pixel 7 Pro]` -- **Android Build:** `[String, e.g., TQ2A.230505.002]` - -## System Vitals Summary - -- **Status:** `[Nominal | Degraded | Critical]` -- **Flags Raised:** _(Only list systemic issues that are actually detected)_ - - **Metric:** `[String, e.g., thermal_throttling, lmkd, binder_contention]` - - **Description:** `[e.g., 'Thermal throttling during symptom window']` - -## Candidate - -- **Issue Classification:** `[String, e.g., Startup, Jank, ANR_Input, Crash]` -- **Package Name:** `[String]` -- **Process Name:** `[String]` -- **Thread Name:** `[String]` -- **UPID:** `[Integer]` -- **Target UTID:** `[Integer]` _(The primary thread)_ -- **Render Thread UTID:** `[Integer or null]` _(If issue is jank)_ -- **Start TS:** `[Integer Timestamp]` -- **End TS:** `[Integer Timestamp]` -- **Duration (ms):** `[Float]` -- **Severity Note:** `[String, e.g., '150ms missed frame - worst instance']` -``` diff --git a/profilers/android-profiler/recording/recording_orchestrator.md b/profilers/android-profiler/recording/recording_orchestrator.md deleted file mode 100644 index 507b592..0000000 --- a/profilers/android-profiler/recording/recording_orchestrator.md +++ /dev/null @@ -1,36 +0,0 @@ -# Recording Orchestrator - -Use the guidelines below to orchestrate between recording workflows after -disambiguating the user intent (for example, recording a system trace or a -heap dump). The guidelines below ensure pre-flight checks are always performed -and domain-specific profiling flags are applied accurately. - -## Workflow Selection - -At this point, you should have an answer to the following question: "User -intends to record a _____". For example, "User intends to record a system -trace". - -## Handling Composite Requests - -If the user's request involves multiple distinct recording goals (for example, -setting up a specialized environment AND recording a generic trace), do not -execute them simultaneously. - -1. Break down the request and propose a sequential execution plan to the user. -2. Ask the user for confirmation to start the first step. -3. Do not proceed to the next workflow until the current one is completed. - -## Workflow Discovery and Routing - -This skill supports multiple specialized recording workflows. To determine the -right workflows to use: - -1. Use your file search tools (for example, `grep_search`) to recursively scan - the `$SKILL_ROOT/recording/workflows/` directory for workflow entrypoints - (markdown files defining a top-level `name:` key in their frontmatter, - ignoring internal `references/` subdirectories). -2. Compare the user's request and intent against the `name:`, `description:`, - and `keywords:` fields to identify matching workflows. If multiple are - found, present them as options to the user and proceed with the - user's selection. \ No newline at end of file diff --git a/profilers/android-profiler/recording/workflows/perfetto-trace-recording/perfetto_trace_recording.md b/profilers/android-profiler/recording/workflows/perfetto-trace-recording/perfetto_trace_recording.md deleted file mode 100644 index 7a86e3c..0000000 --- a/profilers/android-profiler/recording/workflows/perfetto-trace-recording/perfetto_trace_recording.md +++ /dev/null @@ -1,148 +0,0 @@ ---- -name: perfetto-trace-recording -description: > - Records Java/native heap dumps, system traces, or custom configs via - Perfetto helper scripts on Android. -keywords: - - perfetto - - record - - system trace - - heap dump - - memory leak - - custom config ---- - -# Recording Perfetto Traces on Android (Helper Scripts) - -> [!IMPORTANT] **Scope:** This guide is **strictly for recording traces on -> Android devices**. For other platforms (Linux, macOS, or Chrome), refer to -> the platform documentation on [perfetto.dev/docs](https://perfetto.dev/docs/). - -Rather than running raw `adb` commands, use the official Perfetto helper -scripts. They automatically handle pushing configurations, starting tracing -daemons, pulling the trace file, and optionally opening it in the browser. - -Ensure **Developer options** and **USB debugging** are enabled, and your -device is connected via USB before starting. - ---- - -## 0. Download the Helper Scripts - -Download the helper scripts from the official Perfetto repository: - -```bash -TOOLS_URL="https://raw.githubusercontent.com/google/perfetto/main/tools" - -# Java Heap Dump (ART) -curl -O "$TOOLS_URL/java_heap_dump" && chmod +x java_heap_dump - -# Native Heap Profiling (heapprofd) -curl -O "$TOOLS_URL/heap_profile" && chmod +x heap_profile - -# CPU Stack Sampling (traced_perf) -curl -O "$TOOLS_URL/cpu_profile" && chmod +x cpu_profile - -# General Tracing (Ftrace, ATrace, custom configs) -curl -O "$TOOLS_URL/record_android_trace" && chmod +x record_android_trace -``` - ---- - -## 1. Memory Tracing - -Use these tools to analyze memory leaks, object retention, C/C++ allocations, -or system-wide memory counters. - -### A. Java Heap Dump (ART) - -Capture a snapshot of all Java objects in a process to investigate memory -leaks. Reference docs: -. - -```bash -# Trigger a Java heap dump for a specific app -./java_heap_dump -n YOUR_APP_PACKAGE_NAME -o ./heap_dump.perfetto-trace -``` - -### B. Native C/C++ Heap Profiling (heapprofd) - -Track C/C++ memory allocations (malloc/free) to find native leaks. Reference -docs: . - -```bash -# Profile native allocations for a specific app -./heap_profile -n YOUR_APP_PACKAGE_NAME - -# Profile with custom sampling interval (default is 4096 bytes) -./heap_profile -n YOUR_APP_PACKAGE_NAME -i 2048 -``` - -### C. System-wide Memory Counters - -Track RSS, Swap, and process stats over time. Reference docs: -. - -**Note:** To record memory counters, you must use a custom config via -`record_android_trace` (see Section 4). - ---- - -## 2. Stack Sampling / Callstack Profiling (traced_perf) - -Identify CPU hotspots in C/C++ or Rust code by periodically sampling -callstacks. Reference docs: -. - -```bash -# Profile CPU usage by sampling callstacks at 100Hz (default) for 10 seconds -./cpu_profile -n YOUR_APP_PACKAGE_NAME -d 10000 - -# Profile at a custom frequency (e.g., 200Hz) -./cpu_profile -n YOUR_APP_PACKAGE_NAME -f 200 -``` - ---- - -## 3. System Tracing (CPU, Scheduling, & ATrace) - -Investigate jank, slow transitions, CPU scheduling, and system calls. -Reference docs: . - -You can specify duration, buffer size, and categories directly on the command -line: - -```bash -# Record scheduling, frequency, and window manager events for 5 seconds -./record_android_trace -t 5s -b 32mb sched gfx wm -a YOUR_APP_PACKAGE_NAME -``` - -Common categories: `sched` (CPU scheduling), `freq` (CPU frequency), `gfx` -(Graphics), `am` (Activity Manager), `wm` (Window Manager), `view` (View -System). - ---- - -## 4. Custom Configs (Mix & Match Data Sources) - -If you need a custom mixture of data sources (e.g., combining Java heap dumps -with ftrace), or control that command-line flags do not offer (ring buffers, -long traces, per-counter polling), synthesize a config: - -1. **Read the config reference:** - `$SKILL_ROOT/recording/workflows/perfetto-trace-recording/references/trace_config_reference.md` - explains config structure and data sources, pointing to exemplar configs in - `$SKILL_ROOT/recording/workflows/perfetto-trace-recording/references/example-configs/` - that you can start from and merge. For other sources, consult the - official guide at . -2. **Save the Config:** Write the synthesized text configuration to a local - file (e.g., `config.pftxt`). -3. **Execute the Trace:** Run the trace using the general recorder script - `record_android_trace` (specialized scripts do not accept custom configs): - -```bash -./record_android_trace -c config.pftxt -o ./my_trace.perfetto-trace -``` - -If the config has a typo, this fails fast with a parse error naming the bad -field - fix the config and retry. diff --git a/profilers/android-profiler/recording/workflows/perfetto-trace-recording/references/example-configs/app_jank.pftxt b/profilers/android-profiler/recording/workflows/perfetto-trace-recording/references/example-configs/app_jank.pftxt deleted file mode 100644 index 531a992..0000000 --- a/profilers/android-profiler/recording/workflows/perfetto-trace-recording/references/example-configs/app_jank.pftxt +++ /dev/null @@ -1,43 +0,0 @@ -# App jank / slow UI: framework + app atrace events, frame deadlines from -# SurfaceFlinger, and the scheduling context underneath. -# Replace com.example.myapp with the app's package name. -buffers { - size_kb: 65536 - fill_policy: RING_BUFFER -} -duration_ms: 10000 - -data_sources { - config { - name: "linux.ftrace" - ftrace_config { - ftrace_events: "sched/sched_switch" - ftrace_events: "sched/sched_wakeup" - ftrace_events: "sched/sched_waking" - ftrace_events: "power/cpu_frequency" - ftrace_events: "power/cpu_idle" - atrace_categories: "gfx" - atrace_categories: "view" - atrace_categories: "wm" - atrace_categories: "am" - atrace_categories: "input" - atrace_apps: "com.example.myapp" - } - } -} - -# Per-frame expected vs actual timelines (actual jank classification). -data_sources { - config { - name: "android.surfaceflinger.frametimeline" - } -} - -data_sources { - config { - name: "linux.process_stats" - process_stats_config { - scan_all_processes_on_start: true - } - } -} diff --git a/profilers/android-profiler/recording/workflows/perfetto-trace-recording/references/example-configs/cpu_profile.pftxt b/profilers/android-profiler/recording/workflows/perfetto-trace-recording/references/example-configs/cpu_profile.pftxt deleted file mode 100644 index f4e17b4..0000000 --- a/profilers/android-profiler/recording/workflows/perfetto-trace-recording/references/example-configs/cpu_profile.pftxt +++ /dev/null @@ -1,35 +0,0 @@ -# CPU callstack sampling via traced_perf: periodic stack samples to find hot -# functions. Prefer the cpu_profile helper script for a standalone profile; -# use this config when combining with other data sources. -# Replace com.example.myapp with the app's package name, or drop the scope -# block to sample all processes. -buffers { - size_kb: 65536 - fill_policy: RING_BUFFER -} -duration_ms: 10000 - -data_sources { - config { - name: "linux.perf" - perf_event_config { - timebase { - frequency: 100 - } - callstack_sampling { - scope { - target_cmdline: "com.example.myapp" - } - } - } - } -} - -data_sources { - config { - name: "linux.process_stats" - process_stats_config { - scan_all_processes_on_start: true - } - } -} \ No newline at end of file diff --git a/profilers/android-profiler/recording/workflows/perfetto-trace-recording/references/example-configs/java_heap_dump.pftxt b/profilers/android-profiler/recording/workflows/perfetto-trace-recording/references/example-configs/java_heap_dump.pftxt deleted file mode 100644 index ffb38ff..0000000 --- a/profilers/android-profiler/recording/workflows/perfetto-trace-recording/references/example-configs/java_heap_dump.pftxt +++ /dev/null @@ -1,21 +0,0 @@ -# Java heap dump (retention graph) of one app. Prefer the java_heap_dump -# helper script for a standalone dump; use this config when combining a heap -# dump with other data sources in one trace. -# Replace com.example.myapp with the app's package name. -buffers { - size_kb: 102400 -} -duration_ms: 30000 - -data_sources { - config { - name: "android.java_hprof" - java_hprof_config { - process_cmdline: "com.example.myapp" - } - } -} - -# Heap dumps can be large; stream to the output file instead of relying on -# the in-memory buffer alone. -write_into_file: true diff --git a/profilers/android-profiler/recording/workflows/perfetto-trace-recording/references/example-configs/long_background.pftxt b/profilers/android-profiler/recording/workflows/perfetto-trace-recording/references/example-configs/long_background.pftxt deleted file mode 100644 index e662dd9..0000000 --- a/profilers/android-profiler/recording/workflows/perfetto-trace-recording/references/example-configs/long_background.pftxt +++ /dev/null @@ -1,37 +0,0 @@ -# Long / field trace: runs until stopped, keeps the most recent data in a -# ring buffer, and periodically flushes to the output file so the trace -# survives longer than RAM allows. -buffers { - size_kb: 65536 - fill_policy: RING_BUFFER -} - -# No duration_ms: stop manually (Ctrl-C on record_android_trace), or add one. -# Move data to the output file periodically instead of only at the end. -write_into_file: true -file_write_period_ms: 2500 -# Commit app shared-memory buffers periodically so events stay ordered. -flush_period_ms: 30000 -# Stop when the output file reaches this size (bytes). -max_file_size_bytes: 1000000000 - -data_sources { - config { - name: "linux.ftrace" - ftrace_config { - ftrace_events: "sched/sched_switch" - ftrace_events: "sched/sched_wakeup" - ftrace_events: "power/cpu_frequency" - ftrace_events: "power/cpu_idle" - } - } -} - -data_sources { - config { - name: "linux.process_stats" - process_stats_config { - scan_all_processes_on_start: true - } - } -} diff --git a/profilers/android-profiler/recording/workflows/perfetto-trace-recording/references/example-configs/memory_counters.pftxt b/profilers/android-profiler/recording/workflows/perfetto-trace-recording/references/example-configs/memory_counters.pftxt deleted file mode 100644 index 2daa797..0000000 --- a/profilers/android-profiler/recording/workflows/perfetto-trace-recording/references/example-configs/memory_counters.pftxt +++ /dev/null @@ -1,45 +0,0 @@ -# System and per-process memory over time: meminfo/vmstat counters polled -# periodically, per-process RSS via both polling and kernel rss_stat events, -# and low-memory-killer activity. -buffers { - size_kb: 32768 -} -duration_ms: 30000 - -data_sources { - config { - name: "linux.sys_stats" - sys_stats_config { - meminfo_period_ms: 1000 - meminfo_counters: MEMINFO_MEM_TOTAL - meminfo_counters: MEMINFO_MEM_FREE - meminfo_counters: MEMINFO_MEM_AVAILABLE - meminfo_counters: MEMINFO_SWAP_FREE - vmstat_period_ms: 1000 - vmstat_counters: VMSTAT_NR_FREE_PAGES - vmstat_counters: VMSTAT_PGFAULT - vmstat_counters: VMSTAT_PGMAJFAULT - } - } -} - -data_sources { - config { - name: "linux.process_stats" - process_stats_config { - proc_stats_poll_ms: 10000 - scan_all_processes_on_start: true - } - } -} - -data_sources { - config { - name: "linux.ftrace" - ftrace_config { - ftrace_events: "kmem/rss_stat" - ftrace_events: "lowmemorykiller/lowmemory_kill" - ftrace_events: "oom/oom_score_adj_update" - } - } -} diff --git a/profilers/android-profiler/recording/workflows/perfetto-trace-recording/references/example-configs/native_heap.pftxt b/profilers/android-profiler/recording/workflows/perfetto-trace-recording/references/example-configs/native_heap.pftxt deleted file mode 100644 index af084c0..0000000 --- a/profilers/android-profiler/recording/workflows/perfetto-trace-recording/references/example-configs/native_heap.pftxt +++ /dev/null @@ -1,21 +0,0 @@ -# Native (C/C++) heap profiling via heapprofd: sampled malloc/free callstacks -# for one app. Prefer the heap_profile helper script for a standalone profile; -# use this config when combining with other data sources. -# Replace com.example.myapp with the app's package name. -buffers { - size_kb: 65536 -} -duration_ms: 30000 - -data_sources { - config { - name: "android.heapprofd" - heapprofd_config { - sampling_interval_bytes: 4096 - process_cmdline: "com.example.myapp" - # To profile ART/JNI and custom allocators too, add: all_heaps: true - } - } -} - -write_into_file: true diff --git a/profilers/android-profiler/recording/workflows/perfetto-trace-recording/references/example-configs/sched_cpu.pftxt b/profilers/android-profiler/recording/workflows/perfetto-trace-recording/references/example-configs/sched_cpu.pftxt deleted file mode 100644 index a327d6b..0000000 --- a/profilers/android-profiler/recording/workflows/perfetto-trace-recording/references/example-configs/sched_cpu.pftxt +++ /dev/null @@ -1,33 +0,0 @@ -# CPU scheduling: who ran when, on which core, at what clock speed. -# The base layer for almost any performance investigation. -buffers { - size_kb: 32768 - fill_policy: RING_BUFFER -} -duration_ms: 10000 - -data_sources { - config { - name: "linux.ftrace" - ftrace_config { - ftrace_events: "sched/sched_switch" - ftrace_events: "sched/sched_wakeup" - ftrace_events: "sched/sched_waking" - ftrace_events: "sched/sched_process_exit" - ftrace_events: "sched/sched_process_free" - ftrace_events: "power/cpu_frequency" - ftrace_events: "power/cpu_idle" - ftrace_events: "power/suspend_resume" - } - } -} - -# Resolves thread/process ids in sched events to readable names. -data_sources { - config { - name: "linux.process_stats" - process_stats_config { - scan_all_processes_on_start: true - } - } -} diff --git a/profilers/android-profiler/recording/workflows/perfetto-trace-recording/references/trace_config_reference.md b/profilers/android-profiler/recording/workflows/perfetto-trace-recording/references/trace_config_reference.md deleted file mode 100644 index 7356297..0000000 --- a/profilers/android-profiler/recording/workflows/perfetto-trace-recording/references/trace_config_reference.md +++ /dev/null @@ -1,102 +0,0 @@ -# Synthesizing Perfetto Trace Configs (Mix & Match) - -Read this when you need a custom trace config - a mixture of data sources the -specialized helper scripts do not cover. Start from the exemplar configs in -`$SKILL_ROOT/recording/workflows/perfetto-trace-recording/references/example-configs/`, -then add, remove, or merge the pieces described below. - -## Shape of a config - -A config is a `TraceConfig` protobuf - authoritative schema: - -(per-source option messages live under `protos/perfetto/config/` in the same -repo, e.g. `.../ftrace/ftrace_config.proto`) - written in protobuf **text -format** (conventionally saved as `config.pftxt`): - -```protobuf -buffers { - size_kb: 32768 # sizes are in KB, not bytes - fill_policy: RING_BUFFER # keep newest data; DISCARD keeps oldest -} -duration_ms: 10000 # omit to trace until stopped manually - -data_sources { - config { - name: "linux.ftrace" # which producer to enable - ftrace_config { ... } # that producer's own options - } -} -# ...more data_sources blocks, one per source... -``` - -Merging two configs = keep one `buffers` section and concatenate their -`data_sources` blocks. Each data source may appear at most once (the -`linux.ftrace` config especially: merge all `ftrace_events`, -`atrace_categories`, and `atrace_apps` entries into a single block). - -## Exemplar configs - -Location: -`$SKILL_ROOT/recording/workflows/perfetto-trace-recording/references/example-configs/` - -| File | Use case | -| :--- | :--- | -| `sched_cpu.pftxt` | CPU scheduling + frequency/idle (base layer). | -| `app_jank.pftxt` | Slow UI / dropped frames: atrace + frame timeline. | -| `memory_counters.pftxt` | System + per-process memory, LMK activity. | -| `java_heap_dump.pftxt` | Java heap retention graph for one app. | -| `native_heap.pftxt` | Sampled native malloc/free callstacks (heapprofd). | -| `cpu_profile.pftxt` | Periodic CPU callstack samples (traced_perf). | -| `long_background.pftxt` | Long/field traces: ring buffer + periodic writes. | - -For a standalone heap dump, native heap profile, or CPU profile, prefer the -dedicated helper scripts in: -`$SKILL_ROOT/recording/workflows/perfetto-trace-recording/perfetto_trace_recording.md`. -Use exemplars when one trace must combine several sources. - -## Data sources at a glance - -| `name` | What it records | Key options | -| :--- | :--- | :--- | -| `linux.ftrace` | Kernel events and atrace | `ftrace_events`, `atrace_categories`, `atrace_apps` | -| `linux.process_stats` | Process/thread names & stats | `scan_all_processes_on_start`, `proc_stats_poll_ms` | -| `linux.sys_stats` | Periodic `/proc` counters | `meminfo_period_ms`, `vmstat_period_ms`, `stat_period_ms` | -| `android.log` | Logcat | `android_log_config { log_ids: ... }` | -| `android.surfaceflinger.frametimeline` | Frame timelines (jank) | None needed | -| `android.java_hprof` | Java heap dump | `java_hprof_config { process_cmdline: ... }` | -| `android.heapprofd` | Native heap profiling | `heapprofd_config { sampling_interval_bytes, ... }` | -| `linux.perf` | CPU callstack sampling | `perf_event_config { timebase, callstack_sampling }` | -| `android.packages_list` | Package mapping | None needed | -| `android.power` | Battery counters | `android_power_config { battery_poll_ms, ... }` | -| `track_event` | Custom app trace events | `track_event_config { enabled_categories }` | - -The full, authoritative field list for every data source is the generated -[TraceConfig reference](https://perfetto.dev/docs/reference/trace-config-proto); -per-source guides live under -[perfetto.dev/docs/data-sources](https://perfetto.dev/docs/data-sources/). - -## Top-level knobs - -- `duration_ms` - trace length. Omit it to trace until the recording command - is stopped (Ctrl-C on `record_android_trace`). -- `buffers.fill_policy` - `RING_BUFFER` keeps newest data (right choice when - the interesting moment is at the end); `DISCARD` keeps the oldest. -- Long traces: `write_into_file: true` + `file_write_period_ms` stream buffer - to disk periodically so the trace can exceed RAM; `flush_period_ms: 30000` - keeps app-emitted events ordered; `max_file_size_bytes` bounds output. -- Buffer sizing rule of thumb: 32–64 MB (`size_kb: 32768`–`65536`) is plenty - for most 10–30s traces; heap dumps need ~100 MB or `write_into_file`. - -## Pitfalls - -- All buffer sizes are **KB** (`size_kb: 32768` = 32 MB); durations are - **ms**. -- atrace data (categories and app events) only flows through the - `linux.ftrace` data source - there is no separate "atrace" source, and an - app's custom trace events appear only if its package is listed in - `atrace_apps` (or `atrace_apps: "*"`). -- Field-name typos are only caught when the config is parsed at record time: - `record_android_trace -c config.pftxt` fails fast with a parse error naming - the bad field, so treat that as your validator, fix, and retry. -- Text-format enum values are bare identifiers (`fill_policy: RING_BUFFER`), - and strings are quoted. diff --git a/profilers/android-profiler/references/env_setup.md b/profilers/android-profiler/references/env_setup.md deleted file mode 100644 index c304bfc..0000000 --- a/profilers/android-profiler/references/env_setup.md +++ /dev/null @@ -1,15 +0,0 @@ -# Environment setup - -## Set `$SKILL_ROOT` - -Set up `$SKILL_ROOT` once per session. - -Every file this skill references - workflow markdown, reference docs, helper -scripts, and downloaded dependencies - is written in the form `$SKILL_ROOT/...`, -relative to the **skill root** (the directory holding this skill's `SKILL.md`). -Set it once to the absolute path of the directory you loaded `SKILL.md` from: - -```sh -# Substitute with the directory the SKILL.md lives in. -export SKILL_ROOT="/absolute/path/to/skills/profilers/android-profiler" -``` diff --git a/profilers/android-profiler/references/perfetto/bin/trace_processor b/profilers/android-profiler/references/perfetto/bin/trace_processor deleted file mode 100644 index fe28ebe..0000000 --- a/profilers/android-profiler/references/perfetto/bin/trace_processor +++ /dev/null @@ -1,313 +0,0 @@ -#!/usr/bin/env python3 -# Copyright (C) 2021 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. - -# !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -# DO NOT EDIT. Auto-generated by tools/gen_amalgamated_python_tools -# !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - -# This file should do the same thing when being invoked in any of these ways: -# ./trace_processor -# python trace_processor -# bash trace_processor -# cat ./trace_processor | bash -# cat ./trace_processor | python - - -BASH_FALLBACK = """ " -exec python3 - "$@" <<'#'EOF -#""" # yapf: disable - - -# ----- Amalgamator: begin of python/perfetto/prebuilts/manifests/trace_processor_shell.py -# This file has been generated by: tools/release/roll-prebuilts v57.2 -TRACE_PROCESSOR_SHELL_MANIFEST = [ - { - 'arch': 'mac-amd64', - 'file_name': 'trace_processor_shell', - 'file_size': 13787208, - 'url': ( - 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v57.2/mac-amd64/trace_processor_shell' - ), - 'sha256': ( - 'c0f61397901da47cbe1bb9a0843624f7c2038ac92176ce15e3736ce9aa0afef0' - ), - 'platform': 'darwin', - 'machine': ['x86_64'], - }, - { - 'arch': 'mac-arm64', - 'file_name': 'trace_processor_shell', - 'file_size': 12704456, - 'url': ( - 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v57.2/mac-arm64/trace_processor_shell' - ), - 'sha256': ( - '98a41b80e9f60da0373d64aff6455681f8c26b7c391ae5736324a5b11e3dacc2' - ), - 'platform': 'darwin', - 'machine': ['arm64'], - }, - { - 'arch': 'linux-amd64', - 'file_name': 'trace_processor_shell', - 'file_size': 14008280, - 'url': ( - 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v57.2/linux-amd64/trace_processor_shell' - ), - 'sha256': ( - '55ba613fc6d4f71df81eee2dbfc293020063655c241b3e314bff75345b802684' - ), - 'platform': 'linux', - 'machine': ['x86_64'], - }, - { - 'arch': 'linux-arm', - 'file_name': 'trace_processor_shell', - 'file_size': 10321532, - 'url': ( - 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v57.2/linux-arm/trace_processor_shell' - ), - 'sha256': ( - '0f3221a1fb6dbe584c4bdc4a7541bcf15e6e5c5dde5e4910744dfb8913ee62c5' - ), - 'platform': 'linux', - 'machine': ['armv6l', 'armv7l', 'armv8l'], - }, - { - 'arch': 'linux-arm64', - 'file_name': 'trace_processor_shell', - 'file_size': 13297064, - 'url': ( - 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v57.2/linux-arm64/trace_processor_shell' - ), - 'sha256': ( - '1dcc1d9aaff2eb92e8bc58f1957e4e445600294bd61dbc09345c1018c5ff0868' - ), - 'platform': 'linux', - 'machine': ['aarch64'], - }, - { - 'arch': 'android-arm', - 'file_name': 'trace_processor_shell', - 'file_size': 10293452, - 'url': ( - 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v57.2/android-arm/trace_processor_shell' - ), - 'sha256': ( - 'a92d4138966a494bbf017d054fd9c859bf1a3deeca56dc6f84715292d87b6073' - ), - }, - { - 'arch': 'android-arm64', - 'file_name': 'trace_processor_shell', - 'file_size': 13098064, - 'url': ( - 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v57.2/android-arm64/trace_processor_shell' - ), - 'sha256': ( - '05e95758a26cf57fb8d552d3c1d532486681a6ecc4ee705a484a8deb4d092d09' - ), - }, - { - 'arch': 'android-x86', - 'file_name': 'trace_processor_shell', - 'file_size': 14811628, - 'url': ( - 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v57.2/android-x86/trace_processor_shell' - ), - 'sha256': ( - '21b72f5f2821f8e6c050ee3788df9043af1b83b5e90726c343405b4f3c9837a3' - ), - }, - { - 'arch': 'android-x64', - 'file_name': 'trace_processor_shell', - 'file_size': 13724184, - 'url': ( - 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v57.2/android-x64/trace_processor_shell' - ), - 'sha256': ( - '0e3fe6d80fc00891035e09b7dbf7fa6b2e9752282ac4b8f1ebe792377877f7d8' - ), - }, - { - 'arch': 'windows-amd64', - 'file_name': 'trace_processor_shell.exe', - 'file_size': 13617152, - 'url': ( - 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v57.2/windows-amd64/trace_processor_shell.exe' - ), - 'sha256': ( - '100334b6091596fbc97f872556849a5747bf47a7f7190c485ba8cea8d2409c7b' - ), - 'platform': 'win32', - 'machine': ['amd64'], - }, -] - -# ----- Amalgamator: end of python/perfetto/prebuilts/manifests/trace_processor_shell.py - -# ----- Amalgamator: begin of python/perfetto/prebuilts/perfetto_prebuilts.py -# Copyright (C) 2021 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. -""" -Functions to fetch pre-pinned Perfetto prebuilts. - -This function is used in different places: -- Into the //tools/{trace_processor, traceconv} scripts, which are just plain - wrappers around executables. -- Into the //tools/{heap_profiler, record_android_trace} scripts, which contain - some other hand-written python code. - -The manifest argument looks as follows: -TRACECONV_MANIFEST = [ - { - 'arch': 'mac-amd64', - 'file_name': 'traceconv', - 'file_size': 7087080, - 'url': https://commondatastorage.googleapis.com/.../trace_to_text', - 'sha256': 7d957c005b0dc130f5bd855d6cec27e060d38841b320d04840afc569f9087490', - 'platform': 'darwin', - 'machine': 'x86_64' - }, - ... -] - -The intended usage is: - - from perfetto.prebuilts.manifests.traceconv import TRACECONV_MANIFEST - bin_path = get_perfetto_prebuilt(TRACECONV_MANIFEST) - subprocess.call(bin_path, ...) -""" - -import hashlib -import os -import platform -import random -import subprocess -import sys - - -def download_or_get_cached(file_name, url, sha256): - """Downloads a prebuilt or returns a cached version - - The first time this is invoked, it downloads the |url| and caches it into - ~/.local/share/perfetto/prebuilts/$tool_name-$sha256. On subsequent - invocations it just runs the cached version. - - The (short) SHA-256 is embedded in the cached file name so that several - versions of the same tool can coexist on the same machine. This matters when - e.g. two virtualenvs pin different Perfetto releases, or the //tools wrappers - and the Python API request different versions: without the SHA in the name - they would all map to the same path, clobber each other and trigger a - re-download on every switch. - """ - dir = os.path.join( - os.path.expanduser('~'), '.local', 'share', 'perfetto', 'prebuilts' - ) - os.makedirs(dir, exist_ok=True) - # Embed the SHA in the file name, preserving any extension (e.g. .exe) as the - # last component since callers (and the OS, on Windows) rely on it. - root, ext = os.path.splitext(file_name) - bin_path = os.path.join(dir, '%s-%s%s' % (root, sha256[:16], ext)) - - # The cached file is only ever created via an atomic rename after the SHA-256 - # has been verified, so if a file at this (SHA-named) path exists we can trust - # it without recomputing the hash on every invocation. - if os.path.exists(bin_path): - return bin_path - - # Use a unique random file to guard against concurrent executions. - # See https://github.com/google/perfetto/issues/786 . - tmp_path = '%s.%d.tmp' % (bin_path, random.randint(0, 100000)) - print('Downloading ' + url) - subprocess.check_call(['curl', '-f', '-L', '-#', '-o', tmp_path, url]) - with open(tmp_path, 'rb') as fd: - actual_sha256 = hashlib.sha256(fd.read()).hexdigest() - if actual_sha256 != sha256: - raise Exception( - 'Checksum mismatch for %s (actual: %s, expected: %s)' - % (url, actual_sha256, sha256) - ) - os.chmod(tmp_path, 0o755) - os.replace(tmp_path, bin_path) - return bin_path - - -def get_perfetto_prebuilt(manifest, soft_fail=False, arch=None): - """Downloads the prebuilt, if necessary, and returns its path on disk.""" - plat = sys.platform.lower() - machine = platform.machine().lower() - manifest_entry = None - for entry in manifest: - # If the caller overrides the arch, just match that (for Android prebuilts). - if arch: - if entry.get('arch') == arch: - manifest_entry = entry - break - continue - # Otherwise guess the local machine arch. - if entry.get('platform') == plat and machine in entry.get('machine', []): - manifest_entry = entry - break - if manifest_entry is None: - if soft_fail: - return None - raise Exception( - 'No prebuilts available for %s-%s\n' % (plat, machine) - + 'See https://perfetto.dev/docs/contributing/build-instructions' - ) - - # Placeholder entries (e.g. before a release has been rolled) have an empty - # URL. Treat them the same as a missing entry when soft_fail is set. - if not manifest_entry.get('url'): - if soft_fail: - return None - raise Exception( - 'No prebuilt URL available for %s on %s-%s. ' - 'The prebuilt may not have been rolled yet.' - % (manifest_entry.get('file_name', '?'), plat, machine) - ) - - return download_or_get_cached( - file_name=manifest_entry['file_name'], - url=manifest_entry['url'], - sha256=manifest_entry['sha256'], - ) - - -def run_perfetto_prebuilt(manifest): - bin_path = get_perfetto_prebuilt(manifest) - if sys.platform.lower() == 'win32': - sys.exit(subprocess.check_call([bin_path, *sys.argv[1:]])) - os.execv(bin_path, [bin_path] + sys.argv[1:]) - - -# ----- Amalgamator: end of python/perfetto/prebuilts/perfetto_prebuilts.py - -if __name__ == '__main__': - run_perfetto_prebuilt(TRACE_PROCESSOR_SHELL_MANIFEST) - -# EOF \ No newline at end of file diff --git a/profilers/android-profiler/references/perfetto/setup.md b/profilers/android-profiler/references/perfetto/setup.md deleted file mode 100644 index f745ff5..0000000 --- a/profilers/android-profiler/references/perfetto/setup.md +++ /dev/null @@ -1,28 +0,0 @@ -# Getting `trace_processor` working - -> **Prerequisite:** Ensure `$SKILL_ROOT` is initialized per -> `$SKILL_ROOT/references/env_setup.md`. - -## Put the bundled `trace_processor` on the `PATH` - -The skill ships a `trace_processor` wrapper at -`$SKILL_ROOT/references/perfetto/bin/trace_processor`. Make it invokable for -this session: - -```sh -# some installs lose the exec bit -chmod +x "$SKILL_ROOT/references/perfetto/bin/trace_processor" -export PATH="$SKILL_ROOT/references/perfetto/bin:$PATH" -trace_processor --version # smoke test -``` - -After this, every bare `trace_processor ...` command in this skill works -verbatim. If each command runs in a fresh shell, repeat the `export` line in -that command. On Windows, skip the `PATH` setup and invoke it as -`python "$SKILL_ROOT/references/perfetto/bin/trace_processor" ...` instead. - -Notes: - -- The first invocation downloads the prebuilt native binary (picking the - right one for the host platform) into `~/.local/share/perfetto/prebuilts/` - and caches it; only the first call pays the download cost. diff --git a/profilers/android-profiler/references/perfetto/sql.md b/profilers/android-profiler/references/perfetto/sql.md deleted file mode 100644 index 07f84af..0000000 --- a/profilers/android-profiler/references/perfetto/sql.md +++ /dev/null @@ -1,327 +0,0 @@ -# Querying Perfetto traces - -This reference explains how to extract data from a Perfetto trace file -(`.pftrace`, `.perfetto-trace`, `.pb`) using `trace_processor` and -PerfettoSQL. Read it for ad-hoc querying outside a guided workflow; the -workflows under `$SKILL_ROOT/analysis/workflows` and -`$SKILL_ROOT/recording/workflows` carry their own queries. - -The `trace_processor` binary is what every other Perfetto analysis tool -runs on top of, including the Perfetto UI. Reference docs: -. - -> **Prerequisite - `trace_processor` must be invokable.** Before -> running any of the shell commands below, read -> `$SKILL_ROOT/references/perfetto/setup.md`. It defines how to make -> the bare `trace_processor` commands below work in this environment. - -## Querying a trace: sessions - -Querying goes through a **session**: load the trace once into a named -background session, then run every query against it with `--remote`. -Parsing a trace is the expensive part (tens of seconds for a multi-GB -trace); the session pays it once, and every real analysis runs more than -one query. - -```sh -# 1. Load the trace into a background session - once per trace. -# Pick a descriptive session name (e.g. derived from the trace file). -trace_processor server unix --name mysession --daemonize TRACE_FILE - -# 2. Run queries against the warm session: instant, no reparse. -trace_processor query --remote mysession \ - "SELECT ts, dur, name FROM slice WHERE dur > 5e8 LIMIT 5" - -# 3. When you are completely done with the trace: -trace_processor server kill mysession -``` - -Multiple statements separated by `;` are supported in one invocation. - -Session rules: - -- Session names are managed by trace_processor in a per-user session - directory - there are no ports to choose and no collisions with other - agents or the Perfetto UI. -- **Session state persists across `query --remote` calls.** A - `CREATE PERFETTO TABLE` or `INCLUDE PERFETTO MODULE` run in one call is - visible in the next, so materializing intermediate results pays off - across invocations. -- Flags that configure trace loading (`--full-sort`, `--add-sql-package`, ...) - belong on the `server unix` invocation, not on `query --remote` - the client - rejects them with an explanatory error. -- `--remote` also accepts an absolute `*.sock` path or `host:port`; - names are the common case. -- Forgotten sessions are reaped automatically after 30 minutes idle - (`--idle-timeout`), but kill your session when the analysis is done. - -`TRACE_FILE` can be a local path, an `http(s)://` URL, or a Perfetto UI -share link (`https://ui.perfetto.dev/#!/?s=`) - in the last two -cases trace_processor downloads the trace for you (cached under -`~/.cache/perfetto/` or the platform equivalent), resolving the share -link to its underlying trace first. - -For a single throwaway query on a small trace you _can_ skip the session -(`trace_processor query TRACE_FILE "..."` parses, queries, and exits), -but treat that as the exception: it re-parses the trace on every -invocation and forgets created tables and included modules between -calls. Default to a session. - -## Discovering what's in the trace - -PerfettoSQL ships with **intrinsic table-functions** for browsing the -loaded standard library - modules, tables/views, functions, macros. Use -these to find what's available and to verify if a Standard Library module -already provides the needed abstraction before drafting custom logic. - -**Mandatory Schema Check:** Do not guess column names or join keys. Always -use a plain `LIMIT 0` query to read the exact column schema of any specific -table, view, or query result before drafting your query. - -> **Intrinsic surface - not stable API.** The `__intrinsic_*` names below -> are an implementation detail of trace processor. They're fair game for -> an agent to use during a session because this reference is loaded, but -> **don't bake `__intrinsic_*` names into committed scripts, dashboards, -> or stdlib modules** - they can change without notice. - -```sql --- 1. List every stdlib module currently available. -SELECT package, module FROM __intrinsic_stdlib_modules ORDER BY 1, 2; - --- 2. List the tables/views a specific module exposes --- (after INCLUDE PERFETTO MODULE). -INCLUDE PERFETTO MODULE slices.with_context; -SELECT name, type, exposed, description -FROM __intrinsic_stdlib_tables('slices.with_context'); - --- 3. List functions / macros a module exposes. -SELECT name, return_type, args -FROM __intrinsic_stdlib_functions('slices.with_context'); -SELECT name, return_type, args -FROM __intrinsic_stdlib_macros('android.memory.heap_graph.helpers'); - --- 4. Read the column schema of any table, view, or query. --- LIMIT 0 returns the result header with no row scan; trace_processor --- prints "column N = " lines for each column. -SELECT * FROM slice LIMIT 0; -SELECT * FROM thread_or_process_slice LIMIT 0; -SELECT * FROM (SELECT ts, dur, name FROM slice WHERE dur > 0) LIMIT 0; -``` - -Useful starting points for any trace: - -| View | What's in it | -| :------------- | :--------------------------------------------------------------- | -| `slice` | Atrace slices, async slices, anything with a duration on a track | -| `thread` | One row per thread | -| `process` | One row per process | -| `thread_state` | State transitions (Running, Runnable, Sleeping, ...) | -| `sched_slice` | When threads were on-CPU | -| `counter` | Time-series counter samples | -| `track` | Every track in the trace; join on `track_id` to other tables | - -Static reference for the public surface (does not require a running -trace_processor): . - -## Using the standard library - -Most useful queries are _much_ shorter when you build on stdlib modules -instead of joining raw tables yourself. Generated stdlib reference: -. - -Include a module before referencing the views, tables or macros it -defines: - -```sql -INCLUDE PERFETTO MODULE slices.with_context; - -SELECT name, dur, thread_name, process_name -FROM thread_or_process_slice -WHERE dur > 1e9 -- slices longer than 1s -ORDER BY dur DESC -LIMIT 20; -``` - -A few commonly used modules to know: - -- `slices.with_context` - slice rows joined with their thread / process. -- `sched.with_context` - `sched_slice` joined with thread / process. -- `android.startup.startups` - one row per app startup. -- `stacks.cpu_profiling` - flat samples and call-graph helpers. -- `android.memory.heap_graph.dominator_tree` - retained-size analysis for - Java heap dumps. - -The module name maps directly to the file path under the stdlib root: -`foo.bar` lives at `foo/bar.sql`. Browse the full list at the stdlib -reference linked above. - -## Tips for writing good PerfettoSQL - -- **Reach for stdlib first.** If you find yourself joining `slice` to - `thread_track` to `thread` to `process`, there is almost certainly a - stdlib module that already does it. Check the stdlib reference before - writing the join. -- **Filter on `dur > 0` and Trace Boundaries carefully.** Some slices have - `dur = -1` (still open at trace end) and some have `dur = 0` (instant - events). Be explicit about which you mean. When calculating a bounding box - (for example, `ts + dur`) or summing durations (`SUM(dur)`), handle - incomplete durations using: `IIF(dur = -1, trace_end() - ts, dur)`. -- **Robust State Transitions.** Avoid manual timestamp arithmetic (for - example, `ts + dur = next.ts`) to join adjacent events. Rely on standard - library modules (for example, `sched.runnable`, `linux.perf.counters`, - `intervals.overlap`) which safely handle trace gaps and preemptions. -- **Working with Identifiers:** - - **Use Unique Identifiers for Joins:** When writing SQL queries in - Perfetto, you must join tables using `utid` (unique thread ID) or `upid` - (unique process ID) instead of regular `tid` or `pid`. **Why it's - useful**: The operating system recycles `TIDs` and `PIDs`, while `UTIDs` - and `UPIDs` remain unique for the lifetime of the trace, which prevents - incorrect joins. - - Columns like `id`, `utid`, `upid`, `track_id` are not stable across traces - or even runs of trace_processor on the same trace. You can use them - **inside** a query as join keys, but alongside IDs, always join out to - a stable name (`thread.name`, `process.name`, `slice.name`) when reporting - results to the user. - - **Materialize expensive intermediate results.** `CREATE PERFETTO TABLE foo - AS SELECT ...` caches the result so subsequent queries don't redo the work. - - _Note for `SPAN_JOIN`:_ Intermediate tables fed into a `SPAN_JOIN` must - be materialized using `CREATE PERFETTO TABLE`, not `CREATE VIEW`. -- **Idempotency.** Ensure queries are idempotent to prevent "already exists" - errors during multiple executions. - - For Perfetto objects, always use `CREATE OR REPLACE`: - `CREATE OR REPLACE PERFETTO {TABLE|VIEW|MACRO|FUNCTION}`. - - For SQLite Virtual Tables (such as `SPAN_JOIN`), `CREATE OR REPLACE` is - not supported. Explicitly drop them first: - `DROP TABLE IF EXISTS my_table;` - `CREATE VIRTUAL TABLE my_table USING SPAN_JOIN(...);` - - For standard SQLite indexes, prepend `DROP INDEX IF EXISTS index_name;`. -- **`SPAN_JOIN` safety.** `SPAN_JOIN` will crash if intervals **within the - same input table** overlap. Always use the `PARTITIONED {column}` (for - example, `PARTITIONED track_id`) clause to isolate intervals. -- **Avoid `SELECT *` in saved queries.** Trace processor table schemas can - gain columns; pin the columns you actually use. -- **Use `EXPLAIN QUERY PLAN` if a query is slow.** It shows whether SQLite is - using indexes. Counter and slice tables have built-in indexes on `ts` and - `track_id`; queries that don't filter on either will scan the whole table. -- **Argument Extraction:** Use `EXTRACT_ARG(arg_set_id, 'key')` to fetch event - properties instead of manually joining the `args` table. -- **JSON Parsing:** When dealing with JSON text, use standard SQLite JSON - functions (for example, `json_extract()`) to extract values. -- **String Matching (Always use GLOB).** Use `GLOB` instead of `LIKE`. `LIKE` - causes performance bottlenecks and treats underscores (`_`) as wildcards, - leading to bugs. - - **Exact matches:** Use `=`. - - **Substring matches:** Use `GLOB` with `*` (for example, - `name GLOB '*RenderThread*'`). - - **Case-insensitive matches:** Use `LOWER(name) GLOB` and make sure the - search string is fully lowercase (for example, - `LOWER(name) GLOB '*renderthread*'`). Use this when dealing with - inconsistent trace capitalization (for example, `WakeLock` vs `wakelock`). -- **Alias Precision.** Always prefix column names with table or view alias, - that is: `{alias}.{column_name}`. - -## Common Analysis Patterns - -- **Calculating Time Overlaps & CPU Time:** - 1. **Primary Method (MANDATORY):** Always search the standard library first - before writing custom interval logic. For example, to find the exact CPU - execution time of a slice, do not calculate it manually; instead, search - the docs and use the `slices.cpu_time` module. - 2. **Fallback Method (Use ONLY if you have verified no stdlib module or - `SPAN_JOIN` applies):** If you must calculate custom overlap durations - between two different sets of time intervals `[start1, end1]` and - `[start2, end2]`: - - **Condition:** The intervals overlap if `start1 < end2` and - `start2 < end1`. - - **Duration:** The overlap duration is calculated as - `MIN(end1, end2) - MAX(start1, start2)`. - - **Important:** Incomplete Perfetto slices have a duration of -1 - (`dur = -1`). Always calculate the effective end time using - `ts + IIF(dur = -1, trace_end() - ts, dur)` before applying this logic. -- Include the `android.startup.startups` module and query - `android_thread_slices_for_all_startups` (or `android_startups`) for - app startup requests. -- Join `counter_track` with `counter` to get values of counter with a - specific name. -- When querying for a CPU frequency counter, include the `linux.cpu.frequency` - module and use the `cpu_frequency_counters` table. -- **Window Size:** When looking for events around a specific timestamp, start - with 100ms as the window size. -- **Total Duration:** To calculate the total time spent in slices matching a - specific name pattern (for example, `*{name_pattern}*`), you must sum their - durations. **Why it's useful**: This helps quantify the total impact of a - specific function or feature on performance across multiple calls. Here is - an example query (note the safe handling of incomplete slices): - ```sql - SELECT - count(*) as total_count, - sum(IIF(slice.dur = -1, trace_end() - slice.ts, slice.dur)) / 1e6 - as total_dur_ms - FROM slice - WHERE slice.name GLOB '*{name_pattern}*'; - ``` - -## Analytical Workflow (Standard Operating Procedure) - -To ensure accuracy and efficiency, follow these steps: - -1. **Research & Dissection:** Identify the core question and required data - points. -2. **Mandatory Schema Validation:** Locate relevant modules via - `__intrinsic_stdlib_modules` and their tables via - `__intrinsic_stdlib_tables('module_name')`. Verify column names and types. - - **Intent Check:** You must verify if a stdlib module already provides - the needed abstraction before drafting manual arithmetic or custom joins. - -- **IMPORTANT:** If your query requires calculating overlaps, intersections, - or boundaries between intervals, you MUST search `__intrinsic_stdlib_modules` - globally (for example, `WHERE module GLOB '*overlap*'`) before writing - `MIN()/MAX()` or `IIF(dur = -1...)` logic. - -3. **Draft & Validate Loop (Max 3 Iterations):** - -- [ ] **Draft:** Use only verified schemas. Ensure `INCLUDE PERFETTO MODULE` - is present for non-prelude modules. -- [ ] **Verify Idempotency:** Use `CREATE OR REPLACE` or `DROP TABLE IF EXISTS` - for virtual tables. -- [ ] **Check Precision:** Are ALL columns prefixed with aliases (e.g., - `s.name`)? Are you joining on `utid`/`upid`? -- [ ] **String Matching:** Did you use `GLOB` or `=` instead of `LIKE`? -- [ ] **Span Join Check:** If using `SPAN_JOIN`, are tables `PARTITIONED` - and materialized? -- [ ] **Execute:** Run against the session: - `trace_processor query --remote SESSION "QUERY"`. - -**Execution Rules:** - -- **File Usage:** If you must create a SQL file to execute queries (for - example, due to query length or escaping issues), you must create them - in the `/tmp/` directory. -- **Failure Resilience:** Debug and fix SQL syntax and logic errors when - query fails. Don't simplify the analytical intent to pass validation. - For example, if requested to calculate an overlap or intersection, you - must fix the intersection math. Don't substitute with disjoint queries - (for example, returning independent total durations) as a workaround. - -4. **Cleanup & Finalize:** - -- Explicitly return and state the final validated SQL and explain the - results to the user. -- **Save an analysis report.** Write a markdown file in the working - directory (default `perfetto_analysis_report.md`) containing: the - question investigated, the trace file(s) analyzed, the findings with - concrete numbers, the final validated queries (so the analysis can be - re-run), and open questions / next steps. Point the user at it in - your final message. -- Before finishing, delete any temporary SQL files created in `/tmp/`. - -## Where to look for more - -- Language tour: - -- Trace processor reference: - -- Generated table reference: - -- Generated stdlib reference: - diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..abf4b05 --- /dev/null +++ b/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "local>android/.github:renovate-config" + ] +} diff --git a/security/android-intent-security/SKILL.md b/security/android-intent-security/SKILL.md deleted file mode 100644 index e028309..0000000 --- a/security/android-intent-security/SKILL.md +++ /dev/null @@ -1,550 +0,0 @@ ---- -name: android-intent-security -description: Best practices for Android Intent security. Use this skill when auditing - component configurations in AndroidManifest.xml activities, services, receivers) - or source code handling incoming Intents (getIntent, getParcelableExtra) to prevent - Intent Redirection and unauthorized access. -license: Complete terms in LICENSE.txt -metadata: - author: Google LLC - last-updated: '2026-09-25' - keywords: - - recipe - - Android - - Security - - Intent - - Redirection - - PendingIntent - - ContentProvider - - Service - - Signature - - Verification - - sanitizer - - Vulnerability - - Best Practices ---- - -This skill provides guidelines and patterns to secure Android components -(Activities, Services, Broadcast Receivers, Content Providers) and handle -Intents safely, preventing privilege escalation and unauthorized access. - -## Glossary - -- **Intent:** An asynchronous messaging object used to request an action from another app component. -- **Exported component:** A component (`android:exported="true"`) that can be launched by other apps on the device. -- **Sticky intent:** A broadcast intent that remains in the system cache after it's sent, allowing any app to retrieve its contents. -- **Signature permission:** A permission whose protection level is set to `signature`, granted only to apps signed with the same developer key. -- **onNewIntent:** An activity lifecycle callback invoked when an activity is launched with `FLAG_ACTIVITY_SINGLE_TOP` and is already running at the top of the history stack. -- **PendingIntent:** A token granted to a foreign application (for example, system services) allowing it to execute a predefined Intent with the creator's permissions. -- **Mutable PendingIntent:** A PendingIntent whose underlying Intent parameters can be modified by the receiving application. -- **ContentProvider:** A component that encapsulates data and provides it to other applications via standard query/insert interfaces. -- **IntentSanitizer:** A utility class in AndroidX Core used to build a safe, sanitized copy of an incoming Intent by filtering out unauthorized components, actions, or extras. -- **Intent redirection (forwarding):** A vulnerability where an application receives an intent from an untrusted source and uses it to launch a private, non-exported component. - -## Prerequisites - -- The agent **MUST** be able to describe the function and security implications of `onCreate`, `onNewIntent`, and the `singleTop` launch mode. -- The agent **MUST** be able to declare ``, ``, ``, and `` tags in `AndroidManifest.xml` and define their `android:exported` and `android:permission` attributes. -- The agent **MUST** be able to implement signature verification checks using `PackageManager`. - -## Limitations - -- This skill focuses on local inter-component and inter-app communication security on the Android platform. -- This skill **doesn't** cover network security, web integration, or host-to-server security. - -## Setup and dependencies - -- **Android SDK:** Minimum API Level 23 (Android 6.0) is required for standard hardware-backed keystore operations and component validation. -- **AndroidX Core Library:** `androidx.core:core:1.9.0` or higher is **mandatory** to leverage `IntentSanitizer`. -- **Standard API access:** Standard Android `PackageManager` APIs are required for runtime component verification. - -*** ** * ** *** - -## Intent security logic and decisions - -### 1. Intent routing comparison - -Evaluate the security features of different intent delivery methods: - -| Intent Delivery Method | Scope | Recommended Use Case | -|---|---|---| -| Explicit Intent (Internal) | App Private | Launching internal activities/services | -| Implicit Intent | System Wide | Launching system camera, dialer, or sharing | -| Local Broadcasts (LocalBroadcastManager) (DEPRECATED) | App Private | Internal asynchronous event routing. **Deprecated**: Use in-app observers like Kotlin Flows/SharedFlow, LiveData, or reactive patterns instead. | -| System Broadcasts | System Wide | Receiving system events (NFC, Bluetooth) | - -### 2. PendingIntent mutability flag options - -Evaluate the security implications of PendingIntent mutability flags: - -| Flag Name | Mutability | Recommended Use Case | -|---|---|---| -| `PendingIntent.FLAG_IMMUTABLE` | Immutable | Default for almost all PendingIntents, such as alarms and notifications | -| `PendingIntent.FLAG_MUTABLE` | Mutable | Inline notifications replies, slice actions (requires explicit target intent) | - -### 3. Intent handling and redirection logic - -IF (the component receives a nested Intent as an extra) { -IF (AndroidX Core 1.9.0+ and higher is available) { -MUST construct an `IntentSanitizer` to explicitly allowlist components, actions, data, and extras. -MUST call `sanitizeByThrowing()` or `sanitizeByFiltering()` before launching. -} ELSE { -MUST verify that the nested Intent's target package matches the current application package. -MUST verify that the target component of the nested Intent is publicly exported. -} -NEVER launch the nested Intent directly without validation. -} ELSE IF (the component handles broadcasts) { -MUST rely on the system's Protected Broadcast mechanism for system events (which guarantees the sender is the system framework). -MUST protect custom receivers with signature-level permissions or use `RECEIVER_NOT_EXPORTED` for dynamic receivers to restrict the sender. -IF (using `ContextCompat.registerReceiver` with `RECEIVER_NOT_EXPORTED` AND `minSdk < 33`) { -MUST verify `` and `` are declared in `AndroidManifest.xml`. -} -} - -### 4. PendingIntent security logic - -IF (a PendingIntent is created for delivery to another application) { -MUST use `PendingIntent.FLAG_IMMUTABLE` by default. -IF (the PendingIntent must be mutable) { -MUST set the explicit target component or package name on the base `Intent`. -NEVER create an implicit, mutable `PendingIntent`. -} -} - -### 5. ContentProvider security logic - -IF (the ContentProvider is only for internal app use) { -MUST set `android:exported="false"`. -} ELSE { -MUST protect it with `android:readPermission` and `android:writePermission`. -MUST set `android:grantUriPermissions="false"` unless temporary URL access is strictly required. -} - -### 6. Service caller verification logic - -IF (an exported service communicates with trusted sister/partner apps) { -MUST retrieve the calling UID using `Binder.getCallingUid()` and resolve it to package names using `PackageManager.getPackagesForUid()`. -MUST verify that the calling package signature fingerprint matches your trusted certificate hash. -} - -*** ** * ** *** - -## Code and configuration patterns - -### 1. Safe intent redirection (manual verification) - -Validate the target of a nested intent before launching it when modern -sanitization libraries are unavailable. - -- **Expected Inputs:** - - An incoming `Intent` containing a nested `Intent` extra named `EXTRA_NESTED_INTENT`. -- **Expected Outputs:** - - Launches the target component if safe; throws `SecurityException` if validation fails. - - -```kotlin -fun safeIntentRedirectionManual() { - val nestedIntent = IntentCompat.getParcelableExtra(intent, "EXTRA_NESTED_INTENT", Intent::class.java) - if (nestedIntent != null) { - // 1. Check for URI permission grants to prevent URI permission bypass - val hasUriPermissionGrants = ( - nestedIntent.flags and ( - Intent.FLAG_GRANT_READ_URI_PERMISSION or - Intent.FLAG_GRANT_WRITE_URI_PERMISSION or - Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or - Intent.FLAG_GRANT_PREFIX_URI_PERMISSION - ) - ) != 0 - if (hasUriPermissionGrants) { - throw SecurityException("Nested intent contains forbidden URI permission grant flags!") - } - - val pm = packageManager - val target = nestedIntent.resolveActivity(pm) - if (target != null) { - // 2. Verify target is within the same package - if (target.packageName != packageName) { - throw SecurityException("Cross-app intent redirection is forbidden!") - } - try { - // 3. Verify target activity is exported - val info = pm.getActivityInfo(target, 0) - if (!info.exported) { - throw SecurityException("Target activity is private: ${target.className}") - } - // 4. Explicitly set the component to prevent intent interception - nestedIntent.component = target - // Safe to launch - startActivity(nestedIntent) - } catch (e: PackageManager.NameNotFoundException) { - Log.e("Security", "Failed to resolve target activity", e) - } - } - } -} -``` - -
- -### 2. Safe intent redirection using IntentSanitizer - -Filter or reject dynamic intents using AndroidX `IntentSanitizer` (AndroidX Core -1.9.0+). - -- **Expected Inputs:** - - An untrusted incoming `Intent`. -- **Expected Outputs:** - - `Intent`: A sanitized copy containing only allowlisted components, categories, and actions. Throws `SecurityException` on violations if using `sanitizeByThrowing()`. - - -```kotlin -fun safeIntentRedirectionSanitizer() { - val untrustedIntent = IntentCompat.getParcelableExtra(intent, "EXTRA_NESTED_INTENT", Intent::class.java) - if (untrustedIntent != null) { - // Define the strict boundaries for allowed redirection target - val sanitizer = IntentSanitizer.Builder() - .allowComponent(ComponentName("com.example.app", "com.example.app.SafeTargetActivity")) // Explicitly allowed target - .allowAction(Intent.ACTION_VIEW) // Explicitly allowed actions - .allowDataWithAuthority("com.example.app.provider") // Allowed URI authority - .allowType("text/plain") // Allowed mime type - .allowExtra("user_display_name", String::class.java) // Safe type-enforced extras - // Note: URI permission flags are NOT allowed, so the sanitizer will automatically strip or throw on them - .build() - - try { - // Option A: Throws SecurityException if the intent violates policies - val safeIntent = sanitizer.sanitizeByThrowing(untrustedIntent) - startActivity(safeIntent) - } catch (e: SecurityException) { - Log.e("SECURITY_ALERT", "Attempted launch of non-allowlisted intent blocked", e) - } - - // Option B: Silently filter and launch only the authorized parts (no exception thrown) - // val filteredIntent = sanitizer.sanitizeByFiltering(untrustedIntent) - // startActivity(filteredIntent) - } -} -``` - -
- -### 3. Custom signature permission protection - -Declare a custom signature-level permission in the manifest to secure family app -communication. - -- **Expected Inputs:** Manifest configuration. -- **Expected Outputs:** An activity that can only be launched by apps signed with the same developer certificate. - - -```xml - -``` - -
- - -```xml - - - - - - -``` - -
- -### 4. Safe onNewIntent lifecycle verification (warm boot protection) - -Ensure that activities reusing dynamic intents (for example, in background -launch paths) apply the same strict security filters inside `onNewIntent`. - -- **Expected Inputs:** - - `newIntent` (`Intent`): The newly delivered intent. -- **Expected Outputs:** - - Executes processing logic only if the new intent passes security validation. - - -```kotlin -override fun onNewIntent(newIntent: Intent) { - super.onNewIntent(newIntent) - - // Set the intent to ensure intent returns the new one - intent = newIntent - - // Validate the intent payload - if (validateIntent(newIntent)) { - processIntentPayload(newIntent) - } else { - Log.w("SECURITY_ALERT", "Received invalid or insecure intent during warm boot") - } -} - -private fun validateIntent(intent: Intent): Boolean { - return intent.hasExtra("VALID_PAYLOAD_MARKER") -} -``` - -
- -### 5. Secure PendingIntent creation - -Enforce immutability unless mutability is explicitly required. - -- **Expected Inputs (Immutable):** An intent target. -- **Expected Outputs (Immutable):** A `PendingIntent` that cannot be altered by the receiver. -- **Expected Inputs (Mutable):** An intent with an explicit component set. -- **Expected Outputs (Mutable):** A mutable `PendingIntent` locked to a specific receiver component to prevent hijacking. - - -```kotlin -fun createPendingIntents(context: Context) { - // 1. Secure Immutable PendingIntent (Default) - val intent = Intent(context, TargetActivity::class.java) - val pendingIntent = PendingIntent.getActivity( - context, - 0, - intent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) - - // 2. Secure Mutable PendingIntent (e.g., Notification Direct Reply) - val mutableIntent = Intent().apply { - // MUST set explicit target component to prevent redirection hijacking - component = ComponentName(context, ReplyReceiver::class.java) - } - val mutablePendingIntent = PendingIntent.getBroadcast( - context, - 0, - mutableIntent, - PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) -} -``` - -
- -### 6. Secure ContentProvider configuration and queries - -Expose a ContentProvider securely and parameterize queries to prevent SQL -injection. - -- **Expected Inputs:** - - `uri` (`Uri`): The query URI. - - `projection` (`String[]`): Columns to retrieve. - - `selection` (`String`): Query criteria. - - `selectionArgs` (`String[]`): Values mapping to selection placeholders (`?`). -- **Expected Outputs:** - - `Cursor`: Filtered query results, strictly bound to projection maps. - - -```xml - -``` - -
- - -```kotlin -override fun query( - uri: Uri, - projection: Array?, - selection: String?, - selectionArgs: Array?, - sortOrder: String? -): Cursor? { - val queryBuilder = SQLiteQueryBuilder() - queryBuilder.tables = tableName - // Strict projection map to prevent querying unauthorized columns - queryBuilder.projectionMap = mapOf( - "_id" to "_id", - "display_name" to "display_name" - ) - // Enable strict validation (always available since minSdk is 36) - queryBuilder.setStrict(true) - queryBuilder.setStrictColumns(true) - queryBuilder.setStrictGrammar(true) - - // MUST parameterize selection criteria; NEVER append selection strings directly - val db = dbHelper.readableDatabase - return queryBuilder.query(db, projection, selection, selectionArgs, null, null, sortOrder) -} -``` - -
- -### 7. Service caller signature verification - -Verify the calling application's signature before binding to a service. - -- **Expected Inputs:** - - `intent` (`Intent`): The binding request intent. -- **Expected Outputs:** - - `IBinder`: Local binder instance if caller signature matches trusted partner; throws `SecurityException` otherwise. - - -```kotlin -class SecureBoundService : Service() { - companion object { - // Expected SHA-256 hash of the trusted app's signing certificate (Base64 encoded) - private const val TRUSTED_PARTNER_SHA256 = "A1B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6Q7R8S9T0U1V=" - } - - override fun onBind(intent: Intent): IBinder { - // Return the binder. Do NOT perform signature verification in onBind() because - // the binder connection is cached by Android, which can bypass checks on subsequent binds. - return LocalBinder() - } - - private fun enforceTrustedCaller() { - val callingUid = Binder.getCallingUid() - // Allow calls from the same application - if (callingUid == Process.myUid()) { - return - } - val pm = packageManager - val packages = pm.getPackagesForUid(callingUid) - - if (packages.isNullOrEmpty() || !verifySignature(pm, packages[0])) { - throw SecurityException("Access Denied: Caller signature is untrusted.") - } - } - - private fun verifySignature(pm: PackageManager, packageName: String): Boolean { - try { - val trustedSha256Raw = Base64.decode(TRUSTED_PARTNER_SHA256, Base64.DEFAULT) - // API 28+ handles rotated certificates and avoids manual hashing. - // Since minSdk is 36, this is always available. - return pm.hasSigningCertificate(packageName, trustedSha256Raw, PackageManager.CERT_INPUT_SHA256) - } catch (e: Exception) { - Log.e("SECURITY_ERROR", "Verification failed for package: $packageName", e) - } - return false - } - - inner class LocalBinder : Binder() { - fun doSecureWork() { - // Verify caller identity on every transaction method call - enforceTrustedCaller() - // Safe to proceed with sensitive operations - } - } -} -``` - -
- -*** ** * ** *** - -## Error handling - -Handle component binding, database queries, and intent redirection failures -securely to avoid exposing internal structures. - - -```kotlin -fun safeErrorHandling(callingPackage: String?) { - try { - val payload = intent.getStringExtra("DATA_EXTRA") ?: throw IllegalArgumentException("Payload parameter missing.") - // Create a specific target intent using the validated payload - val targetIntent = Intent(this, TargetActivity::class.java).apply { - putExtra("SECURE_PAYLOAD", payload) - } - startActivity(targetIntent) - } catch (e: SecurityException) { - // MUST log security violations for audit, but NEVER expose exception details to the user. - Log.e("SECURITY_ERROR", "Unauthorized component transition blocked. Calling Package: ${callingPackage ?: "Unknown"}", e) - // MUST provide generic user feedback. - showFeedbackToUser("Process request failed: Access Denied.") - } catch (e: IllegalArgumentException) { - Log.w("INTEGRITY_WARNING", "Missing intent parameter", e) - } -} -``` - -
- - // Secure handling of ContentProvider queries on the client side: - try { - val cursor = contentResolver.query(providerUri, projection, selection, selectionArgs, null) - } catch (e: SQLiteException) { - Log.e("PROVIDER_ERROR", "ContentProvider database query failed", e) - // Secure handling: prevent raw query syntax details from leaking to UI - } - -*** ** * ** *** - -## Reporting guidelines - -When this skill is executed to apply security hardening updates to a codebase, -the agent **MUST** generate a structured "Best Practices and Security Alignment -Update" report for the developer. The report **must** be written to the session -artifact folder (or printed in the final response) and include: - -1. **Security alignment area:** The category of improvement applied (for example, Safe Intent Redirection, Secure PendingIntent Configuration, ContentProvider Data Guarding). -2. **Impact and priority:** The potential safety risk addressed by the update (for example, Component Hijacking Prevention, Private Data Isolation). -3. **Scope of changes:** A list of all modified classes, XML files, and dependencies. -4. **Implementation summary:** Concrete details of the solution (for example, "Updated nested intent parsing to use the `IntentSanitizer` API with a strict component allowlist"). -5. **Code diff:** Standard unified diffs showing the exact modifications. - -### Best practices and security alignment update template - -Use the following markdown template when reporting changes to developers: - - ### Best practices and security alignment update: [Security Alignment Area] - - * **Improvement Description:** [Brief description of the hardening update and why it's recommended] - * **Priority Level:** [High / Medium / Low] - * **Alignment Action:** [Summary of updates, for example, converted to FLAG_IMMUTABLE] - - #### Files modified - * `[Relative path to File 1]` - * `[Relative path to File 2]` - - #### Implementation diff - ```diff - // Insert Unified Diff here - -#### Testing and verification - -1. \[Step 1 to verify the component behaves correctly, for example, run component unit test\] -2. \[Step 2 to verify regression safety\] \`\`\` - -*** ** * ** *** - -## Antipatterns - -- **NEVER** launch a nested `Intent` received from an untrusted source without verifying its target package and exported status. -- **NEVER** use sticky broadcasts (`sendStickyBroadcast`). -- **NEVER** assume an exported component is safe because it runs in a background thread or performs internal checks. -- **NEVER** expose sensitive functionalities (like SSO authentication or payment processors) to components without signature-level permission restrictions. -- **NEVER** process incoming intents in `onNewIntent` without applying the same security controls as `onCreate`. -- **NEVER** create a mutable `PendingIntent` without setting an explicit target component in the base `Intent`. -- **NEVER** use dynamic string concatenation to construct selection blocks inside a `ContentProvider` query. -- **NEVER** use `Binder.getCallingUid` inside a `BroadcastReceiver.onReceive` to identify the sender of a broadcast, as it returns the receiver's own UID, not the sender's. - -## Best Practices - -- **MUST** explicitly set `android:exported="false"` for all components that don't need external communication. -- **MUST** protect all exported components with custom permissions utilizing `android:protectionLevel="signature"` when communicating between family apps. -- **MUST** validate all incoming intent extras and handle missing parameters gracefully to prevent crashes. -- **MUST** rely on the system's **Protected Broadcast** mechanism for system events (for example, boot completed, package changes), as the system prevents untrusted apps from spoofing these actions. -- **MUST** protect custom broadcasts with signature-level permissions or use `RECEIVER_NOT_EXPORTED` for dynamic receivers to restrict the sender identity (and when `minSdk < 33`, **MUST** verify that `` and `` are present in `AndroidManifest.xml` when using `ContextCompat.registerReceiver`). -- **MUST** call `setIntent(newIntent)` inside `onNewIntent()` before processing payloads to keep active references updated. -- **MUST** use `PendingIntent.FLAG_IMMUTABLE` by default when constructing `PendingIntent` instances. -- **MUST** protect exported `ContentProviders` with `readPermission` and `writePermission`. -- **MUST** enforce parameterized selection structures in `ContentProvider` query/update methods. -- **MUST** verify the package signature fingerprint of binding applications at runtime inside exported services. -- **MUST** use `androidx.core.content.IntentSanitizer` to sanitize incoming dynamic intents before redirection, if AndroidX Core 1.9.0+ is imported in the project. diff --git a/security/android-permissions-security/SKILL.md b/security/android-permissions-security/SKILL.md deleted file mode 100644 index 4a84a3c..0000000 --- a/security/android-permissions-security/SKILL.md +++ /dev/null @@ -1,598 +0,0 @@ ---- -name: android-permissions-security -description: Audits, detects gaps, and remediates Android permissions and IPC component - security vulnerabilities. Use when reviewing AndroidManifest.xml, defining custom - permissions, securing Services, Receivers, and Providers, verifying caller signatures - and UID using Binder, or implementing runtime permission flows. -license: Complete terms in LICENSE.txt -metadata: - author: Google LLC - last-updated: '2026-09-25' - keywords: - - recipe - - Android - - Security - - Permissions - - IPC - - Binder - - UID - - Caller Verification - - knownSigner - - signature - - ContentProvider - - Service - - Receiver - - Broadcast - - UriPermission - - runtime permissions - - SecurityException - - Photo Picker - - Vulnerability - - Remediation ---- - -Guidelines for auditing permission vulnerabilities, enforcing least privilege, -securing IPC boundaries, and handling runtime permissions across the Android -ecosystem. - -## Glossary - -- **Runtime Permission:** Dangerous permission (e.g., Camera, Location) that must be granted by the user at runtime on Android 6.0+ (API 23+). -- **Custom Permission:** A permission defined by an app (``) to restrict access to its exported components. -- **Protection Level:** Attribute defining how the OS grants the permission (`normal`, `dangerous`, `signature`, `knownSigner`). -- **knownSigner:** Protection flag (API 31+) granting signature-level access to partner apps matching trusted certificate digests. -- **SigningInfo:** Object (API 28+) providing signing history and multiple signer certificates for an application. -- **Binder Calling Identity:** Reliable UID representing the calling process obtained using `Binder.getCallingUid()`. -- **URI Permission:** Temporary, granular access granted to a content URI using `grantUriPermissions` and intent flags. - -## Prerequisites - -- Add `androidx.core:core-ktx` and `androidx.activity:activity-ktx` (or `androidx.fragment:fragment-ktx`) dependencies to the app-level `build.gradle` file for `ContextCompat` and `ActivityResultContracts`. -- For `knownSigner` partner permissions (`android:knownCerts`), ensure the project `compileSdk` and `targetSdk` are at least 31 (Android 12). -- For broadcast identity sharing (`BroadcastOptions.setShareIdentityEnabled`), ensure `compileSdk` and `targetSdk` are at least 34 (Android 14). - -*** ** * ** *** - -## 1. Security audit and permission gap detection playbook - -When auditing an Android project for permission and IPC security -vulnerabilities, systematically inspect each area: - -| Vulnerability Area | Search In / Pattern | Risk | Remediation Action | -|---|---|---|---| -| **Weak Custom Permission Protection** | `AndroidManifest.xml` with `android:protectionLevel="normal"`, `"dangerous"`, or `"signatureOrSystem"` | Unauthorized apps request or claim permission without restriction | Upgrade to `signature` or `signature|knownSigner` with `android:knownCerts`. | -| **Overly Broad URI Permission Grants** | `` or `pathPrefix=""` in `` | Leaks all provider data to third parties | Replace with explicit scoped subpath (e.g. `android:pathPrefix="/shared/"`). Retain `android:grantUriPermissions="true"`. Remove any wildcard `pathPrefix="/"`. | -| **Missing ContentProvider Split Permissions** | `` with only generic `android:permission` or missing `android:readPermission` and `android:writePermission` | Read-only callers execute write operations or write-only callers access read operations | Configure granular `android:readPermission="...READ_DATA"` and `android:writePermission="...WRITE_DATA"`, AND declare the custom `` tags with `android:protectionLevel="signature"` (or `signature|knownSigner`) at the `` root. | -| **Spoofable Caller Identity Checks** | `intent.getStringExtra("calling_package")`, `intent.getStringExtra("sender")`, `callingPackage == "..."` in Services | Malicious callers forge intent extras or strings | Remove string checks; authenticate caller UID cryptographically using `Binder.getCallingUid()` and `pm.hasSigningCertificate()`. | -| **Bound Service Permission Bypass** | `checkCallingOrSelfPermission()` or `enforceCallingOrSelfPermission()` in Binder AIDL stubs | Falls back to host app UID outside IPC or when identity is cleared, causing Confused Deputy bypass | Replace with `enforceCallingPermission("...WRITE_DATA", ...)` or `checkCallingPermission("...WRITE_DATA")`. | -| **Missing Method-Level Checks in Bound Services** | Bound Service AIDL methods mutating state without enforcing write permissions | Callers with read-only binding access invoke mutating write operations | Add `enforceCallingPermission("...WRITE_DATA", "Caller lacks WRITE_DATA permission")` inside mutating AIDL methods. | -| **Flawed Runtime Permission Flow** | Missing `shouldShowRequestPermissionRationale()`, missing permanent denial handling, or re-requesting in loops | Poor user experience, infinite loops, or inability to recover permissions | Implement 3-state flow: check grant -\> check rationale dialog -\> launch contract. On permanent denial, redirect to `Settings.ACTION_APPLICATION_DETAILS_SETTINGS`. | -| **Simultaneous Location Requests** | `arrayOf(ACCESS_FINE_LOCATION, ACCESS_BACKGROUND_LOCATION)` in one request | Rejected by Android 11+ (API 30+) | Request foreground location (`ACCESS_FINE_LOCATION`, `ACCESS_COARSE_LOCATION`) first. Request `ACCESS_BACKGROUND_LOCATION` only after foreground is granted in a separate user step. | -| **Broad Storage Permissions for Media** | Requesting `READ_EXTERNAL_STORAGE` or `READ_MEDIA_IMAGES` for user image selection | Over-privilege, user privacy violation | Migrate to zero-permission Photo Picker (`ActivityResultContracts.PickVisualMedia`). | -| **Cached Permission State** | Storing permission status in `var cachedLocationPermissionGranted = ...` | Fails when permission is revoked or expires | Query `ContextCompat.checkSelfPermission()` dynamically at invocation time and wrap protected calls in try-catch blocks catching `SecurityException`. | -| **Insecure Broadcasts** | `sendBroadcast(intent)` without package target or receiver permission; receiver without sender authentication | Data interception or command spoofing | Set explicit package (`intent.setPackage(...)`), pass receiver permission, and enable `BroadcastOptions.setShareIdentityEnabled(true)` (API 34+) to verify `sentFromPackage`. | - -*** ** * ** *** - -## 2. Custom permissions and protection levels - -When declaring custom permissions or partner certificates in the manifest, you -must follow the instructions in the [`` element guide](references/android/guide/topics/manifest/permission-element.md). - -- **`signature`**: Restricts access to apps signed with the same developer key. -- **`signature|knownSigner`** (API 31+): Restricts access to partner apps whose signing certificate digests match declared hashes in `android:knownCerts`. -- **`normal` or `dangerous`** : Do **not** use for private inter-app IPC. -- **`signatureOrSystem`** : **Deprecated** (API 23). Upgrade deprecated `signatureOrSystem` permissions to `signature`. Avoid in non-system apps. - -### Manifest snippets - -#### Signature-level custom permission - -Use `signature` permissions when restricting component access exclusively to -apps signed with the same developer certificate by declaring the `` -in the manifest and applying `android:permission` to the target component. - - -```xml - -``` - -
- -#### KnownSigner partner permission (API 31+) - -Use `signature|knownSigner` permissions when granting access to specific -external partner apps by declaring the `` in the manifest with -`android:knownCerts` referencing a resource array of trusted SHA-256 -certificate digests. - - -```xml - - - - 103938ee4537e59e8ee792f654504fb8346fc6b346d0bbc4415fc339fcfc8ec1 - - -``` - -
- - -```xml - -``` - -
- -*** ** * ** *** - -## 3. Exported component protection and URI permissions - -- **Private components** : Always set `android:exported="false"`. -- **Exported components** : Require permissions (`android:permission`, `android:readPermission`, `android:writePermission`). -- **Granular URI Grants** : Retain `android:grantUriPermissions="true"` for secure sharing, but scope grants to specific sub-paths. **Never** use wildcard `pathPrefix="/"`. -- **Custom Permission Declarations** : When splitting read and write permissions on a provider, you **MUST** declare the `` tags with `android:protectionLevel="signature"` (or `signature|knownSigner`) at the `` root level. -- **Remediating Existing Wildcard Grants** : You **MUST** find and remove any existing `` or `pathPrefix=""` and replace it with an explicit scoped subpath (e.g. `android:pathPrefix="/shared/"`). -- **Granular `UriPermission` Management** : Instead of broad manifest grants, use runtime `android.content.UriPermission` tokens for least-privilege per-URI sharing: - - *Grant temporary access* : `context.grantUriPermission(targetPackage, - uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)` or attach `Intent.FLAG_GRANT_READ_URI_PERMISSION` to outgoing intents. - - *Persist access* : The receiving app calls `contentResolver.takePersistableUriPermission(uri, modeFlags)` for durable access across reboots. - - *Audit active grants* : Inspect `contentResolver.persistedUriPermissions` (`List`) to verify granted URIs (`uriPermission.uri`, `uriPermission.isReadPermission()`, `uriPermission.isWritePermission()`). - - *Revoke access* : Call `context.revokeUriPermission(uri, modeFlags)` or `contentResolver.releasePersistableUriPermission(uri, modeFlags)` as soon as operations conclude. - -### Manifest snippet: Complete secure ContentProvider declaration - -Declare the split read and write permissions at the `` root: - - -```xml - - -``` - -
- -Then configure the provider with those permissions and a scoped URI grant: - - -```xml - - - -``` - -
- -### Runtime granular UriPermission management snippet - - -```kotlin -object UriPermissionManager { - - /** Grants temporary read access to a specific URI for a partner package. */ - fun grantScopedUriAccess(context: Context, targetPackage: String, uri: Uri) { - context.grantUriPermission( - targetPackage, - uri, - Intent.FLAG_GRANT_READ_URI_PERMISSION - ) - } - - /** Audits active persisted URI permissions held by the application. */ - fun getActivePersistedPermissions(context: Context): List { - return context.contentResolver.persistedUriPermissions - } - - /** Revokes temporary URI access when data transfer completes. */ - fun revokeScopedUriAccess(context: Context, uri: Uri) { - context.revokeUriPermission( - uri, - Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION - ) - } -} -``` - -
- -*** ** * ** *** - -## 4. Cryptographic caller identity verification - -Never trust caller package names provided in Intents or string arguments -(`getCallingPackage()`, `intent.getStringExtra("calling_package")`, -`intent.getStringExtra("sender")`, `intent.getStringExtra("package")`). Intent -extras and string package names are forged by malicious applications. - -### Critical vulnerability: Package name spoofing using intent extras - - -```kotlin -// VULNERABLE PATTERN: DO NOT DO THIS -val callingPackage = intent?.getStringExtra("calling_package") -if (callingPackage != null && SignatureUtils.verifyPartnerPackage(this, callingPackage)) { - // A malicious app passes "com.example.partner" in the extra. - // The signature check verifies the installed partner on disk, but the CALLER was malicious! -} -``` - -
- -### Secure pattern: Authenticate calling UID cryptographically - -1. **Completely remove** all `intent.getStringExtra("calling_package")`, `intent.getStringExtra("sender")`, and `callingPackage == "..."` checks. -2. Obtain caller identity using `Binder.getCallingUid()`. -3. Verify caller certificate using `PackageManager.hasSigningCertificate()` (API 28+) or `PackageManager.getPackagesForUid()`. - -### Complete CallerVerifier implementation - -Apply this to the service that currently performs the spoofable check. - -- **Placement** : declare `CallerVerifier` in the **same file** as the service you are fixing (for example inside `SecureDataService.kt`). Do not move the verification into a new file and leave the original service untouched. -- **Nullability** : `packageInfo.signatures` is `Array?`. Copy the `?: return false` guard exactly as written below. Iterating `packageInfo.signatures` directly fails to compile with "Non-nullable value required to call an 'iterator()' method in a for-loop." - - -```kotlin -object CallerVerifier { - private const val TRUSTED_PARTNER_SHA256 = - "A1B2C3D4E5F60708090A0B0C0D0E0F1011121314151617181920212223242526" - - fun isCallerAuthorized(context: Context): Boolean { - val callingUid = Binder.getCallingUid() - if (callingUid == android.os.Process.myUid()) return true - - val pm = context.packageManager - // Modern API 28+ check by UID: - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - val certBytes = hexStringToByteArray(TRUSTED_PARTNER_SHA256) - if (pm.hasSigningCertificate(callingUid, certBytes, PackageManager.CERT_INPUT_SHA256)) { - return true - } - } - - // Fallback for legacy APIs: - val callingPackages = pm.getPackagesForUid(callingUid) ?: return false - for (pkg in callingPackages) { - if (verifyPackageSignature(pm, pkg)) { - return true - } - } - return false - } - - @Suppress("DEPRECATION") - private fun verifyPackageSignature(pm: PackageManager, packageName: String): Boolean { - return try { - val packageInfo = pm.getPackageInfo(packageName, PackageManager.GET_SIGNATURES) - val signatures = packageInfo.signatures ?: return false - for (sig in signatures) { - val digest = java.security.MessageDigest.getInstance("SHA-256").digest(sig.toByteArray()) - val hex = digest.joinToString("") { "%02X".format(it) } - if (hex.equals(TRUSTED_PARTNER_SHA256, ignoreCase = true)) return true - } - false - } catch (e: PackageManager.NameNotFoundException) { - false - } - } - - private fun hexStringToByteArray(s: String): ByteArray { - val len = s.length - val data = ByteArray(len / 2) - for (i in 0 until len step 2) { - data[i / 2] = ((Character.digit(s[i], 16) shl 4) + Character.digit(s[i + 1], 16)).toByte() - } - return data - } -} -``` - -
- -*** ** * ** *** - -## 5. Method-level Binder enforcement in bound services (SecureBoundService.kt) - -In bound services exposing AIDL Binder stubs, protect state-modifying methods -using `enforceCallingPermission()`. - -### Critical security rule: Never use CallingOrSelfPermission in services - -- **NEVER** use `checkCallingOrSelfPermission()` or `enforceCallingOrSelfPermission()`. -- **Why:** During an active Binder transaction, this method checks the caller's permission. However, if the method is invoked locally (outside of an active IPC) or if the IPC calling identity is dropped, it falls back to checking the host app's permissions. Because the host app holds the permission, this fallback grants access and bypasses the security boundary, causing a Confused Deputy vulnerability. -- **MUST USE:** `enforceCallingPermission("com.example.snippets.permission.WRITE_DATA", - "Caller lacks WRITE_DATA permission")` or `checkCallingPermission("com.example.snippets.permission.WRITE_DATA")`. - -### Complete SecureBoundService implementation - - -```kotlin -class SecureBoundService : Service() { - - private val binder = object : IMyService.Stub() { - override fun getData(): String { - // Read-only operation guarded by manifest-level permission - return "Confidential Data" - } - - override fun modifyData(newData: String) { - // MUST use enforceCallingPermission or checkCallingPermission. - // NEVER use checkCallingOrSelfPermission or enforceCallingOrSelfPermission. - this@SecureBoundService.enforceCallingPermission( - "com.example.snippets.permission.WRITE_DATA", - "Caller lacks WRITE_DATA permission" - ) - updateInternalState(newData) - Log.d("SecureBoundService", "Data modified to: $newData with proper WRITE_DATA permission check") - } - } - - override fun onBind(intent: Intent?): IBinder = binder - - private fun updateInternalState(data: String) { - // Internal state update logic - } -} -``` - -
- -*** ** * ** *** - -## 6. Safe runtime permission workflow (RuntimePermissionsActivity.kt) - -When implementing runtime permission requests or handling permission denial -states, you must follow the instructions in the [Request runtime permissions -guide](references/android/training/permissions/requesting.md) and [Permissions overview](references/android/guide/topics/permissions/overview.md). Implement a 3-state flow without -caching states or creating infinite retry loops: - -1. **Check Granted State** : `ContextCompat.checkSelfPermission(this, - permission) == PackageManager.PERMISSION_GRANTED`. -2. **Check Rationale** : `shouldShowRequestPermissionRationale(permission)` to present educational context before requesting. -3. **Launch Contract** : `ActivityResultContracts.RequestPermission()`. -4. **Handle Permanent Denial** : In the callback, if permission was denied and `!shouldShowRequestPermissionRationale(permission)`, guide the user to `Settings.ACTION_APPLICATION_DETAILS_SETTINGS`. - -### Complete RuntimePermissionsActivity.kt implementation - - -```kotlin -class RuntimePermissionsActivity : ComponentActivity() { - - private val cameraLauncher = - registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> - if (isGranted) { - startCameraPreview() - } else { - if (!shouldShowRequestPermissionRationale(Manifest.permission.CAMERA)) { - // User selected 'Don't ask again' or permanently denied. - // Direct user to Application Details Settings. - val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { - data = Uri.fromParts("package", packageName, null) - } - startActivity(intent) - } else { - showSnackbar("Camera permission is required to preview camera feed.") - } - } - } - - fun requestCameraPermissionSafely() { - val permission = Manifest.permission.CAMERA - when { - ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED -> { - startCameraPreview() - } - shouldShowRequestPermissionRationale(permission) -> { - showSnackbar("Camera permission is needed to preview camera feed.") - cameraLauncher.launch(permission) - } - else -> { - cameraLauncher.launch(permission) - } - } - } - - private fun startCameraPreview() { - Log.d("RuntimePermissionsActivity", "Camera preview started") - } - - private fun showSnackbar(msg: String) { - Log.i("RuntimePermissionsActivity", msg) - } -} -``` - -
- -*** ** * ** *** - -## 7. Zero-permission alternatives and sequential flows - -When implementing photo or media selection, you must follow the instructions in -the [Photo Picker API guide](https://developer.android.com/training/data-storage/shared/photopicker) to avoid requesting broad storage permissions. - -- **Photo Picker** : Prefer `ActivityResultContracts.PickVisualMedia()` over requesting wide storage permissions (`READ_MEDIA_IMAGES`, `READ_EXTERNAL_STORAGE`). -- **Sequential Location Flow** : Request `ACCESS_FINE_LOCATION` and `ACCESS_COARSE_LOCATION` first. Never request `ACCESS_BACKGROUND_LOCATION` simultaneously. Only request background location in a separate step after foreground is granted. - -### Photo picker implementation - - -```kotlin -private val photoPickerLauncher = - registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> - if (uri != null) { - handleImageUri(uri) - } - } - -fun selectPhoto() { - photoPickerLauncher.launch( - PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) - ) -} -``` - -
- -### Sequential location request implementation - - -```kotlin -private val foregroundLocationLauncher = - registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> - val fineGranted = permissions[Manifest.permission.ACCESS_FINE_LOCATION] ?: false - val coarseGranted = permissions[Manifest.permission.ACCESS_COARSE_LOCATION] ?: false - if (fineGranted || coarseGranted) { - startForegroundLocationUpdates() - } - } - -fun requestForegroundLocation() { - foregroundLocationLauncher.launch( - arrayOf( - Manifest.permission.ACCESS_FINE_LOCATION, - Manifest.permission.ACCESS_COARSE_LOCATION - ) - ) -} - -// Background location requested only AFTER foreground is granted and user explicitly opts in: -private val backgroundLocationLauncher = - registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> - if (isGranted) { - startBackgroundTracking() - } - } -``` - -
- -*** ** * ** *** - -## 8. Stateless checks and error boundaries - -- **Never cache permission states** in in-memory variables (e.g. `cachedLocationPermissionGranted`). Check `ContextCompat.checkSelfPermission()` dynamically before each operation. -- **Wrap API calls in try-catch** : Catch `SecurityException` around permission-protected framework calls (e.g., location queries) to handle one-time permission revocation gracefully. - - -```kotlin -fun performLocationAccess() { - val fine = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) - val coarse = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) - if (fine != PackageManager.PERMISSION_GRANTED && coarse != PackageManager.PERMISSION_GRANTED) { - requestForegroundLocation() - return - } - - val provider = if (fine == PackageManager.PERMISSION_GRANTED) { - LocationManager.GPS_PROVIDER - } else { - LocationManager.NETWORK_PROVIDER - } - - try { - val location = locationManager.getLastKnownLocation(provider) - processLocation(location) - } catch (e: SecurityException) { - Log.e("LocationAccess", "Permission revoked at runtime", e) - } -} -``` - -
- -*** ** * ** *** - -## 9. Secure broadcasts (API 34+ share identity) - -Use explicit intents, required receiver permissions, or Android 14+ broadcast -options: - - -```kotlin -// Sender: Enforce permission and share identity -val intent = Intent("com.example.snippets.permission.ACTION_SECRET_UPDATE").apply { - setPackage("com.example.partner") // Explicit target -} -val isUdc = Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE -val options = if (isUdc) { - BroadcastOptions.makeBasic().apply { - setShareIdentityEnabled(true) - }.toBundle() -} else null - -sendBroadcast(intent, "com.example.snippets.permission.RECEIVE_SECRET_UPDATE", options) -``` - -
- - -```kotlin -// Receiver: Validate sender on Android 14+ -class ProtectedReceiver : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - if (intent.action == "com.example.snippets.permission.ACTION_SECRET_UPDATE") { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - val sender = sentFromPackage - if (sender != "com.example.trusted_sender") { - return // Reject unauthorized sender - } - } - processUpdate(intent) - } - } - - private fun processUpdate(intent: Intent) {} -} -``` - -
- -*Legacy compatibility (Pre-API 34)* : For apps targeting API 33 or lower, -attach an immutable `PendingIntent` extra (`FLAG_IMMUTABLE`) to the broadcast -and authenticate the sender using `pendingIntent.creatorPackage`. - -*** ** * ** *** - -## Quality checklist - -### DO - -- **MUST** declare `` (or `signature|knownSigner`) at the `` root for custom permissions and ContentProvider split permissions. -- **MUST** explicitly set `android:exported="false"` for all private components to remove the need for custom permission management. -- **MUST** authenticate callers cryptographically using `Binder.getCallingUid()` and certificate hashes (`PackageManager.hasSigningCertificate` or `getPackagesForUid`) rather than checking spoofable intent extras. -- **MUST** check `shouldShowRequestPermissionRationale()` before runtime prompts and during denial handling. -- **MUST** direct user to `Settings.ACTION_APPLICATION_DETAILS_SETTINGS` when permission is permanently denied. -- **MUST** enforce method-level checks with `enforceCallingPermission()` or `checkCallingPermission()` on modifying AIDL methods. -- **MUST** scope ContentProvider URI permissions to explicit sub-paths (e.g. `android:pathPrefix="/shared/"`) while retaining `android:grantUriPermissions="true"`. -- **MUST** catch `SecurityException` around protected API calls. -- **MUST** request foreground and background location permissions sequentially, never simultaneously. - -### NEVER - -- **NEVER** extract or verify package names from Intent extras (`intent.getStringExtra("calling_package")`, `intent.getStringExtra("sender")`, `intent.getStringExtra("package")`). Always authenticate using `Binder.getCallingUid()` and certificates. -- **NEVER** remediate caller spoofing by merely adding manifest permissions while keeping spoofable intent extra checks. -- **NEVER** use `checkCallingOrSelfPermission()` or `enforceCallingOrSelfPermission()` to guard external caller methods. Always use `enforceCallingPermission()` or `checkCallingPermission()`. -- **NEVER** use deprecated `signatureOrSystem`. -- **NEVER** declare or retain `` or `pathPrefix=""`. Always replace with a scoped sub-path. -- **NEVER** create infinite permission request loops on denial. -- **NEVER** request foreground and background location permissions in the same request. -- **NEVER** cache permission grant states in in-memory fields. diff --git a/security/android-permissions-security/references/android/guide/topics/manifest/permission-element.md b/security/android-permissions-security/references/android/guide/topics/manifest/permission-element.md deleted file mode 100644 index 061f023..0000000 --- a/security/android-permissions-security/references/android/guide/topics/manifest/permission-element.md +++ /dev/null @@ -1,111 +0,0 @@ -syntax: -: - - ```xml - - ``` - -contained in: -: `https://developer.android.com/guide/topics/manifest/manifest-element` - -description: -: Declares a security permission used to - limit access to specific components or features of this or other applications. - For more information about how permissions work, see the [Permissions](https://developer.android.com/guide/topics/manifest/manifest-intro#perms) section in the app manifest overview and [Security tips](https://developer.android.com/guide/topics/security/security). - -attributes: -: - - `android:description` - - : A user-readable description of the permission that is longer and more informative than the label. It might display, for example, to explain the permission to the user when the user is asked to grant the permission to another application.
- - - This attribute is set as a reference to a string resource. - Unlike the `label` attribute, it can't be a raw string. - - `android:icon` - : A reference to a drawable resource for an icon that represents the - permission. - - `android:label` - - : A user-readable name for the permission.
- - - As a convenience, the label can be directly set - as a raw string while you're developing the application. However, - when the application is ready to publish, set it as a - reference to a string resource, so that it can be localized like other - strings in the user interface. - - `android:name` - : The name to be used in - code to refer to the permission, such as in a - `https://developer.android.com/guide/topics/manifest/uses-permission-element` element or the - `permission` attributes of application components. - - - **Note:** The system doesn't let multiple packages declare - a permission with the same name unless all the packages are signed with the - same certificate. If a package declares a permission, the system doesn't permit - the user to install other packages with the same permission name, unless - those packages are signed with the same certificate as the first package. - - For this reason, Google recommends prefixing permissions with the app's package name, - using reverse-domain-style naming. - Follow this prefix with `.permission.` and then a description of the - capability that the permission represents in upper SNAKE_CASE. For example: - `com.example.myapp.permission.ENGAGE_HYPERSPACE`. - - Following this recommendation avoids naming collisions and helps clearly identify the owner - and intention of a custom permission. - - `android:permissionGroup` - : Assigns this permission to a group. The value of this attribute is - the name of the group, which is declared with the - `https://developer.android.com/guide/topics/manifest/permission-group-element` element in this - or another application. If this attribute isn't set, the permission - doesn't belong to a group. - - `android:protectionLevel` - - : - Characterizes the potential risk implied in the permission and - indicates the procedure for the system to follow when determining - whether to grant the permission to an application requesting it. - - - Each protection level consists of a base permission type and zero or more - flags. For example, the `"dangerous"` protection level has no - flags. In contrast, the protection level `"signature|privileged"` - is a combination of the `"signature"` base permission type and the - `"privileged"` flag. - - - The following table shows all base permission types. For a list of flags, see - `https://developer.android.com/reference/android/R.attr#protectionLevel`. - - | Value | Meaning | - |---|---| - | `"normal"` | The default value. A lower-risk permission that gives requesting applications access to isolated application-level features with minimal risk to other applications, the system, or the user. The system automatically grants this type of permission to a requesting application at installation, without asking for the user's explicit approval, though the user always has the option to review these permissions before installing. | - | `"dangerous"` | A higher-risk permission that gives a requesting application access to private user data or control over the device that can negatively impact the user. Because this type of permission introduces potential risk, the system might not automatically grant it to the requesting application. For example, any dangerous permissions requested by an application might be displayed to the user and require confirmation before proceeding, or some other approach might be taken to avoid the user automatically granting the use of such facilities. | - | `"signature"` | A permission that the system grants only if the requesting application is signed with the same certificate as the application that declared the permission. If the certificates match, the system automatically grants the permission without notifying the user or asking for the user's explicit approval. | - | `"knownSigner"` | A permission that the system grants only if the requesting application is signed with [an allowed certificate](https://developer.android.com/guide/topics/permissions/defining#grant-signature-permissions). If the requester's certificate is listed, the system automatically grants the permission without notifying the user or asking for the user's explicit approval. | - | `"signatureOrSystem"` | *Old synonym for `"signature|privileged"`. Deprecated in API level 23.* A permission that the system grants only to applications that are in a dedicated folder on the Android system image *or* that are signed with the same certificate as the application that declared the permission. Avoid using this option, as the `"signature"` protection level is sufficient for most needs and works regardless of where apps are installed. The `"signatureOrSystem"` permission is used for certain special situations where multiple vendors have applications built into a system image and need to share specific features explicitly because they are being built together. | - -introduced in: -: API level 1 - -see also: -: `https://developer.android.com/guide/topics/manifest/uses-permission-element` - - `https://developer.android.com/guide/topics/manifest/permission-tree-element` - - `https://developer.android.com/guide/topics/manifest/permission-group-element` \ No newline at end of file diff --git a/security/android-permissions-security/references/android/guide/topics/permissions/overview.md b/security/android-permissions-security/references/android/guide/topics/permissions/overview.md deleted file mode 100644 index 1f875cd..0000000 --- a/security/android-permissions-security/references/android/guide/topics/permissions/overview.md +++ /dev/null @@ -1,207 +0,0 @@ -App permissions help support user privacy by protecting access to the following: - -- **Restricted data**, such as system state and users' contact information -- **Restricted actions**, such as connecting to a paired device and recording audio - -[Video](https://www.youtube.com/watch?v=zCAx4WZ98rs) - -This page provides an overview to how Android permissions work, including a -high-level workflow for using permissions, descriptions of different types of -permissions, and some best practices for using permissions in your app. Other -pages explain how to [minimize your app's requests for -permissions](https://developer.android.com/training/permissions/evaluating), [declare -permissions](https://developer.android.com/training/permissions/declaring), [request runtime -permissions](https://developer.android.com/training/permissions/requesting), and [restrict how other apps can -interact](https://developer.android.com/training/permissions/restrict-interactions) with your app's -components. - -To view a complete list of Android app permissions, visit the [permissions API -reference page](https://developer.android.com/reference/android/Manifest.permission). - -To view some sample apps that demonstrate the permissions workflow, visit the -[Android permissions samples -repository](https://github.com/android/platform-samples/tree/main/samples/privacy/permissions) -on GitHub. - -## Workflow for using permissions - -If your app offers functionality that might require access to restricted data or -restricted actions, determine whether you can get the information or perform the -actions [without needing to declare -permissions](https://developer.android.com/training/permissions/evaluating). You can fulfill many use cases -in your app, such as taking photos, pausing media playback, and displaying -relevant ads, without needing to declare any permissions. - -If you decide that your app must access restricted data or perform restricted -actions to fulfill a use case, declare the appropriate permissions. Some -permissions, known as [install-time permissions](https://developer.android.com/guide/topics/permissions/overview#install-time), are -automatically granted when your app is installed. Other permissions, known as -[runtime permissions](https://developer.android.com/guide/topics/permissions/overview#runtime), require your app to go a step further and -request the permission at runtime. - -Figure 1 illustrates the workflow for using app permissions: -![](https://developer.android.com/static/images/training/permissions/workflow-overview.svg) **Figure 1.** High-level workflow for using permissions on Android. - -## Types of permissions - -Android categorizes permissions into different types, including install-time -permissions, runtime permissions, and special permissions. Each permission's -type indicates the scope of restricted data that your app can access, and the -scope of restricted actions that your app can perform, when the system grants -your app that permission. The protection level for each permission is based on -its type and is shown on the -[permissions API reference](https://developer.android.com/reference/android/Manifest.permission) page. - -### Install-time permissions - -![The left image shows a list of an app's install-time permissions. The -right image shows a pop-up dialog that contains 2 options: allow and deny.](https://developer.android.com/static/images/training/permissions/install-time.svg) **Figure 2.** The list of an app's install-time permissions, which appears in an app store. - -Install-time permissions give your app limited access to restricted data or -let your app perform restricted actions that minimally affect the -system or other apps. When you declare install-time permissions in your app, an -app store presents an install-time permission notice to the user when -they view an app's details page, as shown in figure 2. The -system automatically grants your app the permissions when the user installs your -app. - -Android includes several sub-types of install-time permissions, including -normal permissions and signature permissions. - -#### Normal permissions - -These permissions allow access to data and actions that extend beyond your app's -sandbox but present very little risk to the user's -privacy and the operation of other apps. - -The system assigns the `normal` protection level to normal permissions. - -#### Signature permissions - -The system grants a signature permission to an app only when the app is -signed by the same certificate as the app or the OS that defines the permission. - -Applications that implement privileged services, such as autofill or VPN -services, also make use of signature permissions. These apps require -service-binding signature permissions so that only the system can bind -to the services. - -> [!NOTE] -> **Note:** Some signature permissions aren't for use by third-party apps. - -The system assigns the `signature` protection level to signature permissions. - -### Runtime permissions - -![A pop-up dialog that contains 2 options: allow and deny.](https://developer.android.com/static/images/training/permissions/runtime.svg) **Figure 3.** The system permission prompt that appears when your app requests a runtime permission. - -Runtime permissions, also known as dangerous permissions, give your app -additional access to restricted data or let your app perform -restricted actions that more substantially affect the system and other apps. -Therefore, you need to [request runtime -permissions](https://developer.android.com/training/permissions/requesting) in your app before you can access -the restricted data or perform restricted actions. Don't assume -that these permissions have been previously granted---check them and, -if needed, request them before each access. - -When your app requests a -runtime permission, the system presents a runtime permission prompt, as shown in -figure 3. - -Many runtime permissions access *private user data*, a special type of -restricted data that includes potentially sensitive information. Examples of -private user data include location and contact information. - -The microphone and camera provide access to particularly sensitive information. -Therefore, the system helps you [explain why your app accesses this -information](https://developer.android.com/training/permissions/explaining-access). - -The system assigns the `dangerous` protection level to runtime permissions. - -### Special permissions - -Special permissions correspond to particular app operations. Only the platform -and OEMs can define special permissions. Additionally, the platform and OEMs -usually define special permissions when they want to protect access to -particularly powerful actions, such as drawing over other apps. - -The **Special app access** page in system settings contains a set of -user-toggleable operations. Many of these operations are implemented as special -permissions. - -Learn more about how to [request special -permissions](https://developer.android.com/training/permissions/requesting-special). - -The system assigns the `appop` protection level to special permissions. - -### Permission groups - -Permissions can belong to [permission groups](https://developer.android.com/guide/topics/manifest/permission-group-element.html). -Permission groups consist of a set of logically related permissions. For -example, permissions to send and receive SMS messages might belong to the same -group, as they both relate to the application's interaction with SMS. - -Permission groups help the system minimize the number of system dialogs that are -presented to the user when an app requests closely related permissions. When a -user is presented with a prompt to grant permissions for an application, -permissions belonging to the same group are presented in the same interface. -However, permissions can change groups without notice, so don't assume that a -particular permission is grouped with any other permission. - -## Best practices - -App permissions build on [system security -features](https://source.android.com/security/features) and help Android support -the following goals related to user privacy: - -- **Control:** The user has control over the data that they share with apps. -- **Transparency:** The user understands what data an app uses and why the app accesses this data. -- **Data minimization:** An app accesses and uses only the data that's required for a specific task or action that the user invokes. - -This section presents a set of core best practices for using permissions -effectively in your app. For more details on how you can work with permissions -on Android, visit the [app permissions best -practices](https://developer.android.com/training/permissions/usage-notes) page. - -### Request a minimal number of permissions - -When the user requests a particular action in your app, your app should request -only the permissions that it needs to complete that action. Depending on how you -are using the permissions, there might be an [alternative way to fulfill your -app's use case](https://developer.android.com/training/permissions/evaluating) without relying on access to -sensitive information. - -### Associate runtime permissions with specific actions - -Request permissions as late into the flow of your app's use cases as possible. -For example, if your app lets users send audio messages to others, wait -until the user has navigated to the messaging screen and has pressed the **Send -audio message** button. After the user presses the button, your app can then -request access to the microphone. - -### Consider your app's dependencies - -When you include a library, you also inherit its permission requirements. Be -aware of the permissions that each dependency requires and what those -permissions are used for. - -### Be transparent - -When you make a permissions request, be clear about what you're accessing, -why, and what functionalities are affected if permissions are denied, -so users can make informed decisions. - -### Make system accesses explicit - -When you access sensitive data or hardware, such as the camera or microphone, -provide a continuous indication in your app if the system doesn't already -[provide these indicators](https://developer.android.com/training/permissions/explaining-access#indicators). -This reminder helps users understand exactly when your app accesses restricted -data or performs restricted actions. - -## Permissions in system components - -Permissions aren't only for requesting system functionality. Your app's system -components can restrict which other apps can interact with your app, as -described on the page about how to [restrict interactions with other -apps](https://developer.android.com/training/permissions/restrict-interactions). \ No newline at end of file diff --git a/security/android-permissions-security/references/android/training/permissions/requesting.md b/security/android-permissions-security/references/android/training/permissions/requesting.md deleted file mode 100644 index 9e7b927..0000000 --- a/security/android-permissions-security/references/android/training/permissions/requesting.md +++ /dev/null @@ -1,677 +0,0 @@ -[Video](https://www.youtube.com/watch?v=x38dYUm7tCY) - -Every Android app runs in a limited-access sandbox. If your app needs to use -resources or information outside of its own sandbox, you can [declare a -runtime permission](https://developer.android.com/training/permissions/declaring) and set up a permission request that provides this -access. These steps are part of the [workflow for using permissions](https://developer.android.com/training/basics/permissions#workflow). - -If you declare any [dangerous permissions](https://developer.android.com/guide/topics/permissions/overview#dangerous_permissions), and if your app is installed on -a device that runs Android 6.0 (API level 23) or higher, you must request the -dangerous permissions at runtime by following the steps in this guide. - -If you don't declare any dangerous permissions, or if your app is installed on -a device that runs Android 5.1 (API level 22) or lower, the permissions are -automatically granted, and you don't need to complete any of the remaining -steps on this page. - -> [!NOTE] -> **Note:** Some permissions guard access to system resources that are particularly sensitive or are not directly related to user privacy. There's a [different -> process for requesting these special permissions](https://developer.android.com/training/permissions/requesting-special). - -## Basic principles - -The basic principles for requesting permissions at runtime are as follows: - -- Ask for a permission in context, when the user starts to interact with the feature that requires it. -- Don't block the user. Always provide the option to cancel an educational UI flow, such as a flow that explains the rationale for requesting permissions. -- If the user denies or revokes a permission that a feature needs, gracefully degrade your app so that the user can continue using your app, possibly by disabling the feature that requires the permission. -- Don't assume any system behavior. For example, don't assume that permissions appear in the same *permission group*. A permission group merely helps the system minimize the number of system dialogs that are presented to the user when an app requests closely related permissions. - -## Workflow for requesting permissions - -Before you declare and request runtime permissions in your app, [evaluate -whether your app needs to do so](https://developer.android.com/training/permissions/evaluating). You can fulfill many use cases in your -app, such as taking photos, pausing media playback, and displaying relevant -ads, without needing to declare any permissions. - -If you conclude that your app needs to declare and request runtime permissions, -complete these steps: - -1. [**Declare** the permissions](https://developer.android.com/training/permissions/declaring) your app might need to request in your app's manifest file. -2. **Design** your app's user experience (UX) so that specific actions in your app are associated with specific runtime permissions. Let users know which actions might require them to grant permission for your app to access private user data. -3. [**Wait** for the user](https://developer.android.com/training/permissions/requesting#principles) to invoke the task or action in your app that requires access to specific private user data. At that time, your app can request the runtime permission that is required for accessing that data. -4. [**Check** whether the user has already granted](https://developer.android.com/training/permissions/declaring) - - runtime permission your app requires. If so, your app can access the private - user data. If not, continue to the next step. - - You must check whether you have a permission every time you perform an - operation that requires that permission. -5. [**Check** whether your app should show a rationale](https://developer.android.com/training/permissions/requesting#explain) to the user, - explaining why your app needs the user to grant a particular runtime - permission. If the system determines that your app shouldn't show a - rationale, continue to the next step directly, without showing a UI element. - - If the system determines that your app should show a rationale, however, - present the rationale to the user in a UI element. In this rationale, - clearly explain what data your app is trying to access and what benefits the - app can provide to the user if they grant the runtime permission. After the - user acknowledges the rationale, continue to the next step. -6. [**Request** the runtime permission](https://developer.android.com/training/permissions/requesting#request-permission) your app requires - to access the private user data. The system displays a runtime permission - prompt, such as the one shown on the [permissions overview page](https://developer.android.com/guide/topics/permissions/overview#fig-runtime). - -7. **Check** the user's response---whether they chose to grant or deny the - runtime permission. - -8. If the user granted the permission to your app, you can **access** the - private user data. If the user denied the permission instead, [gracefully - degrade your app experience](https://developer.android.com/training/permissions/requesting#handle-denial) so that it provides - functionality to the user without the information that's protected by that - permission. - -Figure 1 illustrates the workflow and set of decisions associated with this -process: -![A flowchart illustrating the decision process for requesting runtime permissions, starting with an app action, checking if permission is granted, checking if rationale is needed, requesting permission, and handling the user's response.](https://developer.android.com/static/images/training/permissions/workflow-runtime.svg) **Figure 1.** Diagram that shows the workflow for declaring and requesting runtime permissions on Android. - -## Determine whether your app was already granted the permission - -To check whether the user already granted your app a particular permission, pass -that permission into the [`ContextCompat.checkSelfPermission()`](https://developer.android.com/reference/androidx/core/content/ContextCompat#checkSelfPermission(android.content.Context,%20java.lang.String)) method. -This method returns either [`PERMISSION_GRANTED`](https://developer.android.com/reference/android/content/pm/PackageManager#PERMISSION_GRANTED) or -[`PERMISSION_DENIED`](https://developer.android.com/reference/android/content/pm/PackageManager#PERMISSION_DENIED), depending on whether your app has the permission. - -## Explain why your app needs the permission - -The permissions dialog shown by the system when you call -[`requestPermissions()`](https://developer.android.com/reference/androidx/core/app/ActivityCompat#requestPermissions(android.app.Activity,%20java.lang.String%5B%5D,%20int)) says what permission your app wants, but doesn't -say why. In some cases, the user might find that puzzling. It is a good idea to -explain to the user why your app wants the permissions before you call -`requestPermissions()`. - -Research shows that users are much more comfortable with permissions requests if -they know why the app needs them, such as whether the permission is needed to -support a core feature of the app or for advertising. As a result, if you are -only using a fraction of the API calls that fall under a permission group, -explicitly list which of those permissions you are using and why. For example, -if you are only using coarse location, let the user know this in your app -description or in help articles about your app. - -Under certain conditions, it is also helpful to let users know about sensitive -data access in real time. For example, if you are accessing the camera or -microphone, consider letting the user know by using a notification icon -somewhere in your app, or in the notification tray (if the application is -running in the background), so it doesn't seem like you are collecting data -surreptitiously. - -> [!NOTE] -> **Note:** Starting in Android 12 (API level 31), [privacy indicators](https://developer.android.com/training/permissions/explaining-access#indicators) notify the user whenever applications access the microphone or camera. - -If you need to request a permission for a feature to work, but the reason is -not clear to the user, find a way to explain why you need sensitive permissions. - -If the `ContextCompat.checkSelfPermission()` method returns `PERMISSION_DENIED`, -call [`shouldShowRequestPermissionRationale()`](https://developer.android.com/reference/androidx/core/app/ActivityCompat#shouldShowRequestPermissionRationale(android.app.Activity,%20java.lang.String)). If this method returns -`true`, show an educational UI to the user. In this UI, describe why -the feature that the user wants to enable needs a particular permission. - -Additionally, if your app requests a permission related to location, microphone, -or camera, consider [explaining why your app needs access](https://developer.android.com/training/permissions/explaining-access) to this -information. - -## Request permissions - -After the user views an educational UI, or the return value of -`shouldShowRequestPermissionRationale()` indicates that you don't need to show -an educational UI, request the permission. Users see a system permission -dialog, where they can choose whether to grant a particular permission to your -app. - -To do this, use the [`RequestPermission`](https://developer.android.com/reference/androidx/activity/result/contract/ActivityResultContracts.RequestPermission) contract, included in an AndroidX -library, where you [allow the system to manage the permission request code](https://developer.android.com/training/permissions/requesting#allow-system-manage-request-code) -for you. Because using the `RequestPermission` contract simplifies your logic, -it is the recommended solution when possible. However, if needed you can also -[manage a request code yourself](https://developer.android.com/training/permissions/requesting#manage-request-code-yourself) as part of the permission request and -include this request code in your permission callback logic. - -### Allow the system to manage the permission request code - -To allow the system to manage the request code that is associated with a -permissions request, add dependencies on the following libraries in your -module's `build.gradle` file: - -- [`androidx.activity`](https://developer.android.com/jetpack/androidx/releases/activity#declaring_dependencies), version 1.2.0 or later -- [`androidx.fragment`](https://developer.android.com/jetpack/androidx/releases/fragment#declaring_dependencies), version 1.3.0 or later - -You can then use one of the following classes: - -- To request a single permission, use [`RequestPermission`](https://developer.android.com/reference/androidx/activity/result/contract/ActivityResultContracts.RequestPermission). -- To request multiple permissions at the same time, use [`RequestMultiplePermissions`](https://developer.android.com/reference/androidx/activity/result/contract/ActivityResultContracts.RequestMultiplePermissions). - -The following steps show how to use the `RequestPermission` contract. The -process is nearly the same for the `RequestMultiplePermissions` contract. - -1. In your activity or fragment's initialization logic, pass in an - implementation of [`ActivityResultCallback`](https://developer.android.com/reference/androidx/activity/result/ActivityResultCallback) into a call to - [`registerForActivityResult()`](https://developer.android.com/reference/androidx/activity/result/ActivityResultCaller#registerForActivityResult(androidx.activity.result.contract.ActivityResultContract%3CI,%20O%3E,%20androidx.activity.result.ActivityResultCallback%3CO%3E)). The `ActivityResultCallback` defines - how your app handles the user's response to the permission request. - - Keep a reference to the return value of `registerForActivityResult()`, which - is of type [`ActivityResultLauncher`](https://developer.android.com/reference/androidx/activity/result/ActivityResultLauncher). -2. To display the system permissions dialog when necessary, call the - [`launch()`](https://developer.android.com/reference/androidx/activity/result/ActivityResultLauncher#launch(I)) method on the instance of `ActivityResultLauncher` that - you saved in the previous step. - - After `launch()` is called, the system permissions dialog appears. When the - user makes a choice, the system asynchronously invokes your implementation - of `ActivityResultCallback`, which you defined in the previous step. - **Note:** Your app *cannot* customize the dialog that appears - when you call `launch()`. To provide more information or - context to the user, change your app's UI so that it's easier for users to - understand why a feature in your app needs a particular permission. For - example, you might change the text in the button that enables the - feature. - - Also, the text in the system permission dialog references the - [permission - group](https://developer.android.com/guide/topics/permissions/overview#group) associated with the permission that you requested. This - permission grouping is designed for system ease-of-use, and your app - shouldn't rely on permissions being within or outside of a specific - permission group. - -The following code snippet shows how to handle the permissions response: - -### Kotlin - -```kotlin -when { - ContextCompat.checkSelfPermission( - CONTEXT, - Manifest.permission.REQUESTED_PERMISSION - ) == PackageManager.PERMISSION_GRANTED -> { - // You can use the API that requires the permission. - performAction(...) - } - ActivityCompat.shouldShowRequestPermissionRationale( - this, Manifest.permission.REQUESTED_PERMISSION) -> { - // In an educational UI, explain to the user why your app requires this - // permission for a specific feature to behave as expected, and what - // features are disabled if it's declined. In this UI, include a - // "cancel" or "no thanks" button that lets the user continue - // using your app without granting the permission. - showInContextUI(...) - } - else -> { - // You can directly ask for the permission. - requestPermissions(CONTEXT, - arrayOf(Manifest.permission.REQUESTED_PERMISSION), - REQUEST_CODE) - } -} -``` - -### Java - -```java -if (ContextCompat.checkSelfPermission( - CONTEXT, Manifest.permission.REQUESTED_PERMISSION) == - PackageManager.PERMISSION_GRANTED) { - // You can use the API that requires the permission. - performAction(...); -} else if (ActivityCompat.shouldShowRequestPermissionRationale( - this, Manifest.permission.REQUESTED_PERMISSION)) { - // In an educational UI, explain to the user why your app requires this - // permission for a specific feature to behave as expected, and what - // features are disabled if it's declined. In this UI, include a - // "cancel" or "no thanks" button that lets the user continue - // using your app without granting the permission. - showInContextUI(...); -} else { - // You can directly ask for the permission. - requestPermissions(CONTEXT, - new String[] { Manifest.permission.REQUESTED_PERMISSION }, - REQUEST_CODE); -} -``` - -And this code snippet demonstrates the recommended process to check for -a permission and to request a permission from the user when necessary: - -### Kotlin - -```kotlin -when { - ContextCompat.checkSelfPermission( - CONTEXT, - Manifest.permission.REQUESTED_PERMISSION - ) == PackageManager.PERMISSION_GRANTED -> { - // You can use the API that requires the permission. - } - ActivityCompat.shouldShowRequestPermissionRationale( - this, Manifest.permission.REQUESTED_PERMISSION) -> { - // In an educational UI, explain to the user why your app requires this - // permission for a specific feature to behave as expected, and what - // features are disabled if it's declined. In this UI, include a - // "cancel" or "no thanks" button that lets the user continue - // using your app without granting the permission. - showInContextUI(...) - } - else -> { - // You can directly ask for the permission. - // The registered ActivityResultCallback gets the result of this request. - requestPermissionLauncher.launch( - Manifest.permission.REQUESTED_PERMISSION) - } -} -``` - -### Java - -```java -if (ContextCompat.checkSelfPermission( - CONTEXT, Manifest.permission.REQUESTED_PERMISSION) == - PackageManager.PERMISSION_GRANTED) { - // You can use the API that requires the permission. - performAction(...); -} else if (ActivityCompat.shouldShowRequestPermissionRationale( - this, Manifest.permission.REQUESTED_PERMISSION)) { - // In an educational UI, explain to the user why your app requires this - // permission for a specific feature to behave as expected, and what - // features are disabled if it's declined. In this UI, include a - // "cancel" or "no thanks" button that lets the user continue - // using your app without granting the permission. - showInContextUI(...); -} else { - // You can directly ask for the permission. - // The registered ActivityResultCallback gets the result of this request. - requestPermissionLauncher.launch( - Manifest.permission.REQUESTED_PERMISSION); -} -``` - -### Manage the permission request code yourself - -As an alternative to [allowing the system to manage the permission request -code](https://developer.android.com/training/permissions/requesting#allow-system-manage-request-code), you can manage the permission request code yourself. To do so, -include the request code in a call to [`requestPermissions()`](https://developer.android.com/reference/androidx/core/app/ActivityCompat#requestPermissions(android.app.Activity,%20java.lang.String%5B%5D,%20int)). - -> [!NOTE] -> **Note:** Your app cannot customize the dialog that appears when you call `requestPermissions()`. The text in the system permission dialog references a [permission group](https://developer.android.com/guide/topics/permissions/overview#perm-groups), but this permission grouping is designed for system ease-of-use. Your app must not rely on permissions being within or outside of a specific permission group. - -The following code snippet demonstrates how to request a permission using a -request code: - -### Kotlin - -```kotlin -when { - ContextCompat.checkSelfPermission( - CONTEXT, - Manifest.permission.REQUESTED_PERMISSION - ) == PackageManager.PERMISSION_GRANTED -> { - // You can use the API that requires the permission. - performAction(...) - } - ActivityCompat.shouldShowRequestPermissionRationale( - this, Manifest.permission.REQUESTED_PERMISSION) -> { - // In an educational UI, explain to the user why your app requires this - // permission for a specific feature to behave as expected, and what - // features are disabled if it's declined. In this UI, include a - // "cancel" or "no thanks" button that lets the user continue - // using your app without granting the permission. - showInContextUI(...) - } - else -> { - // You can directly ask for the permission. - requestPermissions(CONTEXT, - arrayOf(Manifest.permission.REQUESTED_PERMISSION), - REQUEST_CODE) - } -} -``` - -### Java - -```java -if (ContextCompat.checkSelfPermission( - CONTEXT, Manifest.permission.REQUESTED_PERMISSION) == - PackageManager.PERMISSION_GRANTED) { - // You can use the API that requires the permission. - performAction(...); -} else if (ActivityCompat.shouldShowRequestPermissionRationale( - this, Manifest.permission.REQUESTED_PERMISSION)) { - // In an educational UI, explain to the user why your app requires this - // permission for a specific feature to behave as expected, and what - // features are disabled if it's declined. In this UI, include a - // "cancel" or "no thanks" button that lets the user continue - // using your app without granting the permission. - showInContextUI(...); -} else { - // You can directly ask for the permission. - requestPermissions(CONTEXT, - new String[] { Manifest.permission.REQUESTED_PERMISSION }, - REQUEST_CODE); -} -``` - -After the user responds to the system permissions dialog, the system then -invokes your app's implementation of [`onRequestPermissionsResult()`](https://developer.android.com/reference/androidx/core/app/ActivityCompat.OnRequestPermissionsResultCallback#onRequestPermissionsResult(int,%20java.lang.String%5B%5D,%20int%5B%5D)). The -system passes in the user response to the permission dialog, as well as the -request code that you defined, as shown in the following code snippet: - -### Kotlin - -```kotlin -override fun onRequestPermissionsResult(requestCode: Int, - permissions: Array, grantResults: IntArray) { - when (requestCode) { - PERMISSION_REQUEST_CODE -> { - // If request is cancelled, the result arrays are empty. - if ((grantResults.isNotEmpty() && - grantResults[0] == PackageManager.PERMISSION_GRANTED)) { - // Permission is granted. Continue the action or workflow - // in your app. - } else { - // Explain to the user that the feature is unavailable because - // the feature requires a permission that the user has denied. - // At the same time, respect the user's decision. Don't link to - // system settings in an effort to convince the user to change - // their decision. - } - return - } - - // Add other 'when' lines to check for other - // permissions this app might request. - else -> { - // Ignore all other requests. - } - } -} -``` - -### Java - -```java -@Override -public void onRequestPermissionsResult(int requestCode, String[] permissions, - int[] grantResults) { - switch (requestCode) { - case PERMISSION_REQUEST_CODE: - // If request is cancelled, the result arrays are empty. - if (grantResults.length > 0 && - grantResults[0] == PackageManager.PERMISSION_GRANTED) { - // Permission is granted. Continue the action or workflow - // in your app. - } else { - // Explain to the user that the feature is unavailable because - // the feature requires a permission that the user has denied. - // At the same time, respect the user's decision. Don't link to - // system settings in an effort to convince the user to change - // their decision. - } - return; - } - // Other 'case' lines to check for other - // permissions this app might request. - } -} -``` - -### Request location permissions - -When you request location permissions, follow the same best practices as -for any other [runtime permission](https://developer.android.com/training/permissions/requesting). One important difference when it comes -to location permissions is that the system includes multiple permissions related -to location. Which permissions you request, and how you request them, depend on -the location requirements for your app's use case. - -#### Foreground location - -If your app contains a feature that shares or receives location information -only once, or for a defined amount of time, then that feature requires -foreground location access. Some examples include the following: - -- Within a navigation app, a feature lets users get turn-by-turn directions. -- Within a messaging app, a feature lets users share their current location with another user. - -The system considers your app to be using foreground location if a feature of -your app accesses the device's current location in one of the following -situations: - -- An activity that belongs to your app is visible. -- Your app is running a foreground service. When a foreground service is - running, the system raises user awareness by showing a persistent - notification. Your app retains access when it is placed in the background, - such as when the user presses the **Home** button on their device or turns - their device's display off. - - On Android 10 (API level 29) and higher, you must declare a [foreground - service type](https://developer.android.com/guide/topics/manifest/service-element#foregroundservicetype) of `location`, as shown in the following code snippet. On - earlier versions of Android, we recommend that you declare this foreground - service type. - - ```xml - - - - - - ``` - -You declare a need for foreground location when your app requests either the -[`ACCESS_COARSE_LOCATION`](https://developer.android.com/reference/android/Manifest.permission#ACCESS_COARSE_LOCATION) permission or the -[`ACCESS_FINE_LOCATION`](https://developer.android.com/reference/android/Manifest.permission#ACCESS_FINE_LOCATION) permission, as shown in the following snippet: - -```xml - - - - - - - -``` - -#### Background location - -An app requires background location access if a feature within the app -constantly shares location with other users or uses the [Geofencing API](https://developer.android.com/training/location/geofencing). -Several examples include the following: - -- Within a family location sharing app, a feature lets users continuously share location with family members. -- Within an IoT app, a feature lets users configure their home devices such that they turn off when the user leaves their home and turn back on when the user returns home. - -The system considers your app to be using background location if it accesses -the device's current location in any situation other than the ones described in -the [foreground location](https://developer.android.com/training/permissions/requesting#foreground-location) section. The background -location accuracy is the same as the [foreground location accuracy](https://developer.android.com/training/location/permissions#accuracy), which -depends on the location permissions that your app declares. - -On Android 10 (API level 29) and higher, you must declare the -[`ACCESS_BACKGROUND_LOCATION`](https://developer.android.com/reference/android/Manifest.permission#ACCESS_BACKGROUND_LOCATION) permission in your app's manifest to request -background location access at runtime. On earlier versions of Android, when your -app receives foreground location access, it automatically receives background -location access as well. - - - - - - -> [!NOTE] -> **Note:** The Google Play Store has a [location policy](https://support.google.com/googleplay/android-developer/answer/9799150) concerning device location, restricting background location access to apps that need it for their core functionality and meet related policy requirements. - -## Handle permission denial - -If the user denies a permission request, your app must help them understand the implications of denying the permission. In particular, your app must make users aware of the features that don't work because of the missing permission. When you do so, keep the following best practices in mind: - -- **Minimize functionality loss.** Users should be able to access the app to whatever extent is possible without the requested permissions. If functionality works at least to some extent with the data available, even if it is degraded compared to what would have been possible with the permission, keeping it on is the best option. -- **Guide the user's attention.** Highlight a specific part of your app's UI where there is limited functionality because your app doesn't have the necessary permission. Examples of what you can do include the following: - - - Show a message where the feature's results or data would have appeared. - - Display a different button that contains an error icon and color. -- **Be specific.** Don't display a generic message. Instead, make clear which features are unavailable because your app doesn't have the necessary permission. - -- **Don't block the user interface.** In other words, don't display a full-screen warning message that prevents users from continuing to use your app at all. - -- **Accept the user's preference:** While it is okay to communicate to users how their functionality is being impacted by lack of access to the requested permissions, they should not feel pressured to change their mind. Letting a user know that they aren't getting full feature access when it is appropriate (such as when attempting to use a part of the app blocked without the permission) is important, but persistent nagging to reconsider is not respectful of their choice. - -Following these guidelines can help ensure that users feel empowered to manage access to their private data in a way that aligns with their values. Failing to gracefully accept highly visible user privacy decisions damages the trust and reputation not only of your app, but of the ecosystem as a whole. - -> [!TIP] -> **Tip:** We recommend that your app encourages the best user experience possible, even after permission denials. For example, if microphone access is denied, you must still promote full usability of text functionality. - -Additionally, if the user taps Deny for a specific permission more than once during your app's lifetime of installation on a device, the user will no longer see the system permissions dialog if your app requests that permission again. The user's action implies "don't ask again," and is considered a permanent denial. It is very important to only prompt users for permissions when they need access to a specific feature; otherwise, you might inadvertently lose the ability to re-request permissions. -In certain situations, the permission might be denied automatically, without the user taking any action. (A permission might be granted automatically as well.) It is important to not assume anything about automatic behavior. Each time your app needs to access functionality that requires a permission, check that your app is still granted that permission. -To provide the best user experience when asking for app permissions, also see [App permissions best practices](https://developer.android.com/training/permissions/usage-notes). - -### Inspect denial status when testing and debugging - -To identify whether an app has been permanently denied permissions (for -debugging and testing purposes), use the following command: - -``` -adb shell dumpsys package PACKAGE_NAME -``` - -Where PACKAGE_NAME is the name of the package to inspect. - -The output of the command contains sections that look like this: - -``` -... -runtime permissions: - android.permission.POST_NOTIFICATIONS: granted=false, flags=[ USER_SENSITIVE_WHEN_GRANTED|USER_SENSITIVE_WHEN_DENIED] - android.permission.ACCESS_FINE_LOCATION: granted=false, flags=[ USER_SET|USER_FIXED|USER_SENSITIVE_WHEN_GRANTED|USER_SENSITIVE_WHEN_DENIED] - android.permission.BLUETOOTH_CONNECT: granted=false, flags=[ USER_SENSITIVE_WHEN_GRANTED|USER_SENSITIVE_WHEN_DENIED] -... -``` - -Permissions that have been denied once by the user are flagged by `USER_SET`. -Permissions that have been denied permanently by selecting **Deny** twice are -flagged by `USER_FIXED`. - -To make sure that testers see the request dialog during testing, reset these -flags when you're done debugging your app. To do this, use the command: - -``` -adb shell pm clear-permission-flags PACKAGE_NAME PERMISSION_NAME user-set user-fixed -``` - -PERMISSION_NAME is the name of the permission you want to -reset. - -`PERMISSION_NAME` is the name of the permission you want to reset. - -To view a complete list of Android app permissions, visit the [permissions API -reference page](https://developer.android.com/reference/android/Manifest.permission#constants_1). - -## One-time permissions - -![The option called 'Only this time' is the second of three buttons in -the dialog.](https://developer.android.com/static/images/training/permissions/one-time-prompt.svg) **Figure 2.** System dialog that appears when an app requests a one-time permission. - -Starting in Android 11 (API level 30), whenever your app requests a permission -related to location, microphone, or camera, the user-facing permissions dialog -contains an option called **Only this time** , as shown in figure 2. If the user -selects this option in the dialog, your app is granted a temporary *one-time -permission*. - -Your app can then access the related data for a period of time that depends on -your app's behavior and the user's actions: - -- While your app's activity is visible, your app can access the data. -- If the user sends your app to the background, your app can continue to access the data for a short period of time. -- If you launch a foreground service while the activity is visible, and the user then moves your app to the background, your app can continue to access the data until the foreground service stops. - -### App process terminates when permission revoked - -If the user revokes the one-time permission, such as in system settings, your -app cannot access the data, regardless of whether you launched a foreground -service. As with any permission, if the user revokes your app's one-time -permission, your app's process terminates. - -When the user next opens your app and a feature in your app requests access to -location, microphone, or camera, the user is prompted for the permission again. - -> [!NOTE] -> **Note:** If your app already follows [best practices](https://developer.android.com/training/permissions/usage-notes) when it requests runtime permissions, you don't need to add or change any logic in your app to support one-time permissions. - -## Reset unused permissions - -Android provides several ways to reset unused runtime permissions to their -default, denied state: - -- An API where you can proactively [remove your app's access](https://developer.android.com/training/permissions/requesting#remove-app-access) to an unused runtime permission. -- A system mechanism that automatically [resets the permissions of unused - apps](https://developer.android.com/training/permissions/requesting#auto-reset-permissions-unused-apps). - -### Remove app access - -On Android 13 (API level 33) and higher, you can remove your app's access to -runtime permissions that your app no longer requires. When you update your app, -perform this step so that users are more likely to understand why your app -continues to request specific permissions. This knowledge helps build user -trust in your app. - -To remove access to a runtime permission, pass the name of that permission into -[`revokeSelfPermissionOnKill()`](https://developer.android.com/reference/android/content/Context#revokeSelfPermissionOnKill(java.lang.String)). To remove access to a group of runtime -permissions at the same time, pass a collection of permission names into -[`revokeSelfPermissionsOnKill()`](https://developer.android.com/reference/android/content/Context#revokeSelfPermissionsOnKill(java.util.Collection%3Cjava.lang.String%3E)). The permission removal process happens -asynchronously and kills all processes associated with your app's UID. - -> [!NOTE] -> **Note:** For system settings to show that your app doesn't access data in a particular [permission group](https://developer.android.com/reference/android/Manifest.permission_group), you must remove access to **all** permissions in that permission group. In this case, it can be helpful to call `revokeSelfPermissionsOnKill()` and pass in multiple permissions within the permission group. - -For the system to remove your app's access to the permissions, all processes -tied to your app must be killed. When you call the API, the system determines -when it is safe to kill these processes. Usually, the system waits until your -app spends an extended period of time running in the background instead of the -foreground. - -To inform the user that your app no longer requires access to specific runtime -permissions, show a dialog the next time the user launches your app. This -dialog can include the list of permissions. - -### Auto-reset permissions of unused apps - -If your app targets Android 11 (API level 30) or higher and is not used for a -few months, the system protects user data by automatically resetting the -sensitive runtime permissions that the user had granted your app. Learn more in -the guide about [app hibernation](https://developer.android.com/topic/performance/app-hibernation). - -## Request to become the default handler if necessary - -Some apps depend on access to sensitive user information related to call logs -and SMS messages. If you want to request the permissions specific to call logs -and SMS messages and publish your app to the Play Store, you must prompt the -user to set your app as the *default handler* for a core system function before -requesting these runtime permissions. - -For more information on default handlers, including guidance on showing a -default handler prompt to users, [see the guide about permissions used only in -default handlers](https://developer.android.com/guide/topics/permissions/default-handlers). - -## Grant all runtime permissions for testing purposes - -To grant all runtime permissions automatically when you install an app on an -emulator or test device, use the `-g` option for the `adb shell install` -command, as demonstrated in the following code snippet: - - adb shell install -g PATH_TO_APK_FILE - -## Additional resources - -For additional information about permissions, read these articles: - -- [Permissions overview](https://developer.android.com/guide/topics/permissions/overview) -- [App permissions best practices](https://developer.android.com/training/permissions/usage-notes) - -To learn more about requesting permissions, review the [permissions samples](https://github.com/android/platform-samples/tree/main/samples/privacy/permissions). - -You can also complete this [codelab that demonstrates privacy best -practices](https://developer.android.com/codelabs/android-privacy-codelab). \ No newline at end of file diff --git a/system/edge-to-edge/SKILL.md b/system/edge-to-edge/SKILL.md index 77cc0d6..a4a4207 100644 --- a/system/edge-to-edge/SKILL.md +++ b/system/edge-to-edge/SKILL.md @@ -7,7 +7,6 @@ description: Use this skill to migrate your Jetpack Compose app to add adaptive license: Complete terms in LICENSE.txt metadata: author: Google LLC - last-updated: '2026-08-24' keywords: - android - compose @@ -185,7 +184,7 @@ applied twice, once with innerPadding, which contains IME insets from the passed ```kotlin // WRONG -Scaffold(contentWindowInsets = WindowInsets.safeDrawing) { innerPadding -> +Scaffold( contentWindowInsets = WindowInsets.safeDrawing ) { innerPadding -> Column( modifier = Modifier .padding(innerPadding) @@ -423,4 +422,4 @@ Dialog( - \[ \] 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. +- \[\] Does the project build? Run `./gradlew build` to be sure. \ No newline at end of file diff --git a/testing/testing-setup/SKILL.md b/testing/testing-setup/SKILL.md deleted file mode 100644 index 128f412..0000000 --- a/testing/testing-setup/SKILL.md +++ /dev/null @@ -1,209 +0,0 @@ ---- -name: testing-setup -description: Analyze and create a testing strategy for Android apps - install testing - libraries, set up test infrastructure, create harnesses for unit tests, UI tests, - screenshot tests, and end-to-end tests. -license: Complete terms in LICENSE.txt -metadata: - author: Google LLC - last-updated: '2026-09-30' - keywords: - - android - - testing - - ui tests - - screenshot tests - - coverage ---- - -## Step 1: analyze the current testing setup - -To understand the testing setup of an existing project, look for these -dependencies in the libs.versions.toml file, or build files: - -1. Dependency Injection framework used. For example, Hilt, Koin, Anvil, or vanilla Dagger. -2. Unit (local) testing framework this project uses. For example, JUnit4 or JUnit5. -3. Mocking framework (if any) used for unit tests, and for instrumented and UI tests. For example, Mockito or Mockk. -4. Robolectric. Identify which of these three uses apply: - 1. Used in unit tests to have fakes for platform entities - 2. To run behavior UI tests without a device or emulator. For example, used to run Espresso or Compose tests. - 3. To do screenshot testing with Roborazzi -5. Is the app 100% Compose, Views or hybrid? -6. Behavior UI tests: - 1. Compose Tests (`androidx.compose.ui:ui-test-*`) - 2. Espresso Tests for Views. Check for wrappers such as Kaspresso. Dependencies: `androidx.test.espresso:espresso-core`, `androidx.test:runner`, `androidx.test:rules`. -7. Screenshot tests. Identify which of these types the project uses: - 1. Instrumented (device-based). For example, using Dropshots. - 2. Based on Robolectric, so they run locally. For example, using Roborazzi. - 3. Based on LayoutLib, so they run locally. For example, Paparazzi, or the Compose Preview Screenshot Testing tool. -8. End to end tests (also known as Release Candidate tests) always run on device and use high-level frameworks such as UIAutomator, Appium, or Robotium. -9. Generate a Markdown report with the analysis - -## Step 2: Set up Dependency Injection frameworks for testing - -If there is no Dependency Injection framework, install one: if it's a -multiplatform application, ask the user whether they want to install Koin, or -kotlin-inject. If it's not multiplatform, install Hilt. - -Install the testing dependencies (for example `com.google.dagger:hilt-compiler` -that should be applied with a `kspAndroidTest` configuration). - -> [!IMPORTANT] -> **Important:** Always consult the documentation of the applicable framework to learn about testing (for example: [Hilt testing guide](references/android/training/dependency-injection/hilt-testing.md), [Koin Instrumented tests](https://insert-koin.io/docs/reference/koin-android/instrumented-testing)). - -For instrumented tests, create and configure (by adding -`testInstrumentationRunner` to the build gradle files) a new test runner and -apply the testing rules required by the framework (for example in Hilt, annotate -your test classes with `@HiltAndroidTest` and apply the `HiltAndroidRule`). -Other frameworks use other mechanisms, consult their documentation. - -## Step 3: Install frameworks - -Unless otherwise specified, respect the current stack of testing frameworks. - -If there are no testing frameworks, and the user didn't specify any preference, -install the following: - -- JUnit4 for local and instrumented tests -- Jacoco for test coverage -- For UI tests: if the project has views, Espresso. If it's fully Compose, use the Compose Testing APIs. -- Robolectric to run UI Tests -- Compose Preview Screenshot Testing tool for screenshot tests. Check the - project's AGP version first, then pick the matching setup and follow it - strictly: - - - **AGP 9.5.0-alpha03 or higher (recommended):** configure an AGP test suite using `testOptions { screenshotTests.create(...) }`, as described in [Screenshot testing with test suites](references/android/studio/preview/compose-screenshot-testing-with-testsuites.md). - - **Lower AGP versions:** use the [legacy standalone plugin setup](references/android/studio/preview/compose-screenshot-testing.md) with `com.android.compose.screenshot`, and tell the user it's deprecated. Don't upgrade AGP as part of setting up screenshot tests. Mention that test suites are available on 9.5.0-alpha03 or higher, and leave the upgrade as a separate decision. - - Don't combine both setups in the same module. If the project already uses - the standalone plugin and its AGP version supports test suites, offer to - migrate it using the tasks and paths mapping table in - [Screenshot testing with test suites](references/android/studio/preview/compose-screenshot-testing-with-testsuites.md). -- Dropshots for device screenshot tests - -- If a mocking framework is necessary, install Mockk (`io.mockk:mockk`). Do - not install it unless it is clearly necessary. - -If instrumented screenshot tests are requested, install Dropshots. - -If end-to-end testing is requested, install UI Automator. - -## Step 4: Refactor and create fakes for testing - -### **Refactor for unit tests** - -In the next sections you'll be asked to create tests. If you have dependencies -on Android framework classes, or entities that are not part of the codebase: - -- First, use a fake. If it doesn't exist, create an interface for the class - and a "Default" implementation with the existing code. Add the Fake version - to the test sourceset (test or androidTest). - -- If not possible to use a fake (example: no access to the class or - interface), mock the dependencies. - -### **Refactor for UI tests** - -If you need to fake components to make testing easier and faster and more -reliable, replace slow and problematic dependencies with fakes. Use runtime -fakes using the Dependency Injection framework installed to: - -- **Simulate** different scenarios with the user (wrong credentials or a password reset flow), with a server (no connection, server down, or bad JSON from the server) or with a platform component (insufficient permissions, no disk space, no front camera available) -- **Improve** speed and reliability (replacing a database with an in-memory database, replacing a repository with an in-memory fake to avoid hitting the network) - -## Step 5: Unit testing - -Create a task to add or review unit tests in every file that contains business -logic (ViewModels, Repositories, and database-related classes such as DAOs). -Don't create unit tests for Activities, Compose layouts, or dependency injection -configuration files. - -## Step 6: UI testing - -Espresso or Compose UI tests live in the `test` sourceset because they will be -run with Robolectric. If instrumented (emulator or device) tests are requested, -put them in the `androidTest` sourceset. - -## Step 7: Test databases - -If the database is using SQLite (through a library such as Room or SQLDelight), -create instrumented tests using an in-memory database to make sure that they -work with the SQLite engine on device. - -## Step 8: Screenshot tests - -Regardless of the framework used, screenshot tests focus on two types of tests: - -- Screen-level screenshot tests, where each screen is tested in nine different sizes, combining compact, medium and expanded widths (400, 610, 900 dp) and heights (400, 500 and 1000 dp). -- Screen-level variations. Add a mobile (400x500) screenshot of: - - All the alternative themes, if used. - - Font scale set to 1.5. -- Component-level screenshot tests, where each component is tested in different themes and font scales. - -Behavior isn't tested with screenshots, but do test different common scenarios -if their UIs change a lot depending on the state. For example, test loading -screens by injecting a loading state to the UI or simulating it with a fake. - -## Step 9: UI Behavior tests - -Test the UI logic using behavior tests, which ensures that the UIs react as -expected when different states are passed, and when user actions are performed. - -### **Compose UI behavior tests** - -- Use the ComposeTestRule with a `ComponentActivity` to access resources such as strings. -- Always match with semantic matchers first. If the matcher is too complicated to write (using more than three matchers to find a single element), use `testTag`. -- Always verify state restoration. - -### **Views (XML) UI behavior tests** - -Use Espresso to match views and interact with them. - -## Step 10: Navigation tests - -Create a test suite to verify navigation logic. Include: - -- Back handling -- Deeplinks -- Special patterns like "exit through home" with multiple backstacks. - -## Step 11: Simulate different window sizes and settings - -For Compose layouts, use `DeviceConfigurationOverride` described in "[UI testing -common patterns](references/android/develop/ui/compose/testing/common-patterns.md)" to simulate different window sizes, font scales - -## Step 12: End-to-end tests - -Create a low number (about 5% of all tests) of end-to-end tests that cover big -user journeys. Use Compose Test APIs or Espresso for that. If you have to access -platform features such as notifications or system UI, use UI Automator. - -If you need to take screenshots of the app running in a device, use -[Dropshots](https://raw.githubusercontent.com/dropbox/dropshots/refs/heads/main/README.md). You need a device for screenshot tests when verifying -interaction with the system UI (examples: edge-to-edge rendering, notifications, -picture-in-picture) - -### Step 13: Instrumented Screenshot tests - -Install the `com.dropbox.dropshots` plugin in the module and a `Dropshots()` -JUnit Rule. Create a new instrumented screenshot test for one of the app's -features. - -### Step 14: Install jacoco - -Install jacoco for local testing code coverage. - -- Add the `jacoco` plugin to each module that contains tests. - -## Final touches - -- Ask whether to document the findings of the analysis and the changes applied - to the testing strategy. If the user agrees: - - - If there is an AGENTS.md file present in the project, update it with any - changes you've made to the testing strategy. - - - If there is no AGENTS.md file, create a new file (docs/testing.md) with - a description of the testing strategy, including the commands needed to - run every type of test and where the screenshot reference files live. - Also create a new AGENTS.md file in the root and create a link to - docs/testing.md. diff --git a/testing/testing-setup/references/android/develop/ui/compose/testing/common-patterns.md b/testing/testing-setup/references/android/develop/ui/compose/testing/common-patterns.md deleted file mode 100644 index bc11653..0000000 --- a/testing/testing-setup/references/android/develop/ui/compose/testing/common-patterns.md +++ /dev/null @@ -1,279 +0,0 @@ -[Video](https://www.youtube.com/watch?v=Y9GWnwi9D0I) - -You can test your Compose app with well-established approaches and patterns. - -## Test in isolation - -[`ComposeTestRule`](https://developer.android.com/reference/kotlin/androidx/compose/ui/test/junit4/ComposeTestRule) lets you start an activity displaying any composable: -your full application, a single screen, or a small element. It's also a good -practice to check that your composables are correctly encapsulated and they work -independently, allowing for easier and more focused UI testing. - -This doesn't mean you should *only* create unit UI tests. UI tests scoping -larger parts of your UI are also very important. - -## Access the activity and resources after setting your own content - -Oftentimes you need to set the content under test using -`composeTestRule.setContent` and you also need to access activity resources, for -example to assert that a displayed text matches a string resource. However, you -can't call `setContent` on a rule created with `createAndroidComposeRule()` if -the activity already calls it. - -A common pattern to achieve this is to create an `AndroidComposeTestRule` using -an empty activity such as [`ComponentActivity`](https://developer.android.com/reference/androidx/activity/ComponentActivity). - - class MyComposeTest { - - @get:Rule - val composeTestRule = createAndroidComposeRule() - - @Test - fun myTest() { - // Start the app - composeTestRule.setContent { - MyAppTheme { - MainScreen(uiState = exampleUiState, /*...*/) - } - } - val continueLabel = composeTestRule.activity.getString(R.string.next) - composeTestRule.onNodeWithText(continueLabel).performClick() - } - } - -Note that `ComponentActivity` needs to be added to your app's -`AndroidManifest.xml` file. Enable that by adding this dependency to your -module: - - debugImplementation("androidx.compose.ui:ui-test-manifest:$compose_version") - -## Custom semantics properties - -You can create custom [semantics](https://developer.android.com/develop/ui/compose/testing/semantics) properties to expose information to tests. -To do this, define a new `SemanticsPropertyKey` and make it available using the -`SemanticsPropertyReceiver`. - - // Creates a semantics property of type Long. - val PickedDateKey = SemanticsPropertyKey("PickedDate") - var SemanticsPropertyReceiver.pickedDate by PickedDateKey - -Now use that property in the `semantics` modifier: - - val datePickerValue by remember { mutableStateOf(0L) } - MyCustomDatePicker( - modifier = Modifier.semantics { pickedDate = datePickerValue } - ) - -From tests, use `SemanticsMatcher.expectValue` to assert the value of the -property: - - composeTestRule - .onNode(SemanticsMatcher.expectValue(PickedDateKey, 1445378400)) // 2015-10-21 - .assertExists() - -> [!WARNING] -> **Warning:** You should only use custom Semantics properties when it's hard to match a specific item using the given finders and matchers. Using custom Semantics properties to expose visual properties such as colors, font size or rounded corner radius is not recommended, as it can pollute production code and wrong implementations can lead to bugs that are hard to find. - -## Verify state restoration - -Verify that the state of your Compose elements is correctly restored when the -activity or process is recreated. Perform such checks without relying on -activity recreation with the [`StateRestorationTester`](https://developer.android.com/reference/kotlin/androidx/compose/ui/test/junit4/StateRestorationTester) class. - -This class lets you simulate the recreation of a composable. It's especially -useful to verify the implementation of [`rememberSaveable`](https://developer.android.com/reference/kotlin/androidx/compose/runtime/saveable/rememberSaveable.composable#rememberSaveable(kotlin.Array,androidx.compose.runtime.saveable.Saver,kotlin.String,kotlin.Function0)). - - - class MyStateRestorationTests { - - @get:Rule - val composeTestRule = createComposeRule() - - @Test - fun onRecreation_stateIsRestored() { - val restorationTester = StateRestorationTester(composeTestRule) - - restorationTester.setContent { MainScreen() } - - // TODO: Run actions that modify the state - - // Trigger a recreation - restorationTester.emulateSavedInstanceStateRestore() - - // TODO: Verify that state has been correctly restored. - } - } - -## Test different device configurations - -Android apps need to adapt to many changing conditions: window sizes, locales, -font sizes, dark and light themes, and more. Most of these conditions are -derived from device-level values controlled by the user and exposed with the -current [`Configuration`](https://developer.android.com/reference/android/content/res/Configuration) instance. Testing different configurations -directly in a test is difficult since the test must configure device-level -properties. - -[`DeviceConfigurationOverride`](https://developer.android.com/reference/kotlin/androidx/compose/ui/test/DeviceConfigurationOverride) is a test-only API that lets you simulate -different device configurations in a localized way for the `@Composable` content -under test. - -The companion object of `DeviceConfigurationOverride` has the following -extension functions, which override device-level configuration properties: - -- [`DeviceConfigurationOverride.DarkMode()`](https://developer.android.com/reference/kotlin/androidx/compose/ui/test/DeviceConfigurationOverride.Companion#(androidx.compose.ui.test.DeviceConfigurationOverride.Companion).DarkMode(kotlin.Boolean)): Overrides the system to dark theme or light theme. -- [`DeviceConfigurationOverride.FontScale()`](https://developer.android.com/reference/kotlin/androidx/compose/ui/test/DeviceConfigurationOverride.Companion#(androidx.compose.ui.test.DeviceConfigurationOverride.Companion).FontScale(kotlin.Float)): Overrides the [system font - scale](https://developer.android.com/training/multiscreen/screendensities#TaskUseDP). -- [`DeviceConfigurationOverride.FontWeightAdjustment()`](https://developer.android.com/reference/kotlin/androidx/compose/ui/test/DeviceConfigurationOverride.Companion#(androidx.compose.ui.test.DeviceConfigurationOverride.Companion).FontWeightAdjustment(kotlin.Int)): Overrides the system font weight adjustment. -- [`DeviceConfigurationOverride.ForcedSize()`](https://developer.android.com/reference/kotlin/androidx/compose/ui/test/DeviceConfigurationOverride.Companion#(androidx.compose.ui.test.DeviceConfigurationOverride.Companion).ForcedSize(androidx.compose.ui.unit.DpSize)): Forces a specific amount of space regardless of device size. -- [`DeviceConfigurationOverride.LayoutDirection()`](https://developer.android.com/reference/kotlin/androidx/compose/ui/test/DeviceConfigurationOverride.Companion#(androidx.compose.ui.test.DeviceConfigurationOverride.Companion).LayoutDirection(androidx.compose.ui.unit.LayoutDirection)): Overrides the [layout - direction](https://developer.android.com/training/basics/supporting-devices/languages#SupportLayoutMirroring) (left-to-right or right-to-left). -- [`DeviceConfigurationOverride.Locales()`](https://developer.android.com/reference/kotlin/androidx/compose/ui/test/DeviceConfigurationOverride.Companion#(androidx.compose.ui.test.DeviceConfigurationOverride.Companion).Locales(androidx.compose.ui.text.intl.LocaleList)): Overrides the [locale](https://developer.android.com/guide/topics/resources/localization). -- [`DeviceConfigurationOverride.RoundScreen()`](https://developer.android.com/reference/kotlin/androidx/compose/ui/test/DeviceConfigurationOverride.Companion#(androidx.compose.ui.test.DeviceConfigurationOverride.Companion).RoundScreen(kotlin.Boolean)): Overrides if the screen is [round](https://developer.android.com/design/ui/wear/guides/foundations/getting-started#design-for-round). - -To apply a specific override, wrap the content under test in a call to the -[`DeviceConfigurationOverride()`](https://developer.android.com/reference/kotlin/androidx/compose/ui/test/DeviceConfigurationOverride.composable#DeviceConfigurationOverride(androidx.compose.ui.test.DeviceConfigurationOverride,kotlin.Function0)) top-level function, passing the override -to apply as a parameter. - -For example, the following code applies the -`DeviceConfigurationOverride.ForcedSize()` override to change the density -locally, forcing the `MyScreen` composable to be rendered in a large landscape -window, even if the device the test is running on doesn't support that window -size directly: - - -```kotlin -composeTestRule.setContent { - DeviceConfigurationOverride( - DeviceConfigurationOverride.ForcedSize(DpSize(1280.dp, 800.dp)) - ) { - MyScreen() // Will be rendered in the space for 1280dp by 800dp without clipping. - } -} -``` - -
- -To apply multiple overrides together, use -[`DeviceConfigurationOverride.then()`](https://developer.android.com/reference/kotlin/androidx/compose/ui/test/DeviceConfigurationOverride#(androidx.compose.ui.test.DeviceConfigurationOverride).then(androidx.compose.ui.test.DeviceConfigurationOverride)): - - -```kotlin -composeTestRule.setContent { - DeviceConfigurationOverride( - DeviceConfigurationOverride.FontScale(1.5f) then - DeviceConfigurationOverride.FontWeightAdjustment(200) - ) { - Text(text = "text with increased scale and weight") - } -} -``` - -
- -## Custom failure handling in tests - -Debugging UI tests often requires inspecting the screen state and composition -hierarchy when an assertion fails. - -Compose provides a native failure handling pipeline to streamline diagnostics. -You can automatically capture screenshots and UI hierarchies upon failure, or -register custom handlers to forward crash telemetry to external tools. - -> [!NOTE] -> **Note:** The APIs associated with the failure handler pipeline ([`TestFailurePolicy`](https://developer.android.com/reference/kotlin/androidx/compose/ui/test/TestFailurePolicy), [`TestFailureHandler`](https://developer.android.com/reference/kotlin/androidx/compose/ui/test/TestFailureHandler), and [`FailureContext`](https://developer.android.com/reference/kotlin/androidx/compose/ui/test/FailureContext)) are included in [`androidx.compose.ui:ui-test:1.13.0-alpha01+`](https://developer.android.com/jetpack/androidx/releases/compose-ui#1.13.0-alpha01) and [`androidx.compose.ui:ui-test-junit4:1.13.0-alpha01+`](https://developer.android.com/jetpack/androidx/releases/compose-ui#1.13.0-alpha01). - -### Configure failure handling - -To configure failure handling, pass a [`TestFailurePolicy`](https://developer.android.com/reference/kotlin/androidx/compose/ui/test/TestFailurePolicy) object to -[`ComposeUiTestConfig`](https://developer.android.com/reference/kotlin/androidx/compose/ui/test/ComposeUiTestConfig). - -#### Capture built-in failure artifacts - -To capture screenshots and UI hierarchies upon test failure, set the -[`screenshotCaptureMode`](https://developer.android.com/reference/kotlin/androidx/compose/ui/test/TestFailurePolicy#screenshotCaptureMode()) and [`uiHierarchyCaptureMode`](https://developer.android.com/reference/kotlin/androidx/compose/ui/test/TestFailurePolicy#uiHierarchyCaptureMode()) properties in -`TestFailurePolicy`. - - -```kotlin -class MyComposeTest { - private val customConfig = ComposeUiTestConfig( - failurePolicy = TestFailurePolicy( - screenshotCaptureMode = TestFailurePolicy.CaptureMode.Enabled, - uiHierarchyCaptureMode = TestFailurePolicy.CaptureMode.Enabled - ) - ) - - @Test - fun myFirstTest() = runComposeUiTest(config = customConfig) { - // ... - } -} -``` - -
- -### Use a custom failure handler - -To define custom behavior for test failures, add a -[`TestFailureHandler`](https://developer.android.com/reference/kotlin/androidx/compose/ui/test/TestFailureHandler) to the [`failureHandlers` list](https://developer.android.com/reference/kotlin/androidx/compose/ui/test/TestFailurePolicy#failureHandlers()). Each handler -receives a [`FailureContext`](https://developer.android.com/reference/kotlin/androidx/compose/ui/test/FailureContext) object that contains the original error and a -list of artifacts generated by the preceding pipeline handlers. - - -```kotlin -class MyComposeTestWithHandler { - private val customConfig = ComposeUiTestConfig( - failurePolicy = TestFailurePolicy( - screenshotCaptureMode = TestFailurePolicy.CaptureMode.Enabled, - failureHandlers = listOf( - TestFailureHandler { context -> - val screenshot = context.artifacts.firstOrNull { - it.type == FailureArtifact.Type.Screenshot - } - // ... - } - ) - ) - ) - - @get:Rule - val rule = createComposeRule(config = customConfig) - - // ... -} -``` - -
- -### Configure captures for an entire test suite - -You can enable failure artifact captures for an entire test suite by passing -instrumentation runner arguments in your module's `build.gradle` file. This -eliminates the need to update individual test files. - - // build.gradle.kts - android { - defaultConfig { - // ... - testInstrumentationRunnerArguments["androidx.compose.ui.test.failure.isUiHierarchyCaptureEnabled"] = "true" - testInstrumentationRunnerArguments["androidx.compose.ui.test.failure.isScreenshotCaptureEnabled"] = "true" - } - } - -#### Configuration precedence - -The framework evaluates both the local `TestFailurePolicy` and the suite-level -runner arguments: - -- If you explicitly set a mode to `CaptureMode.Enabled`/`Disabled` in your test `failurePolicy`, this overrides the runner argument. -- If you set a mode to `CaptureMode.Unspecified` (or leave `failurePolicy` as `null`), the framework falls back to the runner argument. - -## Additional Resources - -- **[Test apps on Android](https://developer.android.com/training/testing)**: The main Android testing landing page provides a broader view of testing fundamentals and techniques. -- **[Fundamentals of testing](https://developer.android.com/training/testing/fundamentals):** Learn more about the core concepts behind testing an Android app. -- **[Local tests](https://developer.android.com/training/testing/local-tests):** You can run some tests locally, on your own workstation. -- **[Instrumented tests](https://developer.android.com/training/testing/instrumented-tests):** It is good practice to also run instrumented tests. That is, tests that run directly on-device. -- **[Continuous integration](https://developer.android.com/training/testing/continuous-integration):** Continuous integration lets you integrate your tests into your deployment pipeline. -- **[Test different screen sizes](https://developer.android.com/training/testing/different-screens):** With some many devices available to users, you should test for different screen sizes. -- **[Espresso](https://developer.android.com/training/testing/espresso)**: While intended for View-based UIs, Espresso knowledge can still be helpful for some aspects of Compose testing. \ No newline at end of file diff --git a/testing/testing-setup/references/android/studio/preview/compose-screenshot-testing-with-testsuites.md b/testing/testing-setup/references/android/studio/preview/compose-screenshot-testing-with-testsuites.md deleted file mode 100644 index 1691d1d..0000000 --- a/testing/testing-setup/references/android/studio/preview/compose-screenshot-testing-with-testsuites.md +++ /dev/null @@ -1,181 +0,0 @@ -> [!WARNING] -> **Experimental:** Compose Preview Screenshot Testing with AGP test suites is in active development. APIs and DSL syntax are subject to change. Report feedback and issues through the [issue tracker](https://issuetracker.google.com/issues/new?component=192708&template=840533). - -Starting with Android Gradle Plugin (AGP) 9.5.0-alpha03 and Compose Preview -Screenshot Testing engine `0.0.1-alpha16`, screenshot testing is integrated with -AGP's native [test suites](https://developer.android.com/reference/tools/gradle-api/9.5/com/android/build/api/dsl/AgpTestSuite) framework. - -This approach replaces the standalone screenshot plugin -(`com.android.compose.screenshot`). We recommend adopting AGP test suites for -the following reasons: - -- **Native Gradle task lifecycle**: Screenshot tests integrate directly into standard Gradle and AGP testing lifecycles, improving task isolation and test execution reliability. -- **Multi-variant and custom suite support** : You can create multiple distinct screenshot test suites (such as `screenshotTest`, `uiTests`, or `smokeTests`) within a single module and target specific build variants (such as `demoDebug` or `release`), rather than being restricted to a single pre-configured source set. -- **Enhanced build performance and isolation**: AGP test suites use built-in artifact transforms (such as Layoutlib runtime extraction) and isolated classloading, with full support for Gradle Configuration Caching and Project Isolation. - - -## Android skills - -[View on GitHub](https://github.com/android/skills/blob/main/testing/testing-setup) - -### Create screenshot tests - -Use the `testing-setup` [Android skill](https://developer.android.com/tools/agents/android-skills) to develop a testing strategy and create screenshot tests. To install the skill from the [Android CLI](https://developer.android.com/tools/agents/android-cli), run: - - android skills add testing-setup - -
- -## Requirements - -To use Compose Screenshot Testing with test suites, ensure your environment -meets the following requirements: - -- Android Studio Rabbit 1 Canary 4 or higher. -- Android Gradle Plugin (AGP) version 9.5.0-alpha03 or higher. -- Compose Screenshot Engine version 0.0.1-alpha16 or higher. -- JDK version 17 or higher. -- Compose enabled for your project. We recommend enabling Compose using the [Compose Compiler Gradle plugin](https://developer.android.com/develop/ui/compose/compiler). - -## Setup and configuration - -To configure Compose screenshot testing with test suites, complete the -following steps: - -### 1. Enable experimental flags - -In your project's root `gradle.properties` file, enable screenshot testing and -test suite support: - - android.experimental.enableScreenshotTest=true - android.experimental.testSuiteSupport=true - -### 2. Configure the test suite in the `build.gradle.kts` file - -In your module's `build.gradle.kts` file, define a screenshot test suite within -the `testOptions` block: - - android { - testOptions { - screenshotTests.create("screenshotTest") { // suiteName can be customized (for example, "uiTests") - engineVersion = "0.0.1-alpha16" - targetVariants.add("demoDebug") // Add specific variants to test - - dependencies { - implementation(libs.androidx.compose.ui.tooling) - implementation("com.android.tools.screenshot:screenshot-validation-api:0.0.1-alpha16") - } - } - } - } - -### 3. Create the test source set - -Create a dedicated source set directory matching your suite name: - -`{module}/src/{suiteName}/kotlin/` - -For example, for a suite named `screenshotTest`: - -`feature/foryou/impl/src/screenshotTest/kotlin/com/example/app/ForYouScreenTest.kt` - -### 4. Define composable preview tests - -Annotate composables with `@PreviewTest` and standard `@Preview` or -multi-preview annotations: - - 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 ForYouScreenPreview() { - AppTheme { - ForYouScreen(isSyncing = false) - } - } - -## Run screenshot tests - -AGP test suites generate dedicated Gradle tasks based on your suite name, -target, and variants. - -### 1. Generate or update reference images - -Render composable previews and store the golden baseline reference images: - -- **Linux and macOS** : `./gradlew update{SuiteName}{Target}{Variant}TestSuite` (for example, `./gradlew updateScreenshotTestDefaultDemoDebugTestSuite`) -- **Windows** : `gradlew updateScreenshotTestDefaultDemoDebugTestSuite` - -Reference images are generated and saved at: - -`{module}/src/{suiteName}{Target}{Variant}/reference/` - -### 2. Verify and run tests - -Render fresh screenshots and compare them against reference images: - -- **Linux and macOS** : `./gradlew test{SuiteName}{Target}{Variant}TestSuite` (for example, `./gradlew testScreenshotTestDefaultDemoDebugTestSuite`) -- **Windows** : `gradlew testScreenshotTestDefaultDemoDebugTestSuite` - -## Inspect test reports - -If differences are detected or tests fail, AGP generates an HTML test report. - -- **Report location** : `{module}/build/reports/tests/{taskName}/index.html` (for example, `app/build/reports/tests/testScreenshotTestDefaultDemoDebugTestSuite/index.html`) - -The updated report includes: - -- **Header metadata card**: Displays test name, preview method, variant, suite, and status badge. -- **Error categorization** : Clearly flags `Reference Image Missing`, `Image Size Mismatch`, or `Pixel Mismatch` with copyable stack traces. -- **Dynamic visual diff**: Highlights subtle modifications with lower intensity and major changes with high-contrast emphasis to prevent nested element swallowing. - -## Migrate from the legacy standalone plugin - -To migrate from the legacy standalone screenshot plugin to AGP test suites, -update your Gradle configuration and task commands. - -### Build configuration DSL comparison - -#### Legacy standalone plugin (deprecated) - - // In build.gradle.kts - plugins { - alias(libs.plugins.screenshot) - } - - dependencies { - screenshotTestImplementation(libs.androidx.compose.ui.tooling) - screenshotTestImplementation("com.android.tools.screenshot:screenshot-validation-api:0.0.1-alpha16") - } - -#### AGP test suites (recommended) - - // In build.gradle.kts - android { - testOptions { - screenshotTests.create("screenshotTest") { - engineVersion = "0.0.1-alpha16" - targetVariants.add("demoDebug") - - dependencies { - implementation(libs.androidx.compose.ui.tooling) - implementation("com.android.tools.screenshot:screenshot-validation-api:0.0.1-alpha16") - } - } - } - } - -### Tasks and paths mapping - -| Concept | Legacy setup (deprecated) | AGP test suites (recommended) | -|---|---|---| -| **Update task** | `./gradlew updateDebugScreenshotTest` | `./gradlew update{SuiteName}{Target}{Variant}TestSuite` | -| **Test task** | `./gradlew validateDebugScreenshotTest` | `./gradlew test{SuiteName}{Target}{Variant}TestSuite` | -| **Reference path** | `src/screenshotTestDebug/reference` | `src/{suiteName}{Target}{Variant}/reference` | -| **Report path** | `build/reports/screenshotTest/debug/` | `build/reports/tests/{taskName}/` | \ No newline at end of file diff --git a/testing/testing-setup/references/android/studio/preview/compose-screenshot-testing.md b/testing/testing-setup/references/android/studio/preview/compose-screenshot-testing.md deleted file mode 100644 index 2cdcc80..0000000 --- a/testing/testing-setup/references/android/studio/preview/compose-screenshot-testing.md +++ /dev/null @@ -1,261 +0,0 @@ -> [!WARNING] -> **Deprecated:** Starting with Android Gradle Plugin (AGP) 9.5.0-alpha03 and Compose Preview Screenshot Testing 0.0.1-alpha16, we recommend configuring screenshot tests using [AGP test suites](https://developer.android.com/studio/preview/compose-screenshot-testing-with-testsuites). The standalone plugin method described on this page is deprecated. - -> [!WARNING] -> **Experimental:** Compose Preview Screenshot Testing is still in development. Its features and APIs are subject to change substantially during the alpha phase. Report any feedback and issues through the [issue tracker](https://issuetracker.google.com/issues/new?component=192708&template=840533). - -Screenshot testing is an effective way to verify how your UI looks to users. -The Compose Preview Screenshot Testing tool combines the simplicity and -features of [composable previews](https://developer.android.com/develop/ui/compose/tooling/previews) with the productivity -gains of running host-side screenshot tests. Compose Preview Screenshot Testing -is designed to be as straightforward to use as composable previews. - - -## Android skills - -[View on GitHub](https://github.com/android/skills/blob/main/testing/testing-setup) - -### Create screenshot tests - -Use the `testing-setup` [Android skill](https://developer.android.com/tools/agents/android-skills) to develop a testing strategy and create screenshot tests. To install the skill from the [Android CLI](https://developer.android.com/tools/agents/android-cli), run: - - android skills add testing-setup - -
- -A screenshot test is an automated test that takes a screenshot of a piece of UI -and then compares it against a previously approved reference image. If the -images don't match, the test fails and produces an HTML report to help you -compare and find the differences. - -With the Compose Preview Screenshot Testing tool, you can: - -- Use `@PreviewTest` to create screenshot tests for existing or new composable previews. -- Generate reference images from those composable previews. -- Generate an HTML report that identifies changes to those previews after you make code changes. -- Use `@Preview` parameters, such as `uiMode` or `fontScale`, and multi-previews to help you scale your tests. -- Modularize your tests with the new `screenshotTest` source set. - -![](https://developer.android.com/static/studio/images/compose-screenshot-testing.png) **Figure 1.** Example HTML report. - -## IDE integration - -While you can use the Compose Preview Screenshot Testing tool by running the -underlying Gradle tasks (`updateScreenshotTest` and `validateScreenshotTest`) -manually, Android Studio Otter 3 Feature Drop Canary 4 introduces a full IDE -integration. This lets you generate reference images, run tests, and analyze -validation failures entirely within the IDE. Here are some of the key features: - -- **In-editor gutter icons.** You can now run tests or update reference images directly from the source code. Green run icons appear in the gutter next to composables and classes annotated with `@PreviewTest`. - - **Run screenshot tests.** Execute tests specifically for a single function or for an entire class. - - **Add or update reference images.** Trigger the update flow specifically for the selected scope. - -- **Interactive reference management.** Updating reference images is now safer and more granular. - - **New reference image generation dialog.** Instead of running a bulk Gradle task, a new dialog lets you visualize and select exactly which previews to generate or update. - - **Preview variations.** The dialog lists all preview variations (such as light theme or dark theme, or different devices) individually, allowing you to select or clear specific items before generating images. - -- **Integrated test results and diff viewer.** View results without leaving the IDE. - - **Unified run panel.** Screenshot test results appear in the standard **Run** tool window. Tests are grouped by class and function, with pass or fail status clearly marked. - - **Visual diff tool.** When a test fails, the **Screenshot** tab lets you compare the *Reference* , *Actual* , and *Diff* images side-by-side. - - **Detailed attributes.** An **Attributes** tab provides metadata on failed tests, including match percentage, image dimensions, and the specific preview configuration used (for example, `uiMode` or `fontScale`). - -- **Flexible test scoping.** You can now execute screenshot tests with various scopes directly from the Project View. Right-click a module, directory, file, or class to run screenshot tests specifically for that selection. - -## Requirements - -> [!NOTE] -> **Note:** For requirements and setup instructions for AGP test suites, see [AGP test suites](https://developer.android.com/studio/preview/compose-screenshot-testing-with-testsuites). - -To use Compose Preview Screenshot Testing through the full IDE integration, your -project must meet the following requirements: - -- Android Studio Panda 1 Canary 4 or higher. -- Android Gradle Plugin (AGP) version 9.0 or higher. -- Compose Preview Screenshot Testing plugin version [0.0.1-alpha16](https://developer.android.com/studio/preview/compose-screenshot-testing-release-notes#alpha16) or higher. -- Kotlin version 2.2.10 or higher. -- JDK version 17 or higher. -- Compose enabled for your project. We recommend enabling Compose using the [Compose Compiler Gradle plugin](https://developer.android.com/develop/ui/compose/compiler). - -If you only want to use the underlying Gradle tasks without the IDE integration, -the requirements are as follows: - -- Android Gradle Plugin (AGP) version 8.5.0 or higher. -- Compose Preview Screenshot Testing plugin version [0.0.1-alpha16](https://developer.android.com/studio/preview/compose-screenshot-testing-release-notes#alpha16) or higher. -- Kotlin version 1.9.20 or higher. We recommend using Kotlin 2.0 or higher so you can use the Compose Compiler Gradle plugin. -- JDK version 17 or higher. -- Compose enabled for your project. We recommend enabling Compose using the [Compose Compiler Gradle plugin](https://developer.android.com/develop/ui/compose/compiler). - -> [!NOTE] -> **Note:** If you can't use the Compose Compiler Gradle plugin, you can enable Compose by [declaring a dependency on the Compose Compiler directly](https://developer.android.com/jetpack/androidx/releases/compose-kotlin#kts). Make sure you use `kotlinCompilerExtensionVersion` version 1.5.4 or higher. - -## Setup - -Both the integrated tool and the underlying Gradle tasks rely on the Compose -Preview Screenshot Testing plugin. To set up the plugin, follow these steps: - -1. Enable the experimental property in your project's `gradle.properties` file. - - android.experimental.enableScreenshotTest=true - -2. In the `android {}` block of your module-level `build.gradle.kts` file, - enable the experimental flag to use the `screenshotTest` source set. - - android { - experimentalProperties["android.experimental.enableScreenshotTest"] = true - } - -3. Add the `com.android.compose.screenshot` plugin, version `0.0.1-alpha16` to - your project. - - 1. Add the plugin to your version catalogs file: - - [versions] - agp = "9.0.0-rc03" - kotlin = "2.2.10" - screenshot = "0.0.1-alpha16" - - [plugins] - screenshot = { id = "com.android.compose.screenshot", version.ref = "screenshot"} - - 2. In your module-level `build.gradle.kts` file, add the plugin in the - `plugins {}` block: - - plugins { - alias(libs.plugins.screenshot) - } - -4. Add the [`screenshot-validation-api`](https://maven.google.com/web/index.html?q=screenshot-validation-api#com.android.tools.screenshot:screenshot-validation-api) - and [`ui-tooling`](https://maven.google.com/web/index.html?q=tooling#androidx.compose.ui:ui-tooling) - dependencies. - - 1. Add them to your version catalogs: - - [libraries] - screenshot-validation-api = { group = "com.android.tools.screenshot", name = "screenshot-validation-api", version.ref = "screenshot"} - androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling"} - - 2. Add them to your module-level `build.gradle.kts` file: - - dependencies { - screenshotTestImplementation(libs.screenshot.validation.api) - screenshotTestImplementation(libs.androidx.ui.tooling) - } - -## Designate composable previews to use for screenshot tests - -To designate the composable previews you want to use for screenshot tests, mark -the previews with the `@PreviewTest` annotation. The previews must be located in -the new `screenshotTest` source set, for example: - -`app/src/screenshotTest/kotlin/com/example/yourapp/` -`ExamplePreviewScreenshotTest.kt` - -You can add more composables or previews, including multi-previews, in -this file or other files created in the same source set. - - package com.example.yourapp - - import androidx.compose.runtime.Composable - import androidx.compose.ui.tooling.preview.Preview - import com.android.tools.screenshot.PreviewTest - import com.example.yourapp.ui.theme.MyApplicationTheme - - @PreviewTest - @Preview(showBackground = true) - @Composable - fun GreetingPreview() { - MyApplicationTheme { - Greeting("Android!") - } - } - -## Generate reference images - -After you set up a test class, you need to generate reference images for each -preview. These reference images are used to identify changes later, after you -make code changes. To generate reference images for your composable preview -screenshot tests, follow the instructions in this section for the IDE -integration or for the Gradle tasks. - - -## Android CLI - -[Download the Android CLI](https://developer.android.com/tools/agents) - -### Try Android CLI to render a Compose preview - -Try [Android CLI](https://developer.android.com/tools/agents) if you're not using Android Studio or prefer to do things from the command line. - -For example, use the [`android studio render-compose-preview`](https://developer.android.com/tools/agents/android-cli/commands/studio_render-compose-preview) command when you need to render a Compose preview for visual testing. - - android studio render-compose-preview - -
- -### In the IDE - -Click the gutter icon next to a `@PreviewTest` function and select **Add/Update -Reference Images** . Select the previews in the dialog and click **Add**. - -### With the Gradle tasks - -Run the following Gradle task: - -- Linux and macOS: `./gradlew updateDebugScreenshotTest` (`./gradlew :{module}:update{Variant}ScreenshotTest`) -- Windows: `gradlew updateDebugScreenshotTest` (`gradlew :{module}:update{Variant}ScreenshotTest`) - -After the task completes, find the reference images in -`app/src/screenshotTestDebug/reference` -(`{module}/src/screenshotTest{Variant}/reference`). - -> [!NOTE] -> **Note:** The reference images are named with a concatenation of the fully-qualified name of the test function and a hash of the preview parameters, for example `com.sample.screenshottests.test1_da39a3ee_c2200e98_0.png`. - -## Generate a test report - -After reference images exist, generate a test report by following the -instructions in this section for the IDE integration or for the Gradle tasks. - -### In the IDE - -Click the gutter icon next to a `@PreviewTest` function and select **Run -'ScreenshotTests'**. - -If a test fails, click the test name in the **Run** panel. Select the -**Screenshot** tab to inspect the image diff using the integrated zoom and pan -controls. - -> [!NOTE] -> **Note:** Renaming a function annotated with `@PreviewTest` breaks the association with existing reference images. In that case, you must [regenerate reference images](https://developer.android.com/studio/preview/compose-screenshot-testing#generate-reference-images) for the new function name. - -### With the Gradle tasks - -Run the validate task to take a new screenshot and compare it with the -reference image: - -- Linux and macOS: `./gradlew validateDebugScreenshotTest` (`./gradlew :{module}:validate{Variant}ScreenshotTest`) -- Windows: `gradlew validateDebugScreenshotTest` (`gradlew :{module}:validate{Variant}ScreenshotTest`) - -The verification task creates an HTML report at -`{module}/build/reports/screenshotTest/preview/{variant}/index.html`. - -## Troubleshooting - -Compose Preview Screenshot Testing runs host-side tests, which can be -memory-intensive. You can increase the maximum heap size for the test JVM by -adding the following property to your `gradle.properties` file: - - android.compose.screenshot.maxHeapSize=4g - -## Known issues - -- **Kotlin Multiplatform (KMP):** Both the IDE and the underlying plugin are engineered exclusively for Android projects. They don't support non-Android targets in KMP projects. - -You can find the complete list of current known issues in the tool's -[issue tracker component](https://issuetracker.google.com/issues?q=status:open+componentid:1581441&s=created_time:desc). Report any other feedback and issues -through the [issue tracker](https://issuetracker.google.com/issues/new?component=192708&template=840533). - -## Release updates - -For a full list of release updates, see the -[release notes](https://developer.android.com/studio/preview/compose-screenshot-testing-release-notes). \ No newline at end of file diff --git a/testing/testing-setup/references/android/training/dependency-injection/hilt-testing.md b/testing/testing-setup/references/android/training/dependency-injection/hilt-testing.md deleted file mode 100644 index 022bf07..0000000 --- a/testing/testing-setup/references/android/training/dependency-injection/hilt-testing.md +++ /dev/null @@ -1,447 +0,0 @@ -One of the benefits of using dependency injection frameworks like Hilt is that -it makes testing your code easier. - -## Unit tests - -Hilt isn't necessary for unit tests, since when testing a class that uses -constructor injection, you don't need to use Hilt to instantiate that class. -Instead, you can directly call a class constructor by passing in fake or mock -dependencies, just as you would if the constructor weren't annotated: - -```kotlin -@ActivityScoped -class AnalyticsAdapter @Inject constructor( - private val service: AnalyticsService -) { ... } - -class AnalyticsAdapterTest { - - @Test - fun `Happy path`() { - // You don't need Hilt to create an instance of AnalyticsAdapter. - // You can pass a fake or mock AnalyticsService. - val adapter = AnalyticsAdapter(fakeAnalyticsService) - assertEquals(...) - } -} -``` - -The same applies to ViewModel classes obtained by calling `hiltViewModel()` in -your composables. In unit tests, construct the ViewModel directly with fakes. -For information on how state flows from a ViewModel into composables, see -[State and Jetpack Compose](https://developer.android.com/develop/ui/compose/state) and [Where to hoist state](https://developer.android.com/develop/ui/compose/state-hoisting). - -## End-to-end tests - -For integration tests, Hilt injects dependencies as it would in your production -code. Testing with Hilt requires no maintenance because Hilt automatically -generates a new set of components for each test. - -### Adding testing dependencies - -To use Hilt in your tests, include the `hilt-android-testing` dependency in your -project: - -```kotlin -dependencies { - // For Robolectric tests. - testImplementation("com.google.dagger:hilt-android-testing:2.57.1") - kspTest("com.google.dagger:hilt-android-compiler:2.57.1") - - // For instrumented tests. - androidTestImplementation("com.google.dagger:hilt-android-testing:2.57.1") - kspAndroidTest("com.google.dagger:hilt-android-compiler:2.57.1") - - // Compose UI test rule. - androidTestImplementation("androidx.compose.ui:ui-test-junit4") - debugImplementation("androidx.compose.ui:ui-test-manifest") - -} -``` - -> [!NOTE] -> **Note:** If you use [Jetpack integrations](https://developer.android.com/training/dependency-injection/hilt-jetpack) (like `hilt-navigation-compose` to obtain a ViewModel through `hiltViewModel()`), you must also add their annotation processors to your test dependencies. - -### UI test setup - -You must annotate any UI test that uses Hilt with `@HiltAndroidTest`. This -annotation is responsible for generating the Hilt components for each test. - -Also, you need to add the `HiltAndroidRule` to the test class. It manages the -components' state and is used to perform injection on your test: - -```kotlin -@HiltAndroidTest -class SettingsScreenTest { - - @get:Rule(order = 0) - val hiltRule = HiltAndroidRule(this) - - @get:Rule(order = 1) - val composeRule = createAndroidComposeRule() - - // Compose UI tests here. -} -``` - -> [!NOTE] -> **Note:** If you have other rules in your test, see [Multiple TestRule objects in -> your instrumented test](https://developer.android.com/training/dependency-injection/hilt-testing#multiple-testrules). - -Next, your test needs to know about the `Application` class that Hilt -automatically generates for you. - -To let Hilt inject dependencies, you must create an empty activity named -`HiltTestActivity` in your `androidTest` source set and annotate it with -`@AndroidEntryPoint`. `createAndroidComposeRule` then uses this activity as the -host for your composable content. - -#### Test application - -You must execute instrumented tests that use Hilt in an `Application` object -that supports Hilt. The library provides `HiltTestApplication` for use in tests. -If your tests need a different base application, see [Custom application for -tests](https://developer.android.com/training/dependency-injection/hilt-testing#custom-application). - -You must set your test application to run in your [instrumented -tests](https://developer.android.com/training/testing/ui-testing) or [Robolectric -tests](http://robolectric.org/). The following instructions aren't -specific to Hilt, but are general guidelines on how to specify a custom -application to run in tests. - -##### Set the test application in instrumented tests - -To use the Hilt test application in [instrumented -tests](https://developer.android.com/training/testing/ui-testing), you need to configure a new test runner. -This makes Hilt work for all of the instrumented tests in your project. Perform -the following steps: - -1. Create a custom class that extends [`AndroidJUnitRunner`](https://developer.android.com/reference/kotlin/androidx/test/runner/AndroidJUnitRunner) in the `androidTest` folder. -2. Override the `newApplication` function and pass in the name of the generated Hilt test application. - -```kotlin -// A custom runner to set up the instrumented application class for tests. -class CustomTestRunner : AndroidJUnitRunner() { - - override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application { - return super.newApplication(cl, HiltTestApplication::class.java.name, context) - } -} -``` - -Next, configure this test runner in your Gradle file as described in the -[instrumented unit test -guide](https://developer.android.com/training/testing/unit-testing/instrumented-unit-tests#setup). Make sure -you use the full classpath: - -```kotlin -android { - defaultConfig { - // Replace com.example.android.dagger with your class path. - testInstrumentationRunner = "com.example.android.dagger.CustomTestRunner" - } -} -``` - -##### Set the test application in Robolectric tests - -If you use Robolectric to test your UI layer, you can specify which application -to use in the `robolectric.properties` file: - -`application = dagger.hilt.android.testing.HiltTestApplication` - -Alternatively, you can configure the application on each test individually by -using Robolectric's `@Config` annotation: - -```kotlin -@HiltAndroidTest -@Config(application = HiltTestApplication::class) -class SettingsScreenTest { - - @get:Rule - var hiltRule = HiltAndroidRule(this) - - // Robolectric tests here. -} -``` - -### Testing features - -Once Hilt is ready to use in your tests, you can use several features to -customize the testing process. - -#### Inject types in tests - -To inject types into a test, use `@Inject` for field injection. To tell Hilt to -populate the `@Inject` fields, call `hiltRule.inject()`. - -See the following example of an instrumented test: - -```kotlin -@HiltAndroidTest -class SettingsScreenTest { - - @get:Rule(order = 0) - val hiltRule = HiltAndroidRule(this) - - @get:Rule(order = 1) - val composeRule = createAndroidComposeRule() - - @Inject - lateinit var analyticsAdapter: AnalyticsAdapter - - @Before - fun init() { - hiltRule.inject() - } - - @Test - fun settingsScreen_showsTitle() { - composeRule.setContent { - SettingsScreen() - } - composeRule.onNodeWithText("Settings").assertIsDisplayed() - // analyticsRepository is available here. - } -} -``` - -#### Replace a binding - -If you need to inject a fake or mock instance of a dependency, you need to tell -Hilt not to use the binding that it used in production code and to use a -different one instead. To replace a binding, you need to replace the module that -contains the binding with a test module that contains the bindings that you want -to use in the test. - -For example, suppose your production code declares a binding for -`AnalyticsService` as follows: - -```kotlin -@Module -@InstallIn(SingletonComponent::class) -abstract class AnalyticsModule { - - @Singleton - @Binds - abstract fun bindAnalyticsService( - analyticsServiceImpl: AnalyticsServiceImpl - ): AnalyticsService -} -``` - -To replace the `AnalyticsService` binding in tests, create a new Hilt module in -the `test` or `androidTest` folder with the fake dependency and annotate it -with `@TestInstallIn`. All the tests in that folder are injected with the fake -dependency instead. - -```kotlin -@Module -@TestInstallIn( - components = [SingletonComponent::class], - replaces = [AnalyticsModule::class] -) -abstract class FakeAnalyticsModule { - - @Singleton - @Binds - abstract fun bindAnalyticsService( - fakeAnalyticsService: FakeAnalyticsService - ): AnalyticsService -} -``` - -Because composables typically consume these dependencies indirectly through a -ViewModel obtained with `hiltViewModel()`, replacing the binding in Hilt is -enough. The composable under test picks up the fake automatically. - -#### Replace a binding in a single test - -To replace a binding in a single test instead of all tests, uninstall a Hilt -module from a test using the `@UninstallModules` annotation and create a new -test module inside the test. - -Following the `AnalyticsService` example from the previous version, begin by -telling Hilt to ignore the production module by using the `@UninstallModules` -annotation in the test class: - -```kotlin -@UninstallModules(AnalyticsModule::class) -@HiltAndroidTest -class SettingsScreenTest { ... } -``` - -Next, you must replace the binding. Create a new module within the test class -that defines the test binding: - -```kotlin -@UninstallModules(AnalyticsModule::class) -@HiltAndroidTest -class SettingsScreenTest { - - @Module - @InstallIn(SingletonComponent::class) - abstract class TestModule { - - @Singleton - @Binds - abstract fun bindAnalyticsService( - fakeAnalyticsService: FakeAnalyticsService - ): AnalyticsService - } - - // ... -} -``` - -This only replaces the binding for a single test class. If you want to replace -the binding for all test classes, use the `@TestInstallIn` annotation from the -section above. Alternatively, you can put the test binding in the `test` module -for Robolectric tests, or in the `androidTest` module for instrumented tests. -The recommendation is to use `@TestInstallIn` whenever possible. - -> [!WARNING] -> **Warning:** You cannot uninstall modules that are not annotated with `@InstallIn`. Attempting to do so causes a compilation error. - -> [!WARNING] -> **Warning:** `@UninstallModules` can only uninstall `@InstallIn` modules, not `@TestInstallIn` modules. Attempting to do so causes a compilation error. - -> [!NOTE] -> **Note:** As Hilt creates new components for tests that use `@UninstallModules`, it can significantly impact unit test build times. Use it when necessary and prefer using `@TestInstallIn` when the bindings need to be replaced in all test classes. - -#### Binding new values - -Use the `@BindValue` annotation to easily bind fields in your test into the Hilt -dependency graph. Annotate a field with `@BindValue` and it will be bound under -the declared field type with any qualifiers that are present for that field. - -In the `AnalyticsService` example, you can replace `AnalyticsService` with a -fake by using `@BindValue`: - -```kotlin -@UninstallModules(AnalyticsModule::class) -@HiltAndroidTest -class SettingsScreenTest { - - @BindValue @JvmField - val analyticsService: AnalyticsService = FakeAnalyticsService() - - ... -} -``` - -This simplifies both replacing a binding and referencing a binding in your test -by allowing you to do both at the same time. - -`@BindValue` works with qualifiers and other testing annotations. For example, -if you use testing libraries such as -[Mockito](https://site.mockito.org/), you could use it in a -Robolectric test as follows: - -```kotlin -... -class SettingsScreenTest { - ... - - @BindValue @ExampleQualifier @Mock - lateinit var qualifiedVariable: ExampleCustomType - - // Robolectric tests here -} -``` - -If you need to add a [multibinding](https://dagger.dev/dev-guide/multibindings), -you can use the `@BindValueIntoSet` and `@BindValueIntoMap` annotations in place -of `@BindValue`. `@BindValueIntoMap` requires you to also annotate the field -with a map key annotation. - -## Special cases - -Hilt also provides features to support nonstandard use cases. - -### Custom application for tests - -If you cannot use `HiltTestApplication` because your test application needs to -extend another application, annotate a new class or interface with -`@CustomTestApplication`, passing in the value of the base class you want the -generated Hilt application to extend. - -`@CustomTestApplication` will generate an `Application` class ready for testing -with Hilt that extends the application you passed as a parameter. - -```kotlin -@CustomTestApplication(BaseApplication::class) -interface HiltTestApplication -``` - -In the example, Hilt generates an `Application` named -`HiltTestApplication_Application` that extends the `BaseApplication` class. In -general, the name of the generated application is the name of the annotated -class appended with `_Application`. You must set the generated Hilt test -application to run in your [instrumented tests](https://developer.android.com/training/testing/ui-testing) or -[Robolectric tests](http://robolectric.org/) as described in [Test -application](https://developer.android.com/training/dependency-injection/hilt-testing#test-application). - -> [!NOTE] -> **Note:** Because `HiltTestApplication_Application` is code that Hilt generates at runtime, the IDE might highlight it in red until you run your tests. - -### Multiple TestRule objects in your instrumented test - -Compose UI tests already combine `HiltAndroidRule` with a Compose test rule -such as `createAndroidComposeRule`. If you have additional `TestRule` objects, -make sure `HiltAndroidRule` runs first. Declare the execution order with the -`order` attribute on `@Rule`: - -```kotlin -@HiltAndroidTest -class SettingsScreenTest { - - @get:Rule(order = 0) - var hiltRule = HiltAndroidRule(this) - - @get:Rule(order = 1) - val composeRule = createAndroidComposeRule() - - @get:Rule(order = 2) - val otherRule = SomeOtherRule() - - // UI tests here. -} -``` - -Alternatively, you can wrap the rules with `RuleChain`, placing -`HiltAndroidRule` as the outer rule. - -```kotlin -@HiltAndroidTest -class SettingsScreenTest { - - @get:Rule - var rule = RuleChain.outerRule(HiltAndroidRule(this)). - around(SettingsScreenTestRule(...)) - - // UI tests here. -} -``` - -### Use an entry point before the singleton component is available - -The `@EarlyEntryPoint` annotation provides an escape hatch when a Hilt entry -point needs to be created before the singleton component is available in a -Hilt test. - -More information about `@EarlyEntryPoint` in the -[Hilt documentation](https://dagger.dev/hilt/early-entry-point). - -## Additional resources - -To learn more about testing, see the following additional resources: - -### Documentation - -- [Test your Compose layout](https://developer.android.com/develop/ui/compose/testing) -- [Testing cheatsheet](https://developer.android.com/develop/ui/compose/testing/testing-cheatsheet) - -### Views content - -- [Hilt testing guide (Views)](https://developer.android.com/topic/architecture/views/dependency-injection/hilt-testing-views) \ No newline at end of file diff --git a/tv/leanback-to-compose-tv-migration/SKILL.md b/tv/leanback-to-compose-tv-migration/SKILL.md deleted file mode 100644 index bf3487e..0000000 --- a/tv/leanback-to-compose-tv-migration/SKILL.md +++ /dev/null @@ -1,740 +0,0 @@ ---- -name: leanback-to-compose-tv-migration -description: Provides instructions and architectural patterns for migrating Android - TV applications from legacy Leanback UI Toolkit, Android Views, or Support Fragments - to Jetpack Compose for TV (androidx.tv). Use this skill for Leanback to Compose - migrations, including browse screen, settings screen, authentication screen, login - screen, or video playback screen migrations, or when replacing BrowseSupportFragment, - LeanbackSettingsFragment, PreferenceFragment, BaseLeanbackPreferenceFragmentCompat, - VideoSupportFragment, GuidedStepSupportFragment, SearchSupportFragment, VerticalGridSupportFragment, - Presenter, ArrayObjectAdapter, or CursorMapper with modern Compose equivalents, - implementing immersive carousels with focus memory, Media3 video playback with PlayerSurface, - or custom 10-foot hero layouts. -license: Complete terms in LICENSE.txt -metadata: - author: Google LLC - last-updated: '2026-09-18' - keywords: - - Android TV - - Jetpack Compose - - Leanback - - migration - - androidx.tv - - Carousel - - 10-foot UI - - PlayerSurface - - Media3 - - focusRestorer - - FocusRequester - - TV development - - Android Views - - LeanbackSettingsFragment - - PreferenceFragment - - BrowseSupportFragment ---- - -## The 10-foot UI - -A "10-foot UI" is a design paradigm for televisions that tailors an interface -for viewing from approximately 3 meters (10 feet) away. When designing for this -experience, account for these key characteristics: - -- **Viewing distance**: Viewers are sitting far from the screen, "leaning back". Screen layouts are uncluttered, with text and UI elements that are large enough to be comfortably readable from a distance, without dense blocks of text. -- **Color contrast**: To avoid washed out colors on TV displays with low contrast ratios, the design uses high-contrast palettes and distinct visual indicators so focused states remain visible across different TV panels. -- **D-pad navigation** : Interaction relies on a directional remote control with limited 4-way navigation (`Up`, `Down`, `Left`, `Right`) with components organized into clear spatial grids and carousels without focus traps. - -## Core architecture and library selection - -When migrating an Android TV application to Jetpack Compose, you must use -`androidx.tv` libraries and follow these 10-foot UI patterns: - -- **UI modernization**: Focus on custom, cinematic layouts over legacy direct 1:1 templates. You must use Jetpack Compose for TV features like dynamic gradient hero backdrops, custom focus animations, custom navigation drawers, and custom layouts. -- **Primary design system** : You must always use `androidx.tv.material3.*` (`androidx.tv:tv-material`) over mobile `androidx.compose.material3.*`. TV Material 3 provides built-in D-Pad focus handling, focus zoom scaling, and TV-optimized typography and shapes. To set up Compose for TV dependencies, follow [Compose for TV setup](references/android/training/tv/playback/compose/index.md). -- **Focus zoom animation** : For interactive cards, you must use `CompactCard`, `ClassicCard`, or `WideCardContainer` with `scale = - CardDefaults.scale(focusedScale = 1.1f)` to provide standard TV focus animation. -- **Coil image loading** : To use declarative `AsyncImage(model, - contentDescription, ...)` without passing an explicit `ImageLoader` parameter, you must include `io.coil-kt:coil-compose` in your Gradle dependencies. -- **Explicit imports** : You must always import TV Material 3 classes explicitly (for example, `import androidx.tv.material3.Surface`, `import - androidx.tv.material3.ListItem`) instead of using wildcard imports (`import - androidx.tv.material3.*`). -- **File naming conventions** : You must name Composable screen files after the screen (for example, name `BrowseScreen` as `BrowseScreen.kt`, `PlaybackScreen` as `PlaybackScreen.kt`, and `AuthenticationScreen` as `AuthenticationScreen.kt`). Don't use generic prefixes like `Main`. -- **Overscan and bezels** : You must apply horizontal padding (for example, `horizontal = 48.dp` or `32.dp`, `vertical = 24.dp`) to root containers, carousels, and top bars to prevent clipping. -- **Reading width constrainment** : You must constrain reading width using `Modifier.widthIn(max = 600.dp)` on text columns for long-form text. -- **Media3 Compose dependencies** : Include `androidx.media3:media3-ui-compose` in `app/build.gradle` when modernizing media playback screens. -- **Prohibition of legacy AndroidView wrappers** : Don't use legacy `AndroidView` wrappers to embed View-based components into Jetpack Compose screens. All migrated screens must use Compose components or Media3 Compose surfaces (`PlayerSurface`). - -## D-pad focus handling and navigation - -Jetpack Compose for TV (`androidx.tv.material3`) requires explicit focus -management, as components don't receive initial focus automatically and -navigation uses 2D spatial coordinates. To configure TV D-pad navigation, follow -instructions in [TV Navigation guide](references/android/training/tv/get-started/navigation.md). - -### Initial focus - -You must assign initial focus to the primary interactive element on every screen -(such as the first action button, card, or `ListItem`) using `FocusRequester` -when entering a screen. Define `val focusRequester = remember { FocusRequester() -}`, attach `Modifier.focusRequester(focusRequester)` to the primary element, and -request focus inside `LaunchedEffect(Unit) { focusRequester.requestFocus() }`: - - -```kotlin -val focusRequester = remember { FocusRequester() } -val focusManager = LocalFocusManager.current - -LaunchedEffect(Unit) { - focusRequester.requestFocus() -} -``` - -
- -*Note: For screens with dynamic state or pagers (like `OnboardingScreen` using -`HorizontalPager`), you must pass the state key to `LaunchedEffect` (for example -`LaunchedEffect(pagerState.currentPage)`) so that focus is re-applied when the -page changes.* - -### Bidirectional focus routing and avoiding focus traps - -When interactive elements sit on opposite sides of the display, standard 2D -spatial navigation fails to find targets across them. This creates focus traps -where users are unable to navigate out of an area using the D-pad. - -For symmetrical, bidirectional D-pad navigation without focus traps, you must -rely on Compose's 2D spatial focus engine whenever possible. When connecting -adjacent UI elements across scrollable containers (like `LazyColumn` or -`LazyRow`), don't set directional overrides (`up = ...`, `down = ...`) targeting -individual items inside lazy lists. When an item scrolls off-screen during -vertical navigation, its `FocusRequester` becomes uninitialized, throwing -`IllegalStateException` during focus searches: - - -```kotlin -Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 48.dp, vertical = 16.dp), - horizontalArrangement = Arrangement.End -) { - Button( - onClick = { /* Search */ }, - modifier = Modifier.focusRequester(topBarFocusRequester) - ) { Text("Search") } -} -``` - -
- -### Row focus recollection (`Modifier.focusRestorer`) - -When navigating vertically between horizontal carousels (`LazyRow`), Compose's -default 2D spatial focus engine searches along the X coordinate of the focused -item. If a user scrolls right in Row 1 (for example to Item 4 at X=800dp) and -presses DOWN to navigate to Row 2, spatial routing focuses whatever item sits at -X=800dp in Row 2. - -To make every row maintain its own recollection of card focus (restoring focus -to the previously visited item when revisited), you must attach -**`Modifier.focusRestorer`** (with no arguments) directly to the `LazyRow`. -Don't pass custom fallback `FocusRequester` lambdas in lazy containers, as -calling `requestFocus` on an unattached or off-screen item during rapid D-pad -scrolling throws `IllegalStateException`. - - -```kotlin -LazyRow( - modifier = Modifier.focusRestorer(), - contentPadding = PaddingValues(horizontal = 48.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp) -) { - itemsIndexed(videos) { vidIndex, video -> - CompactCard( - onClick = { onVideoClick(video) }, - image = { - AsyncImage( - model = video.cardImageUrl, - contentDescription = video.title, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize() - ) - }, - title = { Text(video.title) }, - modifier = Modifier - .then( - if (catIndex == 0 && vidIndex == 0) { - Modifier.focusRequester(firstCardFocusRequester) - } else { - Modifier - } - ) - .onFocusChanged { focusState -> - if (focusState.isFocused) { - focusedVideo = video - focusedCategoryIndex = catIndex - } - } - ) - } -} -``` - -
- -### Text input, hardware keyboard enter interception, and IME focus chaining - -When migrating search bars or login forms from Leanback -(`SearchSupportFragment`, `GuidedStepSupportFragment`), don't use bare -`BasicTextField` containers or empty `Surface(onClick = {})` wrappers, as they -prevent D-pad CENTER from attaching the virtual keyboard (IME). - -1. **Clickable TV surface wrapper with Back-key interception - (`onPreviewKeyEvent`)** : Wrap standard M3 `TextField` inside a focusable TV `Surface(onClick = { focusRequester.requestFocus() }, scale = - ClickableSurfaceDefaults.scale(focusedScale = 1.01f), border = - ClickableSurfaceDefaults.border(focusedBorder = Border(BorderStroke(2.dp, - Color.White))))` to provide a focused border outline and D-pad focus scaling. You must attach `Modifier.onPreviewKeyEvent` on the text field or wrapper to intercept `Key.Back` and `Key.Escape` so the user's able to remove focus from the input field without exiting the screen. -2. **Why Back-key interception is mandatory** : When editing a text field on Android TV, pressing the D-pad **Back** button normally navigates back and exits the screen. By intercepting `Key.Back` and `Key.Escape` on `KeyUp` in `onPreviewKeyEvent` to stop editing (clearing focus), the user's able to return to D-pad form navigation without being trapped in the text field or accidentally exiting the screen. -3. **IME focus chaining** : For multi-field forms (such as Username and Password in authentication screens), attach `KeyboardActions(onNext = { - focusManager.moveFocus(FocusDirection.Down) })` with `ImeAction.Next` on top fields to route focus down to the next input box, and `ImeAction.Done` on the bottom field to route focus directly to the submit button: - - -```kotlin -Surface( - onClick = { focusRequester.requestFocus() }, - border = ClickableSurfaceDefaults.border(focusedBorder = Border(border = BorderStroke(2.dp, Color.White))), - modifier = Modifier - .fillMaxWidth() - .focusRequester(focusRequester) -) { - OutlinedTextField( - value = username, - onValueChange = { username = it }, - label = { Text("Username") }, - singleLine = true, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), - keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }), - modifier = Modifier - .fillMaxWidth() - .onPreviewKeyEvent { event -> - if (event.type == KeyEventType.KeyDown && (event.key == Key.Enter || event.key == Key.NumPadEnter)) { - focusManager.moveFocus(FocusDirection.Down) - true - } else false - } - ) -} - -Surface( - onClick = {}, - border = ClickableSurfaceDefaults.border(focusedBorder = Border(border = BorderStroke(2.dp, Color.White))), - modifier = Modifier.fillMaxWidth() -) { - OutlinedTextField( - value = password, - onValueChange = { password = it }, - label = { Text("Password") }, - singleLine = true, - visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { onLoginSuccess() }), - modifier = Modifier - .fillMaxWidth() - .onPreviewKeyEvent { event -> - if (event.type == KeyEventType.KeyDown && (event.key == Key.Enter || event.key == Key.NumPadEnter)) { - onLoginSuccess() - true - } else false - } - ) -} -``` - -
- -## Lazy containers - -When implementing scrollable lists or grids in Jetpack Compose for TV, you must -use standard `LazyColumn`, `LazyRow`, and `LazyVerticalGrid` from -`androidx.compose.foundation.lazy` and `androidx.compose.foundation.lazy.grid`. -For catalog browsing layouts, follow instructions in [Catalog Browser guide](references/android/training/tv/playback/compose/browse.md). - -1. **Pivot scrolling with `BringIntoViewSpec`** : When defining a custom pivot scroll line for catalog rows using `BringIntoViewSpec` and `CompositionLocalProvider(LocalBringIntoViewSpec provides ...)`, you must ensure your project compiles against Compose Foundation 1.7.0+ by adding `implementation platform('androidx.compose:compose-bom:2024.06.00')` (or newer) or `implementation 'androidx.compose.foundation:foundation:1.7.0'` in `app/build.gradle`. Without Compose Foundation 1.7.0+, `import - androidx.compose.foundation.gestures.LocalBringIntoViewSpec` will fail with `Unresolved reference: LocalBringIntoViewSpec`. -2. **Row focus recollection (`Modifier.focusRestorer`)** : Annotate your composable with `@OptIn(ExperimentalFocusRestorerApi::class, - ExperimentalComposeUiApi::class)` and attach `Modifier.focusRestorer` on every category `LazyRow` to remember and restore the last focused card when navigating vertically across catalog rows. - -When populated with focusable TV Material 3 components (`CompactCard`, -`ListItem`, `Button`), standard Compose lazy containers handle 2D D-Pad focus -routing, focus memory, and edge scrolling automatically: - - -```kotlin -@Composable -fun CatalogBrowser( - featuredContentList: List, - sectionList: List
, - modifier: Modifier = Modifier, - onItemSelected: (Movie) -> Unit = {}, -) { - LazyColumn( - modifier = modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - items(sectionList.size) { index -> - val section = sectionList[index] - SectionRow(section, onItemSelected = onItemSelected) - } - } -} -``` - -
- -## Media3 video playback and transport controls - -When migrating legacy Leanback video playback (`VideoSupportFragment` / -`PlaybackGlue`), use Compose Media3 `PlayerSurface` -(`androidx.media3.ui.compose.PlayerSurface`) combined with a translucent -transport controls overlay: - -1. **Mandatory Media3 transport control buttons (`PlayPauseButton`)** : Over the `PlayerSurface`, you must layer a translucent bottom controls bar (`Box(modifier = Modifier.align(Alignment.BottomCenter))`) containing explicit Media3 UI Compose buttons: at minimum `PlayPauseButton`, `SeekBackButton`, and `SeekForwardButton`. Never leave the transport controls overlay empty. To use these composables, you must add `implementation 'androidx.media3:media3-ui-compose-material3:1.6.0'` alongside `androidx.media3:media3-ui-compose` in your `app/build.gradle` dependencies. -2. **D-pad directional seeking (`onPreviewKeyEvent`)** : To support seeking backward and forward with the remote control D-pad, attach `Modifier.onPreviewKeyEvent` on the container or controls overlay and intercept Compose `Key.DirectionLeft` and `Key.DirectionRight` (for example, `keyEvent.key == Key.DirectionLeft`) to seek backward and forward by 10 seconds (`exoPlayer.seekTo(exoPlayer.currentPosition - 10000)`). Never use legacy Android View keycodes (`KeyEvent.KEYCODE_DPAD_LEFT` or `nativeKeyEvent.keyCode`). -3. **Prohibition of legacy AndroidView wrappers** : Don't wrap legacy `PlayerView` or `StyledPlayerView` in `AndroidView { ... }`. You must use `PlayerSurface` (`androidx.media3.ui.compose.PlayerSurface`) with `ExoPlayer` for video rendering in Compose for TV. - -## Phased migration strategy - -To migrate an app cleanly without breaking compilation or introducing circular -dependencies, execute in five distinct phases: - -- **Phase 1**: Foundation and design system -- **Phase 2**: Leaf and standalone screens -- **Phase 3**: Core browsing and discovery screens -- **Phase 4**: Details and media playback -- **Phase 5**: Final unification and cleanup - -### Phase 1: Foundation and design system - -- Create `TvTheme.kt` wrapping `TvMaterialTheme` with custom `ColorScheme`, `Typography`, and `Shapes`. -- Build atomic reusable components: `MovieCard`, `SectionHeader`, `LoadingIndicator`, `ErrorState`. - -### Phase 2: Leaf and standalone screens - -- Migrate screens with no outbound navigation first, such as error messages, onboarding screens, or settings screens, by replacing them with `ErrorDialog`, `OnboardingScreen`, and `SettingsScreen`. -- Replace anything using `BaseLeanbackPreferenceFragmentCompat`, `BaseLeanbackPreferenceFragment`, or `LeanbackSettingsFragment` to use Compose, for example by using `ListItem` + `Switch` bound directly to `SharedPreferences` and replacing the `findPreference` implementation. -- Ensure screens receive initial D-pad focus on the first or main component of that screen using `FocusRequester`, for example on the first `ListItem`. -- Replace legacy `Fragment` classes with activities of type `ComponentActivity` that declaratively use components in Compose. -- Clean up legacy style and theme references in `res/values/styles.xml` and `res/values/themes.xml` (such as removing `preferenceTheme` that points to `@style/PreferenceThemeOverlay.v14.Leanback`) that aren't supported once leanback dependencies are removed: - - -```kotlin -@Composable -fun TvSettingsScreen( - modifier: Modifier = Modifier -) { - var autoPlayNext by remember { mutableStateOf(true) } - var highQualityAudio by remember { mutableStateOf(false) } - - Column( - modifier = modifier - .fillMaxSize() - .padding(48.dp) - ) { - Text( - text = "Settings", - style = MaterialTheme.typography.headlineMedium, - modifier = Modifier.padding(bottom = 24.dp) - ) - - LazyColumn( - verticalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(vertical = 8.dp) - ) { - item { - ListItem( - selected = false, - onClick = { autoPlayNext = !autoPlayNext }, - headlineContent = { Text("Autoplay Next Video") }, - supportingContent = { Text("Automatically start playing next item in queue") }, - trailingContent = { - Switch( - checked = autoPlayNext, - onCheckedChange = null - ) - } - ) - } - - item { - ListItem( - selected = false, - onClick = { highQualityAudio = !highQualityAudio }, - headlineContent = { Text("High Quality Audio") }, - supportingContent = { Text("Use spatial audio and multi-channel output when available") }, - trailingContent = { - Switch( - checked = highQualityAudio, - onCheckedChange = null - ) - } - ) - } - } - } -} -``` - -
- -### Phase 3: Core browsing and discovery screens - -- Migrate `VerticalGridScreen` (`LazyVerticalGrid`), `SearchScreen` (`BasicTextField` with live list filtering), and `BrowseScreen` (`LazyColumn` of `LazyRow`s). -- Eradicate legacy `ArrayObjectAdapter`, `ListRowPresenter`, `CardPresenter`, and `HeaderItem` classes. - -### Phase 4: Details and media playback - -- Migrate `VideoDetailsScreen` and `GuidedStepScreen`. -- Migrate `PlaybackScreen` using Compose Media3 `PlayerSurface` (`androidx.media3.ui.compose.PlayerSurface`) paired with a translucent bottom overlay containing Media3 Compose transport controls (such as `PlayPauseButton`, `SeekBackButton`, `SeekForwardButton`). - -### Phase 5: Final unification and cleanup - -- Remove all remaining legacy `.java` activities, fragments, presenters, and XML layout files. -- Ensure all activities extend `ComponentActivity` or `FragmentActivity` calling `setContent { ... }`. -- Completely remove legacy Leanback themes and style declarations from `res/values/styles.xml` and `res/values/themes.xml` (for example, any styles inheriting from `Theme.Leanback` or referencing `lb_` styles). - -## Component and class mapping guide - -| Legacy Leanback / View Class | Modern Jetpack Compose Equivalent | -|---|---| -| `BrowseSupportFragment` / `MainFragment` | `BrowseScreen` (`LazyColumn` containing categorized `LazyRow`s + Hero Banner) | -| `DetailsSupportFragment` | `VideoDetailsScreen` (Poster image, text column, action buttons, related `LazyRow`) | -| `VideoSupportFragment` / `PlaybackGlue` | `PlaybackScreen` (Media3 `ExoPlayer` + Compose `PlayerSurface`) | -| `GuidedStepSupportFragment` | `GuidedStepScreen` (Split-screen layout: 40% left guidance pane, 60% right actions pane) | -| `SearchSupportFragment` | `SearchScreen` (`BasicTextField` + live filtering + `LazyVerticalGrid`) | -| `VerticalGridSupportFragment` | `VerticalGridScreen` (`LazyVerticalGrid(columns = GridCells.Fixed(5))`) | -| `ArrayObjectAdapter` / `Presenter` | Declarative `@Composable` functions observing immutable `State>` | -| `CursorMapper` / `LoaderManager` / `CursorLoader` | Kotlin Coroutines / `withContext(Dispatchers.IO)` in a Repository object | -| `OnboardingSupportFragment` | `OnboardingScreen` (`HorizontalPager` + D-Pad navigation buttons) | -| `LeanbackSettingsFragment` / `PreferenceFragment` | `SettingsScreen` (`FocusRequester` on first `ListItem` + trailing `Switch` bound to `SharedPreferences`) | - -## Reference implementations and battle-tested patterns - -### Modern TV immersive list architecture (`BrowseScreen`) - -When building a 10-foot TV browse screen or Immersive List, don't place a hero -banner before or outside a scrolling list, and don't fight Compose's automatic -`BringIntoView` system with programmatic `animateScrollToItem` calls. - -Instead, use `BringIntoViewSpec` with `LocalBringIntoViewSpec` from Compose -Foundation to define exact TV pivot scrolling (for example, pivoting active rows -at 35% from the top edge of the display). Combine this with a reshaping -immersive row: when lower rows are focused (`focusedCategoryIndex > 0`), hide -the hero text and display a normal section header on Row 0: - - -```kotlin -@Composable -fun ImmersiveBrowseScreen( - categories: Map>, - onVideoClick: (Video) -> Unit -) { - var focusedVideo by remember { mutableStateOf(null) } - var focusedCategoryIndex by remember { mutableStateOf(0) } - val listState = rememberLazyListState() - val topBarFocusRequester = remember { FocusRequester() } - val firstCardFocusRequester = remember { FocusRequester() } - - Box(modifier = Modifier.fillMaxSize()) { - AsyncImage( - model = focusedVideo?.bgImageUrl, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize() - ) - - PositionFocusedItemInLazyLayout(parentFraction = 0.35f, childFraction = 0.5f) { - LazyColumn( - state = listState, - contentPadding = PaddingValues(top = 36.dp, bottom = 64.dp), - verticalArrangement = Arrangement.spacedBy(28.dp), - modifier = Modifier.fillMaxSize() - ) { - item { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 48.dp, vertical = 16.dp), - horizontalArrangement = Arrangement.End - ) { - Button( - onClick = { /* Search */ }, - modifier = Modifier.focusRequester(topBarFocusRequester) - ) { Text("Search") } - } - } - - itemsIndexed(categories.entries.toList()) { catIndex, (categoryName, videos) -> - Column { - if (catIndex == 0 && focusedCategoryIndex == 0) { - Column( - modifier = Modifier - .heightIn(min = 200.dp) - .padding(horizontal = 48.dp) - ) { - Text( - text = focusedVideo?.title ?: "", - style = MaterialTheme.typography.displayMedium - ) - Text( - text = focusedVideo?.description ?: "", - style = MaterialTheme.typography.bodyLarge - ) - } - } else { - Text( - text = categoryName, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(horizontal = 48.dp, vertical = 8.dp) - ) - } - - LazyRow( - modifier = Modifier.focusRestorer(), - contentPadding = PaddingValues(horizontal = 48.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - itemsIndexed(videos) { vidIndex, video -> - CompactCard( - onClick = { onVideoClick(video) }, - image = { - AsyncImage( - model = video.cardImageUrl, - contentDescription = video.title, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize() - ) - }, - title = { Text(video.title) }, - modifier = Modifier - .then( - if (catIndex == 0 && vidIndex == 0) { - Modifier.focusRequester(firstCardFocusRequester) - } else { - Modifier - } - ) - .onFocusChanged { focusState -> - if (focusState.isFocused) { - focusedVideo = video - focusedCategoryIndex = catIndex - } - } - ) - } - } - } - } - } - } - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun PositionFocusedItemInLazyLayout( - parentFraction: Float = 0.35f, - childFraction: Float = 0.5f, - content: @Composable () -> Unit, -) { - val bringIntoViewSpec = remember(parentFraction, childFraction) { - object : BringIntoViewSpec { - override fun calculateScrollDistance( - offset: Float, - size: Float, - containerSize: Float - ): Float { - if (offset >= 0f && offset <= containerSize * 0.45f) { - return 0f - } - val initialTargetForLeadingEdge = parentFraction * containerSize - (childFraction * size) - val targetForLeadingEdge = if (size <= containerSize && (containerSize - initialTargetForLeadingEdge) < size) { - containerSize - size - } else { - initialTargetForLeadingEdge - } - return offset - targetForLeadingEdge - } - } - } - CompositionLocalProvider(LocalBringIntoViewSpec provides bringIntoViewSpec, content = content) -} -``` - -
- -### Media3 playback in Compose TV (`PlaybackScreen`) - -Implement a custom playback screen using -`androidx.media3.ui.compose.PlayerSurface` as the video rendering canvas. Layer -Material3 transport controls (`SeekBackButton`, `PlayPauseButton`, -`SeekForwardButton` from `androidx.media3:media3-ui-compose-material3`) over the -surface in a translucent bottom overlay, and handle D-pad remote key events with -an auto-hide timeout: - - -```kotlin -@OptIn(UnstableApi::class) -@Composable -fun Media3PlaybackScreen( - video: Video, - onFinish: () -> Unit, - modifier: Modifier = Modifier -) { - val context = LocalContext.current - val exoPlayer = remember(context) { ExoPlayer.Builder(context).build() } - var showControls by remember { mutableStateOf(true) } - val focusRequester = remember { FocusRequester() } - val coroutineScope = rememberCoroutineScope() - var autoHideJob by remember { mutableStateOf(null) } - - fun scheduleAutoHide() { - autoHideJob?.cancel() - autoHideJob = coroutineScope.launch { - delay(5000) - showControls = false - } - } - - DisposableEffect(exoPlayer) { - val listener = object : Player.Listener { - override fun onPlaybackStateChanged(playbackState: Int) { - if (playbackState == Player.STATE_ENDED) { - onFinish() - } - } - } - exoPlayer.addListener(listener) - onDispose { - autoHideJob?.cancel() - exoPlayer.removeListener(listener) - exoPlayer.release() - } - } - - LaunchedEffect(video) { - focusRequester.requestFocus() - scheduleAutoHide() - - val mediaItem = MediaItem.fromUri(video.videoUrl) - exoPlayer.setMediaItem(mediaItem) - exoPlayer.prepare() - exoPlayer.playWhenReady = true - } - - Box( - modifier = modifier - .fillMaxSize() - .background(Color.Black) - .focusRequester(focusRequester) - .focusable() - .onPreviewKeyEvent { event -> - if (event.type == KeyEventType.KeyDown) { - when (event.key) { - Key.DirectionCenter, Key.Enter, Key.NumPadEnter, Key.MediaPlayPause, Key.Spacebar -> { - if (exoPlayer.isPlaying) exoPlayer.pause() else exoPlayer.play() - showControls = true - scheduleAutoHide() - true - } - Key.DirectionLeft, Key.MediaRewind -> { - val newPos = (exoPlayer.currentPosition - 10_000L).coerceAtLeast(0L) - exoPlayer.seekTo(newPos) - showControls = true - scheduleAutoHide() - true - } - Key.DirectionRight, Key.MediaFastForward -> { - val duration = exoPlayer.duration.coerceAtLeast(0L) - val newPos = (exoPlayer.currentPosition + 10_000L).coerceAtMost(duration) - exoPlayer.seekTo(newPos) - showControls = true - scheduleAutoHide() - true - } - Key.DirectionUp, Key.DirectionDown -> { - showControls = !showControls - if (showControls) scheduleAutoHide() else autoHideJob?.cancel() - true - } - else -> false - } - } else false - }, - contentAlignment = Alignment.Center - ) { - PlayerSurface( - player = exoPlayer, - modifier = Modifier.fillMaxSize() - ) - - if (showControls) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color(0x80000000)) - .padding(48.dp), - contentAlignment = Alignment.BottomCenter - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(24.dp), - verticalAlignment = Alignment.CenterVertically - ) { - SeekBackButton(player = exoPlayer) - PlayPauseButton(player = exoPlayer) - SeekForwardButton(player = exoPlayer) - } - } - } - } -} -``` - -
- -### Replacing CursorLoader with reactive coroutine flow - -Replace legacy `LoaderManager.LoaderCallbacks` and `CursorObjectAdapter` -with a repository returning a `Flow` that observes database changes and triggers -asynchronous fetching when empty: - - -```kotlin -object VideoFlowRepository { - fun getVideosFlow(context: Context, contentUri: Uri): Flow> = callbackFlow { - val contentObserver = object : ContentObserver(Handler(Looper.getMainLooper())) { - override fun onChange(selfChange: Boolean) { - trySend(queryVideos(context, contentUri)) - } - } - - context.contentResolver.registerContentObserver( - contentUri, - true, - contentObserver - ) - - val initialVideos = queryVideos(context, contentUri) - trySend(initialVideos) - - awaitClose { - context.contentResolver.unregisterContentObserver(contentObserver) - } - }.flowOn(Dispatchers.IO) - - private fun queryVideos(context: Context, uri: Uri): List