@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 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({ tasks, logPrefix, args, env, androidProjectPath, appModuleName, logHeader = [] }) {
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 gradle = (0, execa.execa)(getGradleWrapper(), gradleArgs, {
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, "&amp;").replace(/"/gu, "&quot;").replace(/</gu, "&lt;").replace(/>/gu, "&gt;");
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 result = await runGradle({
469
- args: { extraParams: [`-PMIN_BUNDLE_ID=${minBundleId}`] },
470
- env: await resolveGradleSigningEnv({
471
- schemeConfig,
472
- credentials
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
- * Whether Google Play's internal testing track already releases the
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 findPlayInternalRelease = async ({ versionCode, packageName, serviceAccount: { json }, fetch = globalThis.fetch }) => {
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
- const { releases = [] } = await request("Reading the internal track", "GET", `${edit}/tracks/${INTERNAL_TRACK}`);
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;