189 lines
11 KiB
TypeScript
189 lines
11 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 } 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<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));
|
|
}
|