@hot-updater/android-helper 1.2.0-rc.12 → 1.2.0-rc.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +461 -24
- package/dist/index.d.cts +67 -6
- package/dist/index.d.mts +67 -6
- package/dist/index.mjs +455 -25
- package/package.json +5 -4
package/dist/index.mjs
CHANGED
|
@@ -4,6 +4,9 @@ import fs from "fs";
|
|
|
4
4
|
import { ExecaError, execa } from "execa";
|
|
5
5
|
import os from "os";
|
|
6
6
|
import JSZip from "jszip";
|
|
7
|
+
import { createRequire } from "module";
|
|
8
|
+
import { SANDBOX_ACTIVITY, SANDBOX_APPLICATION_ID_SUFFIX, SANDBOX_META_DATA, sandboxChannelOf, sandboxUrlSchemeOf } from "@hot-updater/protocol/native-artifact";
|
|
9
|
+
import { XMLParser } from "fast-xml-parser";
|
|
7
10
|
import crypto from "crypto";
|
|
8
11
|
import { pipeline } from "stream/promises";
|
|
9
12
|
import { createInflateRaw } from "zlib";
|
|
@@ -88,10 +91,34 @@ function getTaskNames(moduleName, tasks) {
|
|
|
88
91
|
* Get gradle wrapper command based on platform
|
|
89
92
|
*/
|
|
90
93
|
const getGradleWrapper = () => process.platform.startsWith("win") ? "gradlew.bat" : "./gradlew";
|
|
94
|
+
/** A variable a POSIX shell can't name, as the signing environment's are. */
|
|
95
|
+
const isShellName = (name) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(name);
|
|
96
|
+
/**
|
|
97
|
+
* The command that runs the wrapper with `env`. The wrapper's `#!/bin/sh` is
|
|
98
|
+
* dash on Debian and Ubuntu, which silently drops a variable whose name isn't
|
|
99
|
+
* a shell name, such as `ORG_GRADLE_PROJECT_android.injected.signing.store.file`:
|
|
100
|
+
* the build then signs with the project's own config. bash keeps them.
|
|
101
|
+
*/
|
|
102
|
+
const gradleCommand = (env) => {
|
|
103
|
+
const wrapper = getGradleWrapper();
|
|
104
|
+
return wrapper === "./gradlew" && Object.keys(env ?? {}).some((name) => !isShellName(name)) ? ["bash", [wrapper]] : [wrapper, []];
|
|
105
|
+
};
|
|
91
106
|
/**
|
|
92
107
|
* Run gradle build with specified tasks and arguments
|
|
93
108
|
*/
|
|
94
|
-
async function runGradle(
|
|
109
|
+
async function runGradle(args) {
|
|
110
|
+
const logFilePath = await runGradleTasks(args);
|
|
111
|
+
return {
|
|
112
|
+
...await findBuildDirectory({
|
|
113
|
+
androidProjectPath: args.androidProjectPath,
|
|
114
|
+
moduleName: args.appModuleName,
|
|
115
|
+
tasks: args.tasks
|
|
116
|
+
}),
|
|
117
|
+
logFilePath
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/** Runs tasks that leave no artifact to find, and returns the build log, or null. */
|
|
121
|
+
async function runGradleTasks({ tasks, logPrefix, args, env, androidProjectPath, appModuleName, logHeader = [] }) {
|
|
95
122
|
const gradleArgs = getTaskNames(appModuleName, tasks);
|
|
96
123
|
gradleArgs.push("-x", "lint");
|
|
97
124
|
if (args.extraParams) gradleArgs.push(...args.extraParams);
|
|
@@ -110,7 +137,8 @@ async function runGradle({ tasks, logPrefix, args, env, androidProjectPath, appM
|
|
|
110
137
|
});
|
|
111
138
|
let logFilePath = null;
|
|
112
139
|
try {
|
|
113
|
-
const
|
|
140
|
+
const [command, prefix] = gradleCommand(env);
|
|
141
|
+
const gradle = execa(command, [...prefix, ...gradleArgs], {
|
|
114
142
|
cwd: androidProjectPath,
|
|
115
143
|
env,
|
|
116
144
|
all: true,
|
|
@@ -134,14 +162,7 @@ async function runGradle({ tasks, logPrefix, args, env, androidProjectPath, appM
|
|
|
134
162
|
} finally {
|
|
135
163
|
await logger.close();
|
|
136
164
|
}
|
|
137
|
-
return
|
|
138
|
-
...await findBuildDirectory({
|
|
139
|
-
androidProjectPath,
|
|
140
|
-
moduleName: appModuleName,
|
|
141
|
-
tasks
|
|
142
|
-
}),
|
|
143
|
-
logFilePath
|
|
144
|
-
};
|
|
165
|
+
return logFilePath;
|
|
145
166
|
}
|
|
146
167
|
/**
|
|
147
168
|
* Find the build output directory and artifact path
|
|
@@ -434,21 +455,297 @@ const readBuiltVersionCode = async ({ artifactPath, androidProjectPath }) => {
|
|
|
434
455
|
}
|
|
435
456
|
};
|
|
436
457
|
//#endregion
|
|
458
|
+
//#region src/utils/sandboxManifest.ts
|
|
459
|
+
const MAIN = "android.intent.action.MAIN";
|
|
460
|
+
const LAUNCHER = "android.intent.category.LAUNCHER";
|
|
461
|
+
const SHORTCUTS = "android.app.shortcuts";
|
|
462
|
+
const ANDROID = "android:";
|
|
463
|
+
const asArray = (value) => value === void 0 || value === null || value === "" ? [] : Array.isArray(value) ? value : [value];
|
|
464
|
+
const attribute = (element, name) => {
|
|
465
|
+
const value = element[`${ANDROID}${name}`];
|
|
466
|
+
return typeof value === "string" ? value : void 0;
|
|
467
|
+
};
|
|
468
|
+
const filtersOf = (component) => asArray(component["intent-filter"]).map((filter) => ({
|
|
469
|
+
actions: asArray(filter["action"]),
|
|
470
|
+
categories: asArray(filter["category"]),
|
|
471
|
+
data: asArray(filter["data"]),
|
|
472
|
+
attributes: Object.fromEntries(Object.entries(filter).filter(([name]) => name.startsWith(ANDROID)))
|
|
473
|
+
}));
|
|
474
|
+
const opensAsLauncher = ({ actions, categories }) => actions.some((action) => attribute(action, "name") === MAIN) && categories.some((category) => attribute(category, "name") === LAUNCHER);
|
|
475
|
+
/** Task settings a sandbox activity can't share a task with: what, and how to fix it. */
|
|
476
|
+
const taskRefusal = (name, activity) => {
|
|
477
|
+
const launchMode = attribute(activity, "launchMode");
|
|
478
|
+
if (launchMode === "singleInstance" || launchMode === "singleInstancePerTask") return `Sandbox builds don't support launchMode ${launchMode} on the launcher ${name}.\nUse singleTask or the default launch mode.`;
|
|
479
|
+
if (attribute(activity, "taskAffinity") !== void 0) return `Sandbox builds don't support taskAffinity on the launcher ${name}.\nRemove it from this build variant.`;
|
|
480
|
+
const documents = attribute(activity, "documentLaunchMode");
|
|
481
|
+
if (documents !== void 0 && documents !== "none") return `Sandbox builds don't support documentLaunchMode ${documents} on the launcher ${name}.\nRemove it from this build variant.`;
|
|
482
|
+
return null;
|
|
483
|
+
};
|
|
484
|
+
/**
|
|
485
|
+
* The launcher entry of a variant's merged manifest, which already holds what
|
|
486
|
+
* its flavors, build type and libraries declare. It refuses what a sandbox
|
|
487
|
+
* build can't take over: none or several entries, a filter it can't move whole,
|
|
488
|
+
* and task settings its activity can't share a task with.
|
|
489
|
+
*/
|
|
490
|
+
const resolveSandboxLauncher = (mergedManifest) => {
|
|
491
|
+
const application = new XMLParser({
|
|
492
|
+
ignoreAttributes: false,
|
|
493
|
+
attributeNamePrefix: ""
|
|
494
|
+
}).parse(mergedManifest)?.manifest?.application;
|
|
495
|
+
const activities = asArray(application?.["activity"]);
|
|
496
|
+
const aliases = asArray(application?.["activity-alias"]);
|
|
497
|
+
const entries = [...activities.map((element) => ({
|
|
498
|
+
kind: "activity",
|
|
499
|
+
element
|
|
500
|
+
})), ...aliases.map((element) => ({
|
|
501
|
+
kind: "activity-alias",
|
|
502
|
+
element
|
|
503
|
+
}))].flatMap(({ kind, element }) => {
|
|
504
|
+
const filters = filtersOf(element).filter(opensAsLauncher);
|
|
505
|
+
return filters.length === 0 ? [] : [{
|
|
506
|
+
kind,
|
|
507
|
+
element,
|
|
508
|
+
filters
|
|
509
|
+
}];
|
|
510
|
+
});
|
|
511
|
+
const names = entries.map(({ element }) => attribute(element, "name") ?? "?");
|
|
512
|
+
if (entries.length === 0) throw new Error("A sandbox build needs a launcher, and this variant's manifest has none.\nAdd a MAIN + LAUNCHER intent filter to an activity.");
|
|
513
|
+
if (entries.length > 1) throw new Error(`A sandbox build needs one launcher, and this variant has ${names.length}: ${names.join(", ")}.\nKeep one for this build variant.`);
|
|
514
|
+
const { kind, element, filters } = entries[0];
|
|
515
|
+
const name = names[0];
|
|
516
|
+
if (filters.length > 1) throw new Error(`The launcher ${name} has ${filters.length} MAIN + LAUNCHER filters, and a sandbox build can move one.\nKeep one for this build variant.`);
|
|
517
|
+
if (attribute(element, "enabled") === "false") throw new Error(`The launcher ${name} is disabled in the manifest, and a sandbox build can't tell when it would open.\nEnable it for this build variant.`);
|
|
518
|
+
const targetActivity = kind === "activity-alias" ? attribute(element, "targetActivity") : void 0;
|
|
519
|
+
const activity = kind === "activity" ? element : activities.find((item) => attribute(item, "name") === targetActivity);
|
|
520
|
+
if (activity === void 0) throw new Error(`The launcher ${name} targets ${targetActivity ?? "no activity"}, which this variant's manifest doesn't declare.\nFix its android:targetActivity.`);
|
|
521
|
+
const refusal = taskRefusal(name, activity);
|
|
522
|
+
if (refusal !== null) throw new Error(refusal);
|
|
523
|
+
const shortcuts = asArray(element["meta-data"]).find((item) => attribute(item, "name") === SHORTCUTS);
|
|
524
|
+
const label = attribute(element, "label");
|
|
525
|
+
const icon = attribute(element, "icon");
|
|
526
|
+
const roundIcon = attribute(element, "roundIcon");
|
|
527
|
+
const shortcutsResource = shortcuts && attribute(shortcuts, "resource");
|
|
528
|
+
return {
|
|
529
|
+
kind,
|
|
530
|
+
name,
|
|
531
|
+
...targetActivity === void 0 ? {} : { targetActivity },
|
|
532
|
+
filter: filters[0],
|
|
533
|
+
...label === void 0 ? {} : { label },
|
|
534
|
+
...icon === void 0 ? {} : { icon },
|
|
535
|
+
...roundIcon === void 0 ? {} : { roundIcon },
|
|
536
|
+
...shortcutsResource === void 0 ? {} : { shortcuts: shortcutsResource }
|
|
537
|
+
};
|
|
538
|
+
};
|
|
539
|
+
const xml = (text) => text.replace(/&/gu, "&").replace(/"/gu, """).replace(/</gu, "<").replace(/>/gu, ">");
|
|
540
|
+
const attributesOf = (element) => Object.entries(element).filter(([, value]) => typeof value === "string").map(([name, value]) => ` ${name}="${xml(value)}"`).join("");
|
|
541
|
+
/** A launcher filter, element by element: the merger matches a filter by content. */
|
|
542
|
+
const filterXml = ({ actions, categories, data, attributes }, extra = "") => [
|
|
543
|
+
`<intent-filter${attributesOf(attributes)}${extra}>`,
|
|
544
|
+
...actions.map((item) => ` <action${attributesOf(item)} />`),
|
|
545
|
+
...categories.map((item) => ` <category${attributesOf(item)} />`),
|
|
546
|
+
...data.map((item) => ` <data${attributesOf(item)} />`),
|
|
547
|
+
"</intent-filter>"
|
|
548
|
+
].map((line) => ` ${line}`).join("\n");
|
|
549
|
+
const optional = (name, value) => value === void 0 ? "" : ` android:${name}="${xml(value)}"`;
|
|
550
|
+
/**
|
|
551
|
+
* The manifest a sandbox build overlays on the app's. `tools:replace` wins over
|
|
552
|
+
* a channel the app's manifest sets, as an Expo project's does. The launcher's
|
|
553
|
+
* filter, label, icon and static shortcuts move to the sandbox's activity, which
|
|
554
|
+
* also takes the links of `appId`'s URL scheme; the rest of the app's activity
|
|
555
|
+
* stays, so its own links still reach it. `appId` is the scheme's `packageName`,
|
|
556
|
+
* before the `.sandbox` suffix, so a preview link needs no build to derive it.
|
|
557
|
+
*/
|
|
558
|
+
const sandboxManifestOverlay = ({ channel, appId, launcher }) => `<?xml version="1.0" encoding="utf-8"?>
|
|
559
|
+
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
|
|
560
|
+
<application>
|
|
561
|
+
<meta-data android:name="com.hotupdater.CHANNEL" android:value="${xml(channel)}" tools:replace="android:value" />
|
|
562
|
+
<meta-data android:name="${SANDBOX_META_DATA}" android:value="true" />
|
|
563
|
+
<activity android:name="${SANDBOX_ACTIVITY}" android:exported="true" android:launchMode="singleTop" android:theme="@style/HotUpdaterSandboxTheme" android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"${optional("label", launcher.label)}${optional("icon", launcher.icon)}${optional("roundIcon", launcher.roundIcon)}>
|
|
564
|
+
${filterXml(launcher.filter)}
|
|
565
|
+
<intent-filter>
|
|
566
|
+
<action android:name="android.intent.action.VIEW" />
|
|
567
|
+
<category android:name="android.intent.category.DEFAULT" />
|
|
568
|
+
<category android:name="android.intent.category.BROWSABLE" />
|
|
569
|
+
<data android:scheme="${sandboxUrlSchemeOf(appId)}" />
|
|
570
|
+
</intent-filter>
|
|
571
|
+
<meta-data android:name="com.hotupdater.sandbox.HOST_ACTIVITY" android:value="${xml(launcher.name)}" />${launcher.shortcuts === void 0 ? "" : `\n <meta-data android:name="${SHORTCUTS}" android:resource="${xml(launcher.shortcuts)}" />`}
|
|
572
|
+
</activity>
|
|
573
|
+
<${launcher.kind} android:name="${xml(launcher.name)}"${optional("targetActivity", launcher.targetActivity)}>
|
|
574
|
+
${filterXml(launcher.filter, " tools:node=\"remove\"")}${launcher.shortcuts === void 0 ? "" : `\n <meta-data android:name="${SHORTCUTS}" tools:node="remove" />`}
|
|
575
|
+
</${launcher.kind}>
|
|
576
|
+
<provider android:name="com.hotupdater.HotUpdaterSandboxProcess" android:authorities="\${applicationId}.hotupdater.sandbox" android:exported="false" />
|
|
577
|
+
</application>
|
|
578
|
+
</manifest>
|
|
579
|
+
`;
|
|
580
|
+
/** Newest first: the manifest of the task that ran last, under `directory`. */
|
|
581
|
+
const manifestsUnder = (directory) => {
|
|
582
|
+
if (!fs.existsSync(directory)) return [];
|
|
583
|
+
return fs.readdirSync(directory, { recursive: true }).map(String).filter((file) => path.basename(file) === "AndroidManifest.xml").map((file) => path.join(directory, file)).sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
|
|
584
|
+
};
|
|
585
|
+
/** The Gradle task that merges a variant's manifest. */
|
|
586
|
+
const mergeManifestTask = (variant) => `process${variant.charAt(0).toUpperCase()}${variant.slice(1)}MainManifest`;
|
|
587
|
+
/**
|
|
588
|
+
* The manifest the merge task of `variant` wrote: AGP 7 and 8 keep it under
|
|
589
|
+
* `build/intermediates/merged_manifest`, in a directory per variant.
|
|
590
|
+
*/
|
|
591
|
+
const readMergedManifest = ({ androidProjectPath, appModuleName, variant }) => {
|
|
592
|
+
const variantDirectory = `${variant.charAt(0).toLowerCase()}${variant.slice(1)}`;
|
|
593
|
+
const intermediates = path.join(androidProjectPath, appModuleName, "build/intermediates");
|
|
594
|
+
const [manifest] = ["merged_manifest", "merged_manifests"].flatMap((name) => manifestsUnder(path.join(intermediates, name, variantDirectory)));
|
|
595
|
+
if (manifest === void 0) throw new Error(`Couldn't find the merged manifest of ${variant} under android/${appModuleName}/build/intermediates.\nBuild the variant once, then try again.`);
|
|
596
|
+
return fs.readFileSync(manifest, "utf-8");
|
|
597
|
+
};
|
|
598
|
+
//#endregion
|
|
599
|
+
//#region src/utils/sandboxGradle.ts
|
|
600
|
+
const UNSUPPORTED_DSL = `Sandbox builds don't support AGP 9's default DSL yet.
|
|
601
|
+
Set android.newDsl=false in android/gradle.properties.`;
|
|
602
|
+
/** The message when the build type has a manifest the overlay would replace. */
|
|
603
|
+
const ownManifest = (manifest) => `A sandbox build can't merge with ${manifest}.\nMove what it declares to src/main/AndroidManifest.xml.`;
|
|
604
|
+
/** Text as a single-quoted Groovy string. */
|
|
605
|
+
const groovyString = (text) => `'${text.replace(/\\/gu, "\\\\").replace(/'/gu, "\\'").replace(/\n/gu, "\\n")}'`;
|
|
606
|
+
/**
|
|
607
|
+
* The init script of one sandbox build, so no project file changes: on the app
|
|
608
|
+
* module, it appends the sandbox's suffix to defaultConfig, where AGP puts any
|
|
609
|
+
* flavor's and build type's after it, and adds `overlay` to the variant's build
|
|
610
|
+
* type. It runs in `finalizeDsl`, after the project's own settings.
|
|
611
|
+
*/
|
|
612
|
+
const sandboxInitScript = ({ appModuleName, variant, overlay, minVersionCode }) => `// Written by hot-updater for one sandbox build.
|
|
613
|
+
def appModule = ${groovyString(`:${appModuleName}`)}
|
|
614
|
+
def variant = ${groovyString(variant.toLowerCase())}
|
|
615
|
+
def overlay = new File(${groovyString(overlay)})
|
|
616
|
+
def minVersionCode = ${minVersionCode ?? "null"}
|
|
617
|
+
allprojects { project ->
|
|
618
|
+
if (project.path != appModule) return
|
|
619
|
+
project.plugins.withId('com.android.application') {
|
|
620
|
+
def components = project.extensions.getByName('androidComponents')
|
|
621
|
+
if (components.pluginVersion.major >= 9 && project.findProperty('android.newDsl')?.toString() != 'false') {
|
|
622
|
+
throw new GradleException(${groovyString(UNSUPPORTED_DSL)})
|
|
623
|
+
}
|
|
624
|
+
components.finalizeDsl { dsl ->
|
|
625
|
+
def buildType = dsl.buildTypes.collect { it.name }
|
|
626
|
+
.findAll { variant.endsWith(it.toLowerCase()) }
|
|
627
|
+
.max { it.length() }
|
|
628
|
+
if (buildType == null) {
|
|
629
|
+
throw new GradleException("No build type of \${project.path} builds variant \${variant}.")
|
|
630
|
+
}
|
|
631
|
+
def manifest = dsl.sourceSets.getByName(buildType).manifest
|
|
632
|
+
def own = manifest.srcFile
|
|
633
|
+
if (own.exists()) {
|
|
634
|
+
throw new GradleException(${groovyString(ownManifest("@MANIFEST@"))}.replace('@MANIFEST@', 'android/' + project.rootDir.toPath().relativize(own.toPath())))
|
|
635
|
+
}
|
|
636
|
+
dsl.defaultConfig.applicationIdSuffix = (dsl.defaultConfig.applicationIdSuffix ?: '') + ${groovyString(SANDBOX_APPLICATION_ID_SUFFIX)}
|
|
637
|
+
manifest.srcFile(overlay)
|
|
638
|
+
if (minVersionCode != null) {
|
|
639
|
+
dsl.defaultConfig.versionCode = Math.max(dsl.defaultConfig.versionCode ?: 0, minVersionCode)
|
|
640
|
+
dsl.productFlavors.each { flavor ->
|
|
641
|
+
if (flavor.versionCode != null) flavor.versionCode = Math.max(flavor.versionCode, minVersionCode)
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
`;
|
|
648
|
+
/**
|
|
649
|
+
* Runs `build` with the Gradle arguments of a scheme: none for a normal one. A
|
|
650
|
+
* sandbox scheme's attach the SDK's sandbox layer and name the init script,
|
|
651
|
+
* written outside the project with its manifest overlay, and removed after.
|
|
652
|
+
* Kotlin compiles from scratch, since the sandbox layer's sources come and go.
|
|
653
|
+
* `readEffectiveManifest` is the variant's merged manifest before the overlay,
|
|
654
|
+
* which the overlay takes the launcher from.
|
|
655
|
+
*/
|
|
656
|
+
const withSandboxGradleArgs = async (scheme, readEffectiveManifest, build, { minVersionCode } = {}) => {
|
|
657
|
+
if (!scheme.sandbox) return build([]);
|
|
658
|
+
if (!scheme.packageName) throw new Error(`The sandbox scheme ${scheme.hotUpdaterSchemeName} sets no packageName, which its URL scheme comes from.\nSet packageName on the scheme.`);
|
|
659
|
+
const launcher = resolveSandboxLauncher(await readEffectiveManifest());
|
|
660
|
+
const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), "hot-updater-sandbox-"));
|
|
661
|
+
try {
|
|
662
|
+
const overlay = path.join(directory, "AndroidManifest.xml");
|
|
663
|
+
const initScript = path.join(directory, "sandbox.init.gradle");
|
|
664
|
+
await fs.promises.writeFile(overlay, sandboxManifestOverlay({
|
|
665
|
+
channel: sandboxChannelOf(scheme.hotUpdaterSchemeName),
|
|
666
|
+
appId: scheme.packageName,
|
|
667
|
+
launcher
|
|
668
|
+
}));
|
|
669
|
+
await fs.promises.writeFile(initScript, sandboxInitScript({
|
|
670
|
+
appModuleName: scheme.appModuleName,
|
|
671
|
+
variant: scheme.variant,
|
|
672
|
+
overlay,
|
|
673
|
+
...minVersionCode === void 0 ? {} : { minVersionCode }
|
|
674
|
+
}));
|
|
675
|
+
return await build([
|
|
676
|
+
"--init-script",
|
|
677
|
+
initScript,
|
|
678
|
+
`-PhotUpdaterSandbox=${scheme.hotUpdaterSchemeName}`,
|
|
679
|
+
"-Pkotlin.incremental=false"
|
|
680
|
+
]);
|
|
681
|
+
} finally {
|
|
682
|
+
await fs.promises.rm(directory, {
|
|
683
|
+
recursive: true,
|
|
684
|
+
force: true
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
/** The major version of the Android Gradle Plugin the project pins, or null. */
|
|
689
|
+
const readAgpMajor = (androidProjectPath) => {
|
|
690
|
+
const rootBuild = ["build.gradle", "build.gradle.kts"].map((file) => path.join(androidProjectPath, file)).filter((file) => fs.existsSync(file)).map((file) => fs.readFileSync(file, "utf-8")).join("\n");
|
|
691
|
+
const pinned = /com\.android\.tools\.build:gradle:(\d+)/u.exec(rootBuild);
|
|
692
|
+
if (pinned) return Number(pinned[1]);
|
|
693
|
+
try {
|
|
694
|
+
const manifest = createRequire(path.join(androidProjectPath, "package.json")).resolve("react-native/package.json");
|
|
695
|
+
const catalog = fs.readFileSync(path.join(path.dirname(manifest), "gradle/libs.versions.toml"), "utf-8");
|
|
696
|
+
const agp = /^\s*agp\s*=\s*"(\d+)/mu.exec(catalog);
|
|
697
|
+
return agp ? Number(agp[1]) : null;
|
|
698
|
+
} catch {
|
|
699
|
+
return null;
|
|
700
|
+
}
|
|
701
|
+
};
|
|
702
|
+
/**
|
|
703
|
+
* Why a sandbox build of a scheme can't work, read from the project's files
|
|
704
|
+
* before anything is built, or null. The init script checks again, with what
|
|
705
|
+
* Gradle actually resolved.
|
|
706
|
+
*/
|
|
707
|
+
const findSandboxGradleBlocker = ({ variant = "Release", appModuleName = "app" }) => {
|
|
708
|
+
const androidProjectPath = path.join(getCwd(), "android");
|
|
709
|
+
if (!fs.existsSync(androidProjectPath)) return null;
|
|
710
|
+
const properties = path.join(androidProjectPath, "gradle.properties");
|
|
711
|
+
const newDsl = fs.existsSync(properties) ? /^\s*android\.newDsl\s*=\s*(\S+)/mu.exec(fs.readFileSync(properties, "utf-8"))?.[1] : void 0;
|
|
712
|
+
if (newDsl === "true" || newDsl === void 0 && (readAgpMajor(androidProjectPath) ?? 0) >= 9) return UNSUPPORTED_DSL;
|
|
713
|
+
const buildType = /([A-Z]?[a-z0-9]+)$/u.exec(variant)?.[1]?.toLowerCase();
|
|
714
|
+
const manifest = `${appModuleName}/src/${buildType}/AndroidManifest.xml`;
|
|
715
|
+
return buildType && fs.existsSync(path.join(androidProjectPath, manifest)) ? ownManifest(`android/${manifest}`) : null;
|
|
716
|
+
};
|
|
717
|
+
//#endregion
|
|
437
718
|
//#region src/buildAndroid.ts
|
|
438
|
-
const buildAndroid = async ({ schemeConfig, minBundleId = createMinBundleId(), credentials, logHeader }) => {
|
|
719
|
+
const buildAndroid = async ({ schemeConfig, minBundleId = createMinBundleId(), credentials, logHeader, minVersionCode }) => {
|
|
439
720
|
const androidProjectPath = path.join(getCwd(), "android");
|
|
440
|
-
const
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
721
|
+
const env = await resolveGradleSigningEnv({
|
|
722
|
+
schemeConfig,
|
|
723
|
+
credentials
|
|
724
|
+
});
|
|
725
|
+
const result = await withSandboxGradleArgs(schemeConfig, async () => {
|
|
726
|
+
await runGradleTasks({
|
|
727
|
+
args: { extraParams: [`-PMIN_BUNDLE_ID=${minBundleId}`] },
|
|
728
|
+
env,
|
|
729
|
+
appModuleName: schemeConfig.appModuleName,
|
|
730
|
+
tasks: [mergeManifestTask(schemeConfig.variant)],
|
|
731
|
+
logPrefix: `android-${schemeConfig.hotUpdaterSchemeName}-manifest`,
|
|
732
|
+
androidProjectPath,
|
|
733
|
+
logHeader
|
|
734
|
+
});
|
|
735
|
+
return readMergedManifest({
|
|
736
|
+
androidProjectPath,
|
|
737
|
+
appModuleName: schemeConfig.appModuleName,
|
|
738
|
+
variant: schemeConfig.variant
|
|
739
|
+
});
|
|
740
|
+
}, (sandboxArgs) => runGradle({
|
|
741
|
+
args: { extraParams: [`-PMIN_BUNDLE_ID=${minBundleId}`, ...sandboxArgs] },
|
|
742
|
+
env,
|
|
446
743
|
appModuleName: schemeConfig.appModuleName,
|
|
447
744
|
tasks: schemeConfig.aab ? [`bundle${schemeConfig.variant}`] : [`assemble${schemeConfig.variant}`],
|
|
448
745
|
logPrefix: `android-${schemeConfig.hotUpdaterSchemeName}-build`,
|
|
449
746
|
androidProjectPath,
|
|
450
747
|
logHeader
|
|
451
|
-
});
|
|
748
|
+
}), minVersionCode === void 0 ? {} : { minVersionCode });
|
|
452
749
|
return {
|
|
453
750
|
...result,
|
|
454
751
|
builtApp: await readBuiltApp({
|
|
@@ -458,6 +755,105 @@ const buildAndroid = async ({ schemeConfig, minBundleId = createMinBundleId(), c
|
|
|
458
755
|
};
|
|
459
756
|
};
|
|
460
757
|
//#endregion
|
|
758
|
+
//#region src/device.ts
|
|
759
|
+
/** The SDK's adb, or the one on the PATH without an SDK in the environment. */
|
|
760
|
+
const adbPath = () => {
|
|
761
|
+
const sdk = process.env["ANDROID_HOME"] || process.env["ANDROID_SDK_ROOT"];
|
|
762
|
+
const executable = process.platform.startsWith("win") ? "adb.exe" : "adb";
|
|
763
|
+
const fromSdk = sdk && path.join(sdk, "platform-tools", executable);
|
|
764
|
+
return fromSdk && fs.existsSync(fromSdk) ? fromSdk : executable;
|
|
765
|
+
};
|
|
766
|
+
const adb = async (args) => {
|
|
767
|
+
try {
|
|
768
|
+
return (await execa(adbPath(), args)).stdout;
|
|
769
|
+
} catch (error) {
|
|
770
|
+
const { code, stderr, stdout, shortMessage } = error;
|
|
771
|
+
if (code === "ENOENT") throw new Error("adb isn't installed.\nInstall Android platform-tools, or set ANDROID_HOME.");
|
|
772
|
+
throw new Error(stderr?.trim() || stdout?.trim() || shortMessage || String(error));
|
|
773
|
+
}
|
|
774
|
+
};
|
|
775
|
+
/** What `adb devices` lists. */
|
|
776
|
+
const listAdbDevices = async () => (await adb(["devices"])).split("\n").slice(1).map((line) => line.trim().split(/\s+/u)).flatMap(([serial, state]) => serial && state ? [{
|
|
777
|
+
serial,
|
|
778
|
+
state
|
|
779
|
+
}] : []);
|
|
780
|
+
/**
|
|
781
|
+
* The device to install on: `serial`, as adb's own `ANDROID_SERIAL` names it
|
|
782
|
+
* too, or else the only one that takes commands.
|
|
783
|
+
*/
|
|
784
|
+
const selectAdbDevice = (devices, serial = process.env["ANDROID_SERIAL"] || void 0) => {
|
|
785
|
+
if (serial !== void 0) {
|
|
786
|
+
const device = devices.find((candidate) => candidate.serial === serial);
|
|
787
|
+
if (device === void 0) throw new Error(`${serial} isn't connected.\nList them: adb devices`);
|
|
788
|
+
if (device.state !== "device") throw new Error(`${serial} is ${device.state}.\nList them: adb devices`);
|
|
789
|
+
return device;
|
|
790
|
+
}
|
|
791
|
+
const ready = devices.filter(({ state }) => state === "device");
|
|
792
|
+
const [only] = ready;
|
|
793
|
+
if (only === void 0) throw new Error("No Android device or emulator is connected.\nStart an emulator or connect a phone, then run this again.");
|
|
794
|
+
if (ready.length > 1) throw new Error([
|
|
795
|
+
`${ready.length} Android devices are connected:`,
|
|
796
|
+
...ready.map(({ serial }) => ` ${serial}`),
|
|
797
|
+
"Pick one with --device <serial>."
|
|
798
|
+
].join("\n"));
|
|
799
|
+
return only;
|
|
800
|
+
};
|
|
801
|
+
/** A failure adb reports for an install another install of the app stands in the way of. */
|
|
802
|
+
const NEEDS_UNINSTALL = /^INSTALL_FAILED_(UPDATE_INCOMPATIBLE|VERSION_DOWNGRADE)\b/u;
|
|
803
|
+
const LAUNCHER_CATEGORY = "android.intent.category.LAUNCHER";
|
|
804
|
+
/** An application ID's form, checked before it reaches the device's shell. */
|
|
805
|
+
const APP_ID = /^[A-Za-z][A-Za-z0-9_.]*$/u;
|
|
806
|
+
/** `<application ID>/<activity>`, as `resolve-activity --brief` prints it. */
|
|
807
|
+
const ACTIVITY = /^[A-Za-z0-9_.]+\/[A-Za-z0-9_.$]+$/u;
|
|
808
|
+
/**
|
|
809
|
+
* Installs an APK on a device, and opens its launcher activity. `appId` is
|
|
810
|
+
* the APK's own application ID, whatever suffix its build gave it.
|
|
811
|
+
*/
|
|
812
|
+
const installOnAndroidDevice = async ({ serial, apkPath, appId }) => {
|
|
813
|
+
if (!APP_ID.test(appId)) throw new Error(`The APK's application ID isn't valid: ${appId}`);
|
|
814
|
+
try {
|
|
815
|
+
await adb([
|
|
816
|
+
"-s",
|
|
817
|
+
serial,
|
|
818
|
+
"install",
|
|
819
|
+
"-r",
|
|
820
|
+
apkPath
|
|
821
|
+
]);
|
|
822
|
+
} catch (error) {
|
|
823
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
824
|
+
const failure = /Failure \[([^\]]+)\]/u.exec(message)?.[1] ?? message;
|
|
825
|
+
throw new Error(NEEDS_UNINSTALL.test(failure) ? `The installed ${appId} can't be replaced: ${failure}\nUninstall it: adb -s ${serial} uninstall ${appId}` : `adb couldn't install the APK: ${failure}`);
|
|
826
|
+
}
|
|
827
|
+
const activity = (await adb([
|
|
828
|
+
"-s",
|
|
829
|
+
serial,
|
|
830
|
+
"shell",
|
|
831
|
+
"cmd",
|
|
832
|
+
"package",
|
|
833
|
+
"resolve-activity",
|
|
834
|
+
"--brief",
|
|
835
|
+
"-c",
|
|
836
|
+
LAUNCHER_CATEGORY,
|
|
837
|
+
appId
|
|
838
|
+
])).trim().split("\n").at(-1)?.trim();
|
|
839
|
+
if (activity === void 0 || !ACTIVITY.test(activity)) throw new Error(`${appId} is installed, and has no launcher activity to open.`);
|
|
840
|
+
const started = await adb([
|
|
841
|
+
"-s",
|
|
842
|
+
serial,
|
|
843
|
+
"shell",
|
|
844
|
+
"am",
|
|
845
|
+
"start",
|
|
846
|
+
"-a",
|
|
847
|
+
"android.intent.action.MAIN",
|
|
848
|
+
"-c",
|
|
849
|
+
LAUNCHER_CATEGORY,
|
|
850
|
+
"-n",
|
|
851
|
+
`'${activity}'`
|
|
852
|
+
]);
|
|
853
|
+
const error = /^Error: .*$/mu.exec(started)?.[0];
|
|
854
|
+
if (error !== void 0) throw new Error(`${appId} is installed, and didn't open. ${error}`);
|
|
855
|
+
};
|
|
856
|
+
//#endregion
|
|
461
857
|
//#region src/uploadToPlay.ts
|
|
462
858
|
const API_URL = "https://androidpublisher.googleapis.com";
|
|
463
859
|
/** Where Google serves resumable upload sessions, as its docs show both. */
|
|
@@ -680,11 +1076,9 @@ const checkPlayAccess = async ({ serviceAccount: { json }, packageName, fetch =
|
|
|
680
1076
|
}
|
|
681
1077
|
};
|
|
682
1078
|
/**
|
|
683
|
-
*
|
|
684
|
-
* `.aab`'s version code, as when a reused native build was uploaded
|
|
685
|
-
* before. It reads the track in an edit it then deletes, changing nothing.
|
|
1079
|
+
* Reads the app in an edit it then deletes, changing nothing.
|
|
686
1080
|
*/
|
|
687
|
-
const
|
|
1081
|
+
const readInEdit = async ({ packageName, serviceAccount: { json }, fetch }, read) => {
|
|
688
1082
|
const { app, request } = createPlayClient({
|
|
689
1083
|
json,
|
|
690
1084
|
packageName,
|
|
@@ -698,8 +1092,7 @@ const findPlayInternalRelease = async ({ versionCode, packageName, serviceAccoun
|
|
|
698
1092
|
}
|
|
699
1093
|
const edit = `${API_URL}${app}/edits/${encodeURIComponent(editId)}`;
|
|
700
1094
|
try {
|
|
701
|
-
|
|
702
|
-
return releases.some(({ versionCodes = [] }) => versionCodes.includes(String(versionCode)));
|
|
1095
|
+
return await read(request, edit);
|
|
703
1096
|
} catch (error) {
|
|
704
1097
|
throw explain(error, packageName);
|
|
705
1098
|
} finally {
|
|
@@ -707,6 +1100,43 @@ const findPlayInternalRelease = async ({ versionCode, packageName, serviceAccoun
|
|
|
707
1100
|
}
|
|
708
1101
|
};
|
|
709
1102
|
/**
|
|
1103
|
+
* The status of the internal testing release holding `versionCode`, such as
|
|
1104
|
+
* `completed` or `draft`, or null when the track doesn't hold it: never
|
|
1105
|
+
* uploaded, or replaced by a newer release, which testers get instead.
|
|
1106
|
+
*/
|
|
1107
|
+
const readPlayInternalRelease = ({ versionCode, packageName, serviceAccount, fetch = globalThis.fetch }) => readInEdit({
|
|
1108
|
+
packageName,
|
|
1109
|
+
serviceAccount,
|
|
1110
|
+
fetch
|
|
1111
|
+
}, async (request, edit) => {
|
|
1112
|
+
const { releases = [] } = await request("Reading the internal track", "GET", `${edit}/tracks/${INTERNAL_TRACK}`);
|
|
1113
|
+
const release = releases.find(({ versionCodes = [] }) => versionCodes.includes(String(versionCode)));
|
|
1114
|
+
return release ? release.status ?? "completed" : null;
|
|
1115
|
+
});
|
|
1116
|
+
/**
|
|
1117
|
+
* Whether Google Play's internal testing track already releases the
|
|
1118
|
+
* `.aab`'s version code, as when a reused native build was uploaded before.
|
|
1119
|
+
*/
|
|
1120
|
+
const findPlayInternalRelease = async (options) => await readPlayInternalRelease(options) !== null;
|
|
1121
|
+
/**
|
|
1122
|
+
* The highest version code Google Play has taken for the app, on any track,
|
|
1123
|
+
* or null with none: a new `.aab` needs one above it.
|
|
1124
|
+
*/
|
|
1125
|
+
const readPlayHighestVersionCode = ({ packageName, serviceAccount, fetch = globalThis.fetch }) => readInEdit({
|
|
1126
|
+
packageName,
|
|
1127
|
+
serviceAccount,
|
|
1128
|
+
fetch
|
|
1129
|
+
}, async (request, edit) => {
|
|
1130
|
+
const { bundles = [] } = await request("Reading the app's bundles", "GET", `${edit}/bundles`);
|
|
1131
|
+
const codes = bundles.flatMap(({ versionCode }) => versionCode ?? []);
|
|
1132
|
+
return codes.length > 0 ? Math.max(...codes) : null;
|
|
1133
|
+
});
|
|
1134
|
+
/**
|
|
1135
|
+
* The link that opts a tester in to the app's testing tracks. A build on the
|
|
1136
|
+
* internal track installs through it, for the testers Play Console lists.
|
|
1137
|
+
*/
|
|
1138
|
+
const playTestingUrl = (packageName) => `https://play.google.com/apps/testing/${encodeURIComponent(packageName)}`;
|
|
1139
|
+
/**
|
|
710
1140
|
* The common name of every Android debug certificate: Android Studio's and
|
|
711
1141
|
* the Android Gradle plugin's (`CN=Android Debug, O=Android, C=US`), and React
|
|
712
1142
|
* Native's template `debug.keystore` (`CN=Android Debug, OU=Android,
|
|
@@ -1049,4 +1479,4 @@ const extractBuiltInBundle = async ({ artifactPath, outputPath }) => {
|
|
|
1049
1479
|
await (entry.method === 8 ? pipeline(source, createInflateRaw(), fs.createWriteStream(outputPath)) : pipeline(source, fs.createWriteStream(outputPath)));
|
|
1050
1480
|
};
|
|
1051
1481
|
//#endregion
|
|
1052
|
-
export { PlayVersionCodeUsedError, assertPlaySigned, buildAndroid, checkPlayAccess, enrichNativeBuildAndroidScheme, extractBuiltInBundle, findPlayInternalRelease, playConsoleInviteUrl, readBuiltApp, readBuiltPackageName, readBuiltVersionCode, resolvePlayUploadServiceAccount, uploadToPlay };
|
|
1482
|
+
export { PlayVersionCodeUsedError, assertPlaySigned, buildAndroid, checkPlayAccess, enrichNativeBuildAndroidScheme, extractBuiltInBundle, findPlayInternalRelease, findSandboxGradleBlocker, installOnAndroidDevice, listAdbDevices, playConsoleInviteUrl, playTestingUrl, readBuiltApp, readBuiltPackageName, readBuiltVersionCode, readPlayHighestVersionCode, readPlayInternalRelease, resolvePlayUploadServiceAccount, selectAdbDevice, uploadToPlay };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hot-updater/android-helper",
|
|
3
|
-
"version": "1.2.0-rc.
|
|
3
|
+
"version": "1.2.0-rc.13",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.cjs",
|
|
6
6
|
"module": "dist/index.mjs",
|
|
@@ -28,10 +28,11 @@
|
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"execa": "9.5.2",
|
|
31
|
+
"fast-xml-parser": "5.7.2",
|
|
31
32
|
"jszip": "^3.10.1",
|
|
32
|
-
"@hot-updater/plugin-core": "1.2.0-rc.
|
|
33
|
-
"@hot-updater/cli-tools": "1.2.0-rc.
|
|
34
|
-
"@hot-updater/protocol": "1.2.0-rc.
|
|
33
|
+
"@hot-updater/plugin-core": "1.2.0-rc.13",
|
|
34
|
+
"@hot-updater/cli-tools": "1.2.0-rc.13",
|
|
35
|
+
"@hot-updater/protocol": "1.2.0-rc.13"
|
|
35
36
|
},
|
|
36
37
|
"devDependencies": {
|
|
37
38
|
"@types/node": "^20"
|