426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
1032 lines
30 KiB
JavaScript
1032 lines
30 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* validate.mjs — Static validator for a brand's AOSP vendor tree + APK.
|
|
*
|
|
* Brand-aware: package id, app name, product name, classPrefix, and
|
|
* envPrefix are read from the brand config (see brand-config.mjs).
|
|
* Class names like `<Brand>DialActivity` are derived from
|
|
* `brand.classPrefix`; env vars like `<BRAND>_PIXEL_CODENAME` from
|
|
* `brand.envPrefix`.
|
|
*/
|
|
import { spawnSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import process from "node:process";
|
|
import { fileURLToPath } from "node:url";
|
|
import { loadBrandFromArgv } from "./brand-config.mjs";
|
|
import { lintInitRc } from "./lint-init-rc.mjs";
|
|
|
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
// This file lives at packages/scripts/distro-android/, so the repo root is
|
|
// three levels up — brand.vendorDir paths are resolved against it.
|
|
const repoRoot = path.resolve(here, "../../..");
|
|
|
|
const defaultGrantPermissions = [
|
|
"android.permission.READ_CONTACTS",
|
|
"android.permission.WRITE_CONTACTS",
|
|
"android.permission.CALL_PHONE",
|
|
"android.permission.READ_PHONE_STATE",
|
|
"android.permission.ANSWER_PHONE_CALLS",
|
|
"android.permission.READ_CALL_LOG",
|
|
"android.permission.WRITE_CALL_LOG",
|
|
"android.permission.READ_SMS",
|
|
"android.permission.SEND_SMS",
|
|
"android.permission.RECEIVE_SMS",
|
|
"android.permission.RECEIVE_MMS",
|
|
"android.permission.RECEIVE_WAP_PUSH",
|
|
"android.permission.POST_NOTIFICATIONS",
|
|
];
|
|
|
|
const requiredApkPermissions = [
|
|
...defaultGrantPermissions,
|
|
"android.permission.MANAGE_OWN_CALLS",
|
|
"android.permission.RECEIVE_BOOT_COMPLETED",
|
|
"android.permission.PACKAGE_USAGE_STATS",
|
|
"android.permission.SYSTEM_ALERT_WINDOW",
|
|
"android.permission.MANAGE_APP_OPS_MODES",
|
|
];
|
|
|
|
const privilegedPermissions = [
|
|
"android.permission.PACKAGE_USAGE_STATS",
|
|
"android.permission.MANAGE_APP_OPS_MODES",
|
|
];
|
|
|
|
export function parseSubArgs(argv, brand) {
|
|
const args = {
|
|
aospRoot: null,
|
|
apk: null,
|
|
vendorDir: path.resolve(repoRoot, brand.vendorDir),
|
|
};
|
|
const readFlagValue = (flag, index) => {
|
|
const value = argv[index + 1];
|
|
if (!value || value.startsWith("--")) {
|
|
throw new Error(`${flag} requires a path value`);
|
|
}
|
|
return path.resolve(value);
|
|
};
|
|
for (let i = 0; i < argv.length; i += 1) {
|
|
const arg = argv[i];
|
|
if (arg === "--aosp-root") {
|
|
args.aospRoot = readFlagValue(arg, i);
|
|
i += 1;
|
|
} else if (arg === "--apk") {
|
|
args.apk = readFlagValue(arg, i);
|
|
i += 1;
|
|
} else if (arg === "--vendor-dir") {
|
|
args.vendorDir = readFlagValue(arg, i);
|
|
i += 1;
|
|
} else if (arg === "-h" || arg === "--help") {
|
|
console.log(
|
|
"Usage: node packages/scripts/distro-android/validate.mjs [--brand-config <PATH>] [--apk <APK>] [--vendor-dir <VENDOR_DIR>] [--aosp-root <AOSP_ROOT>]",
|
|
);
|
|
process.exit(0);
|
|
} else {
|
|
throw new Error(`Unknown argument: ${arg}`);
|
|
}
|
|
}
|
|
args.apk ??= path.join(
|
|
args.vendorDir,
|
|
"apps",
|
|
brand.appName,
|
|
`${brand.appName}.apk`,
|
|
);
|
|
return args;
|
|
}
|
|
|
|
function fail(message) {
|
|
throw new Error(`[distro-android:validate] ${message}`);
|
|
}
|
|
|
|
function assertFile(filePath, label = filePath) {
|
|
if (!fs.existsSync(filePath)) {
|
|
fail(`Missing ${label}: ${filePath}`);
|
|
}
|
|
}
|
|
|
|
function read(filePath) {
|
|
assertFile(filePath);
|
|
return fs.readFileSync(filePath, "utf8");
|
|
}
|
|
|
|
function assertIncludes(content, needle, label) {
|
|
if (!content.includes(needle)) {
|
|
fail(`${label} is missing ${needle}`);
|
|
}
|
|
}
|
|
|
|
function assertMatches(content, pattern, label, description) {
|
|
if (!pattern.test(content)) {
|
|
fail(`${label} is missing ${description}`);
|
|
}
|
|
}
|
|
|
|
function escapeRegExp(value) {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
function assertCountAtLeast(content, needle, expectedCount, label) {
|
|
const count = content.split(needle).length - 1;
|
|
if (count < expectedCount) {
|
|
fail(
|
|
`${label} needs at least ${expectedCount} occurrence(s) of ${needle}; found ${count}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function xmlStringValue(xml, name, label) {
|
|
const match = xml.match(
|
|
new RegExp(
|
|
`<string\\b(?=[^>]*\\bname="${escapeRegExp(name)}")[^>]*>([^<]*)<\\/string>`,
|
|
),
|
|
);
|
|
if (!match) {
|
|
fail(`${label} is missing string resource ${name}`);
|
|
}
|
|
return match[1].trim();
|
|
}
|
|
|
|
function xmlElementBlockByName(xml, tagName, name, label) {
|
|
const match = xml.match(
|
|
new RegExp(
|
|
`<${tagName}\\b(?=[^>]*\\bname="${escapeRegExp(name)}")[\\s\\S]*?<\\/${tagName}>`,
|
|
),
|
|
);
|
|
if (!match) {
|
|
fail(`${label} is missing ${tagName} ${name}`);
|
|
}
|
|
return match[0];
|
|
}
|
|
|
|
function run(command, args) {
|
|
const result = spawnSync(command, args, {
|
|
encoding: "utf8",
|
|
maxBuffer: 32 * 1024 * 1024,
|
|
});
|
|
if (result.error) {
|
|
fail(`${command} failed: ${result.error.message}`);
|
|
}
|
|
if (result.status !== 0) {
|
|
fail(
|
|
`${command} ${args.join(" ")} failed:\n${result.stderr || result.stdout}`,
|
|
);
|
|
}
|
|
return result.stdout;
|
|
}
|
|
|
|
function commandExists(command) {
|
|
const result = spawnSync(command, ["--version"], {
|
|
encoding: "utf8",
|
|
stdio: "ignore",
|
|
});
|
|
return !result.error;
|
|
}
|
|
|
|
function findFiles(dir, predicate, out = []) {
|
|
if (!fs.existsSync(dir)) return out;
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const fullPath = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
findFiles(fullPath, predicate, out);
|
|
} else if (predicate(fullPath)) {
|
|
out.push(fullPath);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function compareVersions(a, b) {
|
|
const aa = a.split(".").map((part) => Number.parseInt(part, 10) || 0);
|
|
const bb = b.split(".").map((part) => Number.parseInt(part, 10) || 0);
|
|
const len = Math.max(aa.length, bb.length);
|
|
for (let i = 0; i < len; i += 1) {
|
|
const delta = (aa[i] ?? 0) - (bb[i] ?? 0);
|
|
if (delta !== 0) return delta;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function resolveAapt() {
|
|
const explicit = process.env.AAPT;
|
|
if (explicit && fs.existsSync(explicit)) return explicit;
|
|
|
|
const sdkRoots = [
|
|
process.env.ANDROID_HOME,
|
|
process.env.ANDROID_SDK_ROOT,
|
|
path.join(os.homedir(), "Library", "Android", "sdk"),
|
|
path.join(os.homedir(), "Android", "Sdk"),
|
|
].filter(Boolean);
|
|
|
|
for (const sdkRoot of sdkRoots) {
|
|
const buildTools = path.join(sdkRoot, "build-tools");
|
|
if (!fs.existsSync(buildTools)) continue;
|
|
const versions = fs.readdirSync(buildTools).sort(compareVersions).reverse();
|
|
for (const version of versions) {
|
|
const candidate = path.join(buildTools, version, "aapt");
|
|
if (fs.existsSync(candidate)) return candidate;
|
|
}
|
|
}
|
|
|
|
fail("Could not find aapt. Set AAPT or ANDROID_HOME/ANDROID_SDK_ROOT.");
|
|
}
|
|
|
|
export function validateXmlFiles(vendorDir, brand) {
|
|
const xmlFiles = findFiles(vendorDir, (file) => file.endsWith(".xml"));
|
|
if (xmlFiles.length === 0)
|
|
fail(`No XML files found under vendor/${brand.brand}`);
|
|
if (!commandExists("xmllint")) {
|
|
fail(
|
|
"xmllint is required for XML parser validation. Install libxml2 or set PATH to xmllint.",
|
|
);
|
|
}
|
|
run("xmllint", ["--noout", ...xmlFiles]);
|
|
console.log(
|
|
`[distro-android:validate] XML parse check passed for ${xmlFiles.length} file(s).`,
|
|
);
|
|
}
|
|
|
|
export function validateProductLayer(vendorDir, brand) {
|
|
const product = read(
|
|
path.join(vendorDir, "products", `${brand.productName}.mk`),
|
|
);
|
|
// The product makefile must inherit from a device tree. By default we
|
|
// assume the compatibility Cuttlefish base (`aosp_cf.mk`); brand configs for
|
|
// non-Cuttlefish products (e.g. real-hardware SoC fusions like the
|
|
// OpenAgent E1) MUST declare `aospDeviceTreePaths` listing the device
|
|
// tree files they expect to find in the imported AOSP checkout. The
|
|
// first entry is treated as the primary inherit target the product
|
|
// makefile must reference; any additional entries are existence-checked
|
|
// against `--aosp-root` when present (see `validateAospDeviceTreePaths`).
|
|
// We intentionally do not fold a "found one of many" loop here: a
|
|
// product either inherits the documented base or it is misconfigured.
|
|
const deviceTreePaths = brand.aospDeviceTreePaths;
|
|
const primaryInherit =
|
|
Array.isArray(deviceTreePaths) && deviceTreePaths.length > 0
|
|
? deviceTreePaths[0]
|
|
: "device/google/cuttlefish/vsoc_x86_64_only/phone/aosp_cf.mk";
|
|
assertIncludes(product, primaryInherit, "product");
|
|
assertIncludes(
|
|
product,
|
|
`vendor/${brand.brand}/${brand.commonMakefile}`,
|
|
`product (must inherit ${brand.commonMakefile} for shared OS-path invariants)`,
|
|
);
|
|
assertIncludes(product, `${brand.envPrefix}_PRODUCT_TAG`, "product");
|
|
|
|
const common = read(path.join(vendorDir, brand.commonMakefile));
|
|
assertIncludes(common, "PRODUCT_PACKAGES +=", brand.commonMakefile);
|
|
assertIncludes(common, "PRODUCT_PACKAGES -=", brand.commonMakefile);
|
|
assertIncludes(common, brand.appName, brand.commonMakefile);
|
|
assertIncludes(
|
|
common,
|
|
`default-permissions-${brand.packageName}.xml`,
|
|
brand.commonMakefile,
|
|
);
|
|
assertIncludes(
|
|
common,
|
|
`privapp-permissions-${brand.packageName}.xml`,
|
|
brand.commonMakefile,
|
|
);
|
|
// PRODUCT_PACKAGE_OVERLAYS root must mirror the AOSP source tree from
|
|
// there: e.g. <root>/frameworks/base/core/res/res/values/config.xml
|
|
// overlays the framework-res package's config_default* strings. The
|
|
// older path "vendor/<brand>/overlays/framework-res" never merged
|
|
// because Soong looks under the overlay root for `LOCAL_RESOURCE_DIR`
|
|
// (frameworks/base/core/res/res), not for a directory called
|
|
// "framework-res".
|
|
assertIncludes(
|
|
common,
|
|
`vendor/${brand.brand}/overlays`,
|
|
brand.commonMakefile,
|
|
);
|
|
assertFile(
|
|
path.join(
|
|
vendorDir,
|
|
"overlays",
|
|
"frameworks",
|
|
"base",
|
|
"core",
|
|
"res",
|
|
"res",
|
|
"values",
|
|
"config.xml",
|
|
),
|
|
"framework-res overlay (must mirror frameworks/base/core/res/res/...)",
|
|
);
|
|
// Ensure no first-boot UX leaks through.
|
|
for (const marker of ["Provision", "SetupWizard", "ManagedProvisioning"]) {
|
|
assertIncludes(
|
|
common,
|
|
marker,
|
|
`${brand.commonMakefile} PRODUCT_PACKAGES -= strip list`,
|
|
);
|
|
}
|
|
assertIncludes(common, "ro.setupwizard.mode=DISABLED", brand.commonMakefile);
|
|
// Boot-time scaffolds.
|
|
assertIncludes(
|
|
common,
|
|
brand.initRcName,
|
|
`${brand.commonMakefile} PRODUCT_COPY_FILES`,
|
|
);
|
|
assertIncludes(common, "BOARD_VENDOR_SEPOLICY_DIRS", brand.commonMakefile);
|
|
assertIncludes(
|
|
common,
|
|
`vendor/${brand.brand}/sepolicy`,
|
|
brand.commonMakefile,
|
|
);
|
|
if (common.includes("PermissionController")) {
|
|
fail(
|
|
`${brand.commonMakefile} still references a PermissionController overlay; role defaults live in framework-res strings.`,
|
|
);
|
|
}
|
|
|
|
// Per-Pixel templates exist and follow the same <BRAND>_PIXEL_CODENAME contract.
|
|
const pixelMakefile = `${brand.pixelMakefilePrefix}_pixel_phone.mk`;
|
|
const pixelTemplate = read(path.join(vendorDir, "products", pixelMakefile));
|
|
assertIncludes(
|
|
pixelTemplate,
|
|
`${brand.envPrefix}_PIXEL_CODENAME`,
|
|
pixelMakefile,
|
|
);
|
|
assertIncludes(
|
|
pixelTemplate,
|
|
`vendor/${brand.brand}/${brand.commonMakefile}`,
|
|
pixelMakefile,
|
|
);
|
|
|
|
const androidProducts = read(path.join(vendorDir, "AndroidProducts.mk"));
|
|
assertMatches(
|
|
androidProducts,
|
|
new RegExp(`\\$\\(LOCAL_DIR\\)/products/${brand.productName}\\.mk`),
|
|
"AndroidProducts.mk",
|
|
`PRODUCT_MAKEFILES entry for ${brand.productName}`,
|
|
);
|
|
assertMatches(
|
|
androidProducts,
|
|
new RegExp(`${brand.productName}-trunk_staging-userdebug`),
|
|
"AndroidProducts.mk",
|
|
`${brand.productName}-trunk_staging-userdebug lunch choice`,
|
|
);
|
|
|
|
// Init script + sepolicy files required by the product overlay.
|
|
assertFile(
|
|
path.join(vendorDir, "init", brand.initRcName),
|
|
`vendor/${brand.brand} init script`,
|
|
);
|
|
assertFile(
|
|
path.join(vendorDir, "sepolicy", "file_contexts"),
|
|
`vendor/${brand.brand} sepolicy file_contexts`,
|
|
);
|
|
|
|
// Lint the init script syntactically — typos here only show up at
|
|
// boot otherwise.
|
|
const initIssues = lintInitRc(path.join(vendorDir, "init", brand.initRcName));
|
|
const initErrors = initIssues.filter((i) => !i.soft);
|
|
if (initErrors.length > 0) {
|
|
fail(
|
|
`${brand.initRcName} has lint errors:\n - ${initErrors
|
|
.map((i) => `line ${i.line}: ${i.message}`)
|
|
.join("\n - ")}`,
|
|
);
|
|
}
|
|
|
|
const androidBp = read(
|
|
path.join(vendorDir, "apps", brand.appName, "Android.bp"),
|
|
);
|
|
for (const marker of [
|
|
"android_app_import",
|
|
`name: "${brand.appName}"`,
|
|
`apk: "${brand.appName}.apk"`,
|
|
"privileged: true",
|
|
'certificate: "platform"',
|
|
'"Launcher3"',
|
|
'"Launcher3QuickStep"',
|
|
'"Dialer"',
|
|
// Both "messaging" (lowercase, the actual Soong module name from
|
|
// packages/apps/Messaging/Android.bp) and "Messaging" (compatibility
|
|
// / lineage variants) — the lowercase one is the load-bearing
|
|
// entry; the capital is kept for non-AOSP forks that diverge.
|
|
'"messaging"',
|
|
'"Messaging"',
|
|
'"Contacts"',
|
|
'"Trebuchet"',
|
|
]) {
|
|
assertIncludes(androidBp, marker, `${brand.appName} Android.bp`);
|
|
}
|
|
|
|
const frameworkConfig = read(
|
|
path.join(
|
|
vendorDir,
|
|
"overlays",
|
|
"frameworks",
|
|
"base",
|
|
"core",
|
|
"res",
|
|
"res",
|
|
"values",
|
|
"config.xml",
|
|
),
|
|
);
|
|
for (const resourceName of [
|
|
"config_defaultDialer",
|
|
"config_defaultSms",
|
|
"config_defaultAssistant",
|
|
"config_defaultBrowser",
|
|
]) {
|
|
const value = xmlStringValue(
|
|
frameworkConfig,
|
|
resourceName,
|
|
"framework-res overlay",
|
|
);
|
|
if (value !== brand.packageName) {
|
|
fail(
|
|
`framework-res overlay ${resourceName} must be ${brand.packageName}; found ${value || "<empty>"}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const obsoleteRoleFiles = findFiles(vendorDir, (file) =>
|
|
file.endsWith(".xml"),
|
|
).filter((file) => /config_default.*RoleHolders/.test(read(file)));
|
|
if (obsoleteRoleFiles.length > 0) {
|
|
fail(
|
|
`Obsolete PermissionController role-holder resources found: ${obsoleteRoleFiles.join(", ")}`,
|
|
);
|
|
}
|
|
|
|
console.log("[distro-android:validate] Product layer checks passed.");
|
|
}
|
|
|
|
export function validateDefaultPermissions(vendorDir, brand) {
|
|
const defaultPermissions = read(
|
|
path.join(
|
|
vendorDir,
|
|
"permissions",
|
|
`default-permissions-${brand.packageName}.xml`,
|
|
),
|
|
);
|
|
assertIncludes(
|
|
defaultPermissions,
|
|
`<exception package="${brand.packageName}">`,
|
|
"default permissions",
|
|
);
|
|
for (const permission of defaultGrantPermissions) {
|
|
assertIncludes(
|
|
defaultPermissions,
|
|
`name="${permission}"`,
|
|
"default permissions",
|
|
);
|
|
}
|
|
|
|
const privPermissions = read(
|
|
path.join(
|
|
vendorDir,
|
|
"permissions",
|
|
`privapp-permissions-${brand.packageName}.xml`,
|
|
),
|
|
);
|
|
assertIncludes(
|
|
privPermissions,
|
|
`<privapp-permissions package="${brand.packageName}"`,
|
|
"privapp permissions",
|
|
);
|
|
for (const permission of privilegedPermissions) {
|
|
assertIncludes(
|
|
privPermissions,
|
|
`name="${permission}"`,
|
|
"privapp permissions",
|
|
);
|
|
}
|
|
|
|
// The product makefile lists these XMLs by module name in PRODUCT_PACKAGES.
|
|
// Soong needs prebuilt_etc{} declarations or `m` exits with "module not defined".
|
|
const permissionsBp = read(path.join(vendorDir, "permissions", "Android.bp"));
|
|
for (const moduleName of [
|
|
`default-permissions-${brand.packageName}.xml`,
|
|
`privapp-permissions-${brand.packageName}.xml`,
|
|
]) {
|
|
assertIncludes(
|
|
permissionsBp,
|
|
`name: "${moduleName}"`,
|
|
"permissions/Android.bp",
|
|
);
|
|
}
|
|
assertIncludes(
|
|
permissionsBp,
|
|
'sub_dir: "default-permissions"',
|
|
"permissions/Android.bp",
|
|
);
|
|
assertIncludes(
|
|
permissionsBp,
|
|
'sub_dir: "permissions"',
|
|
"permissions/Android.bp",
|
|
);
|
|
|
|
console.log("[distro-android:validate] Permission XML checks passed.");
|
|
}
|
|
|
|
/**
|
|
* Vendor sepolicy files exist on the AOSP build path
|
|
* (`BOARD_VENDOR_SEPOLICY_DIRS += vendor/<brand>/sepolicy`). For the
|
|
* local-agent-on-Android landing we currently rely on the on-device
|
|
* agent running in the priv_app domain (assigned to the privileged
|
|
* APK by AOSP's seapp_contexts). A custom `<brand>_agent` domain was
|
|
* attempted but tripped AOSP's neverallow envelope (priv_app cannot
|
|
* transition to arbitrary domains, app domains cannot have
|
|
* file_contexts targeting /data/data paths, etc.) — landing it
|
|
* requires deeper sepolicy work than the spike scope. The validator
|
|
* pins the file existence so the build doesn't break, but keeps the
|
|
* assertions narrow until the custom domain is reintroduced.
|
|
*
|
|
* See vendor/<brand>/sepolicy/README.md for the design.
|
|
*/
|
|
export function validateSepolicy(vendorDir, brand) {
|
|
// file_contexts must exist for `BOARD_VENDOR_SEPOLICY_DIRS` to point
|
|
// at a non-empty directory; AOSP's sepolicy build chokes if the
|
|
// listed dir has no policy files at all. An empty file is fine.
|
|
const fileContextsPath = path.join(vendorDir, "sepolicy", "file_contexts");
|
|
assertFile(fileContextsPath, `vendor/${brand.brand} sepolicy/file_contexts`);
|
|
|
|
// The agent runs as platform_app and must be able to execve the
|
|
// bundled bun runtime out of /data/data/<pkg>/files/agent/. AOSP's
|
|
// stock platform_app.te has no such allow rule (only priv_app does),
|
|
// so we add it here.
|
|
const teFile = `${brand.brand}_agent.te`;
|
|
const tePath = path.join(vendorDir, "sepolicy", teFile);
|
|
assertFile(tePath, `vendor/${brand.brand} sepolicy/${teFile}`);
|
|
const te = read(tePath);
|
|
assertMatches(
|
|
te,
|
|
/allow\s+platform_app\s+app_data_file\s*:\s*file\b[^;]*\bexecute_no_trans\b[^;]*;/,
|
|
teFile,
|
|
"allow platform_app app_data_file:file { execute execute_no_trans } (on-device agent exec)",
|
|
);
|
|
|
|
console.log("[distro-android:validate] Sepolicy checks passed.");
|
|
}
|
|
|
|
function manifestElementBlocks(manifest, elementName) {
|
|
const blocks = [];
|
|
const lines = manifest.split(/\r?\n/);
|
|
for (let i = 0; i < lines.length; i += 1) {
|
|
const start = lines[i].match(new RegExp(`^(\\s*)E: ${elementName}\\b`));
|
|
if (!start) continue;
|
|
const indent = start[1].length;
|
|
const block = [lines[i]];
|
|
for (let j = i + 1; j < lines.length; j += 1) {
|
|
const nextElement = lines[j].match(/^(\s*)E: /);
|
|
if (nextElement && nextElement[1].length <= indent) break;
|
|
block.push(lines[j]);
|
|
}
|
|
blocks.push(block.join("\n"));
|
|
}
|
|
return blocks;
|
|
}
|
|
|
|
function manifestComponentBlock(manifest, elementName, componentName) {
|
|
const block = manifestElementBlocks(manifest, elementName).find((candidate) =>
|
|
candidate.includes(`"${componentName}"`),
|
|
);
|
|
if (!block) {
|
|
fail(`APK manifest is missing ${elementName} ${componentName}`);
|
|
}
|
|
return block;
|
|
}
|
|
|
|
function assertManifestBlockIncludes(block, needle, label) {
|
|
assertIncludes(block, needle, `APK manifest ${label}`);
|
|
}
|
|
|
|
function validateApkManifest(manifest, brand) {
|
|
const cls = (suffix) => `${brand.classPrefix}${suffix}`;
|
|
const fq = (suffix) => `${brand.packageName}.${cls(suffix)}`;
|
|
|
|
const mainActivity = manifestComponentBlock(
|
|
manifest,
|
|
"activity",
|
|
`${brand.packageName}.MainActivity`,
|
|
);
|
|
assertManifestBlockIncludes(
|
|
mainActivity,
|
|
"android.intent.action.MAIN",
|
|
"MainActivity",
|
|
);
|
|
assertManifestBlockIncludes(
|
|
mainActivity,
|
|
"android.intent.category.HOME",
|
|
"MainActivity",
|
|
);
|
|
assertManifestBlockIncludes(
|
|
mainActivity,
|
|
"android.intent.category.DEFAULT",
|
|
"MainActivity",
|
|
);
|
|
|
|
const dialActivity = manifestComponentBlock(
|
|
manifest,
|
|
"activity",
|
|
fq("DialActivity"),
|
|
);
|
|
assertCountAtLeast(
|
|
dialActivity,
|
|
"android.intent.action.DIAL",
|
|
2,
|
|
`APK manifest ${cls("DialActivity")}`,
|
|
);
|
|
assertManifestBlockIncludes(
|
|
dialActivity,
|
|
"android.intent.category.DEFAULT",
|
|
cls("DialActivity"),
|
|
);
|
|
assertManifestBlockIncludes(
|
|
dialActivity,
|
|
'android:scheme(0x01010027)="tel"',
|
|
cls("DialActivity"),
|
|
);
|
|
|
|
const assistActivity = manifestComponentBlock(
|
|
manifest,
|
|
"activity",
|
|
fq("AssistActivity"),
|
|
);
|
|
assertManifestBlockIncludes(
|
|
assistActivity,
|
|
"android.intent.action.ASSIST",
|
|
cls("AssistActivity"),
|
|
);
|
|
assertManifestBlockIncludes(
|
|
assistActivity,
|
|
"android.intent.category.DEFAULT",
|
|
cls("AssistActivity"),
|
|
);
|
|
|
|
const inCallService = manifestComponentBlock(
|
|
manifest,
|
|
"service",
|
|
fq("InCallService"),
|
|
);
|
|
assertManifestBlockIncludes(
|
|
inCallService,
|
|
"android.permission.BIND_INCALL_SERVICE",
|
|
cls("InCallService"),
|
|
);
|
|
assertManifestBlockIncludes(
|
|
inCallService,
|
|
"android.telecom.InCallService",
|
|
cls("InCallService"),
|
|
);
|
|
assertManifestBlockIncludes(
|
|
inCallService,
|
|
"android.telecom.IN_CALL_SERVICE_UI",
|
|
cls("InCallService"),
|
|
);
|
|
|
|
const smsReceiver = manifestComponentBlock(
|
|
manifest,
|
|
"receiver",
|
|
fq("SmsReceiver"),
|
|
);
|
|
assertManifestBlockIncludes(
|
|
smsReceiver,
|
|
"android.permission.BROADCAST_SMS",
|
|
cls("SmsReceiver"),
|
|
);
|
|
assertManifestBlockIncludes(
|
|
smsReceiver,
|
|
"android.provider.Telephony.SMS_DELIVER",
|
|
cls("SmsReceiver"),
|
|
);
|
|
|
|
const mmsReceiver = manifestComponentBlock(
|
|
manifest,
|
|
"receiver",
|
|
fq("MmsReceiver"),
|
|
);
|
|
assertManifestBlockIncludes(
|
|
mmsReceiver,
|
|
"android.permission.BROADCAST_WAP_PUSH",
|
|
cls("MmsReceiver"),
|
|
);
|
|
assertManifestBlockIncludes(
|
|
mmsReceiver,
|
|
"android.provider.Telephony.WAP_PUSH_DELIVER",
|
|
cls("MmsReceiver"),
|
|
);
|
|
assertManifestBlockIncludes(
|
|
mmsReceiver,
|
|
"application/vnd.wap.mms-message",
|
|
cls("MmsReceiver"),
|
|
);
|
|
|
|
const respondService = manifestComponentBlock(
|
|
manifest,
|
|
"service",
|
|
fq("RespondViaMessageService"),
|
|
);
|
|
assertManifestBlockIncludes(
|
|
respondService,
|
|
"android.permission.SEND_RESPOND_VIA_MESSAGE",
|
|
cls("RespondViaMessageService"),
|
|
);
|
|
assertManifestBlockIncludes(
|
|
respondService,
|
|
"android.intent.action.RESPOND_VIA_MESSAGE",
|
|
cls("RespondViaMessageService"),
|
|
);
|
|
assertManifestBlockIncludes(
|
|
respondService,
|
|
'android:scheme(0x01010027)="smsto"',
|
|
cls("RespondViaMessageService"),
|
|
);
|
|
|
|
const composeActivity = manifestComponentBlock(
|
|
manifest,
|
|
"activity",
|
|
fq("SmsComposeActivity"),
|
|
);
|
|
assertManifestBlockIncludes(
|
|
composeActivity,
|
|
"android.intent.action.SENDTO",
|
|
cls("SmsComposeActivity"),
|
|
);
|
|
assertManifestBlockIncludes(
|
|
composeActivity,
|
|
'android:scheme(0x01010027)="smsto"',
|
|
cls("SmsComposeActivity"),
|
|
);
|
|
|
|
const bootReceiver = manifestComponentBlock(
|
|
manifest,
|
|
"receiver",
|
|
fq("BootReceiver"),
|
|
);
|
|
assertManifestBlockIncludes(
|
|
bootReceiver,
|
|
"android.intent.action.LOCKED_BOOT_COMPLETED",
|
|
cls("BootReceiver"),
|
|
);
|
|
assertManifestBlockIncludes(
|
|
bootReceiver,
|
|
"android.intent.action.BOOT_COMPLETED",
|
|
cls("BootReceiver"),
|
|
);
|
|
|
|
// Replacement activities for stripped role apps. These soft-warn
|
|
// instead of failing because the activity Java sources land in the
|
|
// brand's source tree and a staged APK built before they were added
|
|
// is still a valid OS-path image — just one with intent-resolution
|
|
// gaps for the corresponding system intents.
|
|
const REPLACEMENT_ACTIVITIES = [
|
|
{
|
|
name: cls("BrowserActivity"),
|
|
markers: [
|
|
"android.intent.action.VIEW",
|
|
"android.intent.category.BROWSABLE",
|
|
'android:scheme(0x01010027)="http"',
|
|
'android:scheme(0x01010027)="https"',
|
|
],
|
|
},
|
|
{
|
|
name: cls("ContactsActivity"),
|
|
markers: ["android.intent.category.APP_CONTACTS"],
|
|
},
|
|
{
|
|
name: cls("CameraActivity"),
|
|
markers: [
|
|
"android.media.action.STILL_IMAGE_CAMERA",
|
|
"android.media.action.IMAGE_CAPTURE",
|
|
],
|
|
},
|
|
{
|
|
name: cls("ClockActivity"),
|
|
markers: [
|
|
"android.intent.action.SET_ALARM",
|
|
"android.intent.action.SHOW_ALARMS",
|
|
],
|
|
},
|
|
{
|
|
name: cls("CalendarActivity"),
|
|
markers: ["android.intent.category.APP_CALENDAR"],
|
|
},
|
|
];
|
|
|
|
const replacementWarnings = [];
|
|
for (const { name, markers } of REPLACEMENT_ACTIVITIES) {
|
|
const blocks = manifestElementBlocks(manifest, "activity").filter((b) =>
|
|
b.includes(`"${brand.packageName}.${name}"`),
|
|
);
|
|
if (blocks.length === 0) {
|
|
replacementWarnings.push(
|
|
`[soft] APK manifest is missing activity ${brand.packageName}.${name} — system intent will have no resolver after stripping the corresponding AOSP app.`,
|
|
);
|
|
continue;
|
|
}
|
|
const block = blocks[0];
|
|
for (const marker of markers) {
|
|
if (!block.includes(marker)) {
|
|
replacementWarnings.push(
|
|
`[soft] APK manifest ${name} is missing ${marker}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
if (replacementWarnings.length > 0) {
|
|
console.warn(
|
|
`[distro-android:validate] Soft warnings (rebuild APK to clear):\n - ${replacementWarnings.join("\n - ")}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
export function validateApk(apkPath, brand) {
|
|
assertFile(apkPath, `${brand.appName} APK`);
|
|
const aapt = resolveAapt();
|
|
const badging = run(aapt, ["dump", "badging", apkPath]);
|
|
assertIncludes(
|
|
badging,
|
|
`package: name='${brand.packageName}'`,
|
|
"APK badging",
|
|
);
|
|
assertIncludes(
|
|
badging,
|
|
`application-label:'${brand.appName}'`,
|
|
"APK badging",
|
|
);
|
|
for (const permission of requiredApkPermissions) {
|
|
assertIncludes(
|
|
badging,
|
|
`uses-permission: name='${permission}'`,
|
|
"APK badging",
|
|
);
|
|
}
|
|
|
|
const manifest = run(aapt, [
|
|
"dump",
|
|
"xmltree",
|
|
apkPath,
|
|
"AndroidManifest.xml",
|
|
]);
|
|
validateApkManifest(manifest, brand);
|
|
console.log(`[distro-android:validate] APK checks passed with ${aapt}.`);
|
|
}
|
|
|
|
/**
|
|
* Verify that the brand-declared AOSP device tree files exist in the
|
|
* imported AOSP checkout. This is the non-Cuttlefish counterpart to the
|
|
* implicit "aosp_cf.mk lives at a known path" assumption baked into the
|
|
* legacy validator. For fused-SoC brands (e.g. OpenAgent E1) the chip
|
|
* team's device tree must be rsynced into the AOSP checkout (see the
|
|
* chip's `import-aosp-device.sh`) before validate.mjs is run with
|
|
* `--aosp-root`. If the brand config does not declare
|
|
* `aospDeviceTreePaths`, validation returns because the Cuttlefish path is
|
|
* implicit.
|
|
*/
|
|
export function validateAospDeviceTreePaths(aospRoot, brand) {
|
|
const paths = brand.aospDeviceTreePaths;
|
|
if (!Array.isArray(paths) || paths.length === 0) return;
|
|
for (const relPath of paths) {
|
|
if (typeof relPath !== "string" || relPath.length === 0) {
|
|
fail(
|
|
`brand.aospDeviceTreePaths must be an array of non-empty strings; got ${JSON.stringify(relPath)}`,
|
|
);
|
|
}
|
|
assertFile(
|
|
path.join(aospRoot, relPath),
|
|
`AOSP device tree file declared by brand (${relPath})`,
|
|
);
|
|
}
|
|
console.log(
|
|
`[distro-android:validate] AOSP device tree checks passed for ${paths.length} declared path(s).`,
|
|
);
|
|
}
|
|
|
|
export function validateAospRoot(aospRoot) {
|
|
const buildEnvsetup = path.join(aospRoot, "build", "envsetup.sh");
|
|
assertFile(buildEnvsetup, "AOSP build/envsetup.sh");
|
|
|
|
const rolesXml = read(
|
|
path.join(
|
|
aospRoot,
|
|
"packages",
|
|
"modules",
|
|
"Permission",
|
|
"PermissionController",
|
|
"res",
|
|
"xml",
|
|
"roles.xml",
|
|
),
|
|
);
|
|
const dialerRole = xmlElementBlockByName(
|
|
rolesXml,
|
|
"role",
|
|
"android.app.role.DIALER",
|
|
"AOSP roles.xml",
|
|
);
|
|
assertIncludes(
|
|
dialerRole,
|
|
'defaultHolders="config_defaultDialer"',
|
|
"AOSP DIALER role",
|
|
);
|
|
assertIncludes(dialerRole, "android.intent.action.DIAL", "AOSP DIALER role");
|
|
assertIncludes(
|
|
dialerRole,
|
|
"android.telecom.InCallService",
|
|
"AOSP DIALER role",
|
|
);
|
|
|
|
const smsRole = xmlElementBlockByName(
|
|
rolesXml,
|
|
"role",
|
|
"android.app.role.SMS",
|
|
"AOSP roles.xml",
|
|
);
|
|
assertIncludes(
|
|
smsRole,
|
|
'defaultHolders="config_defaultSms"',
|
|
"AOSP SMS role",
|
|
);
|
|
for (const marker of [
|
|
"android.provider.Telephony.SMS_DELIVER",
|
|
"android.provider.Telephony.WAP_PUSH_DELIVER",
|
|
"android.intent.action.RESPOND_VIA_MESSAGE",
|
|
"android.intent.action.SENDTO",
|
|
]) {
|
|
assertIncludes(smsRole, marker, "AOSP SMS role");
|
|
}
|
|
|
|
const assistantRole = xmlElementBlockByName(
|
|
rolesXml,
|
|
"role",
|
|
"android.app.role.ASSISTANT",
|
|
"AOSP roles.xml",
|
|
);
|
|
assertIncludes(
|
|
assistantRole,
|
|
'defaultHolders="config_defaultAssistant"',
|
|
"AOSP ASSISTANT role",
|
|
);
|
|
assertIncludes(assistantRole, "AssistantRoleBehavior", "AOSP ASSISTANT role");
|
|
|
|
const homeRole = xmlElementBlockByName(
|
|
rolesXml,
|
|
"role",
|
|
"android.app.role.HOME",
|
|
"AOSP roles.xml",
|
|
);
|
|
assertIncludes(homeRole, "android.intent.category.HOME", "AOSP HOME role");
|
|
if (homeRole.includes("defaultHolders=")) {
|
|
fail(
|
|
"AOSP HOME role unexpectedly has a defaultHolders config; revisit brand home defaulting.",
|
|
);
|
|
}
|
|
|
|
const frameworkConfig = read(
|
|
path.join(
|
|
aospRoot,
|
|
"frameworks",
|
|
"base",
|
|
"core",
|
|
"res",
|
|
"res",
|
|
"values",
|
|
"config.xml",
|
|
),
|
|
);
|
|
for (const resourceName of [
|
|
"config_defaultAssistant",
|
|
"config_defaultDialer",
|
|
"config_defaultSms",
|
|
]) {
|
|
assertIncludes(
|
|
frameworkConfig,
|
|
`name="${resourceName}"`,
|
|
"AOSP framework config.xml",
|
|
);
|
|
}
|
|
|
|
console.log(
|
|
"[distro-android:validate] AOSP source compatibility checks passed.",
|
|
);
|
|
}
|
|
|
|
export function main(argv = process.argv.slice(2)) {
|
|
const { brand, remaining } = loadBrandFromArgv(argv);
|
|
const args = parseSubArgs(remaining, brand);
|
|
validateXmlFiles(args.vendorDir, brand);
|
|
validateProductLayer(args.vendorDir, brand);
|
|
validateDefaultPermissions(args.vendorDir, brand);
|
|
validateSepolicy(args.vendorDir, brand);
|
|
validateApk(args.apk, brand);
|
|
if (args.aospRoot) {
|
|
validateAospRoot(args.aospRoot);
|
|
validateAospDeviceTreePaths(args.aospRoot, brand);
|
|
}
|
|
console.log(`[distro-android:validate] ${brand.distroName} checks passed.`);
|
|
}
|
|
|
|
const isMain =
|
|
process.argv[1] &&
|
|
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
|
|
if (isMain) {
|
|
main();
|
|
}
|