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