@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 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,297 @@ 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 shortcuts = asArray(element["meta-data"]).find((item) => attribute(item, "name") === SHORTCUTS);
552
+ const label = attribute(element, "label");
553
+ const icon = attribute(element, "icon");
554
+ const roundIcon = attribute(element, "roundIcon");
555
+ const shortcutsResource = shortcuts && attribute(shortcuts, "resource");
556
+ return {
557
+ kind,
558
+ name,
559
+ ...targetActivity === void 0 ? {} : { targetActivity },
560
+ filter: filters[0],
561
+ ...label === void 0 ? {} : { label },
562
+ ...icon === void 0 ? {} : { icon },
563
+ ...roundIcon === void 0 ? {} : { roundIcon },
564
+ ...shortcutsResource === void 0 ? {} : { shortcuts: shortcutsResource }
565
+ };
566
+ };
567
+ const xml = (text) => text.replace(/&/gu, "&amp;").replace(/"/gu, "&quot;").replace(/</gu, "&lt;").replace(/>/gu, "&gt;");
568
+ const attributesOf = (element) => Object.entries(element).filter(([, value]) => typeof value === "string").map(([name, value]) => ` ${name}="${xml(value)}"`).join("");
569
+ /** A launcher filter, element by element: the merger matches a filter by content. */
570
+ const filterXml = ({ actions, categories, data, attributes }, extra = "") => [
571
+ `<intent-filter${attributesOf(attributes)}${extra}>`,
572
+ ...actions.map((item) => ` <action${attributesOf(item)} />`),
573
+ ...categories.map((item) => ` <category${attributesOf(item)} />`),
574
+ ...data.map((item) => ` <data${attributesOf(item)} />`),
575
+ "</intent-filter>"
576
+ ].map((line) => ` ${line}`).join("\n");
577
+ const optional = (name, value) => value === void 0 ? "" : ` android:${name}="${xml(value)}"`;
578
+ /**
579
+ * The manifest a sandbox build overlays on the app's. `tools:replace` wins over
580
+ * a channel the app's manifest sets, as an Expo project's does. The launcher's
581
+ * filter, label, icon and static shortcuts move to the sandbox's activity, which
582
+ * also takes the links of `appId`'s URL scheme; the rest of the app's activity
583
+ * stays, so its own links still reach it. `appId` is the scheme's `packageName`,
584
+ * before the `.sandbox` suffix, so a preview link needs no build to derive it.
585
+ */
586
+ const sandboxManifestOverlay = ({ channel, appId, launcher }) => `<?xml version="1.0" encoding="utf-8"?>
587
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
588
+ <application>
589
+ <meta-data android:name="com.hotupdater.CHANNEL" android:value="${xml(channel)}" tools:replace="android:value" />
590
+ <meta-data android:name="${_hot_updater_protocol_native_artifact.SANDBOX_META_DATA}" android:value="true" />
591
+ <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)}>
592
+ ${filterXml(launcher.filter)}
593
+ <intent-filter>
594
+ <action android:name="android.intent.action.VIEW" />
595
+ <category android:name="android.intent.category.DEFAULT" />
596
+ <category android:name="android.intent.category.BROWSABLE" />
597
+ <data android:scheme="${(0, _hot_updater_protocol_native_artifact.sandboxUrlSchemeOf)(appId)}" />
598
+ </intent-filter>
599
+ <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)}" />`}
600
+ </activity>
601
+ <${launcher.kind} android:name="${xml(launcher.name)}"${optional("targetActivity", launcher.targetActivity)}>
602
+ ${filterXml(launcher.filter, " tools:node=\"remove\"")}${launcher.shortcuts === void 0 ? "" : `\n <meta-data android:name="${SHORTCUTS}" tools:node="remove" />`}
603
+ </${launcher.kind}>
604
+ <provider android:name="com.hotupdater.HotUpdaterSandboxProcess" android:authorities="\${applicationId}.hotupdater.sandbox" android:exported="false" />
605
+ </application>
606
+ </manifest>
607
+ `;
608
+ /** Newest first: the manifest of the task that ran last, under `directory`. */
609
+ const manifestsUnder = (directory) => {
610
+ if (!fs.default.existsSync(directory)) return [];
611
+ 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);
612
+ };
613
+ /** The Gradle task that merges a variant's manifest. */
614
+ const mergeManifestTask = (variant) => `process${variant.charAt(0).toUpperCase()}${variant.slice(1)}MainManifest`;
615
+ /**
616
+ * The manifest the merge task of `variant` wrote: AGP 7 and 8 keep it under
617
+ * `build/intermediates/merged_manifest`, in a directory per variant.
618
+ */
619
+ const readMergedManifest = ({ androidProjectPath, appModuleName, variant }) => {
620
+ const variantDirectory = `${variant.charAt(0).toLowerCase()}${variant.slice(1)}`;
621
+ const intermediates = path.default.join(androidProjectPath, appModuleName, "build/intermediates");
622
+ const [manifest] = ["merged_manifest", "merged_manifests"].flatMap((name) => manifestsUnder(path.default.join(intermediates, name, variantDirectory)));
623
+ 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.`);
624
+ return fs.default.readFileSync(manifest, "utf-8");
625
+ };
626
+ //#endregion
627
+ //#region src/utils/sandboxGradle.ts
628
+ const UNSUPPORTED_DSL = `Sandbox builds don't support AGP 9's default DSL yet.
629
+ Set android.newDsl=false in android/gradle.properties.`;
630
+ /** The message when the build type has a manifest the overlay would replace. */
631
+ const ownManifest = (manifest) => `A sandbox build can't merge with ${manifest}.\nMove what it declares to src/main/AndroidManifest.xml.`;
632
+ /** Text as a single-quoted Groovy string. */
633
+ const groovyString = (text) => `'${text.replace(/\\/gu, "\\\\").replace(/'/gu, "\\'").replace(/\n/gu, "\\n")}'`;
634
+ /**
635
+ * The init script of one sandbox build, so no project file changes: on the app
636
+ * module, it appends the sandbox's suffix to defaultConfig, where AGP puts any
637
+ * flavor's and build type's after it, and adds `overlay` to the variant's build
638
+ * type. It runs in `finalizeDsl`, after the project's own settings.
639
+ */
640
+ const sandboxInitScript = ({ appModuleName, variant, overlay, minVersionCode }) => `// Written by hot-updater for one sandbox build.
641
+ def appModule = ${groovyString(`:${appModuleName}`)}
642
+ def variant = ${groovyString(variant.toLowerCase())}
643
+ def overlay = new File(${groovyString(overlay)})
644
+ def minVersionCode = ${minVersionCode ?? "null"}
645
+ allprojects { project ->
646
+ if (project.path != appModule) return
647
+ project.plugins.withId('com.android.application') {
648
+ def components = project.extensions.getByName('androidComponents')
649
+ if (components.pluginVersion.major >= 9 && project.findProperty('android.newDsl')?.toString() != 'false') {
650
+ throw new GradleException(${groovyString(UNSUPPORTED_DSL)})
651
+ }
652
+ components.finalizeDsl { dsl ->
653
+ def buildType = dsl.buildTypes.collect { it.name }
654
+ .findAll { variant.endsWith(it.toLowerCase()) }
655
+ .max { it.length() }
656
+ if (buildType == null) {
657
+ throw new GradleException("No build type of \${project.path} builds variant \${variant}.")
658
+ }
659
+ def manifest = dsl.sourceSets.getByName(buildType).manifest
660
+ def own = manifest.srcFile
661
+ if (own.exists()) {
662
+ throw new GradleException(${groovyString(ownManifest("@MANIFEST@"))}.replace('@MANIFEST@', 'android/' + project.rootDir.toPath().relativize(own.toPath())))
663
+ }
664
+ dsl.defaultConfig.applicationIdSuffix = (dsl.defaultConfig.applicationIdSuffix ?: '') + ${groovyString(_hot_updater_protocol_native_artifact.SANDBOX_APPLICATION_ID_SUFFIX)}
665
+ manifest.srcFile(overlay)
666
+ if (minVersionCode != null) {
667
+ dsl.defaultConfig.versionCode = Math.max(dsl.defaultConfig.versionCode ?: 0, minVersionCode)
668
+ dsl.productFlavors.each { flavor ->
669
+ if (flavor.versionCode != null) flavor.versionCode = Math.max(flavor.versionCode, minVersionCode)
670
+ }
671
+ }
672
+ }
673
+ }
674
+ }
675
+ `;
676
+ /**
677
+ * Runs `build` with the Gradle arguments of a scheme: none for a normal one. A
678
+ * sandbox scheme's attach the SDK's sandbox layer and name the init script,
679
+ * written outside the project with its manifest overlay, and removed after.
680
+ * Kotlin compiles from scratch, since the sandbox layer's sources come and go.
681
+ * `readEffectiveManifest` is the variant's merged manifest before the overlay,
682
+ * which the overlay takes the launcher from.
683
+ */
684
+ const withSandboxGradleArgs = async (scheme, readEffectiveManifest, build, { minVersionCode } = {}) => {
685
+ if (!scheme.sandbox) return build([]);
686
+ 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.`);
687
+ const launcher = resolveSandboxLauncher(await readEffectiveManifest());
688
+ const directory = await fs.default.promises.mkdtemp(path.default.join(os.default.tmpdir(), "hot-updater-sandbox-"));
689
+ try {
690
+ const overlay = path.default.join(directory, "AndroidManifest.xml");
691
+ const initScript = path.default.join(directory, "sandbox.init.gradle");
692
+ await fs.default.promises.writeFile(overlay, sandboxManifestOverlay({
693
+ channel: (0, _hot_updater_protocol_native_artifact.sandboxChannelOf)(scheme.hotUpdaterSchemeName),
694
+ appId: scheme.packageName,
695
+ launcher
696
+ }));
697
+ await fs.default.promises.writeFile(initScript, sandboxInitScript({
698
+ appModuleName: scheme.appModuleName,
699
+ variant: scheme.variant,
700
+ overlay,
701
+ ...minVersionCode === void 0 ? {} : { minVersionCode }
702
+ }));
703
+ return await build([
704
+ "--init-script",
705
+ initScript,
706
+ `-PhotUpdaterSandbox=${scheme.hotUpdaterSchemeName}`,
707
+ "-Pkotlin.incremental=false"
708
+ ]);
709
+ } finally {
710
+ await fs.default.promises.rm(directory, {
711
+ recursive: true,
712
+ force: true
713
+ });
714
+ }
715
+ };
716
+ /** The major version of the Android Gradle Plugin the project pins, or null. */
717
+ const readAgpMajor = (androidProjectPath) => {
718
+ 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");
719
+ const pinned = /com\.android\.tools\.build:gradle:(\d+)/u.exec(rootBuild);
720
+ if (pinned) return Number(pinned[1]);
721
+ try {
722
+ const manifest = (0, module$1.createRequire)(path.default.join(androidProjectPath, "package.json")).resolve("react-native/package.json");
723
+ const catalog = fs.default.readFileSync(path.default.join(path.default.dirname(manifest), "gradle/libs.versions.toml"), "utf-8");
724
+ const agp = /^\s*agp\s*=\s*"(\d+)/mu.exec(catalog);
725
+ return agp ? Number(agp[1]) : null;
726
+ } catch {
727
+ return null;
728
+ }
729
+ };
730
+ /**
731
+ * Why a sandbox build of a scheme can't work, read from the project's files
732
+ * before anything is built, or null. The init script checks again, with what
733
+ * Gradle actually resolved.
734
+ */
735
+ const findSandboxGradleBlocker = ({ variant = "Release", appModuleName = "app" }) => {
736
+ const androidProjectPath = path.default.join((0, _hot_updater_cli_tools.getCwd)(), "android");
737
+ if (!fs.default.existsSync(androidProjectPath)) return null;
738
+ const properties = path.default.join(androidProjectPath, "gradle.properties");
739
+ const newDsl = fs.default.existsSync(properties) ? /^\s*android\.newDsl\s*=\s*(\S+)/mu.exec(fs.default.readFileSync(properties, "utf-8"))?.[1] : void 0;
740
+ if (newDsl === "true" || newDsl === void 0 && (readAgpMajor(androidProjectPath) ?? 0) >= 9) return UNSUPPORTED_DSL;
741
+ const buildType = /([A-Z]?[a-z0-9]+)$/u.exec(variant)?.[1]?.toLowerCase();
742
+ const manifest = `${appModuleName}/src/${buildType}/AndroidManifest.xml`;
743
+ return buildType && fs.default.existsSync(path.default.join(androidProjectPath, manifest)) ? ownManifest(`android/${manifest}`) : null;
744
+ };
745
+ //#endregion
465
746
  //#region src/buildAndroid.ts
466
- const buildAndroid = async ({ schemeConfig, minBundleId = (0, _hot_updater_cli_tools.createMinBundleId)(), credentials, logHeader }) => {
747
+ const buildAndroid = async ({ schemeConfig, minBundleId = (0, _hot_updater_cli_tools.createMinBundleId)(), credentials, logHeader, minVersionCode }) => {
467
748
  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
- }),
749
+ const env = await resolveGradleSigningEnv({
750
+ schemeConfig,
751
+ credentials
752
+ });
753
+ const result = await withSandboxGradleArgs(schemeConfig, async () => {
754
+ await runGradleTasks({
755
+ args: { extraParams: [`-PMIN_BUNDLE_ID=${minBundleId}`] },
756
+ env,
757
+ appModuleName: schemeConfig.appModuleName,
758
+ tasks: [mergeManifestTask(schemeConfig.variant)],
759
+ logPrefix: `android-${schemeConfig.hotUpdaterSchemeName}-manifest`,
760
+ androidProjectPath,
761
+ logHeader
762
+ });
763
+ return readMergedManifest({
764
+ androidProjectPath,
765
+ appModuleName: schemeConfig.appModuleName,
766
+ variant: schemeConfig.variant
767
+ });
768
+ }, (sandboxArgs) => runGradle({
769
+ args: { extraParams: [`-PMIN_BUNDLE_ID=${minBundleId}`, ...sandboxArgs] },
770
+ env,
474
771
  appModuleName: schemeConfig.appModuleName,
475
772
  tasks: schemeConfig.aab ? [`bundle${schemeConfig.variant}`] : [`assemble${schemeConfig.variant}`],
476
773
  logPrefix: `android-${schemeConfig.hotUpdaterSchemeName}-build`,
477
774
  androidProjectPath,
478
775
  logHeader
479
- });
776
+ }), minVersionCode === void 0 ? {} : { minVersionCode });
480
777
  return {
481
778
  ...result,
482
779
  builtApp: await readBuiltApp({
@@ -486,6 +783,105 @@ const buildAndroid = async ({ schemeConfig, minBundleId = (0, _hot_updater_cli_t
486
783
  };
487
784
  };
488
785
  //#endregion
786
+ //#region src/device.ts
787
+ /** The SDK's adb, or the one on the PATH without an SDK in the environment. */
788
+ const adbPath = () => {
789
+ const sdk = process.env["ANDROID_HOME"] || process.env["ANDROID_SDK_ROOT"];
790
+ const executable = process.platform.startsWith("win") ? "adb.exe" : "adb";
791
+ const fromSdk = sdk && path.default.join(sdk, "platform-tools", executable);
792
+ return fromSdk && fs.default.existsSync(fromSdk) ? fromSdk : executable;
793
+ };
794
+ const adb = async (args) => {
795
+ try {
796
+ return (await (0, execa.execa)(adbPath(), args)).stdout;
797
+ } catch (error) {
798
+ const { code, stderr, stdout, shortMessage } = error;
799
+ if (code === "ENOENT") throw new Error("adb isn't installed.\nInstall Android platform-tools, or set ANDROID_HOME.");
800
+ throw new Error(stderr?.trim() || stdout?.trim() || shortMessage || String(error));
801
+ }
802
+ };
803
+ /** What `adb devices` lists. */
804
+ const listAdbDevices = async () => (await adb(["devices"])).split("\n").slice(1).map((line) => line.trim().split(/\s+/u)).flatMap(([serial, state]) => serial && state ? [{
805
+ serial,
806
+ state
807
+ }] : []);
808
+ /**
809
+ * The device to install on: `serial`, as adb's own `ANDROID_SERIAL` names it
810
+ * too, or else the only one that takes commands.
811
+ */
812
+ const selectAdbDevice = (devices, serial = process.env["ANDROID_SERIAL"] || void 0) => {
813
+ if (serial !== void 0) {
814
+ const device = devices.find((candidate) => candidate.serial === serial);
815
+ if (device === void 0) throw new Error(`${serial} isn't connected.\nList them: adb devices`);
816
+ if (device.state !== "device") throw new Error(`${serial} is ${device.state}.\nList them: adb devices`);
817
+ return device;
818
+ }
819
+ const ready = devices.filter(({ state }) => state === "device");
820
+ const [only] = ready;
821
+ 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.");
822
+ if (ready.length > 1) throw new Error([
823
+ `${ready.length} Android devices are connected:`,
824
+ ...ready.map(({ serial }) => ` ${serial}`),
825
+ "Pick one with --device <serial>."
826
+ ].join("\n"));
827
+ return only;
828
+ };
829
+ /** A failure adb reports for an install another install of the app stands in the way of. */
830
+ const NEEDS_UNINSTALL = /^INSTALL_FAILED_(UPDATE_INCOMPATIBLE|VERSION_DOWNGRADE)\b/u;
831
+ const LAUNCHER_CATEGORY = "android.intent.category.LAUNCHER";
832
+ /** An application ID's form, checked before it reaches the device's shell. */
833
+ const APP_ID = /^[A-Za-z][A-Za-z0-9_.]*$/u;
834
+ /** `<application ID>/<activity>`, as `resolve-activity --brief` prints it. */
835
+ const ACTIVITY = /^[A-Za-z0-9_.]+\/[A-Za-z0-9_.$]+$/u;
836
+ /**
837
+ * Installs an APK on a device, and opens its launcher activity. `appId` is
838
+ * the APK's own application ID, whatever suffix its build gave it.
839
+ */
840
+ const installOnAndroidDevice = async ({ serial, apkPath, appId }) => {
841
+ if (!APP_ID.test(appId)) throw new Error(`The APK's application ID isn't valid: ${appId}`);
842
+ try {
843
+ await adb([
844
+ "-s",
845
+ serial,
846
+ "install",
847
+ "-r",
848
+ apkPath
849
+ ]);
850
+ } catch (error) {
851
+ const message = error instanceof Error ? error.message : String(error);
852
+ const failure = /Failure \[([^\]]+)\]/u.exec(message)?.[1] ?? message;
853
+ 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}`);
854
+ }
855
+ const activity = (await adb([
856
+ "-s",
857
+ serial,
858
+ "shell",
859
+ "cmd",
860
+ "package",
861
+ "resolve-activity",
862
+ "--brief",
863
+ "-c",
864
+ LAUNCHER_CATEGORY,
865
+ appId
866
+ ])).trim().split("\n").at(-1)?.trim();
867
+ if (activity === void 0 || !ACTIVITY.test(activity)) throw new Error(`${appId} is installed, and has no launcher activity to open.`);
868
+ const started = await adb([
869
+ "-s",
870
+ serial,
871
+ "shell",
872
+ "am",
873
+ "start",
874
+ "-a",
875
+ "android.intent.action.MAIN",
876
+ "-c",
877
+ LAUNCHER_CATEGORY,
878
+ "-n",
879
+ `'${activity}'`
880
+ ]);
881
+ const error = /^Error: .*$/mu.exec(started)?.[0];
882
+ if (error !== void 0) throw new Error(`${appId} is installed, and didn't open. ${error}`);
883
+ };
884
+ //#endregion
489
885
  //#region src/uploadToPlay.ts
490
886
  const API_URL = "https://androidpublisher.googleapis.com";
491
887
  /** Where Google serves resumable upload sessions, as its docs show both. */
@@ -708,11 +1104,9 @@ const checkPlayAccess = async ({ serviceAccount: { json }, packageName, fetch =
708
1104
  }
709
1105
  };
710
1106
  /**
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.
1107
+ * Reads the app in an edit it then deletes, changing nothing.
714
1108
  */
715
- const findPlayInternalRelease = async ({ versionCode, packageName, serviceAccount: { json }, fetch = globalThis.fetch }) => {
1109
+ const readInEdit = async ({ packageName, serviceAccount: { json }, fetch }, read) => {
716
1110
  const { app, request } = createPlayClient({
717
1111
  json,
718
1112
  packageName,
@@ -726,8 +1120,7 @@ const findPlayInternalRelease = async ({ versionCode, packageName, serviceAccoun
726
1120
  }
727
1121
  const edit = `${API_URL}${app}/edits/${encodeURIComponent(editId)}`;
728
1122
  try {
729
- const { releases = [] } = await request("Reading the internal track", "GET", `${edit}/tracks/${INTERNAL_TRACK}`);
730
- return releases.some(({ versionCodes = [] }) => versionCodes.includes(String(versionCode)));
1123
+ return await read(request, edit);
731
1124
  } catch (error) {
732
1125
  throw explain(error, packageName);
733
1126
  } finally {
@@ -735,6 +1128,43 @@ const findPlayInternalRelease = async ({ versionCode, packageName, serviceAccoun
735
1128
  }
736
1129
  };
737
1130
  /**
1131
+ * The status of the internal testing release holding `versionCode`, such as
1132
+ * `completed` or `draft`, or null when the track doesn't hold it: never
1133
+ * uploaded, or replaced by a newer release, which testers get instead.
1134
+ */
1135
+ const readPlayInternalRelease = ({ versionCode, packageName, serviceAccount, fetch = globalThis.fetch }) => readInEdit({
1136
+ packageName,
1137
+ serviceAccount,
1138
+ fetch
1139
+ }, async (request, edit) => {
1140
+ const { releases = [] } = await request("Reading the internal track", "GET", `${edit}/tracks/${INTERNAL_TRACK}`);
1141
+ const release = releases.find(({ versionCodes = [] }) => versionCodes.includes(String(versionCode)));
1142
+ return release ? release.status ?? "completed" : null;
1143
+ });
1144
+ /**
1145
+ * Whether Google Play's internal testing track already releases the
1146
+ * `.aab`'s version code, as when a reused native build was uploaded before.
1147
+ */
1148
+ const findPlayInternalRelease = async (options) => await readPlayInternalRelease(options) !== null;
1149
+ /**
1150
+ * The highest version code Google Play has taken for the app, on any track,
1151
+ * or null with none: a new `.aab` needs one above it.
1152
+ */
1153
+ const readPlayHighestVersionCode = ({ packageName, serviceAccount, fetch = globalThis.fetch }) => readInEdit({
1154
+ packageName,
1155
+ serviceAccount,
1156
+ fetch
1157
+ }, async (request, edit) => {
1158
+ const { bundles = [] } = await request("Reading the app's bundles", "GET", `${edit}/bundles`);
1159
+ const codes = bundles.flatMap(({ versionCode }) => versionCode ?? []);
1160
+ return codes.length > 0 ? Math.max(...codes) : null;
1161
+ });
1162
+ /**
1163
+ * The link that opts a tester in to the app's testing tracks. A build on the
1164
+ * internal track installs through it, for the testers Play Console lists.
1165
+ */
1166
+ const playTestingUrl = (packageName) => `https://play.google.com/apps/testing/${encodeURIComponent(packageName)}`;
1167
+ /**
738
1168
  * The common name of every Android debug certificate: Android Studio's and
739
1169
  * the Android Gradle plugin's (`CN=Android Debug, O=Android, C=US`), and React
740
1170
  * Native's template `debug.keystore` (`CN=Android Debug, OU=Android,
@@ -1084,9 +1514,16 @@ exports.checkPlayAccess = checkPlayAccess;
1084
1514
  exports.enrichNativeBuildAndroidScheme = enrichNativeBuildAndroidScheme;
1085
1515
  exports.extractBuiltInBundle = extractBuiltInBundle;
1086
1516
  exports.findPlayInternalRelease = findPlayInternalRelease;
1517
+ exports.findSandboxGradleBlocker = findSandboxGradleBlocker;
1518
+ exports.installOnAndroidDevice = installOnAndroidDevice;
1519
+ exports.listAdbDevices = listAdbDevices;
1087
1520
  exports.playConsoleInviteUrl = playConsoleInviteUrl;
1521
+ exports.playTestingUrl = playTestingUrl;
1088
1522
  exports.readBuiltApp = readBuiltApp;
1089
1523
  exports.readBuiltPackageName = readBuiltPackageName;
1090
1524
  exports.readBuiltVersionCode = readBuiltVersionCode;
1525
+ exports.readPlayHighestVersionCode = readPlayHighestVersionCode;
1526
+ exports.readPlayInternalRelease = readPlayInternalRelease;
1091
1527
  exports.resolvePlayUploadServiceAccount = resolvePlayUploadServiceAccount;
1528
+ exports.selectAdbDevice = selectAdbDevice;
1092
1529
  exports.uploadToPlay = uploadToPlay;