@hot-updater/android-helper 1.2.0-rc.12 → 1.2.0-rc.14
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 +476 -24
- package/dist/index.d.cts +67 -6
- package/dist/index.d.mts +67 -6
- package/dist/index.mjs +470 -25
- package/package.json +5 -4
package/dist/index.cjs
CHANGED
|
@@ -31,6 +31,9 @@ let os = require("os");
|
|
|
31
31
|
os = __toESM(os, 1);
|
|
32
32
|
let jszip = require("jszip");
|
|
33
33
|
jszip = __toESM(jszip, 1);
|
|
34
|
+
let module$1 = require("module");
|
|
35
|
+
let _hot_updater_protocol_native_artifact = require("@hot-updater/protocol/native-artifact");
|
|
36
|
+
let fast_xml_parser = require("fast-xml-parser");
|
|
34
37
|
let crypto = require("crypto");
|
|
35
38
|
crypto = __toESM(crypto, 1);
|
|
36
39
|
let stream_promises = require("stream/promises");
|
|
@@ -116,10 +119,34 @@ function getTaskNames(moduleName, tasks) {
|
|
|
116
119
|
* Get gradle wrapper command based on platform
|
|
117
120
|
*/
|
|
118
121
|
const getGradleWrapper = () => process.platform.startsWith("win") ? "gradlew.bat" : "./gradlew";
|
|
122
|
+
/** A variable a POSIX shell can't name, as the signing environment's are. */
|
|
123
|
+
const isShellName = (name) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(name);
|
|
124
|
+
/**
|
|
125
|
+
* The command that runs the wrapper with `env`. The wrapper's `#!/bin/sh` is
|
|
126
|
+
* dash on Debian and Ubuntu, which silently drops a variable whose name isn't
|
|
127
|
+
* a shell name, such as `ORG_GRADLE_PROJECT_android.injected.signing.store.file`:
|
|
128
|
+
* the build then signs with the project's own config. bash keeps them.
|
|
129
|
+
*/
|
|
130
|
+
const gradleCommand = (env) => {
|
|
131
|
+
const wrapper = getGradleWrapper();
|
|
132
|
+
return wrapper === "./gradlew" && Object.keys(env ?? {}).some((name) => !isShellName(name)) ? ["bash", [wrapper]] : [wrapper, []];
|
|
133
|
+
};
|
|
119
134
|
/**
|
|
120
135
|
* Run gradle build with specified tasks and arguments
|
|
121
136
|
*/
|
|
122
|
-
async function runGradle(
|
|
137
|
+
async function runGradle(args) {
|
|
138
|
+
const logFilePath = await runGradleTasks(args);
|
|
139
|
+
return {
|
|
140
|
+
...await findBuildDirectory({
|
|
141
|
+
androidProjectPath: args.androidProjectPath,
|
|
142
|
+
moduleName: args.appModuleName,
|
|
143
|
+
tasks: args.tasks
|
|
144
|
+
}),
|
|
145
|
+
logFilePath
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
/** Runs tasks that leave no artifact to find, and returns the build log, or null. */
|
|
149
|
+
async function runGradleTasks({ tasks, logPrefix, args, env, androidProjectPath, appModuleName, logHeader = [] }) {
|
|
123
150
|
const gradleArgs = getTaskNames(appModuleName, tasks);
|
|
124
151
|
gradleArgs.push("-x", "lint");
|
|
125
152
|
if (args.extraParams) gradleArgs.push(...args.extraParams);
|
|
@@ -138,7 +165,8 @@ async function runGradle({ tasks, logPrefix, args, env, androidProjectPath, appM
|
|
|
138
165
|
});
|
|
139
166
|
let logFilePath = null;
|
|
140
167
|
try {
|
|
141
|
-
const
|
|
168
|
+
const [command, prefix] = gradleCommand(env);
|
|
169
|
+
const gradle = (0, execa.execa)(command, [...prefix, ...gradleArgs], {
|
|
142
170
|
cwd: androidProjectPath,
|
|
143
171
|
env,
|
|
144
172
|
all: true,
|
|
@@ -162,14 +190,7 @@ async function runGradle({ tasks, logPrefix, args, env, androidProjectPath, appM
|
|
|
162
190
|
} finally {
|
|
163
191
|
await logger.close();
|
|
164
192
|
}
|
|
165
|
-
return
|
|
166
|
-
...await findBuildDirectory({
|
|
167
|
-
androidProjectPath,
|
|
168
|
-
moduleName: appModuleName,
|
|
169
|
-
tasks
|
|
170
|
-
}),
|
|
171
|
-
logFilePath
|
|
172
|
-
};
|
|
193
|
+
return logFilePath;
|
|
173
194
|
}
|
|
174
195
|
/**
|
|
175
196
|
* Find the build output directory and artifact path
|
|
@@ -462,21 +483,312 @@ const readBuiltVersionCode = async ({ artifactPath, androidProjectPath }) => {
|
|
|
462
483
|
}
|
|
463
484
|
};
|
|
464
485
|
//#endregion
|
|
486
|
+
//#region src/utils/sandboxManifest.ts
|
|
487
|
+
const MAIN = "android.intent.action.MAIN";
|
|
488
|
+
const LAUNCHER = "android.intent.category.LAUNCHER";
|
|
489
|
+
const SHORTCUTS = "android.app.shortcuts";
|
|
490
|
+
const ANDROID = "android:";
|
|
491
|
+
const asArray = (value) => value === void 0 || value === null || value === "" ? [] : Array.isArray(value) ? value : [value];
|
|
492
|
+
const attribute = (element, name) => {
|
|
493
|
+
const value = element[`${ANDROID}${name}`];
|
|
494
|
+
return typeof value === "string" ? value : void 0;
|
|
495
|
+
};
|
|
496
|
+
const filtersOf = (component) => asArray(component["intent-filter"]).map((filter) => ({
|
|
497
|
+
actions: asArray(filter["action"]),
|
|
498
|
+
categories: asArray(filter["category"]),
|
|
499
|
+
data: asArray(filter["data"]),
|
|
500
|
+
attributes: Object.fromEntries(Object.entries(filter).filter(([name]) => name.startsWith(ANDROID)))
|
|
501
|
+
}));
|
|
502
|
+
const opensAsLauncher = ({ actions, categories }) => actions.some((action) => attribute(action, "name") === MAIN) && categories.some((category) => attribute(category, "name") === LAUNCHER);
|
|
503
|
+
/** Task settings a sandbox activity can't share a task with: what, and how to fix it. */
|
|
504
|
+
const taskRefusal = (name, activity) => {
|
|
505
|
+
const launchMode = attribute(activity, "launchMode");
|
|
506
|
+
if (launchMode === "singleInstance" || launchMode === "singleInstancePerTask") return `Sandbox builds don't support launchMode ${launchMode} on the launcher ${name}.\nUse singleTask or the default launch mode.`;
|
|
507
|
+
if (attribute(activity, "taskAffinity") !== void 0) return `Sandbox builds don't support taskAffinity on the launcher ${name}.\nRemove it from this build variant.`;
|
|
508
|
+
const documents = attribute(activity, "documentLaunchMode");
|
|
509
|
+
if (documents !== void 0 && documents !== "none") return `Sandbox builds don't support documentLaunchMode ${documents} on the launcher ${name}.\nRemove it from this build variant.`;
|
|
510
|
+
return null;
|
|
511
|
+
};
|
|
512
|
+
/**
|
|
513
|
+
* The launcher entry of a variant's merged manifest, which already holds what
|
|
514
|
+
* its flavors, build type and libraries declare. It refuses what a sandbox
|
|
515
|
+
* build can't take over: none or several entries, a filter it can't move whole,
|
|
516
|
+
* and task settings its activity can't share a task with.
|
|
517
|
+
*/
|
|
518
|
+
const resolveSandboxLauncher = (mergedManifest) => {
|
|
519
|
+
const application = new fast_xml_parser.XMLParser({
|
|
520
|
+
ignoreAttributes: false,
|
|
521
|
+
attributeNamePrefix: ""
|
|
522
|
+
}).parse(mergedManifest)?.manifest?.application;
|
|
523
|
+
const activities = asArray(application?.["activity"]);
|
|
524
|
+
const aliases = asArray(application?.["activity-alias"]);
|
|
525
|
+
const entries = [...activities.map((element) => ({
|
|
526
|
+
kind: "activity",
|
|
527
|
+
element
|
|
528
|
+
})), ...aliases.map((element) => ({
|
|
529
|
+
kind: "activity-alias",
|
|
530
|
+
element
|
|
531
|
+
}))].flatMap(({ kind, element }) => {
|
|
532
|
+
const filters = filtersOf(element).filter(opensAsLauncher);
|
|
533
|
+
return filters.length === 0 ? [] : [{
|
|
534
|
+
kind,
|
|
535
|
+
element,
|
|
536
|
+
filters
|
|
537
|
+
}];
|
|
538
|
+
});
|
|
539
|
+
const names = entries.map(({ element }) => attribute(element, "name") ?? "?");
|
|
540
|
+
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.");
|
|
541
|
+
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.`);
|
|
542
|
+
const { kind, element, filters } = entries[0];
|
|
543
|
+
const name = names[0];
|
|
544
|
+
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.`);
|
|
545
|
+
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.`);
|
|
546
|
+
const targetActivity = kind === "activity-alias" ? attribute(element, "targetActivity") : void 0;
|
|
547
|
+
const activity = kind === "activity" ? element : activities.find((item) => attribute(item, "name") === targetActivity);
|
|
548
|
+
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.`);
|
|
549
|
+
const refusal = taskRefusal(name, activity);
|
|
550
|
+
if (refusal !== null) throw new Error(refusal);
|
|
551
|
+
const launcherProcess = attribute(activity, "process");
|
|
552
|
+
if (launcherProcess !== void 0 && launcherProcess !== attribute(application ?? {}, "process")) throw new Error(`Sandbox builds run the launcher in the application's process, and ${name} sets android:process ${launcherProcess}.\nRemove it, or set the same android:process on <application>, for this build variant.`);
|
|
553
|
+
const shortcuts = asArray(element["meta-data"]).find((item) => attribute(item, "name") === SHORTCUTS);
|
|
554
|
+
const label = attribute(element, "label");
|
|
555
|
+
const icon = attribute(element, "icon");
|
|
556
|
+
const roundIcon = attribute(element, "roundIcon");
|
|
557
|
+
const shortcutsResource = shortcuts && attribute(shortcuts, "resource");
|
|
558
|
+
return {
|
|
559
|
+
kind,
|
|
560
|
+
name,
|
|
561
|
+
...targetActivity === void 0 ? {} : { targetActivity },
|
|
562
|
+
filter: filters[0],
|
|
563
|
+
...label === void 0 ? {} : { label },
|
|
564
|
+
...icon === void 0 ? {} : { icon },
|
|
565
|
+
...roundIcon === void 0 ? {} : { roundIcon },
|
|
566
|
+
...shortcutsResource === void 0 ? {} : { shortcuts: shortcutsResource }
|
|
567
|
+
};
|
|
568
|
+
};
|
|
569
|
+
const xml = (text) => text.replace(/&/gu, "&").replace(/"/gu, """).replace(/</gu, "<").replace(/>/gu, ">");
|
|
570
|
+
const attributesOf = (element) => Object.entries(element).filter(([, value]) => typeof value === "string").map(([name, value]) => ` ${name}="${xml(value)}"`).join("");
|
|
571
|
+
/** A launcher filter, element by element: the merger matches a filter by content. */
|
|
572
|
+
const filterXml = ({ actions, categories, data, attributes }, extra = "") => [
|
|
573
|
+
`<intent-filter${attributesOf(attributes)}${extra}>`,
|
|
574
|
+
...actions.map((item) => ` <action${attributesOf(item)} />`),
|
|
575
|
+
...categories.map((item) => ` <category${attributesOf(item)} />`),
|
|
576
|
+
...data.map((item) => ` <data${attributesOf(item)} />`),
|
|
577
|
+
"</intent-filter>"
|
|
578
|
+
].map((line) => ` ${line}`).join("\n");
|
|
579
|
+
const optional = (name, value) => value === void 0 ? "" : ` android:${name}="${xml(value)}"`;
|
|
580
|
+
/**
|
|
581
|
+
* The manifest a sandbox build overlays on the app's. `tools:replace` wins over
|
|
582
|
+
* a channel the app's manifest sets, as an Expo project's does. The launcher's
|
|
583
|
+
* filter, label, icon and static shortcuts move to the sandbox's activity, which
|
|
584
|
+
* also takes the links of `appId`'s URL scheme; the rest of the app's activity
|
|
585
|
+
* stays, so its own links still reach it. `appId` is the scheme's `packageName`,
|
|
586
|
+
* before the `.sandbox` suffix, so a preview link needs no build to derive it.
|
|
587
|
+
* The dev server is plain http, which a project's network security config can
|
|
588
|
+
* still refuse: the screen then says so.
|
|
589
|
+
*/
|
|
590
|
+
const sandboxManifestOverlay = ({ channel, appId, launcher }) => `<?xml version="1.0" encoding="utf-8"?>
|
|
591
|
+
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
|
|
592
|
+
<application android:usesCleartextTraffic="true" tools:replace="android:usesCleartextTraffic">
|
|
593
|
+
<meta-data android:name="com.hotupdater.CHANNEL" android:value="${xml(channel)}" tools:replace="android:value" />
|
|
594
|
+
<meta-data android:name="${_hot_updater_protocol_native_artifact.SANDBOX_META_DATA}" android:value="true" />
|
|
595
|
+
<activity android:name="${_hot_updater_protocol_native_artifact.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)}>
|
|
596
|
+
${filterXml(launcher.filter)}
|
|
597
|
+
<intent-filter>
|
|
598
|
+
<action android:name="android.intent.action.VIEW" />
|
|
599
|
+
<category android:name="android.intent.category.DEFAULT" />
|
|
600
|
+
<category android:name="android.intent.category.BROWSABLE" />
|
|
601
|
+
<data android:scheme="${(0, _hot_updater_protocol_native_artifact.sandboxUrlSchemeOf)(appId)}" />
|
|
602
|
+
</intent-filter>
|
|
603
|
+
<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)}" />`}
|
|
604
|
+
</activity>
|
|
605
|
+
<${launcher.kind} android:name="${xml(launcher.name)}"${optional("targetActivity", launcher.targetActivity)}>
|
|
606
|
+
${filterXml(launcher.filter, " tools:node=\"remove\"")}${launcher.shortcuts === void 0 ? "" : `\n <meta-data android:name="${SHORTCUTS}" tools:node="remove" />`}
|
|
607
|
+
</${launcher.kind}>
|
|
608
|
+
<activity android:name="com.hotupdater.HotUpdaterSandboxRelaunchActivity" android:excludeFromRecents="true" android:exported="false" android:launchMode="singleTask" android:noHistory="true" android:process=":hotupdater_restart" android:theme="@android:style/Theme.NoDisplay" />
|
|
609
|
+
<provider android:name="com.hotupdater.HotUpdaterSandboxProcess" android:authorities="\${applicationId}.hotupdater.sandbox" android:exported="false" />
|
|
610
|
+
</application>
|
|
611
|
+
</manifest>
|
|
612
|
+
`;
|
|
613
|
+
/** Newest first: the manifest of the task that ran last, under `directory`. */
|
|
614
|
+
const manifestsUnder = (directory) => {
|
|
615
|
+
if (!fs.default.existsSync(directory)) return [];
|
|
616
|
+
return fs.default.readdirSync(directory, { recursive: true }).map(String).filter((file) => path.default.basename(file) === "AndroidManifest.xml").map((file) => path.default.join(directory, file)).sort((a, b) => fs.default.statSync(b).mtimeMs - fs.default.statSync(a).mtimeMs);
|
|
617
|
+
};
|
|
618
|
+
/** The Gradle task that merges a variant's manifest. */
|
|
619
|
+
const mergeManifestTask = (variant) => `process${variant.charAt(0).toUpperCase()}${variant.slice(1)}MainManifest`;
|
|
620
|
+
/**
|
|
621
|
+
* The manifest the merge task of `variant` wrote: AGP 7 and 8 keep it under
|
|
622
|
+
* `build/intermediates/merged_manifest`, in a directory per variant.
|
|
623
|
+
*/
|
|
624
|
+
const readMergedManifest = ({ androidProjectPath, appModuleName, variant }) => {
|
|
625
|
+
const variantDirectory = `${variant.charAt(0).toLowerCase()}${variant.slice(1)}`;
|
|
626
|
+
const intermediates = path.default.join(androidProjectPath, appModuleName, "build/intermediates");
|
|
627
|
+
const [manifest] = ["merged_manifest", "merged_manifests"].flatMap((name) => manifestsUnder(path.default.join(intermediates, name, variantDirectory)));
|
|
628
|
+
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.`);
|
|
629
|
+
return fs.default.readFileSync(manifest, "utf-8");
|
|
630
|
+
};
|
|
631
|
+
//#endregion
|
|
632
|
+
//#region src/utils/sandboxGradle.ts
|
|
633
|
+
const UNSUPPORTED_DSL = `Sandbox builds don't support AGP 9's default DSL yet.
|
|
634
|
+
Set android.newDsl=false in android/gradle.properties.`;
|
|
635
|
+
/** The message when the build type has a manifest the overlay would replace. */
|
|
636
|
+
const ownManifest = (manifest) => `A sandbox build can't merge with ${manifest}.\nMove what it declares to src/main/AndroidManifest.xml.`;
|
|
637
|
+
/** The message when no project compiled the SDK from source, which a dev server needs. */
|
|
638
|
+
const PREBUILT_SDK = `This app takes @hot-updater/react-native as a prebuilt AAR, and a sandbox build compiles the SDK from source.
|
|
639
|
+
Add the SDK's autolinked project to this build instead of the AAR.`;
|
|
640
|
+
/** Text as a single-quoted Groovy string. */
|
|
641
|
+
const groovyString = (text) => `'${text.replace(/\\/gu, "\\\\").replace(/'/gu, "\\'").replace(/\n/gu, "\\n")}'`;
|
|
642
|
+
/**
|
|
643
|
+
* The init script of one sandbox build, so no project file changes: on the app
|
|
644
|
+
* module, it appends the sandbox's suffix to defaultConfig, where AGP puts any
|
|
645
|
+
* flavor's and build type's after it, and adds `overlay` to the variant's build
|
|
646
|
+
* type. It runs in `finalizeDsl`, after the project's own settings.
|
|
647
|
+
*/
|
|
648
|
+
const sandboxInitScript = ({ appModuleName, variant, overlay, minVersionCode }) => `// Written by hot-updater for one sandbox build.
|
|
649
|
+
def appModule = ${groovyString(`:${appModuleName}`)}
|
|
650
|
+
def variant = ${groovyString(variant.toLowerCase())}
|
|
651
|
+
def overlay = new File(${groovyString(overlay)})
|
|
652
|
+
def minVersionCode = ${minVersionCode ?? "null"}
|
|
653
|
+
allprojects { project ->
|
|
654
|
+
if (project.path != appModule) return
|
|
655
|
+
// In this build alone: an init script also runs for React Native's included
|
|
656
|
+
// Gradle plugin. The SDK's sandbox script sets this on the project it compiles.
|
|
657
|
+
gradle.taskGraph.whenReady {
|
|
658
|
+
if (!gradle.rootProject.ext.has('hotUpdaterSandboxSdkProject')) {
|
|
659
|
+
throw new GradleException(${groovyString(PREBUILT_SDK)})
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
project.plugins.withId('com.android.application') {
|
|
663
|
+
def components = project.extensions.getByName('androidComponents')
|
|
664
|
+
if (components.pluginVersion.major >= 9 && project.findProperty('android.newDsl')?.toString() != 'false') {
|
|
665
|
+
throw new GradleException(${groovyString(UNSUPPORTED_DSL)})
|
|
666
|
+
}
|
|
667
|
+
components.finalizeDsl { dsl ->
|
|
668
|
+
def buildType = dsl.buildTypes.collect { it.name }
|
|
669
|
+
.findAll { variant.endsWith(it.toLowerCase()) }
|
|
670
|
+
.max { it.length() }
|
|
671
|
+
if (buildType == null) {
|
|
672
|
+
throw new GradleException("No build type of \${project.path} builds variant \${variant}.")
|
|
673
|
+
}
|
|
674
|
+
def manifest = dsl.sourceSets.getByName(buildType).manifest
|
|
675
|
+
def own = manifest.srcFile
|
|
676
|
+
if (own.exists()) {
|
|
677
|
+
throw new GradleException(${groovyString(ownManifest("@MANIFEST@"))}.replace('@MANIFEST@', 'android/' + project.rootDir.toPath().relativize(own.toPath())))
|
|
678
|
+
}
|
|
679
|
+
dsl.defaultConfig.applicationIdSuffix = (dsl.defaultConfig.applicationIdSuffix ?: '') + ${groovyString(_hot_updater_protocol_native_artifact.SANDBOX_APPLICATION_ID_SUFFIX)}
|
|
680
|
+
manifest.srcFile(overlay)
|
|
681
|
+
if (minVersionCode != null) {
|
|
682
|
+
dsl.defaultConfig.versionCode = Math.max(dsl.defaultConfig.versionCode ?: 0, minVersionCode)
|
|
683
|
+
dsl.productFlavors.each { flavor ->
|
|
684
|
+
if (flavor.versionCode != null) flavor.versionCode = Math.max(flavor.versionCode, minVersionCode)
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
`;
|
|
691
|
+
/**
|
|
692
|
+
* Runs `build` with the Gradle arguments of a scheme: none for a normal one. A
|
|
693
|
+
* sandbox scheme's attach the SDK's sandbox layer and name the init script,
|
|
694
|
+
* written outside the project with its manifest overlay, and removed after.
|
|
695
|
+
* Kotlin compiles from scratch, since the sandbox layer's sources come and go.
|
|
696
|
+
* `readEffectiveManifest` is the variant's merged manifest before the overlay,
|
|
697
|
+
* which the overlay takes the launcher from.
|
|
698
|
+
*/
|
|
699
|
+
const withSandboxGradleArgs = async (scheme, readEffectiveManifest, build, { minVersionCode } = {}) => {
|
|
700
|
+
if (!scheme.sandbox) return build([]);
|
|
701
|
+
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.`);
|
|
702
|
+
const launcher = resolveSandboxLauncher(await readEffectiveManifest());
|
|
703
|
+
const directory = await fs.default.promises.mkdtemp(path.default.join(os.default.tmpdir(), "hot-updater-sandbox-"));
|
|
704
|
+
try {
|
|
705
|
+
const overlay = path.default.join(directory, "AndroidManifest.xml");
|
|
706
|
+
const initScript = path.default.join(directory, "sandbox.init.gradle");
|
|
707
|
+
await fs.default.promises.writeFile(overlay, sandboxManifestOverlay({
|
|
708
|
+
channel: (0, _hot_updater_protocol_native_artifact.sandboxChannelOf)(scheme.hotUpdaterSchemeName),
|
|
709
|
+
appId: scheme.packageName,
|
|
710
|
+
launcher
|
|
711
|
+
}));
|
|
712
|
+
await fs.default.promises.writeFile(initScript, sandboxInitScript({
|
|
713
|
+
appModuleName: scheme.appModuleName,
|
|
714
|
+
variant: scheme.variant,
|
|
715
|
+
overlay,
|
|
716
|
+
...minVersionCode === void 0 ? {} : { minVersionCode }
|
|
717
|
+
}));
|
|
718
|
+
return await build([
|
|
719
|
+
"--init-script",
|
|
720
|
+
initScript,
|
|
721
|
+
`-PhotUpdaterSandbox=${scheme.hotUpdaterSchemeName}`,
|
|
722
|
+
"-Pkotlin.incremental=false"
|
|
723
|
+
]);
|
|
724
|
+
} finally {
|
|
725
|
+
await fs.default.promises.rm(directory, {
|
|
726
|
+
recursive: true,
|
|
727
|
+
force: true
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
};
|
|
731
|
+
/** The major version of the Android Gradle Plugin the project pins, or null. */
|
|
732
|
+
const readAgpMajor = (androidProjectPath) => {
|
|
733
|
+
const rootBuild = ["build.gradle", "build.gradle.kts"].map((file) => path.default.join(androidProjectPath, file)).filter((file) => fs.default.existsSync(file)).map((file) => fs.default.readFileSync(file, "utf-8")).join("\n");
|
|
734
|
+
const pinned = /com\.android\.tools\.build:gradle:(\d+)/u.exec(rootBuild);
|
|
735
|
+
if (pinned) return Number(pinned[1]);
|
|
736
|
+
try {
|
|
737
|
+
const manifest = (0, module$1.createRequire)(path.default.join(androidProjectPath, "package.json")).resolve("react-native/package.json");
|
|
738
|
+
const catalog = fs.default.readFileSync(path.default.join(path.default.dirname(manifest), "gradle/libs.versions.toml"), "utf-8");
|
|
739
|
+
const agp = /^\s*agp\s*=\s*"(\d+)/mu.exec(catalog);
|
|
740
|
+
return agp ? Number(agp[1]) : null;
|
|
741
|
+
} catch {
|
|
742
|
+
return null;
|
|
743
|
+
}
|
|
744
|
+
};
|
|
745
|
+
/**
|
|
746
|
+
* Why a sandbox build of a scheme can't work, read from the project's files
|
|
747
|
+
* before anything is built, or null. The init script checks again, with what
|
|
748
|
+
* Gradle actually resolved.
|
|
749
|
+
*/
|
|
750
|
+
const findSandboxGradleBlocker = ({ variant = "Release", appModuleName = "app" }) => {
|
|
751
|
+
const androidProjectPath = path.default.join((0, _hot_updater_cli_tools.getCwd)(), "android");
|
|
752
|
+
if (!fs.default.existsSync(androidProjectPath)) return null;
|
|
753
|
+
const properties = path.default.join(androidProjectPath, "gradle.properties");
|
|
754
|
+
const newDsl = fs.default.existsSync(properties) ? /^\s*android\.newDsl\s*=\s*(\S+)/mu.exec(fs.default.readFileSync(properties, "utf-8"))?.[1] : void 0;
|
|
755
|
+
if (newDsl === "true" || newDsl === void 0 && (readAgpMajor(androidProjectPath) ?? 0) >= 9) return UNSUPPORTED_DSL;
|
|
756
|
+
const buildType = /([A-Z]?[a-z0-9]+)$/u.exec(variant)?.[1]?.toLowerCase();
|
|
757
|
+
const manifest = `${appModuleName}/src/${buildType}/AndroidManifest.xml`;
|
|
758
|
+
return buildType && fs.default.existsSync(path.default.join(androidProjectPath, manifest)) ? ownManifest(`android/${manifest}`) : null;
|
|
759
|
+
};
|
|
760
|
+
//#endregion
|
|
465
761
|
//#region src/buildAndroid.ts
|
|
466
|
-
const buildAndroid = async ({ schemeConfig, minBundleId = (0, _hot_updater_cli_tools.createMinBundleId)(), credentials, logHeader }) => {
|
|
762
|
+
const buildAndroid = async ({ schemeConfig, minBundleId = (0, _hot_updater_cli_tools.createMinBundleId)(), credentials, logHeader, minVersionCode }) => {
|
|
467
763
|
const androidProjectPath = path.default.join((0, _hot_updater_cli_tools.getCwd)(), "android");
|
|
468
|
-
const
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
764
|
+
const env = await resolveGradleSigningEnv({
|
|
765
|
+
schemeConfig,
|
|
766
|
+
credentials
|
|
767
|
+
});
|
|
768
|
+
const result = await withSandboxGradleArgs(schemeConfig, async () => {
|
|
769
|
+
await runGradleTasks({
|
|
770
|
+
args: { extraParams: [`-PMIN_BUNDLE_ID=${minBundleId}`] },
|
|
771
|
+
env,
|
|
772
|
+
appModuleName: schemeConfig.appModuleName,
|
|
773
|
+
tasks: [mergeManifestTask(schemeConfig.variant)],
|
|
774
|
+
logPrefix: `android-${schemeConfig.hotUpdaterSchemeName}-manifest`,
|
|
775
|
+
androidProjectPath,
|
|
776
|
+
logHeader
|
|
777
|
+
});
|
|
778
|
+
return readMergedManifest({
|
|
779
|
+
androidProjectPath,
|
|
780
|
+
appModuleName: schemeConfig.appModuleName,
|
|
781
|
+
variant: schemeConfig.variant
|
|
782
|
+
});
|
|
783
|
+
}, (sandboxArgs) => runGradle({
|
|
784
|
+
args: { extraParams: [`-PMIN_BUNDLE_ID=${minBundleId}`, ...sandboxArgs] },
|
|
785
|
+
env,
|
|
474
786
|
appModuleName: schemeConfig.appModuleName,
|
|
475
787
|
tasks: schemeConfig.aab ? [`bundle${schemeConfig.variant}`] : [`assemble${schemeConfig.variant}`],
|
|
476
788
|
logPrefix: `android-${schemeConfig.hotUpdaterSchemeName}-build`,
|
|
477
789
|
androidProjectPath,
|
|
478
790
|
logHeader
|
|
479
|
-
});
|
|
791
|
+
}), minVersionCode === void 0 ? {} : { minVersionCode });
|
|
480
792
|
return {
|
|
481
793
|
...result,
|
|
482
794
|
builtApp: await readBuiltApp({
|
|
@@ -486,6 +798,105 @@ const buildAndroid = async ({ schemeConfig, minBundleId = (0, _hot_updater_cli_t
|
|
|
486
798
|
};
|
|
487
799
|
};
|
|
488
800
|
//#endregion
|
|
801
|
+
//#region src/device.ts
|
|
802
|
+
/** The SDK's adb, or the one on the PATH without an SDK in the environment. */
|
|
803
|
+
const adbPath = () => {
|
|
804
|
+
const sdk = process.env["ANDROID_HOME"] || process.env["ANDROID_SDK_ROOT"];
|
|
805
|
+
const executable = process.platform.startsWith("win") ? "adb.exe" : "adb";
|
|
806
|
+
const fromSdk = sdk && path.default.join(sdk, "platform-tools", executable);
|
|
807
|
+
return fromSdk && fs.default.existsSync(fromSdk) ? fromSdk : executable;
|
|
808
|
+
};
|
|
809
|
+
const adb = async (args) => {
|
|
810
|
+
try {
|
|
811
|
+
return (await (0, execa.execa)(adbPath(), args)).stdout;
|
|
812
|
+
} catch (error) {
|
|
813
|
+
const { code, stderr, stdout, shortMessage } = error;
|
|
814
|
+
if (code === "ENOENT") throw new Error("adb isn't installed.\nInstall Android platform-tools, or set ANDROID_HOME.");
|
|
815
|
+
throw new Error(stderr?.trim() || stdout?.trim() || shortMessage || String(error));
|
|
816
|
+
}
|
|
817
|
+
};
|
|
818
|
+
/** What `adb devices` lists. */
|
|
819
|
+
const listAdbDevices = async () => (await adb(["devices"])).split("\n").slice(1).map((line) => line.trim().split(/\s+/u)).flatMap(([serial, state]) => serial && state ? [{
|
|
820
|
+
serial,
|
|
821
|
+
state
|
|
822
|
+
}] : []);
|
|
823
|
+
/**
|
|
824
|
+
* The device to install on: `serial`, as adb's own `ANDROID_SERIAL` names it
|
|
825
|
+
* too, or else the only one that takes commands.
|
|
826
|
+
*/
|
|
827
|
+
const selectAdbDevice = (devices, serial = process.env["ANDROID_SERIAL"] || void 0) => {
|
|
828
|
+
if (serial !== void 0) {
|
|
829
|
+
const device = devices.find((candidate) => candidate.serial === serial);
|
|
830
|
+
if (device === void 0) throw new Error(`${serial} isn't connected.\nList them: adb devices`);
|
|
831
|
+
if (device.state !== "device") throw new Error(`${serial} is ${device.state}.\nList them: adb devices`);
|
|
832
|
+
return device;
|
|
833
|
+
}
|
|
834
|
+
const ready = devices.filter(({ state }) => state === "device");
|
|
835
|
+
const [only] = ready;
|
|
836
|
+
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.");
|
|
837
|
+
if (ready.length > 1) throw new Error([
|
|
838
|
+
`${ready.length} Android devices are connected:`,
|
|
839
|
+
...ready.map(({ serial }) => ` ${serial}`),
|
|
840
|
+
"Pick one with --device <serial>."
|
|
841
|
+
].join("\n"));
|
|
842
|
+
return only;
|
|
843
|
+
};
|
|
844
|
+
/** A failure adb reports for an install another install of the app stands in the way of. */
|
|
845
|
+
const NEEDS_UNINSTALL = /^INSTALL_FAILED_(UPDATE_INCOMPATIBLE|VERSION_DOWNGRADE)\b/u;
|
|
846
|
+
const LAUNCHER_CATEGORY = "android.intent.category.LAUNCHER";
|
|
847
|
+
/** An application ID's form, checked before it reaches the device's shell. */
|
|
848
|
+
const APP_ID = /^[A-Za-z][A-Za-z0-9_.]*$/u;
|
|
849
|
+
/** `<application ID>/<activity>`, as `resolve-activity --brief` prints it. */
|
|
850
|
+
const ACTIVITY = /^[A-Za-z0-9_.]+\/[A-Za-z0-9_.$]+$/u;
|
|
851
|
+
/**
|
|
852
|
+
* Installs an APK on a device, and opens its launcher activity. `appId` is
|
|
853
|
+
* the APK's own application ID, whatever suffix its build gave it.
|
|
854
|
+
*/
|
|
855
|
+
const installOnAndroidDevice = async ({ serial, apkPath, appId }) => {
|
|
856
|
+
if (!APP_ID.test(appId)) throw new Error(`The APK's application ID isn't valid: ${appId}`);
|
|
857
|
+
try {
|
|
858
|
+
await adb([
|
|
859
|
+
"-s",
|
|
860
|
+
serial,
|
|
861
|
+
"install",
|
|
862
|
+
"-r",
|
|
863
|
+
apkPath
|
|
864
|
+
]);
|
|
865
|
+
} catch (error) {
|
|
866
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
867
|
+
const failure = /Failure \[([^\]]+)\]/u.exec(message)?.[1] ?? message;
|
|
868
|
+
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}`);
|
|
869
|
+
}
|
|
870
|
+
const activity = (await adb([
|
|
871
|
+
"-s",
|
|
872
|
+
serial,
|
|
873
|
+
"shell",
|
|
874
|
+
"cmd",
|
|
875
|
+
"package",
|
|
876
|
+
"resolve-activity",
|
|
877
|
+
"--brief",
|
|
878
|
+
"-c",
|
|
879
|
+
LAUNCHER_CATEGORY,
|
|
880
|
+
appId
|
|
881
|
+
])).trim().split("\n").at(-1)?.trim();
|
|
882
|
+
if (activity === void 0 || !ACTIVITY.test(activity)) throw new Error(`${appId} is installed, and has no launcher activity to open.`);
|
|
883
|
+
const started = await adb([
|
|
884
|
+
"-s",
|
|
885
|
+
serial,
|
|
886
|
+
"shell",
|
|
887
|
+
"am",
|
|
888
|
+
"start",
|
|
889
|
+
"-a",
|
|
890
|
+
"android.intent.action.MAIN",
|
|
891
|
+
"-c",
|
|
892
|
+
LAUNCHER_CATEGORY,
|
|
893
|
+
"-n",
|
|
894
|
+
`'${activity}'`
|
|
895
|
+
]);
|
|
896
|
+
const error = /^Error: .*$/mu.exec(started)?.[0];
|
|
897
|
+
if (error !== void 0) throw new Error(`${appId} is installed, and didn't open. ${error}`);
|
|
898
|
+
};
|
|
899
|
+
//#endregion
|
|
489
900
|
//#region src/uploadToPlay.ts
|
|
490
901
|
const API_URL = "https://androidpublisher.googleapis.com";
|
|
491
902
|
/** Where Google serves resumable upload sessions, as its docs show both. */
|
|
@@ -708,11 +1119,9 @@ const checkPlayAccess = async ({ serviceAccount: { json }, packageName, fetch =
|
|
|
708
1119
|
}
|
|
709
1120
|
};
|
|
710
1121
|
/**
|
|
711
|
-
*
|
|
712
|
-
* `.aab`'s version code, as when a reused native build was uploaded
|
|
713
|
-
* before. It reads the track in an edit it then deletes, changing nothing.
|
|
1122
|
+
* Reads the app in an edit it then deletes, changing nothing.
|
|
714
1123
|
*/
|
|
715
|
-
const
|
|
1124
|
+
const readInEdit = async ({ packageName, serviceAccount: { json }, fetch }, read) => {
|
|
716
1125
|
const { app, request } = createPlayClient({
|
|
717
1126
|
json,
|
|
718
1127
|
packageName,
|
|
@@ -726,8 +1135,7 @@ const findPlayInternalRelease = async ({ versionCode, packageName, serviceAccoun
|
|
|
726
1135
|
}
|
|
727
1136
|
const edit = `${API_URL}${app}/edits/${encodeURIComponent(editId)}`;
|
|
728
1137
|
try {
|
|
729
|
-
|
|
730
|
-
return releases.some(({ versionCodes = [] }) => versionCodes.includes(String(versionCode)));
|
|
1138
|
+
return await read(request, edit);
|
|
731
1139
|
} catch (error) {
|
|
732
1140
|
throw explain(error, packageName);
|
|
733
1141
|
} finally {
|
|
@@ -735,6 +1143,43 @@ const findPlayInternalRelease = async ({ versionCode, packageName, serviceAccoun
|
|
|
735
1143
|
}
|
|
736
1144
|
};
|
|
737
1145
|
/**
|
|
1146
|
+
* The status of the internal testing release holding `versionCode`, such as
|
|
1147
|
+
* `completed` or `draft`, or null when the track doesn't hold it: never
|
|
1148
|
+
* uploaded, or replaced by a newer release, which testers get instead.
|
|
1149
|
+
*/
|
|
1150
|
+
const readPlayInternalRelease = ({ versionCode, packageName, serviceAccount, fetch = globalThis.fetch }) => readInEdit({
|
|
1151
|
+
packageName,
|
|
1152
|
+
serviceAccount,
|
|
1153
|
+
fetch
|
|
1154
|
+
}, async (request, edit) => {
|
|
1155
|
+
const { releases = [] } = await request("Reading the internal track", "GET", `${edit}/tracks/${INTERNAL_TRACK}`);
|
|
1156
|
+
const release = releases.find(({ versionCodes = [] }) => versionCodes.includes(String(versionCode)));
|
|
1157
|
+
return release ? release.status ?? "completed" : null;
|
|
1158
|
+
});
|
|
1159
|
+
/**
|
|
1160
|
+
* Whether Google Play's internal testing track already releases the
|
|
1161
|
+
* `.aab`'s version code, as when a reused native build was uploaded before.
|
|
1162
|
+
*/
|
|
1163
|
+
const findPlayInternalRelease = async (options) => await readPlayInternalRelease(options) !== null;
|
|
1164
|
+
/**
|
|
1165
|
+
* The highest version code Google Play has taken for the app, on any track,
|
|
1166
|
+
* or null with none: a new `.aab` needs one above it.
|
|
1167
|
+
*/
|
|
1168
|
+
const readPlayHighestVersionCode = ({ packageName, serviceAccount, fetch = globalThis.fetch }) => readInEdit({
|
|
1169
|
+
packageName,
|
|
1170
|
+
serviceAccount,
|
|
1171
|
+
fetch
|
|
1172
|
+
}, async (request, edit) => {
|
|
1173
|
+
const { bundles = [] } = await request("Reading the app's bundles", "GET", `${edit}/bundles`);
|
|
1174
|
+
const codes = bundles.flatMap(({ versionCode }) => versionCode ?? []);
|
|
1175
|
+
return codes.length > 0 ? Math.max(...codes) : null;
|
|
1176
|
+
});
|
|
1177
|
+
/**
|
|
1178
|
+
* The link that opts a tester in to the app's testing tracks. A build on the
|
|
1179
|
+
* internal track installs through it, for the testers Play Console lists.
|
|
1180
|
+
*/
|
|
1181
|
+
const playTestingUrl = (packageName) => `https://play.google.com/apps/testing/${encodeURIComponent(packageName)}`;
|
|
1182
|
+
/**
|
|
738
1183
|
* The common name of every Android debug certificate: Android Studio's and
|
|
739
1184
|
* the Android Gradle plugin's (`CN=Android Debug, O=Android, C=US`), and React
|
|
740
1185
|
* Native's template `debug.keystore` (`CN=Android Debug, OU=Android,
|
|
@@ -1084,9 +1529,16 @@ exports.checkPlayAccess = checkPlayAccess;
|
|
|
1084
1529
|
exports.enrichNativeBuildAndroidScheme = enrichNativeBuildAndroidScheme;
|
|
1085
1530
|
exports.extractBuiltInBundle = extractBuiltInBundle;
|
|
1086
1531
|
exports.findPlayInternalRelease = findPlayInternalRelease;
|
|
1532
|
+
exports.findSandboxGradleBlocker = findSandboxGradleBlocker;
|
|
1533
|
+
exports.installOnAndroidDevice = installOnAndroidDevice;
|
|
1534
|
+
exports.listAdbDevices = listAdbDevices;
|
|
1087
1535
|
exports.playConsoleInviteUrl = playConsoleInviteUrl;
|
|
1536
|
+
exports.playTestingUrl = playTestingUrl;
|
|
1088
1537
|
exports.readBuiltApp = readBuiltApp;
|
|
1089
1538
|
exports.readBuiltPackageName = readBuiltPackageName;
|
|
1090
1539
|
exports.readBuiltVersionCode = readBuiltVersionCode;
|
|
1540
|
+
exports.readPlayHighestVersionCode = readPlayHighestVersionCode;
|
|
1541
|
+
exports.readPlayInternalRelease = readPlayInternalRelease;
|
|
1091
1542
|
exports.resolvePlayUploadServiceAccount = resolvePlayUploadServiceAccount;
|
|
1543
|
+
exports.selectAdbDevice = selectAdbDevice;
|
|
1092
1544
|
exports.uploadToPlay = uploadToPlay;
|