// Android release builds: find the Gradle project (native, Capacitor, React Native, Flutter, Unity export), the // bundle task, the signing environment (the upload key from env / .storebound, handed to Gradle as injected // properties through ORG_GRADLE_PROJECT_* variables, so passwords never appear on a command line), and read // package / versionCode / versionName back from the built AAB (its manifest is protocol buffers, aapt2 format). import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import { join, relative } from 'node:path'; import type { AndroidSigning } from './credentials'; import * as rt from './rt'; import { zipEntries, zipRead } from './zip'; export interface PlayBuildConfig { /** Gradle project folder (with gradlew / settings.gradle), relative to the config. Default: android/, else the root. */ dir?: string; /** Application module. Default app (Unity export: launcher). */ module?: string; /** Product flavor, e.g. "prod" -> bundleProdRelease. */ flavor?: string; } export function validatePlayBuild(b: PlayBuildConfig | undefined): string[] { if (!b) return []; const e: string[] = []; if (b.module !== undefined && !/^[A-Za-z0-9_-]+(:[A-Za-z0-9_-]+)*$/.test(b.module)) e.push('play.build.module: a Gradle module name like app'); if (b.flavor !== undefined && !/^[A-Za-z][A-Za-z0-9]*$/.test(b.flavor)) e.push('play.build.flavor: a product flavor name like prod'); return e; } export type AndroidFramework = 'flutter' | 'capacitor' | 'react-native' | 'unity' | 'native'; export interface GradleTarget { framework: AndroidFramework; dir: string; module: string; task: string; notes: string[] } const isGradleDir = (d: string): boolean => ['settings.gradle', 'settings.gradle.kts'].some(f => existsSync(join(d, f))); /** Finds the Gradle project. `root` is the folder of storebound.config.ts. */ export function pickGradle(root: string, cfg: PlayBuildConfig = {}): GradleTarget { const notes: string[] = []; const framework: AndroidFramework = existsSync(join(root, 'pubspec.yaml')) ? 'flutter' : ['capacitor.config.ts', 'capacitor.config.json', 'capacitor.config.js'].some(f => existsSync(join(root, f))) ? 'capacitor' : (() => { try { const p = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); return !!(p.dependencies?.['react-native'] ?? p.devDependencies?.['react-native']); } catch { return false; } })() ? 'react-native' : 'native'; const dirs = cfg.dir ? [join(root, cfg.dir)] : [join(root, 'android'), root]; let dir = dirs.find(isGradleDir); if (!dir) throw new Error(`no Gradle project (settings.gradle) in ${dirs.map(d => relative(root, d) || '.').join(' or ')}: set play.build.dir${framework === 'capacitor' ? ' (or npx cap add android)' : ''}`); const unity = existsSync(join(dir, 'launcher')) && existsSync(join(dir, 'unityLibrary')); const fw: AndroidFramework = unity ? 'unity' : framework; const module = cfg.module ?? (unity ? 'launcher' : 'app'); if (!existsSync(join(dir, ...module.split(':')))) throw new Error(`module "${module}" not found in ${relative(root, dir) || '.'} (set play.build.module)`); const flavor = cfg.flavor ? cfg.flavor[0]!.toUpperCase() + cfg.flavor.slice(1) : ''; if (fw === 'capacitor') notes.push('Capacitor: run npx cap sync android after the web build so the bundle has the current web assets'); if (fw === 'unity') notes.push('Unity: export the Gradle project from Unity (Build Settings > Android > Export Project) before each build'); if (fw === 'flutter') notes.push('Flutter: built with flutter build appbundle (build number = versionCode)'); if (!existsSync(join(dir, 'gradlew')) && !existsSync(join(dir, 'gradlew.bat'))) notes.push('no Gradle wrapper (gradlew) in the project: using gradle from PATH'); return { framework: fw, dir, module, task: `:${module}:bundle${flavor}Release`, notes }; } /** Command line of the release bundle build. Version code / name go in as -PversionCode / -PversionName (the build * file must read them: versionSourceIssues) and as the injected properties Android Studio uses. Those do NOT * override defaultConfig for bundleRelease: checked with AGP 8.13 (2026-10-12), `-Pandroid.injected.version.code=42` * on a build file with `versionCode 1` gives an AAB with versionCode 1; seen live the same day with a Capacitor app. * They stay on the command line for older plugins / IDE parity only; the AAB check after the build is the stop. */ export function gradleArgs(t: GradleTarget, o: { versionCode?: number; versionName?: string; flavor?: string; hasWrapper: boolean; signing?: AndroidSigning | null }, platform: string = process.platform): string[] { if (t.framework === 'flutter') return ['flutter', 'build', 'appbundle', '--release', ...(o.flavor ? ['--flavor', o.flavor] : []), ...(o.versionCode ? ['--build-number', String(o.versionCode)] : []), ...(o.versionName ? ['--build-name', o.versionName] : [])]; // Windows: a bare `gradlew.bat` is searched on PATH only ("Executable not found in $PATH"); cmd /c finds it in the cwd return [...(o.hasWrapper ? (platform === 'win32' ? ['cmd', '/c', 'gradlew.bat'] : ['./gradlew']) : platform === 'win32' ? ['cmd', '/c', 'gradle'] : ['gradle']), t.task, '--no-daemon', '--console=plain', ...(o.versionCode ? [`-Pandroid.injected.version.code=${o.versionCode}`, `-PversionCode=${o.versionCode}`] : []), ...(o.versionName ? [`-Pandroid.injected.version.name=${o.versionName}`, `-PversionName=${o.versionName}`] : []), ...signingArgs(o.signing ?? null)]; } /** The upload key as Gradle project properties (-Pandroid.injected.signing.*). A project whose build file signs only * from a local keystore.properties (absent in CI) gives an unsigned AAB otherwise (Play: 403 "All uploaded bundles * must be signed"). Only the file and alias go on argv; the passwords travel through signingEnv (ORG_GRADLE_PROJECT_*), never argv. */ export function signingArgs(s: AndroidSigning | null): string[] { if (!s) return []; return [`-Pandroid.injected.signing.store.file=${s.storeFile}`, `-Pandroid.injected.signing.key.alias=${s.keyAlias}`]; } /** An argv for logs and plans: the signing passwords are replaced by ***. Pure. */ export function maskArgs(argv: readonly string[]): string[] { return argv.map(x => x.replace(/^(-Pandroid\.injected\.signing\.(?:store|key)\.password=).*$/, '$1***')); } // ---- where the build file takes versionCode / versionName from ---- export interface VersionSourceIssue { field: 'versionCode' | 'versionName'; file: string; line: number | null; text: string; kind: 'literal' | 'other' | 'missing'; current: string; fix: string } /** The application module's build file (build.gradle.kts first, then build.gradle), or null. */ export function appBuildFile(t: Pick): string | null { const base = join(t.dir, ...t.module.split(':')); return ['build.gradle.kts', 'build.gradle'].map(f => join(base, f)).find(f => existsSync(f)) ?? null; } /** The line that reads the -P property storebound passes, keeping `current` as the default. Pure. */ export function versionFixLine(field: 'versionCode' | 'versionName', current: string, kotlin: boolean): string { // Groovy: `versionCode (x) as Integer` parses as versionCode(x) as Integer, which hands Gradle a String (AGP 8.13: // "Value is null"); the cast must sit inside the parentheses if (kotlin) return field === 'versionCode' ? `versionCode = (project.findProperty("versionCode") as String?)?.toInt() ?: ${current}` : `versionName = project.findProperty("versionName") as String? ?: "${current}"`; return field === 'versionCode' ? `versionCode((project.findProperty('versionCode') ?: ${current}) as Integer)` : `versionName project.findProperty('versionName') ?: '${current}'`; } /** The block body after `name {` (brace matched, strings skipped), with the line number it starts on. Pure. */ function block(src: string, name: string): { body: string; line: number } | null { const m = new RegExp(`\\b${name}\\s*\\{`).exec(src); if (!m) return null; let depth = 0, q = ''; const start = m.index + m[0].length; for (let i = start; i < src.length; i++) { const c = src[i]!; if (q) { if (c === '\\') i++; else if (c === q) q = ''; continue; } if (c === '"' || c === "'") q = c; else if (c === '{') depth++; else if (c === '}') { if (depth === 0) return { body: src.slice(start, i), line: src.slice(0, start).split('\n').length }; depth--; } } return null; } /** * Whether defaultConfig of the build file reads versionCode / versionName from the project properties storebound * passes (-PversionCode / -PversionName). A literal (`versionCode 1`), an environment variable or anything else keeps * the AAB at that value whatever `play build` passes (seen live: a Capacitor app read versionCode from an env var and * the AAB stayed at 1). Each issue carries the exact line to use. Pure apart from reading the file. */ export function versionSourceIssues(file: string, src = readFileSync(file, 'utf8')): VersionSourceIssue[] { const kotlin = file.endsWith('.kts'); // comments out first (block and line; a `//` right after a colon is a URL), keeping the line numbers const code = src.replace(/\/\*[\s\S]*?\*\//g, m => m.replace(/[^\n]/g, ' ')).replace(/(^|[^:])\/\/.*$/gm, '$1'); const dc = block(code, 'defaultConfig'); if (!dc) return []; const out: VersionSourceIssue[] = []; const lines = dc.body.split('\n'); for (const field of ['versionCode', 'versionName'] as const) { const fallback = field === 'versionCode' ? '1' : '1.0'; let hit: { line: number; text: string; value: string } | null = null; lines.forEach((l, i) => { for (const stmt of l.split(';')) { const m = new RegExp(`^\\s*(?:${field}|set${field[0]!.toUpperCase()}${field.slice(1)})\\b\\s*(?:=\\s*)?(.*)$`).exec(stmt); if (m && !hit) hit = { line: dc.line + i, text: l.trim(), value: m[1]!.trim() }; } }); if (!hit) { out.push({ field, file, line: null, text: '', kind: 'missing', current: fallback, fix: versionFixLine(field, fallback, kotlin) }); continue; } const h = hit as { line: number; text: string; value: string }; const readsRe = new RegExp(`(?:findProperty|property|hasProperty|getProperty|gradleProperty)\\s*\\(\\s*['"]${field}['"]|properties\\s*\\[\\s*['"]${field}['"]`); if (readsRe.test(h.value)) continue; // a variable set from the property first (`def code = (project.findProperty('versionCode') ?: ...).toInteger()`, // then `versionCode code`): follow the name to its definition anywhere in the file const id = /^\(?\s*(?:project\.)?(?:ext\.)?([A-Za-z_]\w*)\s*(?:\.\s*\w+\s*\(\s*\)\s*)*(?:as\s+\w+\s*)?\)?$/.exec(h.value)?.[1]; if (id && !/^\d/.test(id)) { const def = new RegExp(`(?:\\b(?:def|val|var|final)\\s+(?:\\w+\\s+)?|\\bext\\.|\\bextra\\[\\s*["']|^\\s*)${id}\\b["']?\\s*\\]?\\s*(?::\\s*\\w+\\??\\s*)?=\\s*([^\\n]+)`, 'm').exec(code); if (def && readsRe.test(def[1]!)) continue; } const lit = /^\(?\s*(\d+|"[^"]*"|'[^']*')\s*\)?$/.exec(h.value); const current = lit ? lit[1]!.replace(/^['"]|['"]$/g, '') : field === 'versionCode' ? (/(?:\?:|\|\||,)\s*['"]?(\d+)['"]?/.exec(h.value)?.[1] ?? /\b(\d+)\b/.exec(h.value)?.[1] ?? fallback) : (/['"](\d[\w.+-]*)['"]/.exec(h.value)?.[1] ?? fallback); out.push({ field, file, line: h.line, text: h.text, kind: lit ? 'literal' : 'other', current, fix: versionFixLine(field, current, kotlin) }); } return out; } /** One warning per issue: where, why, and the line to use. Pure. */ export function versionSourceMessage(i: VersionSourceIssue, rel: string, framework?: AndroidFramework): string { const where = i.line ? `${rel}:${i.line}` : rel; const flag = i.field === 'versionCode' ? '-PversionCode' : '-PversionName'; const what = i.kind === 'missing' ? `defaultConfig sets no ${i.field}` : i.kind === 'literal' ? `${i.field} is a literal (${i.text})` : `${i.field} does not come from the project property ${i.field} (${i.text})`; const unity = framework === 'unity' ? ' (Unity export: the file is rewritten on every export; set Bundle Version Code / Version in Player Settings or edit it after each export)' : ''; return `${where}: ${what}, so ${flag} from play build is ignored and the AAB keeps ${i.kind === 'missing' ? 'whatever the manifest says' : i.current}; android.injected.version.* does not override it for bundleRelease. Use in defaultConfig: ${i.fix}${unity}`; } /** Environment that hands the upload key to the Android Gradle plugin (injected signing properties). */ export function signingEnv(s: AndroidSigning | null): Record { if (!s) return {}; return { 'ORG_GRADLE_PROJECT_android.injected.signing.store.file': s.storeFile, 'ORG_GRADLE_PROJECT_android.injected.signing.store.password': s.storePassword, 'ORG_GRADLE_PROJECT_android.injected.signing.key.alias': s.keyAlias, 'ORG_GRADLE_PROJECT_android.injected.signing.key.password': s.keyPassword, }; } /** AABs written since `since` (ms) below the folders a build writes to. Newest first. */ export function findBundles(dirs: string[], since: number): string[] { const out: { f: string; t: number }[] = []; const walk = (d: string, depth: number): void => { let names: string[]; try { names = readdirSync(d); } catch { return; } for (const n of names) { const p = join(d, n); let st; try { st = statSync(p); } catch { continue; } if (st.isDirectory()) { if (depth > 0 && n !== 'intermediates' && n !== 'tmp') walk(p, depth - 1); } else if (n.endsWith('.aab') && st.mtimeMs >= since) out.push({ f: p, t: st.mtimeMs }); } }; for (const d of dirs) walk(d, 6); return out.sort((a, b) => b.t - a.t).map(x => x.f); } // ---- AAB manifest (aapt2 protobuf XmlNode) ---- type Field = { no: number; wire: number; v: number | Buffer }; function fields(b: Buffer): Field[] | null { const out: Field[] = []; let i = 0; const varint = (): number => { let r = 0, mul = 1; for (let k = 0; k < 10; k++) { if (i >= b.length) throw new Error('eof'); const x = b[i++]!; r += (x & 127) * mul; if (!(x & 128)) return r; mul *= 128; } throw new Error('varint'); }; try { while (i < b.length) { const key = varint(), no = Math.floor(key / 8), wire = key & 7; if (no === 0) return null; if (wire === 0) out.push({ no, wire, v: varint() }); else if (wire === 2) { const n = varint(); if (i + n > b.length) return null; out.push({ no, wire, v: b.subarray(i, i + n) }); i += n; } else if (wire === 5) { i += 4; } else if (wire === 1) { i += 8; } else return null; } } catch { return null; } return i === b.length ? out : null; } /** Attributes (name -> raw value) of every element in an aapt2 proto XML, element name first. */ function elements(b: Buffer, out: { name: string; attrs: Record }[] = [], depth = 0): { name: string; attrs: Record }[] { // aapt2 Resources.proto: XmlNode { XmlElement element = 1; string text = 2; SourcePosition source = 3 } // XmlElement { XmlNamespace namespace_declaration = 1; string namespace_uri = 2; string name = 3; XmlAttribute attribute = 4; XmlNode child = 5 } // XmlAttribute { string namespace_uri = 1; string name = 2; string value = 3; ...; Item compiled_item = 6 } if (depth > 64) return out; const node = fields(b); const el = node?.find(f => f.no === 1 && f.wire === 2); if (!el) return out; const ef = fields(el.v as Buffer) ?? []; const name = (ef.find(f => f.no === 3 && f.wire === 2)?.v as Buffer | undefined)?.toString('utf8') ?? ''; const attrs: Record = {}; for (const a of ef.filter(f => f.no === 4 && f.wire === 2)) { const af = fields(a.v as Buffer) ?? []; const an = (af.find(f => f.no === 2)?.v as Buffer | undefined)?.toString('utf8'); let av = (af.find(f => f.no === 3)?.v as Buffer | undefined)?.toString('utf8') ?? ''; if (!av) { // compiled_item { Prim prim = 7 { int_decimal_value = 6 ... } } or { Str str = 2 { value = 1 } } const item = fields((af.find(f => f.no === 6)?.v as Buffer | undefined) ?? Buffer.alloc(0)) ?? []; const prim = fields((item.find(f => f.no === 7)?.v as Buffer | undefined) ?? Buffer.alloc(0)) ?? []; const n = prim.find(f => f.wire === 0); const str = fields((item.find(f => f.no === 2)?.v as Buffer | undefined) ?? Buffer.alloc(0)) ?? []; av = n ? String(n.v) : (str.find(f => f.no === 1)?.v as Buffer | undefined)?.toString('utf8') ?? ''; } if (an) attrs[an] = av; } out.push({ name, attrs }); for (const c of ef.filter(f => f.no === 5 && f.wire === 2)) elements(c.v as Buffer, out, depth + 1); return out; } export interface AabInfo { packageName: string; versionCode: number; versionName?: string; minSdk?: number; targetSdk?: number } /** Package, versionCode and versionName of an Android App Bundle (base/manifest/AndroidManifest.xml). */ export function aabInfo(aab: string | Buffer): AabInfo { const e = zipEntries(aab).find(x => x.name === 'base/manifest/AndroidManifest.xml'); if (!e) throw new Error('not an Android App Bundle (no base/manifest/AndroidManifest.xml)'); const els = elements(zipRead(aab, e)); const m = els.find(x => x.name === 'manifest'); if (!m?.attrs.package || !m.attrs.versionCode) throw new Error('AAB manifest without package / versionCode'); const sdk = els.find(x => x.name === 'uses-sdk')?.attrs; return { packageName: m.attrs.package, versionCode: Number(m.attrs.versionCode), versionName: m.attrs.versionName || undefined, minSdk: sdk?.minSdkVersion ? Number(sdk.minSdkVersion) : undefined, targetSdk: sdk?.targetSdkVersion ? Number(sdk.targetSdkVersion) : undefined }; } /** Builds an aapt2 proto manifest (Resources.proto field numbers, as in a real AGP bundle: namespace declaration, * then name, attributes, children; versionCode as a compiled int like aapt2 writes it). Tests and fixtures. */ const ANDROID_NS = 'http://schemas.android.com/apk/res/android'; export function encodeProtoManifest(els: { name: string; attrs: Record }[]): Buffer { const vint = (n: number): Buffer => { const o: number[] = []; do { let x = n % 128; n = Math.floor(n / 128); if (n) x |= 128; o.push(x); } while (n); return Buffer.from(o); }; const ld = (no: number, b: Buffer): Buffer => Buffer.concat([vint(no * 8 + 2), vint(b.length), b]); const s = (no: number, t: string) => ld(no, Buffer.from(t, 'utf8')); const element = (i: number): Buffer => { const e = els[i]!; const ns = i === 0 ? [ld(1, Buffer.concat([s(1, 'android'), s(2, ANDROID_NS)]))] : []; const attrs = Object.entries(e.attrs).map(([k, v]) => { const plain = k === 'package' || k === 'versionName'; // compiled_item = 6 { Primitive prim = 7 { int_decimal_value = 6 } } for numbers, like aapt2 const item = /^\d+$/.test(v) && !plain ? [ld(6, ld(7, Buffer.concat([vint(6 * 8), vint(Number(v))])))] : []; return ld(4, Buffer.concat([plain ? Buffer.alloc(0) : s(1, ANDROID_NS), s(2, k), s(3, v), ...item])); }); const child = i + 1 < els.length ? [ld(5, ld(1, element(i + 1)))] : []; return Buffer.concat([...ns, s(3, e.name), ...attrs, ...child]); }; return ld(1, element(0)); } // ---- is the AAB signed? ---- /** Whether the bundle carries a JAR signature (META-INF/.SF plus a .RSA / .DSA / .EC block). Play refuses an * unsigned bundle with 403 "All uploaded bundles must be signed". Pure apart from reading the file. */ export function aabSignedEntries(aab: string | Buffer): boolean { const names = zipEntries(aab).map(e => e.name); return names.some(n => /^META-INF\/[^/]+\.SF$/i.test(n)) && names.some(n => /^META-INF\/[^/]+\.(RSA|DSA|EC)$/i.test(n)); } /** The reason an AAB is unsigned (null when signed). `jarsigner` (a JDK) verifies the signature when `verify` is given * and returns its output (null: not available); without it only the META-INF entries are checked. */ export function aabUnsignedReason(aab: string | Buffer, verify?: (path: string) => { ok: boolean; out: string } | null, path?: string): string | null { if (!aabSignedEntries(aab)) return 'it has no META-INF/*.SF + *.RSA|DSA|EC signature files'; if (verify && path) { const r = verify(path); if (r && !r.ok) return `jarsigner -verify: ${r.out.trim().split(/\r?\n/).filter(Boolean).slice(0, 2).join(' ')}`; } return null; } /** Throws when the AAB file is not signed, before any upload. `jarsigner` runs when a JDK is on PATH. */ export function assertAabSigned(path: string, rel = path, verify: (p: string) => { ok: boolean; out: string } | null = jarsignerVerify): void { const why = aabUnsignedReason(path, verify, path); if (why) throw new Error(`${rel} is not signed (${why}): Google Play would refuse it (403 "All uploaded bundles must be signed"). Sign it with the upload key: set ANDROID_KEYSTORE / ANDROID_KEYSTORE_PASSWORD / ANDROID_KEY_ALIAS / ANDROID_KEY_PASSWORD for storebound play build (they are passed to Gradle as -Pandroid.injected.signing.*), or give the project's release signingConfig a keystore in CI. Nothing was uploaded.`); } function jarsignerVerify(path: string): { ok: boolean; out: string } | null { if (!rt.which('jarsigner')) return null; const r = rt.spawnSync(['jarsigner', '-verify', path], { timeout: 120_000 }); const out = `${r.stdout.toString()}${r.stderr.toString()}`; // jarsigner exits 0 for an unsigned jar too and prints "jar is unsigned"; only a signature verdict counts, a zip it // cannot read (bad CRC, ...) is inconclusive and left to the META-INF entry check return { ok: /jar verified/i.test(out) || !/jar is unsigned|did not verify|SecurityException|invalid SHA/i.test(out), out }; }