@magnaboy/cli-android
v0.0.6
Published
Reusable Android SDK, ADB, Gradle, APK and device-test primitives for Node.js CLIs.
Readme
@magnaboy/cli-android
Reusable, shell-free Android SDK and device automation primitives for Node.js repository scripts.
Install
npm i @magnaboy/cli-android @magnaboy/cli-coreRequires Node 25+ and ESM. Android SDK tools are discovered from ANDROID_HOME, ANDROID_SDK_ROOT, or PATH;
the package does not bundle the Android SDK.
ADB
import { AdbClient, findAdb } from '@magnaboy/cli-android';
import { detectHost, NativeProcessRunner } from '@magnaboy/cli-core';
const runner = new NativeProcessRunner();
const executable = await findAdb(await detectHost(runner), runner);
if (!executable) throw new Error('adb is not installed');
const adb = new AdbClient({ executable, runner });
const selected = adb.device('emulator-5554');
await selected.requireReady();
console.log(await selected.capabilities());The serial-bound device client supports shell commands, properties, installation, uninstallation, file transfer and
readiness checks while retaining raw run() and shell() escape hatches.
Gradle and APK inspection
import { createAndroidGradleCommand, inspectApk } from '@magnaboy/cli-android';
const command = createAndroidGradleCommand({
projectDirectory: 'apps/android',
host,
tasks: ['lintRelease', 'assembleRelease'],
configurationCache: false,
kotlinCompilerExecutionStrategy: 'in-process',
jvmArgs: '-Xmx4g -Dfile.encoding=UTF-8'
});
await runner.run(command);
const apk = await inspectApk({ apkPath: 'app-release.apk', runner });APK inspection returns package/version/debuggable metadata and every SHA-256 signing-certificate fingerprint. Release certificate allowlists and version policy remain the caller's responsibility.
Installing an app on a device
import { formatAndroidInstallHelp, installAndroidApp, parseAndroidInstallArgs } from '@magnaboy/cli-android';
const variants = [
{ name: 'development', task: ':app:installDebug', aliases: ['dev', 'debug'], description: 'debug build.' },
{
name: 'production',
task: ':app:installRelease',
aliases: ['prod', 'release'],
description: 'signed release build.',
requiredFiles: [{ path: 'app/keystore.properties', label: 'Release signing configuration' }]
}
];
await installAndroidApp({
projectDirectory: 'apps/android',
options: parseAndroidInstallArgs(process.argv.slice(2), { variants }),
variants,
runner,
prompter,
help: formatAndroidInstallHelp({ usage: 'pnpm install-app --type production', variants })
});parseAndroidInstallArgs handles --type, --device/--serial, --non-interactive, --timeout, --dry-run,
--list-devices and --help. installAndroidApp finds ADB, selects and waits for the device, checks the Gradle
wrapper and every variant prerequisite, then runs the install task with ANDROID_SERIAL bound to the selection.
Gradle command creation, an optional per-device lock and the output streams are injectable.
Release APK verification
import { readCertificateAllowlist, resolveReleaseApk, verifyReleaseApk } from '@magnaboy/cli-android';
const apkPath = await resolveReleaseApk({ outputDirectory: 'app/build/outputs/apk/release' });
verifyReleaseApk(await inspectApk({ apkPath, runner }), {
applicationId: 'com.example.app',
allowedCertificates: (await readCertificateAllowlist('scripts/app-release.certs.json')).myApp
});verifyReleaseApk rejects a wrong applicationId, a debuggable build, a missing versionName, a versionCode that does
not match androidVersionCode(versionName) and an unrecognized signing certificate. Uploading and publishing the
verified APK remain the caller's responsibility.
Device tests and coverage
The package also exports pure ADB-device selection, display/keyguard readiness, camera occupancy, Gradle connected-test output parsing, and Android JaCoCo summary collection. Warning output, clocks, sleeping and interactive streams can be injected, allowing deterministic tests without an attached device.
Identifying and selecting devices
import { parseDeviceAliases, resolveAndroidDevices } from '@magnaboy/cli-android';
const devices = await resolveAndroidDevices(adb, {
spec: 'pixel,s26',
aliases: parseDeviceAliases('pixel=akita s26=SM_S942B')
});
for (const device of devices) console.log(device.slug, device.serial);resolveAndroidDevices lists attached phones, collapses transports, matches a spec, and names each
result. listAndroidDevices stops after the naming step, and matchDevices, mergeDeviceTransports
and assignDeviceSlugs are exported separately so each step can be tested or reused.
A phone reachable over USB and wireless at the same time answers on two serials. Capturing both would
run two collectors against one set of kernel counters and silently halve every number derived from
them, so mergeDeviceTransports keys on ro.serialno and keeps one entry per phone, preferring USB
because a large perf.data crawls over wireless adb. Every transport is kept in transports.
A spec is a comma-separated list of aliases, serials, models or product names, or all. Matching is
case-insensitive with underscores and spaces folded to hyphens, so SM_S942B finds sm-s942b. A
token matching more than one attached phone is an error rather than a guess, because picking one
would silently measure the wrong device.
assignDeviceSlugs gives each device a filesystem-safe name for directories and column headings. The
model alone reads best, and a serial tail is appended only when two selected devices share a model,
so a two-phone session never grows unreadable labels.
Counter snapshots
import { counterSnapshotScript, diffCounterSnapshots, parseCounterSnapshot } from '@magnaboy/cli-android';
const read = async (phase: 'before' | 'after') =>
parseCounterSnapshot((await device.shell([counterSnapshotScript(packageName, phase)])).stdout);
const before = await read('before');
// ... the measured window ...
const diff = diffCounterSnapshots(before, await read('after'), { fallbackSeconds: 30 });counterSnapshotScript builds one shell script that collects the process and per-thread /proc
counters, GPU residency, dumpsys gpu, and battery state. It is deliberately a single adb shell
invocation: read section by section it was seven round trips, and on wireless adb that made a nominal
5s window's before/after counters span 12.7s, so every rate derived from them was wrong by a factor
of 2.5. The device boottime is captured inside the same shell, which is what lets a Perfetto trace be
clipped to exactly the measured window, since ftrace timestamps are CLOCK_BOOTTIME.
diffCounterSnapshots divides by the interval the counters were actually read over, snapshot latency
included, not the duration that was requested; pass fallbackSeconds for when the boottime marks
cannot supply one. If the process restarted mid-window its counters reset, so the CPU and thread
deltas are omitted rather than reported as negative numbers. GPU residency becomes frequency-seconds,
a residency-weighted mean clock, and a windowShort flag, because the per-operating-point counters
advance on state change and a short window ends mid-interval and under-counts.
batteryDischargeMilliwatts returns whole-phone power or the reason it is unavailable. Net charge
can fall while the phone is on USB, and that fall is not discharge power, so both snapshots must show
an unplugged, discharging battery before a number is reported.
Parsing device output
parseProcStat, parseProcStatus, parseTaskFiles and parseThreadTable read /proc. A process
comm is unquoted and may contain spaces and parentheses, so the positional fields are taken from
after the final ). An absent schedstat stays null rather than becoming zero, which would read as
a thread that ran for no time at all. Note that waitSeconds is schedstat field 1: it is documented
as runqueue wait, but some kernels report a value that cannot be that, so do not read it as
starvation.
parseFrameStats, parseMemoryInfo, parseBatteryState and parseThermalState read dumpsys.
Every battery pattern is line-anchored, because an unanchored voltage: also matches
Max charging voltage: 0 and reports 0V for every window. parseThermalState matches the overall
mStatus as a whole line, since each Temperature{...} block carries its own and an unanchored
pattern reports a throttled device as nominal whenever the first sensor happened to be cool.
AdbClient converts CRLF to LF in captured output by default, because adb on Windows terminates
every line that way and a parser written against POSIX output silently fails to match. Pass
normalizeLineEndings: false when the exact bytes matter. AdbDeviceClient.getProperties reads
several properties in one round trip, and logcat streams to a file as a ManagedProcess.
