storebound/cli/lib/gradle.ts

336 lines
22 KiB
TypeScript

// 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, resolve } 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';
// resolve, not join: an absolute play.build.dir / --dir stays absolute (join made /Users/... a folder under the root)
const dirs = cfg.dir ? [resolve(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<GradleTarget, 'dir' | 'module'>): 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<string, string> {
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<string, string> }[] = [], depth = 0): { name: string; attrs: Record<string, string> }[] {
// 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<string, string> = {};
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<string, string> }[]): 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/<name>.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 };
}