mirror of
https://github.com/BrendanGreenlee/openclaw-android-heartbeat.git
synced 2026-08-17 16:49:14 +00:00
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:
commit
7a380c40ed
656 changed files with 209982 additions and 0 deletions
20
fastlane/.env.example
Normal file
20
fastlane/.env.example
Normal 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
3
fastlane/Appfile
Normal 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
614
fastlane/Fastfile
Normal 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
130
fastlane/SETUP.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
18
fastlane/metadata/android/en-US/full_description.txt
Normal file
18
fastlane/metadata/android/en-US/full_description.txt
Normal 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
|
||||
7
fastlane/metadata/android/en-US/release_notes.txt
Normal file
7
fastlane/metadata/android/en-US/release_notes.txt
Normal 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.
|
||||
1
fastlane/metadata/android/en-US/short_description.txt
Normal file
1
fastlane/metadata/android/en-US/short_description.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
Personal AI on your Android devices
|
||||
1
fastlane/metadata/android/en-US/title.txt
Normal file
1
fastlane/metadata/android/en-US/title.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
OpenClaw
|
||||
Loading…
Add table
Add a link
Reference in a new issue