diff --git a/apps/android/README.md b/apps/android/README.md index 1a2805c5491..16e89bc9834 100644 --- a/apps/android/README.md +++ b/apps/android/README.md @@ -32,7 +32,7 @@ cd apps/android ./gradlew :app:installPlayDebug ./gradlew :app:testPlayDebugUnitTest cd ../.. -bun run android:bundle:release +pnpm android:release:archive ``` Third-party debug flavor: @@ -44,10 +44,21 @@ cd apps/android ./gradlew :app:testThirdPartyDebugUnitTest ``` -`bun run android:bundle:release` auto-bumps Android `versionName`/`versionCode` in `apps/android/app/build.gradle.kts`, then builds two signed release bundles: +Android release archives use the pinned version in `apps/android/version.json`. Update it with: -- Play build: `apps/android/build/release-bundles/openclaw--play-release.aab` -- Third-party build: `apps/android/build/release-bundles/openclaw--third-party-release.aab` +```bash +pnpm android:version +pnpm android:version:check +pnpm android:version:pin -- --from-gateway +pnpm android:version:pin -- --version 2026.6.5 --version-code 2026060501 +``` + +`pnpm android:release:archive` builds signed release artifacts into `apps/android/build/release-artifacts/` and writes `.sha256` checksum files: + +- Play build: `openclaw--play-release.aab` +- Third-party build: `openclaw--third-party-release.apk` + +`pnpm android:bundle:release` is an alias for the same archive helper. Flavor-specific direct Gradle tasks: diff --git a/apps/android/scripts/build-release-aab.ts b/apps/android/scripts/build-release-aab.ts deleted file mode 100644 index b50482eb87b..00000000000 --- a/apps/android/scripts/build-release-aab.ts +++ /dev/null @@ -1,163 +0,0 @@ -#!/usr/bin/env bun -/** - * Android release helper that bumps version fields, builds release AAB variants, - * verifies signatures, and prints SHA-256 checksums. - */ - -import { $ } from "bun"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const scriptDir = dirname(fileURLToPath(import.meta.url)); -const androidDir = join(scriptDir, ".."); -const buildGradlePath = join(androidDir, "app", "build.gradle.kts"); -const releaseOutputDir = join(androidDir, "build", "release-bundles"); - -const releaseVariants = [ - { - flavorName: "play", - gradleTask: ":app:bundlePlayRelease", - bundlePath: join(androidDir, "app", "build", "outputs", "bundle", "playRelease", "app-play-release.aab"), - }, - { - flavorName: "third-party", - gradleTask: ":app:bundleThirdPartyRelease", - bundlePath: join( - androidDir, - "app", - "build", - "outputs", - "bundle", - "thirdPartyRelease", - "app-thirdParty-release.aab", - ), - }, -] as const; - -type VersionState = { - versionName: string; - versionCode: number; -}; - -type ParsedVersionMatches = { - versionNameMatch: RegExpMatchArray; - versionCodeMatch: RegExpMatchArray; -}; - -function formatVersionName(date: Date): string { - const year = date.getFullYear(); - const month = date.getMonth() + 1; - const day = date.getDate(); - return `${year}.${month}.${day}`; -} - -function formatVersionCodePrefix(date: Date): string { - const year = date.getFullYear().toString(); - const month = (date.getMonth() + 1).toString().padStart(2, "0"); - const day = date.getDate().toString().padStart(2, "0"); - return `${year}${month}${day}`; -} - -function parseVersionMatches(buildGradleText: string): ParsedVersionMatches { - const versionCodeMatch = buildGradleText.match(/versionCode = (\d+)/); - const versionNameMatch = buildGradleText.match(/versionName = "([^"]+)"/); - if (!versionCodeMatch || !versionNameMatch) { - throw new Error(`Couldn't parse versionName/versionCode from ${buildGradlePath}`); - } - return { versionCodeMatch, versionNameMatch }; -} - -function resolveNextVersionCode(currentVersionCode: number, todayPrefix: string): number { - const currentRaw = currentVersionCode.toString(); - let nextSuffix = 0; - - if (currentRaw.startsWith(todayPrefix)) { - const suffixRaw = currentRaw.slice(todayPrefix.length); - nextSuffix = (suffixRaw ? Number.parseInt(suffixRaw, 10) : 0) + 1; - } - - if (!Number.isInteger(nextSuffix) || nextSuffix < 0 || nextSuffix > 99) { - throw new Error( - `Can't auto-bump Android versionCode for ${todayPrefix}: next suffix ${nextSuffix} is invalid`, - ); - } - - return Number.parseInt(`${todayPrefix}${nextSuffix.toString().padStart(2, "0")}`, 10); -} - -function resolveNextVersion(buildGradleText: string, date: Date): VersionState { - const { versionCodeMatch } = parseVersionMatches(buildGradleText); - const currentVersionCode = Number.parseInt(versionCodeMatch[1] ?? "", 10); - if (!Number.isInteger(currentVersionCode)) { - throw new Error(`Invalid Android versionCode in ${buildGradlePath}`); - } - - const versionName = formatVersionName(date); - const versionCode = resolveNextVersionCode(currentVersionCode, formatVersionCodePrefix(date)); - return { versionName, versionCode }; -} - -function updateBuildGradleVersions(buildGradleText: string, nextVersion: VersionState): string { - return buildGradleText - .replace(/versionCode = \d+/, `versionCode = ${nextVersion.versionCode}`) - .replace(/versionName = "[^"]+"/, `versionName = "${nextVersion.versionName}"`); -} - -async function sha256Hex(path: string): Promise { - const buffer = await Bun.file(path).arrayBuffer(); - const digest = await crypto.subtle.digest("SHA-256", buffer); - return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); -} - -async function verifyBundleSignature(path: string): Promise { - await $`jarsigner -verify ${path}`.quiet(); -} - -async function copyBundle(sourcePath: string, destinationPath: string): Promise { - const sourceFile = Bun.file(sourcePath); - if (!(await sourceFile.exists())) { - throw new Error(`Signed bundle missing at ${sourcePath}`); - } - - await Bun.write(destinationPath, sourceFile); -} - -async function main() { - const buildGradleFile = Bun.file(buildGradlePath); - const originalText = await buildGradleFile.text(); - const nextVersion = resolveNextVersion(originalText, new Date()); - const updatedText = updateBuildGradleVersions(originalText, nextVersion); - - if (updatedText === originalText) { - throw new Error("Android version bump produced no change"); - } - - console.log(`Android versionName -> ${nextVersion.versionName}`); - console.log(`Android versionCode -> ${nextVersion.versionCode}`); - - await Bun.write(buildGradlePath, updatedText); - await $`mkdir -p ${releaseOutputDir}`; - - try { - await $`./gradlew ${releaseVariants[0].gradleTask} ${releaseVariants[1].gradleTask}`.cwd(androidDir); - } catch (error) { - await Bun.write(buildGradlePath, originalText); - throw error; - } - - for (const variant of releaseVariants) { - const outputPath = join( - releaseOutputDir, - `openclaw-${nextVersion.versionName}-${variant.flavorName}-release.aab`, - ); - - await copyBundle(variant.bundlePath, outputPath); - await verifyBundleSignature(outputPath); - const hash = await sha256Hex(outputPath); - - console.log(`Signed AAB (${variant.flavorName}): ${outputPath}`); - console.log(`SHA-256 (${variant.flavorName}): ${hash}`); - } -} - -await main(); diff --git a/apps/android/scripts/build-release-artifacts.ts b/apps/android/scripts/build-release-artifacts.ts new file mode 100644 index 00000000000..8a23b121f39 --- /dev/null +++ b/apps/android/scripts/build-release-artifacts.ts @@ -0,0 +1,209 @@ +#!/usr/bin/env bun +/** + * Android release helper that builds signed release artifacts from the pinned + * version metadata, verifies signatures, and writes SHA-256 checksum files. + */ + +import { $ } from "bun"; +import { existsSync, readdirSync } from "node:fs"; +import { basename, dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { resolveAndroidVersion, syncAndroidVersioning } from "../../../scripts/lib/android-version.ts"; + +type ReleaseArtifact = { + flavorName: "play" | "third-party"; + kind: "aab" | "apk"; + gradleTask: string; + sourcePath: string; +}; + +type CliOptions = { + dryRun: boolean; +}; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const androidDir = join(scriptDir, ".."); +const rootDir = join(androidDir, "..", ".."); +const releaseOutputDir = join(androidDir, "build", "release-artifacts"); + +function parseArgs(argv: string[]): CliOptions { + let dryRun = false; + + for (const arg of argv) { + switch (arg) { + case "--dry-run": { + dryRun = true; + break; + } + case "-h": + case "--help": { + console.log( + [ + "Usage: bun apps/android/scripts/build-release-artifacts.ts [--dry-run]", + "", + "Builds the signed Play AAB and third-party APK from apps/android/version.json.", + ].join("\n"), + ); + process.exit(0); + } + default: { + throw new Error(`Unknown argument: ${arg}`); + } + } + } + + return { dryRun }; +} + +function releaseArtifacts(versionName: string): ReleaseArtifact[] { + return [ + { + flavorName: "play", + kind: "aab", + gradleTask: ":app:bundlePlayRelease", + sourcePath: join( + androidDir, + "app", + "build", + "outputs", + "bundle", + "playRelease", + "app-play-release.aab", + ), + }, + { + flavorName: "third-party", + kind: "apk", + gradleTask: ":app:assembleThirdPartyRelease", + sourcePath: join( + androidDir, + "app", + "build", + "outputs", + "apk", + "thirdParty", + "release", + `openclaw-${versionName}-thirdParty-release.apk`, + ), + }, + ]; +} + +async function sha256Hex(path: string): Promise { + const buffer = await Bun.file(path).arrayBuffer(); + const digest = await crypto.subtle.digest("SHA-256", buffer); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +async function writeSha256File(path: string): Promise { + const hash = await sha256Hex(path); + const checksumPath = `${path}.sha256`; + await Bun.write(checksumPath, `${hash} ${basename(path)}\n`); + return hash; +} + +async function verifyAabSignature(path: string): Promise { + await $`jarsigner -verify ${path}`.quiet(); +} + +function resolveApkSignerFromSdk(sdkRoot: string | undefined): string | null { + if (!sdkRoot) { + return null; + } + + const buildToolsDir = join(sdkRoot, "build-tools"); + if (!existsSync(buildToolsDir)) { + return null; + } + + const candidates = readdirSync(buildToolsDir) + .toSorted((left, right) => right.localeCompare(left)) + .map((version) => join(buildToolsDir, version, "apksigner")) + .filter((candidate) => existsSync(candidate)); + + return candidates[0] ?? null; +} + +async function resolveApkSigner(): Promise { + const sdkApkSigner = + resolveApkSignerFromSdk(Bun.env.ANDROID_HOME) ?? + resolveApkSignerFromSdk(Bun.env.ANDROID_SDK_ROOT); + if (sdkApkSigner) { + return sdkApkSigner; + } + + try { + return (await $`command -v apksigner`.text()).trim(); + } catch { + throw new Error( + "Missing apksigner. Install Android SDK build-tools or put apksigner on PATH.", + ); + } +} + +async function verifyApkSignature(path: string): Promise { + const apkSigner = await resolveApkSigner(); + const apkSignerProcess = Bun.spawn([apkSigner, "verify", path], { + stdout: "ignore", + stderr: "inherit", + }); + const exitCode = await apkSignerProcess.exited; + if (exitCode !== 0) { + throw new Error(`apksigner verification failed for ${path}`); + } +} + +async function copyArtifact(sourcePath: string, destinationPath: string): Promise { + const sourceFile = Bun.file(sourcePath); + if (!(await sourceFile.exists())) { + throw new Error(`Signed release artifact missing at ${sourcePath}`); + } + + await Bun.write(destinationPath, sourceFile); +} + +async function verifyArtifactSignature(artifact: ReleaseArtifact, outputPath: string): Promise { + if (artifact.kind === "aab") { + await verifyAabSignature(outputPath); + } else { + await verifyApkSignature(outputPath); + } +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + syncAndroidVersioning({ mode: "check", rootDir }); + const version = resolveAndroidVersion(rootDir); + const artifacts = releaseArtifacts(version.canonicalVersion); + + console.log(`Android versionName: ${version.canonicalVersion}`); + console.log(`Android versionCode: ${version.versionCode}`); + for (const artifact of artifacts) { + console.log(`Release artifact: ${artifact.flavorName} ${artifact.kind}`); + console.log(`Gradle task: ${artifact.gradleTask}`); + } + + if (options.dryRun) { + console.log("Dry run complete. No Gradle tasks were executed."); + return; + } + + await $`mkdir -p ${releaseOutputDir}`; + await $`./gradlew ${artifacts.map((artifact) => artifact.gradleTask)}`.cwd(androidDir); + + for (const artifact of artifacts) { + const outputPath = join( + releaseOutputDir, + `openclaw-${version.canonicalVersion}-${artifact.flavorName}-release.${artifact.kind}`, + ); + + await copyArtifact(artifact.sourcePath, outputPath); + await verifyArtifactSignature(artifact, outputPath); + const hash = await writeSha256File(outputPath); + + console.log(`Signed ${artifact.kind.toUpperCase()} (${artifact.flavorName}): ${outputPath}`); + console.log(`SHA-256 (${artifact.flavorName}): ${hash}`); + } +} + +await main(); diff --git a/package.json b/package.json index 2d6e23a2a32..851858975e0 100644 --- a/package.json +++ b/package.json @@ -1432,7 +1432,7 @@ "scripts": { "android:assemble": "node scripts/run-android-gradle.mjs :app:assemblePlayDebug", "android:assemble:third-party": "node scripts/run-android-gradle.mjs :app:assembleThirdPartyDebug", - "android:bundle:release": "bun apps/android/scripts/build-release-aab.ts", + "android:bundle:release": "bun apps/android/scripts/build-release-artifacts.ts", "android:format": "cd apps/android && ./gradlew :app:ktlintFormat :benchmark:ktlintFormat", "android:install": "node scripts/run-android-gradle.mjs :app:installPlayDebug", "android:install:third-party": "node scripts/run-android-gradle.mjs :app:installThirdPartyDebug", @@ -1440,6 +1440,7 @@ "android:lint:android": "node scripts/run-android-gradle.mjs :app:lintDebug", "android:run": "node scripts/run-android-gradle.mjs :app:installPlayDebug -- adb shell am start -n ai.openclaw.app/.MainActivity", "android:run:third-party": "node scripts/run-android-gradle.mjs :app:installThirdPartyDebug -- adb shell am start -n ai.openclaw.app/.MainActivity", + "android:release:archive": "bun apps/android/scripts/build-release-artifacts.ts", "android:test": "node scripts/run-android-gradle.mjs :app:testPlayDebugUnitTest", "android:test:integration": "node scripts/run-with-env.mjs OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_ANDROID_NODE=1 -- node scripts/run-vitest.mjs run --config test/vitest/vitest.live.config.ts src/gateway/android-node.capabilities.live.test.ts", "android:test:third-party": "node scripts/run-android-gradle.mjs :app:testThirdPartyDebugUnitTest",