// 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 { 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, process.platform === 'win32' ? 'gradlew.bat' : 'gradlew'))) 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 injected properties (the same ones Android * Studio uses) and as -PversionCode / -PversionName for build files that read those. */ export function gradleArgs(t: GradleTarget, o: { versionCode?: number; versionName?: string; flavor?: string; hasWrapper: boolean }): 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] : [])]; return [o.hasWrapper ? (process.platform === 'win32' ? 'gradlew.bat' : './gradlew') : '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}`] : [])]; } /** 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)); }