Import official OpenClaw Android app (apps/android @ 71a59512ba476df3328cf485d84748121cf341f2)

Pristine fork source for PROJ-0088. v1 will turn this into a WebView web
shell; the WebSocket node infrastructure stays intact for v2 heartbeat.
This commit is contained in:
Brendan Greenlee 2026-08-01 15:13:34 +00:00
commit 7a380c40ed
656 changed files with 209982 additions and 0 deletions

20
fastlane/.env.example Normal file
View file

@ -0,0 +1,20 @@
# Google Play API key (pick one approach)
#
# Recommended local path:
# GOOGLE_PLAY_JSON_KEY=/absolute/path/to/google-play-service-account.json
#
# Or raw JSON content for CI:
# GOOGLE_PLAY_JSON_KEY_DATA={"type":"service_account",...}
# Optional app targeting
# GOOGLE_PLAY_PACKAGE_NAME=ai.openclaw.app
# Release target
# GOOGLE_PLAY_TRACK=internal
# GOOGLE_PLAY_RELEASE_STATUS=completed
# GOOGLE_PLAY_VALIDATE_ONLY=1
# Metadata toggles
# SUPPLY_UPLOAD_METADATA=1
# SUPPLY_UPLOAD_IMAGES=1
# SUPPLY_UPLOAD_SCREENSHOTS=1

3
fastlane/Appfile Normal file
View file

@ -0,0 +1,3 @@
package_name(ENV["GOOGLE_PLAY_PACKAGE_NAME"] || "ai.openclaw.app")
json_key_file(ENV["GOOGLE_PLAY_JSON_KEY"]) if ENV["GOOGLE_PLAY_JSON_KEY"]

614
fastlane/Fastfile Normal file
View file

@ -0,0 +1,614 @@
require "fileutils"
require "json"
require "open3"
require "shellwords"
require "supply"
default_platform(:android)
ANDROID_FASTLANE_ROOT = File.expand_path(__dir__, Dir.pwd)
DEFAULT_PLAY_PACKAGE_NAME = "ai.openclaw.app"
DEFAULT_PLAY_TRACK = "internal"
DEFAULT_PLAY_RELEASE_STATUS = "completed"
ANDROID_RELEASE_SIGNING_GRADLE_PROPERTIES = [
"OPENCLAW_ANDROID_STORE_FILE",
"OPENCLAW_ANDROID_STORE_PASSWORD",
"OPENCLAW_ANDROID_KEY_ALIAS",
"OPENCLAW_ANDROID_KEY_PASSWORD"
].freeze
def load_env_file(path)
return unless File.exist?(path)
File.foreach(path) do |line|
stripped = line.strip
next if stripped.empty? || stripped.start_with?("#")
key, value = stripped.split("=", 2)
next if key.nil? || key.empty? || value.nil?
ENV[key] = value if ENV[key].nil? || ENV[key].strip.empty?
end
end
def env_present?(value)
!value.nil? && !value.strip.empty?
end
def android_root
File.expand_path("..", ANDROID_FASTLANE_ROOT)
end
def repo_root
File.expand_path("../..", android_root)
end
def android_release_signing_script
File.join(repo_root, "scripts", "android-release-signing.mjs")
end
def android_release_signing_materialized_properties_path
File.join(android_root, "build", "release-signing", "gradle.properties")
end
def shell_join(args)
args.shelljoin
end
def play_package_name
raw = ENV["GOOGLE_PLAY_PACKAGE_NAME"].to_s.strip
raw.empty? ? DEFAULT_PLAY_PACKAGE_NAME : raw
end
def play_track
raw = ENV["GOOGLE_PLAY_TRACK"].to_s.strip
raw.empty? ? DEFAULT_PLAY_TRACK : raw
end
def wear_play_track
"wear:#{play_track}"
end
def play_release_status
raw = ENV["GOOGLE_PLAY_RELEASE_STATUS"].to_s.strip
raw.empty? ? DEFAULT_PLAY_RELEASE_STATUS : raw
end
def play_validate_only?
ENV["GOOGLE_PLAY_VALIDATE_ONLY"] == "1"
end
def play_metadata_upload_requested?
ENV["SUPPLY_UPLOAD_METADATA"] == "1"
end
def play_screenshot_upload_requested?
ENV["SUPPLY_UPLOAD_SCREENSHOTS"] == "1"
end
def play_image_upload_requested?
ENV["SUPPLY_UPLOAD_IMAGES"] == "1"
end
def play_auth_options
json_key = ENV["GOOGLE_PLAY_JSON_KEY"].to_s.strip
json_key = ENV["SUPPLY_JSON_KEY"].to_s.strip if json_key.empty?
json_key = ENV["GOOGLE_PLAY_JSON_KEY_PATH"].to_s.strip if json_key.empty?
return { json_key: json_key } unless json_key.empty?
json_key_data = ENV["GOOGLE_PLAY_JSON_KEY_DATA"].to_s.strip
json_key_data = ENV["SUPPLY_JSON_KEY_DATA"].to_s.strip if json_key_data.empty?
return { json_key_data: json_key_data } unless json_key_data.empty?
UI.user_error!("Missing Google Play API credentials. Set GOOGLE_PLAY_JSON_KEY or GOOGLE_PLAY_JSON_KEY_DATA.")
end
def validate_play_auth!
client = nil
begin
client = Supply::Client.make_from_config(params: play_auth_options)
client.begin_edit(package_name: play_package_name)
rescue => e
UI.user_error!("Google Play API credentials are invalid for #{play_package_name}: #{e.message}")
ensure
if client&.current_edit
begin
client.abort_current_edit
rescue => e
UI.user_error!("Google Play API credentials opened a validation edit but could not close it: #{e.message}")
end
end
end
end
def read_android_version_metadata
stdout, stderr, status = Open3.capture3(
"node",
"--import",
"tsx",
File.join(repo_root, "scripts", "android-version.ts"),
"--json",
"--root",
repo_root
)
unless status.success?
detail = stderr.to_s.strip
detail = stdout.to_s.strip if detail.empty?
UI.user_error!("Failed to read Android version metadata: #{detail}")
end
parsed = JSON.parse(stdout)
version = parsed.fetch("canonicalVersion").to_s
version_code = parsed.fetch("versionCode").to_i
UI.user_error!("Android version helper returned incomplete metadata.") if version.empty? || version_code <= 0
{ version: version, version_code: version_code }
rescue JSON::ParserError => e
UI.user_error!("Invalid JSON from Android version helper: #{e.message}")
end
def sync_android_versioning!
sh(shell_join(["node", "--import", "tsx", File.join(repo_root, "scripts", "android-sync-versioning.ts"), "--check", "--root", repo_root]))
end
def android_release_notes_path
File.join(ANDROID_FASTLANE_ROOT, "metadata", "android", "en-US", "release_notes.txt")
end
def validate_android_release_notes!
release_notes_path = android_release_notes_path
UI.user_error!("Missing Android release notes at #{release_notes_path}. Run `pnpm android:version:sync`.") unless File.exist?(release_notes_path)
UI.user_error!("Android release notes at #{release_notes_path} are empty.") unless env_present?(File.read(release_notes_path))
end
def android_changelog_path(version_code)
File.join(ANDROID_FASTLANE_ROOT, "metadata", "android", "en-US", "changelogs", "#{version_code}.txt")
end
def wear_version_code(phone_version_code)
build_number = phone_version_code % 100
UI.user_error!("Android phone versionCode build number must be 01 through 49.") unless (1..49).cover?(build_number)
phone_version_code + 50
end
def sync_android_changelog!(version_code)
validate_android_release_notes!
changelog_path = android_changelog_path(version_code)
FileUtils.mkdir_p(File.dirname(changelog_path))
File.write(changelog_path, File.read(android_release_notes_path))
wear_changelog_path = android_changelog_path(wear_version_code(version_code))
File.write(wear_changelog_path, File.read(android_release_notes_path))
[changelog_path, wear_changelog_path]
end
def play_metadata_languages
Dir.children(play_metadata_path)
.select { |language| File.directory?(File.join(play_metadata_path, language)) }
.reject { |language| language.start_with?(".") }
.sort
end
def play_release_notes(version_code)
play_metadata_languages.filter_map do |language|
changelog_path = File.join(play_metadata_path, language, "changelogs", "#{version_code}.txt")
fallback_path = File.join(play_metadata_path, language, "changelogs", "default.txt")
source_path = File.exist?(changelog_path) ? changelog_path : fallback_path
next unless File.exist?(source_path)
AndroidPublisher::LocalizedText.new(language: language, text: File.read(source_path, encoding: "UTF-8"))
end
end
def update_play_track!(client, track_name:, version_code:, version_name:)
release = AndroidPublisher::TrackRelease.new(
name: version_name,
status: play_release_status,
version_codes: [version_code],
release_notes: play_release_notes(version_code)
)
track = client.tracks(track_name).first || AndroidPublisher::Track.new(track: track_name)
# Google preserves older releases when the edit replaces this list with the newest release.
track.releases = [release]
# Supply::Client owns the active edit; its public API takes (track_name, track_object).
client.update_track(track_name, track)
end
def upload_play_listing_assets!(client, upload_metadata:, upload_images:, upload_screenshots:)
play_metadata_languages.each do |language|
language_path = File.join(play_metadata_path, language)
if upload_metadata
metadata_fields = Supply::AVAILABLE_METADATA_FIELDS.select { |field| File.exist?(File.join(language_path, "#{field}.txt")) }
unless metadata_fields.empty?
# This returns Supply::Listing, whose save writes through the same active edit.
listing = client.listing_for_language(language)
metadata_fields.each do |field|
listing.public_send("#{field}=", File.read(File.join(language_path, "#{field}.txt"), encoding: "UTF-8"))
end
listing.save
end
end
if upload_images
Supply::IMAGES_TYPES.each do |image_type|
path = Dir.glob(File.join(language_path, "images", "#{image_type}.{png,jpg,jpeg}"), File::FNM_CASEFOLD).sort.last
client.upload_image(image_path: path, image_type: image_type, language: language) if path
end
end
next unless upload_screenshots
Supply::SCREENSHOT_TYPES.each do |screenshot_type|
paths = Dir.glob(File.join(language_path, "images", screenshot_type, "*.{png,jpg,jpeg}"), File::FNM_CASEFOLD).sort
next if paths.empty?
client.clear_screenshots(image_type: screenshot_type, language: language)
paths.each { |path| client.upload_image(image_path: path, image_type: screenshot_type, language: language) }
end
end
end
def fastlane_boolean_env(name, default:)
value = ENV[name]
return default if value.nil?
normalized = value.downcase
return true if ["1", "yes", "true", "on"].include?(normalized)
return false if ["0", "no", "false", "off"].include?(normalized)
UI.user_error!("#{name} must be true/false, yes/no, on/off, or 1/0.")
end
def upload_play_builds_atomically!(phone_artifact_path:, wear_artifact_path:, version_metadata:, upload_metadata:, upload_images:, upload_screenshots:)
previous_supply_config = Supply.config
client = nil
begin
Supply.config = {
ack_bundle_installation_warning: fastlane_boolean_env("ACK_BUNDLE_INSTALLATION_WARNING", default: false),
changes_not_sent_for_review: fastlane_boolean_env("SUPPLY_CHANGES_NOT_SENT_FOR_REVIEW", default: false),
rescue_changes_not_sent_for_review: fastlane_boolean_env("SUPPLY_RESCUE_CHANGES_NOT_SENT_FOR_REVIEW", default: true)
}
client = Supply::Client.make_from_config(params: play_auth_options.merge(timeout: (ENV["SUPPLY_TIMEOUT"] || "300").to_i))
client.begin_edit(package_name: play_package_name)
phone_version_code = client.upload_bundle(phone_artifact_path)
wear_version_code_value = client.upload_bundle(wear_artifact_path)
expected_phone_version_code = version_metadata.fetch(:version_code)
expected_wear_version_code = wear_version_code(expected_phone_version_code)
UI.user_error!("Uploaded phone AAB versionCode #{phone_version_code}, expected #{expected_phone_version_code}.") unless phone_version_code.to_i == expected_phone_version_code
UI.user_error!("Uploaded Wear AAB versionCode #{wear_version_code_value}, expected #{expected_wear_version_code}.") unless wear_version_code_value.to_i == expected_wear_version_code
update_play_track!(
client,
track_name: play_track,
version_code: phone_version_code,
version_name: version_metadata.fetch(:version)
)
update_play_track!(
client,
track_name: wear_play_track,
version_code: wear_version_code_value,
version_name: version_metadata.fetch(:version)
)
upload_play_listing_assets!(
client,
upload_metadata: upload_metadata,
upload_images: upload_images,
upload_screenshots: upload_screenshots
)
if play_validate_only?
client.validate_current_edit!
UI.success("Successfully validated the atomic phone and Wear upload.")
else
client.commit_current_edit!
UI.success("Successfully committed the atomic phone and Wear upload.")
end
ensure
if client&.current_edit
begin
client.abort_current_edit
rescue => error
UI.important("Could not abort Google Play edit after failure: #{error.message}")
end
end
Supply.config = previous_supply_config
end
end
def play_metadata_path
File.join(ANDROID_FASTLANE_ROOT, "metadata", "android")
end
def play_screenshot_paths_for_type(screenshot_type)
Dir[File.join(play_metadata_path, "**", "images", screenshot_type, "*.{png,jpg,jpeg}")]
end
def validate_android_screenshots!
return unless play_screenshot_upload_requested?
required_types = %w(phoneScreenshots wearScreenshots)
missing_types = required_types.select { |screenshot_type| play_screenshot_paths_for_type(screenshot_type).empty? }
unless missing_types.empty?
UI.user_error!("SUPPLY_UPLOAD_SCREENSHOTS=1 but no screenshots were found for: #{missing_types.join(', ')}.")
end
end
def release_artifact_path(version)
File.join(android_root, "build", "release-artifacts", "openclaw-#{version}-play-release.aab")
end
def wear_release_artifact_path(version)
File.join(android_root, "build", "release-artifacts", "openclaw-#{version}-wear-release.aab")
end
def play_release_artifact_paths(version)
[release_artifact_path(version), wear_release_artifact_path(version)]
end
def build_release_artifacts!
sh(shell_join(["bun", File.join(android_root, "scripts", "build-release-artifacts.ts")]))
end
def capture_android_screenshots!
sh(shell_join(["bash", File.join(repo_root, "scripts", "android-screenshots.sh")]))
end
def mobile_release_ref_script
File.join(repo_root, "scripts", "mobile-release-ref.ts")
end
def release_git_sha
stdout, stderr, status = Open3.capture3("git", "rev-parse", "HEAD", chdir: repo_root)
UI.user_error!("Unable to resolve release Git SHA: #{stderr.strip}") unless status.success?
stdout.strip
end
def mobile_release_ref_command(command, platform:, version:, build: nil, version_code: nil, sha: nil)
args = [
"node",
"--import",
"tsx",
mobile_release_ref_script,
command,
"--platform",
platform,
"--version",
version,
"--root",
repo_root,
]
args.push("--build", build.to_s) if build
args.push("--version-code", version_code.to_s) if version_code
args.push("--sha", sha.to_s) if sha
sh(shell_join(args))
end
def ensure_mobile_release_ref_available!(platform:, version:, build: nil, version_code: nil, sha: nil)
mobile_release_ref_command(
"preflight",
platform: platform,
version: version,
build: build,
version_code: version_code,
sha: sha
)
end
def record_mobile_release_ref!(platform:, version:, build: nil, version_code: nil, sha: nil)
mobile_release_ref_command(
"record",
platform: platform,
version: version,
build: build,
version_code: version_code,
sha: sha
)
end
def read_android_release_signing_properties!(path)
UI.user_error!("Missing materialized Android release signing properties at #{path}.") unless File.exist?(path)
properties = {}
File.foreach(path) do |line|
stripped = line.strip
next if stripped.empty? || stripped.start_with?("#")
key, value = stripped.split("=", 2)
next if key.nil? || key.empty? || value.nil?
properties[key] = value.strip
end
missing = ANDROID_RELEASE_SIGNING_GRADLE_PROPERTIES.reject { |key| env_present?(properties[key]) }
UI.user_error!("Materialized Android release signing properties are missing: #{missing.join(', ')}.") unless missing.empty?
properties
end
def export_android_release_signing_properties!(path)
read_android_release_signing_properties!(path).each do |key, value|
ENV["ORG_GRADLE_PROJECT_#{key}"] = value
end
end
def sync_android_release_signing!
sh(shell_join(["node", android_release_signing_script, "--mode", "sync-pull"]))
export_android_release_signing_properties!(android_release_signing_materialized_properties_path)
end
def prepare_android_release_signing!
if env_present?(ENV["MATCH_PASSWORD"])
sync_android_release_signing!
elsif File.exist?(android_release_signing_materialized_properties_path)
export_android_release_signing_properties!(android_release_signing_materialized_properties_path)
end
end
def validate_android_release_signing!
Dir.chdir(android_root) do
sh(shell_join(["./gradlew", ":app:bundlePlayRelease", ":wear:bundleRelease", "--dry-run"]))
end
end
def print_android_release_plan!(version_metadata)
UI.message("Android Play release plan:")
UI.message(" package: #{play_package_name}")
UI.message(" track: #{play_track}")
UI.message(" Wear track: #{wear_play_track}")
UI.message(" release_status: #{play_release_status}")
UI.message(" validate_only: #{play_validate_only?}")
UI.message(" versionName: #{version_metadata.fetch(:version)}")
UI.message(" phone versionCode: #{version_metadata.fetch(:version_code)}")
UI.message(" Wear versionCode: #{wear_version_code(version_metadata.fetch(:version_code))}")
end
def validate_android_release_preflight!(version_metadata)
validate_play_auth!
prepare_android_release_signing!
validate_android_release_signing!
validate_android_release_notes!
print_android_release_plan!(version_metadata)
end
def upload_play_store_metadata!(version_metadata)
validate_android_screenshots!
sync_android_changelog!(version_metadata.fetch(:version_code))
upload_to_play_store(
**play_auth_options,
package_name: play_package_name,
track: play_track,
version_code: version_metadata.fetch(:version_code),
metadata_path: play_metadata_path,
skip_upload_apk: true,
skip_upload_aab: true,
skip_upload_metadata: !play_metadata_upload_requested?,
skip_upload_changelogs: false,
skip_upload_images: !play_image_upload_requested?,
skip_upload_screenshots: !play_screenshot_upload_requested?,
validate_only: play_validate_only?
)
end
def upload_play_store_build!(version_metadata, upload_metadata: false, upload_images: false, upload_screenshots: false)
release_sha = release_git_sha
ensure_mobile_release_ref_available!(
platform: "android",
version: version_metadata.fetch(:version),
version_code: version_metadata.fetch(:version_code),
sha: release_sha
)
ENV["SUPPLY_UPLOAD_SCREENSHOTS"] = "1" if upload_screenshots
validate_android_screenshots!
sync_android_changelog!(version_metadata.fetch(:version_code))
artifact_paths = play_release_artifact_paths(version_metadata.fetch(:version))
missing_artifacts = artifact_paths.reject { |path| File.exist?(path) }
unless missing_artifacts.empty?
UI.user_error!("Missing Play release artifacts at #{missing_artifacts.join(', ')}. Run pnpm android:release:archive first.")
end
phone_artifact_path, wear_artifact_path = artifact_paths
upload_play_builds_atomically!(
phone_artifact_path: phone_artifact_path,
wear_artifact_path: wear_artifact_path,
version_metadata: version_metadata,
upload_metadata: upload_metadata,
upload_images: upload_images,
upload_screenshots: upload_screenshots
)
unless play_validate_only?
record_mobile_release_ref!(
platform: "android",
version: version_metadata.fetch(:version),
version_code: version_metadata.fetch(:version_code),
sha: release_sha
)
end
end
load_env_file(File.join(ANDROID_FASTLANE_ROOT, ".env"))
platform :android do
desc "Validate Google Play API credentials"
lane :auth_check do
validate_play_auth!
UI.success("Google Play API credentials are valid.")
end
desc "Print the Android release signing plan"
lane :signing_plan do
sh(shell_join(["node", android_release_signing_script, "--mode", "plan"]))
end
desc "Pull encrypted Android release signing assets and validate Gradle release signing"
lane :signing_check do
sync_android_release_signing!
validate_android_release_signing!
UI.success("Android release signing assets are available locally.")
end
desc "Pull encrypted Android release signing assets from the shared signing repo"
lane :signing_sync_pull do
sync_android_release_signing!
UI.success("Pulled Android release signing assets.")
end
desc "Create or refresh encrypted Android release signing assets in the shared signing repo"
lane :signing_sync_push do
sh(shell_join(["node", android_release_signing_script, "--mode", "sync-push"]))
UI.success("Pushed Android release signing assets.")
end
desc "Validate Android Play release auth, signing, versioning, and release notes"
lane :release_preflight do
sync_android_versioning!
version_metadata = read_android_version_metadata
validate_android_release_preflight!(version_metadata)
UI.success("Android Play release preflight passed for #{version_metadata[:version]} (#{version_metadata[:version_code]}).")
end
desc "Upload Google Play metadata, changelog, and optional screenshots"
lane :metadata do
sync_android_versioning!
version_metadata = read_android_version_metadata
ENV["SUPPLY_UPLOAD_METADATA"] = "1" unless ENV.key?("SUPPLY_UPLOAD_METADATA")
upload_play_store_metadata!(version_metadata)
UI.success("Uploaded Android Play metadata for #{version_metadata[:version]} (#{version_metadata[:version_code]}).")
end
desc "Build signed Android release artifacts locally without uploading"
lane :play_store_archive do
sync_android_versioning!
prepare_android_release_signing!
build_release_artifacts!
end
desc "Generate deterministic Android screenshots for Google Play metadata"
lane :screenshots do
capture_android_screenshots!
end
desc "Upload the signed Play AAB to Google Play"
lane :play_store do
sync_android_versioning!
version_metadata = read_android_version_metadata
upload_play_store_build!(version_metadata)
UI.success("Uploaded Android Play build to #{play_track}: version=#{version_metadata[:version]} code=#{version_metadata[:version_code]}")
end
desc "Upload Android metadata, archive release artifacts, then upload the Play AAB"
lane :release_upload do
sync_android_versioning!
version_metadata = read_android_version_metadata
validate_android_release_preflight!(version_metadata)
screenshots
ENV["SUPPLY_UPLOAD_METADATA"] = "1"
ENV["SUPPLY_UPLOAD_SCREENSHOTS"] = "1"
build_release_artifacts!
upload_play_store_build!(version_metadata, upload_metadata: true, upload_screenshots: true)
UI.success("Uploaded Android Play build to #{play_track}: version=#{version_metadata[:version]} code=#{version_metadata[:version_code]}")
UI.important("Production promotion remains manual in Google Play Console.")
end
end

130
fastlane/SETUP.md Normal file
View file

@ -0,0 +1,130 @@
# fastlane setup (OpenClaw Android)
Install:
```bash
brew install fastlane
```
Create a Google Play service account JSON key with Google Play Developer API access, then grant that service account access to the OpenClaw app in Play Console.
Recommended local auth:
```bash
GOOGLE_PLAY_JSON_KEY=/absolute/path/to/google-play-service-account.json
```
Optional app targeting:
```bash
GOOGLE_PLAY_PACKAGE_NAME=ai.openclaw.app
```
Android release signing uses the same private `apps-signing` repository and `MATCH_PASSWORD` secret as iOS, but with Android-specific encrypted assets. Pull the shared upload key before release validation:
```bash
pnpm android:release:signing:plan
MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:sync:pull
MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:check
```
The pull command materializes decrypted signing files under `apps/android/build/release-signing/`, which is gitignored. Later Fastlane release commands reload those materialized values and export them to Gradle for the current process.
For the first setup or rotation, provide the Play upload keystore and a local signing properties file, then push encrypted assets to `apps-signing`:
```bash
MATCH_PASSWORD=<signing repo password> \
OPENCLAW_ANDROID_UPLOAD_KEYSTORE=<path-to-upload-keystore.jks> \
OPENCLAW_ANDROID_SIGNING_PROPERTIES=<path-to-android-signing.properties> \
pnpm android:release:signing:sync:push
```
The source signing properties file must contain:
```properties
OPENCLAW_ANDROID_STORE_PASSWORD=<store-password>
OPENCLAW_ANDROID_KEY_ALIAS=<upload-key-alias>
OPENCLAW_ANDROID_KEY_PASSWORD=<key-password>
```
Store the Google Play upload key, not the irreplaceable app signing key, when Play App Signing is enabled.
Validate auth:
```bash
cd apps/android
fastlane android auth_check
```
Archive locally without upload:
```bash
pnpm android:release:archive
```
This command is for local archive validation only. It is not a fallback upload
path after `pnpm android:release:upload` fails.
Generate deterministic phone and Wear OS Google Play screenshots:
```bash
pnpm android:screenshots
```
The script creates and boots retained Pixel 2 and Wear OS Large Round AVDs when
needed. Install `system-images;android-36;google_apis;<abi>` and
`system-images;android-34;android-wear;<abi>` first. Use
`--form-factor phone|wear` with `--avd <name>` or `--device <adb-serial>` to
capture one form factor from an explicitly selected emulator.
Upload metadata, release notes, and the Play AAB to the configured Google Play track:
```bash
pnpm android:release:upload
```
Direct Fastlane entry point:
```bash
cd apps/android
fastlane android release_upload
```
Use the direct Fastlane entry point only for maintainer debugging when explicitly
requested. Agent-driven releases must use `pnpm android:release:upload` and stop
if it fails.
Release rules:
- `apps/android/version.json` is the pinned Android release version source.
- `apps/android/Config/Version.properties` is generated from that source and read by Gradle.
- `apps/android/CHANGELOG.md` is the Android-only changelog and release-note source.
- `apps/android/fastlane/metadata/android/en-US/release_notes.txt` is generated from that changelog by `pnpm android:version:sync`.
- `apps/android/Config/ReleaseSigning.json` pins the encrypted Android signing assets in the shared signing repo.
- `apkCertificateSha256` in that manifest pins the upload certificate accepted for standalone release APKs; rotate it only with the encrypted keystore.
- `MATCH_PASSWORD` enables Fastlane to pull encrypted Android signing assets into `apps/android/build/release-signing/` before release validation or archive builds.
- Supported pinned Android versions use CalVer: `YYYY.M.D`.
- Phone `versionCode` uses `YYYYMMDDNN`, where `NN` is `01` through `49`; the matching Wear APK adds `50` and uses `51` through `99`.
- `pnpm android:version:pin -- --from-gateway` promotes the current root gateway version into the pinned Android release version.
- `pnpm android:version:pin -- --version 2026.6.5 --version-code 2026060502` increments another build on the same Android release train.
- `pnpm android:version:sync` updates generated version artifacts.
- `pnpm android:version:check` validates checked-in Android version artifacts.
- `pnpm android:release:preflight` validates Google Play auth, Android release signing, synced versioning, release notes, and prints the package/track/version/versionCode that will be uploaded.
- `pnpm android:release:signing:sync:pull` pulls encrypted Android signing assets from `apps-signing`.
- `pnpm android:release:signing:sync:push` creates or refreshes encrypted Android signing assets in `apps-signing`.
- `pnpm android:screenshots` builds and installs the phone and Wear OS debug
apps, launches deterministic screenshot scenes, and writes Play-ready JPEGs
to the matching `phoneScreenshots` and `wearScreenshots` metadata folders.
- `pnpm android:release:archive` builds the signed phone Play AAB, Wear AAB, and third-party APK into `apps/android/build/release-artifacts/`.
- `pnpm android:release:upload` commits the phone AAB, Wear AAB, metadata, and screenshots in one Google Play edit across the configured phone and `wear:` form-factor tracks. The default tracks are `internal` and `wear:internal`.
- Stable GitHub Release APK publication is separate from Google Play: `OpenClaw Release Publish` dispatches `.github/workflows/android-release.yml`, whose protected `android-release` environment provides `MATCH_PASSWORD`; the repository GitHub App reads the encrypted signing repo.
- Production promotion remains manual in Google Play Console.
- If `pnpm android:release:upload` fails, agent-driven releases must stop and report the failing step. Do not fall back to `pnpm android:release:archive`, `pnpm android:release:metadata`, direct Fastlane lanes, Gradle release artifacts plus Google Play upload commands, or mobile release ref recording.
Screenshots:
- Android screenshot capture writes Play screenshots under
`apps/android/fastlane/metadata/android/<locale>/images/phoneScreenshots/`
and `apps/android/fastlane/metadata/android/<locale>/images/wearScreenshots/`.
- Set `SUPPLY_UPLOAD_SCREENSHOTS=1` to include those screenshots in `fastlane android metadata`.
- Do not commit generated screenshot captures unless they become intentional store metadata assets.

View file

@ -0,0 +1,3 @@
OpenClaw is now available on Android.
Connect to your OpenClaw Gateway to chat with your assistant, use realtime Talk mode, review approvals, and bring Android device capabilities like camera, location, screen, and notifications into your private automation workflows.

View file

@ -0,0 +1,3 @@
Adds settings detail panels, refreshes the Android overview controls, and routes exec approvals into the in-app inbox.
Improves chat acknowledgement handling, gateway pairing readiness, microphone foreground-service behavior, and release screenshot reliability.

View file

@ -0,0 +1,7 @@
Adds a Wear OS companion for sessions, transcripts, text and voice replies, realtime Talk, Gateway controls, notifications, settings, and a launch Tile.
Adds foreground, on-device Voice Wake with editable Gateway-synced wake words, plus copy and save-as-PNG actions for rendered chat widgets.
Fixes composer media leaking across chats and malformed agent or profile initials when display names begin with emoji.
Thanks @sibbl, @IWhatsskill, and @Leon-SK668.

View file

@ -0,0 +1,7 @@
Adds a Wear OS companion for sessions, transcripts, text and voice replies, realtime Talk, Gateway controls, notifications, settings, and a launch Tile.
Adds foreground, on-device Voice Wake with editable Gateway-synced wake words, plus copy and save-as-PNG actions for rendered chat widgets.
Fixes composer media leaking across chats and malformed agent or profile initials when display names begin with emoji.
Thanks @sibbl, @IWhatsskill, and @Leon-SK668.

View file

@ -0,0 +1,7 @@
Adds inline audio/video playback and uploads, session dashboards, run telemetry, chat rewind/fork, a Settings repair assistant, and Wear instant Talk.
Improves the working claw, collapsible details, Skill Workshop flows, and generated images.
Fixes reconnect/session state, Talk transcripts, manual gateway ports, large-text onboarding, reduced motion, and Wear pairing/reply reliability.
Thanks @IWhatsskill, @NianJiuZst, @masatohoshino, @cygnostik, @licheer-zte, and @metaforismo.

View file

@ -0,0 +1,7 @@
Adds inline audio/video playback and uploads, session dashboards, run telemetry, chat rewind/fork, a Settings repair assistant, and Wear instant Talk.
Improves the working claw, collapsible details, Skill Workshop flows, and generated images.
Fixes reconnect/session state, Talk transcripts, manual gateway ports, large-text onboarding, reduced motion, and Wear pairing/reply reliability.
Thanks @IWhatsskill, @NianJiuZst, @masatohoshino, @cygnostik, @licheer-zte, and @metaforismo.

View file

@ -0,0 +1,18 @@
OpenClaw is a personal AI assistant you run on your own devices.
Pair this Android app with your OpenClaw Gateway to use your phone as a secure node for chat, voice, approvals, and device-aware automation.
What you can do:
- Pair with your private OpenClaw Gateway by QR code or setup code
- Chat with your assistant from Android
- Use realtime Talk mode and push-to-talk
- Review Gateway action approvals from your phone
- Enable device capabilities such as camera, screen, location, and notifications when you choose
- Receive push wakes and node status updates for connected workflows
OpenClaw is local-first: you control your gateway, keys, configuration, and permissions. Device access is managed by Android permissions and can be enabled only for the capabilities you want to use.
Getting started:
1) Set up your OpenClaw Gateway
2) Open the Android app and pair with your gateway
3) Start using chat, Talk mode, approvals, and automations from your phone

View file

@ -0,0 +1,7 @@
Adds inline audio/video playback and uploads, session dashboards, run telemetry, chat rewind/fork, a Settings repair assistant, and Wear instant Talk.
Improves the working claw, collapsible details, Skill Workshop flows, and generated images.
Fixes reconnect/session state, Talk transcripts, manual gateway ports, large-text onboarding, reduced motion, and Wear pairing/reply reliability.
Thanks @IWhatsskill, @NianJiuZst, @masatohoshino, @cygnostik, @licheer-zte, and @metaforismo.

View file

@ -0,0 +1 @@
Personal AI on your Android devices

View file

@ -0,0 +1 @@
OpenClaw