@hot-updater/android-helper 1.1.4 → 1.2.0-rc.0

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
@@ -24,12 +24,21 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  let path = require("path");
25
25
  path = __toESM(path, 1);
26
26
  let _hot_updater_cli_tools = require("@hot-updater/cli-tools");
27
- let _hot_updater_plugin_core = require("@hot-updater/plugin-core");
28
27
  let fs = require("fs");
29
28
  fs = __toESM(fs, 1);
30
29
  let execa = require("execa");
31
- let node_os = require("node:os");
32
- node_os = __toESM(node_os, 1);
30
+ let os = require("os");
31
+ os = __toESM(os, 1);
32
+ let jszip = require("jszip");
33
+ jszip = __toESM(jszip, 1);
34
+ let crypto = require("crypto");
35
+ crypto = __toESM(crypto, 1);
36
+ let stream_promises = require("stream/promises");
37
+ let zlib = require("zlib");
38
+ let node_crypto = require("node:crypto");
39
+ node_crypto = __toESM(node_crypto, 1);
40
+ let node_http = require("node:http");
41
+ node_http = __toESM(node_http, 1);
33
42
  //#region src/utils/createGradleLogger.ts
34
43
  const createGradleLogger = ({ logPrefix }) => new _hot_updater_cli_tools.BuildLogger({
35
44
  logPrefix,
@@ -140,25 +149,29 @@ const createGradleLogger = ({ logPrefix }) => new _hot_updater_cli_tools.BuildLo
140
149
  ],
141
150
  ["bundleRelease", "bundleDebug"],
142
151
  ["BUILD SUCCESSFUL", /\d+ actionable tasks:/]
143
- ]
152
+ ],
153
+ failureSummary: {
154
+ lines: [
155
+ /^e: /,
156
+ /(^|:\s)error: /,
157
+ /^BUILD FAILED/
158
+ ],
159
+ blocks: [{
160
+ start: "* What went wrong:",
161
+ end: "* Try:",
162
+ includeEnd: false
163
+ }]
164
+ }
144
165
  });
145
166
  //#endregion
146
167
  //#region src/builder/runGradle.ts
147
- /**
148
- * Clean up gradle error message by removing common noise
149
- */
150
- const getCleanedErrorMessage = (error) => {
151
- const gradleLinesToRemove = [
152
- "FAILURE: Build failed with an exception.",
153
- "* Try:",
154
- "> Run with --stacktrace option to get the stack trace.",
155
- "> Run with --info or --debug option to get more log output.",
156
- "> Run with --scan to get full insights.",
157
- "> Get more help at [undefined](https://help.gradle.org).",
158
- "> Get more help at https://help.gradle.org.",
159
- "BUILD FAILED"
160
- ];
161
- return error.message.split("\n").filter((line) => !gradleLinesToRemove.some((l) => line.includes(l))).join("\n").trim();
168
+ /** Why Gradle failed, without its command line. */
169
+ const describeFailure = (error) => {
170
+ if (error instanceof execa.ExecaError) {
171
+ if (error.signal) return `Gradle was stopped by ${error.signal}.`;
172
+ if (error.exitCode !== void 0) return `Gradle exited with code ${error.exitCode}.`;
173
+ }
174
+ return error instanceof Error ? error.message : String(error);
162
175
  };
163
176
  /**
164
177
  * Generate gradle task names with module prefix
@@ -173,13 +186,13 @@ const getGradleWrapper = () => process.platform.startsWith("win") ? "gradlew.bat
173
186
  /**
174
187
  * Run gradle build with specified tasks and arguments
175
188
  */
176
- async function runGradle({ tasks, logPrefix, args, androidProjectPath, appModuleName }) {
189
+ async function runGradle({ tasks, logPrefix, args, env, androidProjectPath, appModuleName }) {
177
190
  const gradleArgs = getTaskNames(appModuleName, tasks);
178
191
  gradleArgs.push("-x", "lint");
179
192
  if (args.extraParams) gradleArgs.push(...args.extraParams);
180
193
  _hot_updater_cli_tools.p.log.info(`Run Gradle Settings:
181
194
  Project ${androidProjectPath}
182
- App Moudle ${appModuleName}
195
+ App Module ${appModuleName}
183
196
  Tasks ${tasks.join(", ")}
184
197
  Args ${gradleArgs.join(" ")}
185
198
  `);
@@ -187,20 +200,25 @@ Args ${gradleArgs.join(" ")}
187
200
  const logger = createGradleLogger({ logPrefix });
188
201
  await logger.start();
189
202
  try {
190
- const process = (0, execa.execa)(getGradleWrapper(), gradleArgs, {
203
+ const gradle = (0, execa.execa)(getGradleWrapper(), gradleArgs, {
191
204
  cwd: androidProjectPath,
192
- all: true
205
+ env,
206
+ all: true,
207
+ buffer: false
193
208
  });
194
- const outputStream = process.all ?? process.stdout;
195
- if (outputStream) await logger.processStream(outputStream);
196
- await process;
209
+ const outputStream = gradle.all ?? gradle.stdout;
210
+ const [gradleResult, loggingResult] = await Promise.allSettled([gradle, outputStream ? logger.processStream(outputStream) : void 0]);
211
+ if (gradleResult.status === "rejected") throw gradleResult.reason;
212
+ if (loggingResult.status === "rejected") throw loggingResult.reason;
197
213
  logger.stop();
198
214
  } catch (e) {
199
215
  logger.writeError(e);
200
216
  logger.stop("Build failed", false);
201
- if (e instanceof execa.ExecaError) _hot_updater_cli_tools.p.log.error(getCleanedErrorMessage(e));
202
- else if (e instanceof Error) _hot_updater_cli_tools.p.log.error(e.message);
203
- throw new Error("Failed to build the app. See the error above for details from Gradle.");
217
+ throw new _hot_updater_cli_tools.BuildFailedError(`Build failed: ${describeFailure(e)}`, {
218
+ logPath: logger.logPath,
219
+ summary: logger.failureSummary,
220
+ cause: e
221
+ });
204
222
  } finally {
205
223
  await logger.close();
206
224
  }
@@ -243,429 +261,1407 @@ async function getBuildOutputFilePath({ aab, appModuleName, buildDirectory, vari
243
261
  process.exit(1);
244
262
  }
245
263
  //#endregion
246
- //#region src/buildAndroid.ts
247
- const buildAndroid = async ({ schemeConfig }) => {
248
- const androidProjectPath = path.default.join((0, _hot_updater_cli_tools.getCwd)(), "android");
249
- return runGradle({
250
- args: { extraParams: [`-PMIN_BUNDLE_ID=${(0, _hot_updater_plugin_core.generateMinBundleId)()}`] },
251
- appModuleName: schemeConfig.appModuleName,
252
- tasks: schemeConfig.aab ? [`bundle${schemeConfig.variant}`] : [`assemble${schemeConfig.variant}`],
253
- logPrefix: `android-${schemeConfig.hotUpdaterSchemeName}-build`,
254
- androidProjectPath
264
+ //#region src/utils/gradleSigning.ts
265
+ const isDebugVariant = (variant) => /debug$/i.test(variant);
266
+ /**
267
+ * The environment that signs a release variant with the scheme's keystore,
268
+ * through the properties Android Studio's signed builds inject. Gradle reads
269
+ * `ORG_GRADLE_PROJECT_*` variables as project properties, so the passwords
270
+ * never appear in the Gradle command line. The keystore is resolved only
271
+ * here: a debug variant, or a scheme without `signing`, resolves nothing.
272
+ * Without `credentials`, it resolves from the environment or the config's
273
+ * `secrets` adapter, and the temporary files are deleted when the process
274
+ * exits.
275
+ */
276
+ const resolveGradleSigningEnv = async ({ schemeConfig, credentials }) => {
277
+ const reference = schemeConfig.signing?.keystore;
278
+ if (!reference) return;
279
+ if (isDebugVariant(schemeConfig.variant)) {
280
+ _hot_updater_cli_tools.p.log.info(`${schemeConfig.variant} signs with the debug keystore, so signing.keystore is not used.`);
281
+ return;
282
+ }
283
+ const keystore = await (credentials ?? (0, _hot_updater_cli_tools.createCredentialResolver)()).resolve({
284
+ kind: "android-keystore",
285
+ name: (0, _hot_updater_cli_tools.parseSecretReference)(reference, "signing.keystore")
255
286
  });
287
+ return (0, _hot_updater_cli_tools.assertUnreservedEnv)({
288
+ "ORG_GRADLE_PROJECT_android.injected.signing.store.file": keystore.storeFile,
289
+ "ORG_GRADLE_PROJECT_android.injected.signing.store.password": keystore.storePassword,
290
+ "ORG_GRADLE_PROJECT_android.injected.signing.key.alias": keystore.keyAlias,
291
+ "ORG_GRADLE_PROJECT_android.injected.signing.key.password": keystore.keyPassword
292
+ }, "The Gradle signing environment");
256
293
  };
257
294
  //#endregion
258
- //#region src/utils/device.ts
295
+ //#region src/utils/readBuiltApp.ts
296
+ const CHANNEL_META_DATA_KEY = "com.hotupdater.CHANNEL";
297
+ /** The SDK Gradle builds with: the environment's, or local.properties'. */
298
+ const findAndroidSdk = (androidProjectPath) => {
299
+ const fromEnv = process.env["ANDROID_HOME"] || process.env["ANDROID_SDK_ROOT"];
300
+ if (fromEnv) return fromEnv;
301
+ const localProperties = path.default.join(androidProjectPath, "local.properties");
302
+ if (!fs.default.existsSync(localProperties)) return;
303
+ return (fs.default.readFileSync(localProperties, "utf-8").match(/^sdk\.dir=(.*)$/m)?.[1])?.trim().replace(/\\(.)/g, "$1");
304
+ };
305
+ const compareVersions = (a, b) => {
306
+ const left = a.split(/[.-]/).map((part) => Number.parseInt(part, 10) || 0);
307
+ const right = b.split(/[.-]/).map((part) => Number.parseInt(part, 10) || 0);
308
+ for (let i = 0; i < Math.max(left.length, right.length); i++) {
309
+ const difference = (left[i] ?? 0) - (right[i] ?? 0);
310
+ if (difference !== 0) return difference;
311
+ }
312
+ return 0;
313
+ };
314
+ /** The newest build-tools' aapt2, which reads the artifact Gradle wrote. */
315
+ const findAapt2 = (androidProjectPath) => {
316
+ const sdk = findAndroidSdk(androidProjectPath);
317
+ const buildTools = sdk && path.default.join(sdk, "build-tools");
318
+ if (!buildTools || !fs.default.existsSync(buildTools)) return;
319
+ const executable = process.platform.startsWith("win") ? "aapt2.exe" : "aapt2";
320
+ return fs.default.readdirSync(buildTools).sort(compareVersions).reverse().map((version) => path.default.join(buildTools, version, executable)).find((aapt2) => fs.default.existsSync(aapt2));
321
+ };
322
+ const RAW_MARKER = "\" (Raw: \"";
323
+ /**
324
+ * An attribute aapt2 prints as `="<value>" (Raw: "<value>")`, given the text
325
+ * after `="`. aapt2 does not escape the value, so it is the prefix the rest
326
+ * of the line repeats; a value with a line break, which aapt2 indents, reads
327
+ * as undefined.
328
+ */
329
+ const readQuotedValue = (rest) => {
330
+ for (let end = rest.indexOf(RAW_MARKER); end !== -1; end = rest.indexOf(RAW_MARKER, end + 1)) {
331
+ const value = rest.slice(0, end);
332
+ if (rest === `${value}${RAW_MARKER}${value}")`) return value;
333
+ }
334
+ };
259
335
  /**
260
- * Get the path to adb executable from ANDROID_HOME or use system adb
336
+ * The value of the attribute `marker` (such as `:value(0x01010024)=`) on
337
+ * `line`: null without one, and undefined when it cannot be read exactly.
261
338
  */
262
- const getAdbPath = () => {
263
- return process.env["ANDROID_HOME"] ? path.default.join(process.env["ANDROID_HOME"], "platform-tools", "adb") : "adb";
339
+ const readAttribute = (line, marker) => {
340
+ const start = line.indexOf(marker);
341
+ if (start === -1) return null;
342
+ const rest = line.slice(start + marker.length);
343
+ if (rest.startsWith("\"")) return readQuotedValue(rest.slice(1));
344
+ return rest.match(/^@[\w./:]+/)?.[0];
264
345
  };
265
346
  /**
266
- * Parses the output of the 'adb devices' command
347
+ * A `<meta-data>` value from `aapt2 dump xmltree` of the manifest: the
348
+ * literal, or a resource reference such as `@string/channel`. null when the
349
+ * manifest has none, and undefined when aapt2 printed one it cannot read
350
+ * exactly.
267
351
  */
268
- const parseDevicesResult = ({ result }) => {
269
- if (!result) return [];
270
- const devices = [];
271
- const lines = result.trim().split(/\r?\n/);
272
- for (const line of lines) {
273
- const [device, state] = line.split(/[ ,\t]+/).filter((w) => w !== "");
274
- if (device && state === "device") devices.push(device);
352
+ const parseManifestMetaData = (xmltree, name) => {
353
+ const lines = xmltree.split("\n");
354
+ for (let i = 0; i < lines.length; i++) {
355
+ if (!/^\s*E: meta-data\b/.test(lines[i])) continue;
356
+ let metaDataName = null;
357
+ let value = null;
358
+ for (let j = i + 1; j < lines.length && /^\s*A: /.test(lines[j]); j++) {
359
+ metaDataName ||= readAttribute(lines[j], ":name(0x01010003)=");
360
+ if (value === null) value = readAttribute(lines[j], ":value(0x01010024)=");
361
+ }
362
+ if (metaDataName === name) return value ?? void 0;
275
363
  }
276
- return devices;
364
+ return null;
277
365
  };
278
366
  /**
279
- * Executes the commands needed to get a list of devices from ADB
367
+ * The `android:versionName` of the `<manifest>` element in `aapt2 dump
368
+ * xmltree`, which Gradle merges from the variant, with its suffixes: null
369
+ * without one, and undefined when it cannot be read exactly.
280
370
  */
281
- const getConnectedDevices = async () => {
282
- const adbPath = getAdbPath();
371
+ const parseManifestVersionName = (xmltree) => {
372
+ for (const line of xmltree.split("\n")) {
373
+ const value = readAttribute(line, ":versionName(0x0101021c)=");
374
+ if (value !== null) return value;
375
+ }
376
+ return null;
377
+ };
378
+ /**
379
+ * The `android:versionCode` of the `<manifest>` element in `aapt2 dump
380
+ * xmltree`, an integer aapt2 prints bare or quoted: null without one.
381
+ */
382
+ const parseManifestVersionCode = (xmltree) => {
383
+ const match = /:versionCode\(0x0101021b\)="?(\d+)/.exec(xmltree);
384
+ return match ? Number(match[1]) : null;
385
+ };
386
+ /**
387
+ * The string a resource reference resolves to, from `aapt2 dump resources`:
388
+ * its default configuration's value, or else its first. undefined when the
389
+ * table has none aapt2 printed on one line.
390
+ */
391
+ const parseResourceString = (resources, reference) => {
392
+ const lines = resources.split("\n");
393
+ const start = lines.findIndex((line) => {
394
+ const [, id, name] = line.match(/^\s*resource (0x[0-9a-f]+) (\S+)/) ?? [];
395
+ return id !== void 0 && (reference === `@${id}` || reference === `@${name}`);
396
+ });
397
+ if (start === -1) return;
398
+ const values = [];
399
+ for (let i = start + 1; i < lines.length; i++) {
400
+ if (/^\s*resource 0x/.test(lines[i])) break;
401
+ const match = lines[i].match(/^\s*\(([^)]*)\) "(.*)"$/);
402
+ if (match) values.push({
403
+ config: match[1],
404
+ value: match[2]
405
+ });
406
+ }
407
+ return (values.find(({ config }) => config === "") ?? values[0])?.value;
408
+ };
409
+ /**
410
+ * An `.aab` module is a proto-format APK under `base/`, which aapt2 reads
411
+ * once its manifest and resource table sit at the root of a zip.
412
+ */
413
+ const writeBaseModuleApk = async (aabPath, directory) => {
414
+ const bundle = await jszip.default.loadAsync(await fs.default.promises.readFile(aabPath));
415
+ const module = new jszip.default();
416
+ for (const [entry, name] of [["base/manifest/AndroidManifest.xml", "AndroidManifest.xml"], ["base/resources.pb", "resources.pb"]]) {
417
+ const file = bundle.file(entry);
418
+ if (file) module.file(name, await file.async("uint8array"));
419
+ }
420
+ const apkPath = path.default.join(directory, "base.apk");
421
+ await fs.default.promises.writeFile(apkPath, await module.generateAsync({ type: "uint8array" }));
422
+ return apkPath;
423
+ };
424
+ /**
425
+ * What the built `.apk` or `.aab` reports at runtime: the `versionName` and
426
+ * the `com.hotupdater.CHANNEL` meta-data Gradle merged into its manifest,
427
+ * from flavors, build types and `versionNameSuffix`. Without aapt2 it
428
+ * returns undefined, and the build reports the project's values.
429
+ */
430
+ const readBuiltApp = async ({ artifactPath, androidProjectPath }) => {
431
+ const aapt2 = findAapt2(androidProjectPath);
432
+ if (!aapt2) {
433
+ _hot_updater_cli_tools.p.log.warn("aapt2 was not found in the Android SDK's build-tools, so the build reports the app version and channel of the project files.");
434
+ return;
435
+ }
436
+ const directory = await fs.default.promises.mkdtemp(path.default.join(os.default.tmpdir(), "android-helper-"));
283
437
  try {
284
- const { stdout } = await (0, execa.execa)(adbPath, ["devices"]);
285
- return parseDevicesResult({ result: stdout });
286
- } catch {
287
- return [];
438
+ const apkPath = artifactPath.endsWith(".aab") ? await writeBaseModuleApk(artifactPath, directory) : artifactPath;
439
+ const { stdout: xmltree } = await (0, execa.execa)(aapt2, [
440
+ "dump",
441
+ "xmltree",
442
+ "--file",
443
+ "AndroidManifest.xml",
444
+ apkPath
445
+ ]);
446
+ let resources;
447
+ const resolve = async (value) => {
448
+ if (!value?.startsWith("@")) return value;
449
+ resources ??= (0, execa.execa)(aapt2, [
450
+ "dump",
451
+ "resources",
452
+ apkPath
453
+ ]).then(({ stdout }) => stdout);
454
+ return parseResourceString(await resources, value);
455
+ };
456
+ const appVersion = await resolve(parseManifestVersionName(xmltree));
457
+ const channel = await resolve(parseManifestMetaData(xmltree, CHANNEL_META_DATA_KEY));
458
+ if (appVersion === void 0 || channel === void 0) _hot_updater_cli_tools.p.log.warn("aapt2 printed the artifact's versionName or com.hotupdater.CHANNEL in a form that cannot be read exactly, so the build reports the project files' value for it.");
459
+ const versionCode = parseManifestVersionCode(xmltree);
460
+ return {
461
+ appVersion: appVersion ?? null,
462
+ channel,
463
+ buildNumber: versionCode === null ? null : String(versionCode)
464
+ };
465
+ } finally {
466
+ await fs.default.promises.rm(directory, {
467
+ recursive: true,
468
+ force: true
469
+ });
288
470
  }
289
471
  };
290
472
  /**
291
- * Runs ADB reverse tcp:8081 tcp:8081 to allow loading the jsbundle from the packager
292
- * Set up port forwarding from device to development server using adb reverse
473
+ * The application ID of the built `.apk` or `.aab`, the package name Google
474
+ * Play files it under, with the suffixes flavors and build types add.
475
+ * undefined without aapt2.
293
476
  */
294
- async function tryRunAdbReverse({ port = 8081, deviceId }) {
477
+ const readBuiltPackageName = async ({ artifactPath, androidProjectPath }) => {
478
+ const aapt2 = findAapt2(androidProjectPath);
479
+ if (!aapt2) return;
480
+ const directory = await fs.default.promises.mkdtemp(path.default.join(os.default.tmpdir(), "android-helper-"));
295
481
  try {
296
- const adbPath = getAdbPath();
297
- const adbArgs = [
298
- "-s",
299
- deviceId,
300
- "reverse",
301
- `tcp:${port}`,
302
- `tcp:${port}`
303
- ];
304
- _hot_updater_cli_tools.p.log.info(`Connecting "${deviceId}" to the development server`);
305
- await (0, execa.execa)(adbPath, adbArgs);
306
- } catch {
307
- throw new Error(`Failed to connect "${deviceId}" to development server using "adb reverse"`);
482
+ const apkPath = artifactPath.endsWith(".aab") ? await writeBaseModuleApk(artifactPath, directory) : artifactPath;
483
+ const { stdout } = await (0, execa.execa)(aapt2, [
484
+ "dump",
485
+ "packagename",
486
+ apkPath
487
+ ]);
488
+ return stdout.trim() || void 0;
489
+ } finally {
490
+ await fs.default.promises.rm(directory, {
491
+ recursive: true,
492
+ force: true
493
+ });
308
494
  }
309
- }
495
+ };
310
496
  /**
311
- * Get name of Android emulator
312
- */
313
- const getEmulatorName = async (deviceId) => {
314
- const adbPath = getAdbPath();
315
- const { stdout } = await (0, execa.execa)(adbPath, [
316
- "-s",
317
- deviceId,
318
- "emu",
319
- "avd",
320
- "name"
321
- ], { stdio: "pipe" });
322
- if (!stdout) return "";
323
- return stdout.split(node_os.default.EOL)[0].replace(/(\r\n|\n|\r)/gm, "").trim();
324
- };
325
- /**
326
- * Get Android device name in readable format
327
- */
328
- const getPhoneName = async (deviceId) => {
329
- const adbPath = getAdbPath();
330
- const { stdout } = await (0, execa.execa)(adbPath, [
331
- "-s",
332
- deviceId,
333
- "shell",
334
- "getprop",
335
- "ro.product.model"
336
- ], { stdio: "pipe" });
337
- return stdout.replace(/\[ro\.product\.model\]:\s*\[(.*)\]/, "$1").trim();
338
- };
339
- /**
340
- * Check if emulator has finished booting
341
- */
342
- const isEmulatorBooted = async (device) => {
343
- const adbPath = getAdbPath();
344
- const adbArgs = [
345
- "-s",
346
- device,
347
- "shell",
348
- "getprop",
349
- "sys.boot_completed"
350
- ];
497
+ * The version code of the built `.apk` or `.aab`, which Google Play files
498
+ * its releases by. undefined without aapt2, or when the manifest has none.
499
+ */
500
+ const readBuiltVersionCode = async ({ artifactPath, androidProjectPath }) => {
501
+ const aapt2 = findAapt2(androidProjectPath);
502
+ if (!aapt2) return;
503
+ const directory = await fs.default.promises.mkdtemp(path.default.join(os.default.tmpdir(), "android-helper-"));
351
504
  try {
352
- const { stdout } = await (0, execa.execa)(adbPath, adbArgs, { stdio: "pipe" });
353
- return stdout.trim() === "1";
354
- } catch {
355
- return false;
505
+ const apkPath = artifactPath.endsWith(".aab") ? await writeBaseModuleApk(artifactPath, directory) : artifactPath;
506
+ const { stdout } = await (0, execa.execa)(aapt2, [
507
+ "dump",
508
+ "xmltree",
509
+ "--file",
510
+ "AndroidManifest.xml",
511
+ apkPath
512
+ ]);
513
+ return parseManifestVersionCode(stdout) ?? void 0;
514
+ } finally {
515
+ await fs.default.promises.rm(directory, {
516
+ recursive: true,
517
+ force: true
518
+ });
519
+ }
520
+ };
521
+ //#endregion
522
+ //#region src/buildAndroid.ts
523
+ const buildAndroid = async ({ schemeConfig, minBundleId = (0, _hot_updater_cli_tools.createMinBundleId)(), credentials }) => {
524
+ const androidProjectPath = path.default.join((0, _hot_updater_cli_tools.getCwd)(), "android");
525
+ const result = await runGradle({
526
+ args: { extraParams: [`-PMIN_BUNDLE_ID=${minBundleId}`] },
527
+ env: await resolveGradleSigningEnv({
528
+ schemeConfig,
529
+ credentials
530
+ }),
531
+ appModuleName: schemeConfig.appModuleName,
532
+ tasks: schemeConfig.aab ? [`bundle${schemeConfig.variant}`] : [`assemble${schemeConfig.variant}`],
533
+ logPrefix: `android-${schemeConfig.hotUpdaterSchemeName}-build`,
534
+ androidProjectPath
535
+ });
536
+ return {
537
+ ...result,
538
+ builtApp: await readBuiltApp({
539
+ artifactPath: result.buildArtifactPath,
540
+ androidProjectPath
541
+ })
542
+ };
543
+ };
544
+ //#endregion
545
+ //#region src/uploadToPlay.ts
546
+ const API_URL = "https://androidpublisher.googleapis.com";
547
+ /** Where Google serves resumable upload sessions, as its docs show both. */
548
+ const UPLOAD_ORIGINS = /* @__PURE__ */ new Set([API_URL, "https://www.googleapis.com"]);
549
+ const DEFAULT_TOKEN_URI = "https://oauth2.googleapis.com/token";
550
+ const SCOPE = "https://www.googleapis.com/auth/androidpublisher";
551
+ /** Google accepts assertions that expire within an hour. */
552
+ const ASSERTION_LIFETIME_SECONDS = 3600;
553
+ /** An access token is replaced well before it expires. */
554
+ const TOKEN_REUSE_MS = 27e5;
555
+ /** Google asks for multiples of 256 KiB; only one chunk is held in memory. */
556
+ const CHUNK_SIZE = 8388608;
557
+ /** Attempts per chunk, each resuming at the offset Google confirms. */
558
+ const CHUNK_ATTEMPTS = 3;
559
+ /** Commits while Google is still processing the uploaded bundle. */
560
+ const COMMIT_ATTEMPTS = 6;
561
+ /** The track `--submit play-internal` releases to. */
562
+ const INTERNAL_TRACK = "internal";
563
+ const BUNDLE_STILL_PROCESSING = "Some of the Android App Bundle uploads are not completed yet";
564
+ const NEEDS_CHANGES_NOT_SENT_FOR_REVIEW = "Please set the query parameter changesNotSentForReview to true";
565
+ const DRAFT_APP_ERRORS = ["Only releases with status draft may be created on draft app"];
566
+ /** A Google Play Developer API request that failed, with Google's message. */
567
+ var GooglePlayError = class extends Error {
568
+ status;
569
+ apiMessage;
570
+ constructor(action, status, apiMessage) {
571
+ super(`${action} failed (HTTP ${status})${apiMessage ? `: ${apiMessage}` : "."}`);
572
+ this.name = "GooglePlayError";
573
+ this.status = status;
574
+ this.apiMessage = apiMessage;
356
575
  }
357
576
  };
358
- const emulatorCommand = process.env["ANDROID_HOME"] ? `${process.env["ANDROID_HOME"]}/emulator/emulator` : "emulator";
359
577
  /**
360
- * Get a list of available Android emulators
578
+ * Reads the service account JSON in memory: its private key never touches
579
+ * the disk, and its contents are never echoed.
361
580
  */
362
- const getEmulatorNames = async () => {
581
+ const readServiceAccount = (json) => {
582
+ let parsed;
583
+ try {
584
+ parsed = JSON.parse(json);
585
+ } catch {
586
+ throw new Error("The Play service account is not a JSON key file.");
587
+ }
588
+ const { client_email, private_key, token_uri } = parsed;
589
+ if (typeof client_email !== "string" || typeof private_key !== "string") throw new Error("The Play service account has no client_email or private_key: use the JSON key file Google Cloud creates for the service account.");
590
+ (0, _hot_updater_cli_tools.registerSecretValue)(private_key);
591
+ let privateKey;
363
592
  try {
364
- const { stdout } = await (0, execa.execa)(emulatorCommand, ["-list-avds"], { stdio: "pipe" });
365
- return stdout.split(node_os.default.EOL).filter((name) => name !== "" && !name.includes(" "));
593
+ privateKey = crypto.default.createPrivateKey(private_key);
366
594
  } catch {
367
- return [];
595
+ throw new Error("The Play service account's private_key is no private key.");
368
596
  }
597
+ const tokenUri = typeof token_uri === "string" ? token_uri : DEFAULT_TOKEN_URI;
598
+ if (new URL(tokenUri).protocol !== "https:") throw new Error("The Play service account's token_uri is not an https URL.");
599
+ return {
600
+ clientEmail: client_email,
601
+ privateKey,
602
+ tokenUri
603
+ };
604
+ };
605
+ /** The RS256 JWT a service account exchanges for an access token. */
606
+ const createServiceAccountAssertion = ({ clientEmail, privateKey, tokenUri, issuedAtSeconds = Math.floor(Date.now() / 1e3) }) => {
607
+ const encode = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");
608
+ const signingInput = `${encode({
609
+ alg: "RS256",
610
+ typ: "JWT"
611
+ })}.${encode({
612
+ iss: clientEmail,
613
+ scope: SCOPE,
614
+ aud: tokenUri,
615
+ iat: issuedAtSeconds,
616
+ exp: issuedAtSeconds + ASSERTION_LIFETIME_SECONDS
617
+ })}`;
618
+ return `${signingInput}.${crypto.default.sign("sha256", Buffer.from(signingInput), privateKey).toString("base64url")}`;
369
619
  };
370
- const checkEmulatorBootStatus = async ({ port, emulatorName, loader, onBooted }) => {
371
- const connected = (await getConnectedDevices()).find((d) => d.includes(`${port}`));
372
- if (connected) {
373
- loader.message(`Emulator "${emulatorName}" is connected. Waiting for boot`);
374
- if (await isEmulatorBooted(connected)) onBooted(connected);
620
+ const readApiMessage = async (response) => {
621
+ const body = await response.json().catch(() => void 0);
622
+ if (typeof body?.error === "object" && typeof body.error.message === "string") return body.error.message;
623
+ if (typeof body?.error_description === "string") return body.error_description;
624
+ return typeof body?.error === "string" ? body.error : "";
625
+ };
626
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
627
+ /**
628
+ * Google Play refused the upload: it already has a bundle or APK with the
629
+ * `.aab`'s version code, from this or another upload, and takes each version
630
+ * code once.
631
+ */
632
+ var PlayVersionCodeUsedError = class extends Error {
633
+ name = "PlayVersionCodeUsedError";
634
+ constructor(options) {
635
+ super("Google Play already has this .aab's version code. Raise versionCode for every build you upload.", options);
375
636
  }
376
637
  };
377
638
  /**
378
- * Launch the Android emulator and wait for it to boot
639
+ * What Google's message means for the user, for the failures a release to
640
+ * a track runs into.
379
641
  */
380
- const launchEmulator = async (emulatorName, port, loader) => {
381
- const manualCommand = `${emulatorCommand} -avd ${emulatorName}`;
382
- const timeout = 120;
383
- const cp = (0, execa.execa)(emulatorCommand, [
384
- "-avd",
385
- emulatorName,
386
- "-port",
387
- `${port}`
388
- ], {
389
- detached: true,
390
- stdio: "ignore"
391
- });
392
- cp.unref();
393
- return new Promise((resolve, reject) => {
394
- let bootCheckInterval;
395
- let rejectTimeout;
396
- const cleanup = () => {
397
- clearTimeout(rejectTimeout);
398
- clearInterval(bootCheckInterval);
399
- };
400
- const handleError = (message) => {
401
- cleanup();
402
- reject(new Error(message));
403
- };
404
- bootCheckInterval = setInterval(async () => {
405
- await checkEmulatorBootStatus({
406
- port,
407
- emulatorName,
408
- loader,
409
- onBooted: (deviceId) => {
410
- cleanup();
411
- resolve(deviceId);
412
- }
642
+ const explain = (error, packageName) => {
643
+ if (!(error instanceof GooglePlayError)) return error;
644
+ const { status, apiMessage } = error;
645
+ if (status === 401 || /does not have permission/i.test(apiMessage)) return new Error(`The Play service account cannot release ${packageName}. Invite its email in Play Console under Users and permissions, with permission to release to testing tracks, and enable the Google Play Android Developer API in its Google Cloud project.`, { cause: error });
646
+ if (/package not found/i.test(apiMessage)) return new Error(`Google Play has no app ${packageName}. Create the app in Play Console and upload its first .aab there; the API releases the builds after it.`, { cause: error });
647
+ if (/version code .*(already been used|already used)/i.test(apiMessage)) return new PlayVersionCodeUsedError({ cause: error });
648
+ return error;
649
+ };
650
+ /**
651
+ * Requests to the Google Play Developer API for `packageName`, as the
652
+ * service account: each carries an access token, exchanged for a signed
653
+ * assertion and replaced well before it expires.
654
+ */
655
+ const createPlayClient = ({ json, packageName, fetch }) => {
656
+ const account = readServiceAccount(json);
657
+ let token;
658
+ const authorization = async () => {
659
+ if (!token || Date.now() - token.issuedAtMs >= TOKEN_REUSE_MS) {
660
+ const issuedAtMs = Date.now();
661
+ const assertion = createServiceAccountAssertion(account);
662
+ (0, _hot_updater_cli_tools.registerSecretValue)(assertion);
663
+ const response = await fetch(account.tokenUri, {
664
+ method: "POST",
665
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
666
+ body: new URLSearchParams({
667
+ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
668
+ assertion
669
+ }).toString()
413
670
  });
414
- }, 1e3);
415
- rejectTimeout = setTimeout(() => {
416
- handleError(`It took too long to start and connect with Android emulator: ${emulatorName}. You can try starting the emulator manually from the terminal with: ${manualCommand}`);
417
- }, timeout * 1e3);
418
- cp.catch((error) => {
419
- handleError(error);
671
+ if (!response.ok) throw new Error(`Google refused the Play service account (HTTP ${response.status}): ${await readApiMessage(response) || "check that its key is current and the service account is enabled."}`);
672
+ const { access_token } = await response.json();
673
+ if (typeof access_token !== "string" || !access_token) throw new Error("Google answered the Play service account with no access token.");
674
+ (0, _hot_updater_cli_tools.registerSecretValue)(access_token);
675
+ token = {
676
+ value: access_token,
677
+ issuedAtMs
678
+ };
679
+ }
680
+ return `Bearer ${token.value}`;
681
+ };
682
+ const app = `/androidpublisher/v3/applications/${encodeURIComponent(packageName)}`;
683
+ const request = async (action, method, url, body) => {
684
+ const response = await fetch(url, {
685
+ method,
686
+ headers: {
687
+ Authorization: await authorization(),
688
+ ...body ? { "Content-Type": "application/json" } : {}
689
+ },
690
+ ...body ? { body: JSON.stringify(body) } : {}
420
691
  });
421
- });
692
+ if (!response.ok) throw new GooglePlayError(action, response.status, await readApiMessage(response));
693
+ return response.status === 204 ? void 0 : await response.json();
694
+ };
695
+ return {
696
+ app,
697
+ authorization,
698
+ request
699
+ };
422
700
  };
423
- const defaultPort = 5554;
424
- const maxPort = 5682;
425
- /**
426
- * Find an available port for emulator (starting from 5554)
427
- */
428
- const getAvailableDevicePort = async (startPort = defaultPort) => {
429
- /**
430
- * The default value is 5554 for the first virtual device instance running on your machine.
431
- * A virtual device normally occupies a pair of adjacent ports: a console port and an adb port.
432
- * The console of the first virtual device running on a particular machine uses console port 5554 and adb port 5555.
433
- * Subsequent instances use port numbers increasing by two.
434
- * For example, 5556/5557, 5558/5559, and so on. The range is 5554 to 5682, allowing for 64 concurrent virtual devices.
435
- */
436
- const devices = await getConnectedDevices();
437
- for (let port = startPort; port <= maxPort; port += 2) if (!devices.some((d) => d.includes(port.toString()))) return port;
438
- throw new Error("Failed to launch emulator: no available ports");
439
- };
440
- /**
441
- * Try to launch an emulator (or find the first available one)
442
- */
443
- const tryLaunchEmulator = async (name) => {
444
- const port = await getAvailableDevicePort();
445
- const spinner = _hot_updater_cli_tools.p.spinner();
446
- spinner.start("Looking for available emulators");
447
- const emulators = await getEmulatorNames();
448
- const emulatorName = name ?? emulators[0];
449
- let deviceId;
450
- if (emulators.length > 0) try {
451
- spinner.message(`Launching emulator "${emulatorName}"`);
452
- deviceId = await launchEmulator(emulatorName, port, spinner);
453
- spinner.stop(`Launched ${emulatorName} emulator.`);
701
+ /**
702
+ * Whether Google Play's internal testing track already releases the
703
+ * `.aab`'s version code, as when a reused native build was uploaded
704
+ * before. It reads the track in an edit it then deletes, changing nothing.
705
+ */
706
+ const findPlayInternalRelease = async ({ versionCode, packageName, serviceAccount: { json }, fetch = globalThis.fetch }) => {
707
+ const { app, request } = createPlayClient({
708
+ json,
709
+ packageName,
710
+ fetch
711
+ });
712
+ let editId;
713
+ try {
714
+ ({id: editId} = await request("Opening a Google Play edit", "POST", `${API_URL}${app}/edits`, {}));
454
715
  } catch (error) {
455
- spinner.error(`Failed to launch ${emulatorName} emulator. ${error.message}`);
716
+ throw explain(error, packageName);
717
+ }
718
+ const edit = `${API_URL}${app}/edits/${encodeURIComponent(editId)}`;
719
+ try {
720
+ const { releases = [] } = await request("Reading the internal track", "GET", `${edit}/tracks/${INTERNAL_TRACK}`);
721
+ return releases.some(({ versionCodes = [] }) => versionCodes.includes(String(versionCode)));
722
+ } catch (error) {
723
+ throw explain(error, packageName);
724
+ } finally {
725
+ await request("Deleting the Google Play edit", "DELETE", edit).catch(() => void 0);
456
726
  }
457
- else spinner.error("No emulators found as an output of `emulator -list-avds`. Please launch an emulator manually or connect a device");
458
- return deviceId;
459
727
  };
460
728
  /**
461
- * List all Android devices and emulators (connected and available)
729
+ * The common name of every Android debug certificate: Android Studio's and
730
+ * the Android Gradle plugin's (`CN=Android Debug, O=Android, C=US`), and React
731
+ * Native's template `debug.keystore` (`CN=Android Debug, OU=Android,
732
+ * O=Unknown, …`), as `keytool` prints the owner, whatever the label's
733
+ * language.
462
734
  */
463
- const listDevices = async () => {
464
- const devices = await getConnectedDevices();
465
- const allDevices = [];
466
- for (const deviceId of devices) if (deviceId.includes("emulator")) {
467
- const emulatorData = {
468
- deviceId,
469
- readableName: await getEmulatorName(deviceId),
470
- connected: true,
471
- type: "emulator"
472
- };
473
- allDevices.push(emulatorData);
474
- } else {
475
- const phoneData = {
476
- deviceId,
477
- readableName: await getPhoneName(deviceId),
478
- type: "phone",
479
- connected: true
480
- };
481
- allDevices.push(phoneData);
482
- }
483
- const emulatorNames = await getEmulatorNames();
484
- for (const emulatorName of emulatorNames) {
485
- if (allDevices.some((device) => device.readableName === emulatorName)) continue;
486
- const emulatorData = {
487
- deviceId: void 0,
488
- readableName: emulatorName,
489
- type: "emulator",
490
- connected: false
735
+ const ANDROID_DEBUG_SIGNER = /:\s*CN=Android Debug\s*(?:,|$)/mu;
736
+ /**
737
+ * Throws when the `.aab` is signed with the Android debug keystore, which
738
+ * Google Play refuses: a release build type left on `signingConfigs.debug`,
739
+ * as React Native's template ships it. It reads the signer with the JDK's
740
+ * `keytool`, which Gradle needs too; without one it checks nothing.
741
+ */
742
+ const assertPlaySigned = async (aabPath) => {
743
+ const { stdout, exitCode } = await (0, execa.execa)("keytool", [
744
+ "-J-Duser.language=en",
745
+ "-printcert",
746
+ "-jarfile",
747
+ aabPath
748
+ ], { reject: false });
749
+ if (exitCode === 0 && ANDROID_DEBUG_SIGNER.test(String(stdout))) throw new Error(`Google Play refuses an .aab signed with the Android debug keystore, and ${aabPath} is. Set the scheme's signing.keystore, or a release signingConfig in android/app/build.gradle, then build again.`);
750
+ };
751
+ /**
752
+ * Uploads an `.aab` to Google Play's internal testing track with the Google
753
+ * Play Developer API: it opens an edit, uploads the bundle, releases its
754
+ * version code to the internal track, and commits the edit, which it
755
+ * deletes again when any step fails. The service account stays in memory.
756
+ */
757
+ const uploadToPlay = async ({ aabPath, packageName, serviceAccount: { json }, fetch = globalThis.fetch, commitRetryDelayMs = 2e3 }) => {
758
+ const { app, authorization, request } = createPlayClient({
759
+ json,
760
+ packageName,
761
+ fetch
762
+ });
763
+ let editId;
764
+ try {
765
+ ({id: editId} = await request("Opening a Google Play edit", "POST", `${API_URL}${app}/edits`, {}));
766
+ } catch (error) {
767
+ throw explain(error, packageName);
768
+ }
769
+ const edit = `${API_URL}${app}/edits/${encodeURIComponent(editId)}`;
770
+ try {
771
+ _hot_updater_cli_tools.p.log.info(`Uploading ${packageName}'s .aab to Google Play`);
772
+ const versionCode = await uploadBundle({
773
+ aabPath,
774
+ sessionUrl: `${API_URL}/upload${app}/edits/${encodeURIComponent(editId)}/bundles?uploadType=resumable`,
775
+ authorization,
776
+ fetch
777
+ });
778
+ const release = async (status) => {
779
+ await request("Releasing to the internal track", "PUT", `${edit}/tracks/${INTERNAL_TRACK}`, {
780
+ track: INTERNAL_TRACK,
781
+ releases: [{
782
+ versionCodes: [String(versionCode)],
783
+ status
784
+ }]
785
+ });
786
+ await commit({
787
+ request,
788
+ edit,
789
+ retryDelayMs: commitRetryDelayMs
790
+ });
491
791
  };
492
- allDevices.push(emulatorData);
792
+ try {
793
+ await release("completed");
794
+ return {
795
+ versionCode,
796
+ status: "completed"
797
+ };
798
+ } catch (error) {
799
+ if (!(error instanceof GooglePlayError) || !DRAFT_APP_ERRORS.some((message) => error.apiMessage.includes(message))) throw error;
800
+ _hot_updater_cli_tools.p.log.warn(`Play Console has not published ${packageName} yet, which takes only draft releases. The internal release of version code ${versionCode} is a draft: roll it out in Play Console.`);
801
+ await release("draft");
802
+ return {
803
+ versionCode,
804
+ status: "draft"
805
+ };
806
+ }
807
+ } catch (error) {
808
+ await request("Deleting the Google Play edit", "DELETE", edit).catch(() => void 0);
809
+ throw explain(error, packageName);
493
810
  }
494
- return allDevices.sort((a, b) => {
495
- if (a.type === "phone" && b.type === "emulator") return -1;
496
- if (a.type === "emulator" && b.type === "phone") return 1;
497
- return 0;
498
- });
499
811
  };
500
- const matchingDevice = (devices, deviceArg) => {
501
- const deviceByName = devices.find((device) => device.readableName === deviceArg);
502
- const deviceById = devices.find((d) => d.deviceId === deviceArg);
503
- return deviceByName || deviceById;
504
- };
505
- const selectTargetDevice = async ({ interactive, deviceOption }) => {
506
- const availableDevices = await listDevices();
507
- if (deviceOption === true && !interactive) {
508
- _hot_updater_cli_tools.p.log.error("you can't select device manually with non-interactive cli mode(without -i option).");
509
- process.exit(1);
510
- }
511
- if (typeof deviceOption === "string") {
512
- const matchedDevice = matchingDevice(availableDevices, deviceOption);
513
- if (!matchedDevice) {
514
- _hot_updater_cli_tools.p.log.error(`device '${deviceOption}' isn't included in the attached devices.`);
515
- process.exit(1);
812
+ /**
813
+ * Commits the edit, again while Google is still processing the bundle, and
814
+ * with `changesNotSentForReview` when the app's state asks for it. An
815
+ * unanswered commit is reported as unconfirmed, since Google may have
816
+ * applied it.
817
+ */
818
+ const commit = async ({ request, edit, retryDelayMs }) => {
819
+ let changesNotSentForReview = false;
820
+ for (let attempt = 1;; attempt++) try {
821
+ await request("Committing the Google Play edit", "POST", `${edit}:commit${changesNotSentForReview ? "?changesNotSentForReview=true" : ""}`);
822
+ if (changesNotSentForReview) _hot_updater_cli_tools.p.log.warn("Google Play saved the release without sending it for review, as the app's state requires. Send the changes for review in Play Console.");
823
+ return;
824
+ } catch (error) {
825
+ if (!(error instanceof GooglePlayError) || error.status >= 500 || error.status === 429) throw new Error(`Google Play did not confirm the release: ${error instanceof Error ? error.message : String(error)} Check internal testing in Play Console before you upload this version code again.`, { cause: error });
826
+ if (error.status !== 400) throw error;
827
+ if (!changesNotSentForReview && error.apiMessage.includes(NEEDS_CHANGES_NOT_SENT_FOR_REVIEW)) {
828
+ changesNotSentForReview = true;
829
+ continue;
516
830
  }
517
- return matchedDevice;
518
- }
519
- if (interactive) {
520
- if (!availableDevices.length) {
521
- _hot_updater_cli_tools.p.log.error("you passed -d option but there is no attached adb devices.");
522
- process.exit(1);
831
+ if (error.apiMessage.includes(BUNDLE_STILL_PROCESSING) && attempt < COMMIT_ATTEMPTS) {
832
+ await sleep(retryDelayMs * 2 ** (attempt - 1));
833
+ continue;
523
834
  }
524
- const device = await _hot_updater_cli_tools.p.select({
525
- message: "Target Device",
526
- options: availableDevices.map((d) => ({
527
- value: d,
528
- label: d.readableName
529
- }))
835
+ throw error;
836
+ }
837
+ };
838
+ /**
839
+ * Uploads the `.aab` in a resumable upload session, one chunk in memory at a
840
+ * time. A chunk that fails without an answer resumes at the offset Google
841
+ * confirms. Returns the bundle's version code.
842
+ */
843
+ const uploadBundle = async ({ aabPath, sessionUrl, authorization, fetch }) => {
844
+ const file = await fs.default.promises.open(aabPath, "r");
845
+ try {
846
+ const { size } = await file.stat();
847
+ if (size === 0) throw new Error("The .aab is empty.");
848
+ const started = await fetch(sessionUrl, {
849
+ method: "POST",
850
+ headers: {
851
+ Authorization: await authorization(),
852
+ "X-Upload-Content-Type": "application/octet-stream",
853
+ "X-Upload-Content-Length": String(size)
854
+ }
530
855
  });
531
- if (_hot_updater_cli_tools.p.isCancel(device)) process.exit(1);
532
- return device;
856
+ if (!started.ok) throw new GooglePlayError("Starting the .aab upload", started.status, await readApiMessage(started));
857
+ const location = started.headers.get("location");
858
+ if (!location || !UPLOAD_ORIGINS.has(new URL(location).origin)) throw new Error("Google returned no upload session for the .aab.");
859
+ let offset = 0;
860
+ let failures = 0;
861
+ /**
862
+ * Resumes at the offset Google confirms. Progress starts a new retry
863
+ * budget; an attempt that keeps nothing counts against it.
864
+ */
865
+ const resumeAt = (confirmed, reason) => {
866
+ if (confirmed > offset) {
867
+ offset = confirmed;
868
+ failures = 0;
869
+ return;
870
+ }
871
+ offset = confirmed;
872
+ if (++failures === CHUNK_ATTEMPTS) throw new Error(`Uploading the .aab failed: ${reason}.`);
873
+ };
874
+ for (;;) {
875
+ const end = Math.min(offset + CHUNK_SIZE, size);
876
+ const body = Buffer.alloc(end - offset);
877
+ const { bytesRead } = await file.read(body, 0, body.length, offset);
878
+ if (bytesRead !== body.length) throw new Error("The .aab changed while it was uploading.");
879
+ const response = await fetch(location, {
880
+ method: "PUT",
881
+ headers: {
882
+ "Content-Range": `bytes ${offset}-${end - 1}/${size}`,
883
+ "Content-Type": "application/octet-stream"
884
+ },
885
+ body
886
+ }).catch((error) => error);
887
+ if (!(response instanceof Error) && response.ok) return readVersionCode(response);
888
+ if (!(response instanceof Error) && response.status === 308) {
889
+ resumeAt(confirmedOffset(response), "Google kept none of a chunk");
890
+ if (failures > 0) await sleep(1e3 * failures);
891
+ continue;
892
+ }
893
+ if (!(response instanceof Error) && response.status < 500 && response.status !== 429) throw new GooglePlayError("Uploading the .aab", response.status, await readApiMessage(response));
894
+ await sleep(1e3 * (failures + 1));
895
+ const status = await fetch(location, {
896
+ method: "PUT",
897
+ headers: { "Content-Range": `bytes */${size}` }
898
+ }).catch(() => void 0);
899
+ if (status?.ok) return readVersionCode(status);
900
+ resumeAt(status?.status === 308 ? confirmedOffset(status) : offset, response instanceof Error ? response.message : `HTTP ${response.status}`);
901
+ }
902
+ } finally {
903
+ await file.close();
533
904
  }
534
905
  };
535
- const Device = {
536
- getAdbPath,
537
- getConnectedDevices,
538
- tryRunAdbReverse,
539
- tryLaunchEmulator,
540
- listDevices,
541
- selectTargetDevice
906
+ /** The version code of the bundle a finished upload answers with. */
907
+ const readVersionCode = async (response) => {
908
+ const { versionCode } = await response.json();
909
+ if (typeof versionCode !== "number") throw new Error("Google Play read no version code from the .aab.");
910
+ return versionCode;
911
+ };
912
+ /**
913
+ * The next offset of a `308 Resume Incomplete`: past its `Range`, which
914
+ * Google sends as `bytes=0-<last>` or `0-<last>`, and 0 without one.
915
+ */
916
+ const confirmedOffset = (response) => {
917
+ const range = response.headers.get("range")?.match(/^(?:bytes=)?0-(\d+)$/);
918
+ return range ? Number(range[1]) + 1 : 0;
919
+ };
920
+ /**
921
+ * The Play service account a scheme's `.aab` uploads with: the
922
+ * `play-service-account` item. It reads only the config, before the native
923
+ * projects are written, and throws for a scheme whose build Google Play
924
+ * would not take as a release: an `.apk`, which Play takes only as an `.aab`
925
+ * from new apps, or a debug variant, which Play refuses.
926
+ */
927
+ const resolvePlayUploadServiceAccount = ({ scheme, androidScheme }) => {
928
+ if (androidScheme.aab === false) throw new Error(`Google Play takes an .aab, and the scheme ${scheme} builds an .apk (aab: false).`);
929
+ const variant = androidScheme.variant ?? "Release";
930
+ if (/debug$/i.test(variant)) throw new Error(`Google Play takes a release build, and the scheme ${scheme} builds the ${variant} variant, which is debuggable and loads its JavaScript from Metro.`);
931
+ return {
932
+ kind: "play-service-account",
933
+ name: "play-service-account"
934
+ };
542
935
  };
543
936
  //#endregion
544
- //#region src/runner/tryInstallAppOnDevice.ts
545
- const tryInstallAppOnDevice = async ({ device, apkPath }) => {
546
- if (!device.deviceId) {
547
- _hot_updater_cli_tools.p.log.warn(`No device with id "${device.deviceId}", skipping launching the app.`);
937
+ //#region src/utils/enrichNativeBuildAndroidScheme.ts
938
+ const enrichNativeBuildAndroidScheme = async ({ schemeConfig, hotUpdaterSchemeName }) => {
939
+ return {
940
+ aab: true,
941
+ variant: "Release",
942
+ appModuleName: "app",
943
+ applicationId: schemeConfig.packageName,
944
+ ...schemeConfig,
945
+ hotUpdaterSchemeName
946
+ };
947
+ };
948
+ //#endregion
949
+ //#region src/utils/extractBuiltInBundle.ts
950
+ const EOCD_SIGNATURE = 101010256;
951
+ const ZIP64_EOCD_LOCATOR_SIGNATURE = 117853008;
952
+ const ZIP64_EOCD_SIGNATURE = 101075792;
953
+ const CENTRAL_HEADER_SIGNATURE = 33639248;
954
+ const LOCAL_HEADER_SIGNATURE = 67324752;
955
+ const ZIP64_EXTRA_ID = 1;
956
+ const UINT32_MAX = 4294967295;
957
+ /** The end-of-central-directory record and the longest comment after it. */
958
+ const EOCD_SEARCH_LENGTH = 65557;
959
+ const read = async (file, offset, length) => {
960
+ const buffer = Buffer.alloc(length);
961
+ const { bytesRead } = await file.read(buffer, 0, length, offset);
962
+ return buffer.subarray(0, bytesRead);
963
+ };
964
+ /** The central directory's offset and size, from its end record (ZIP64 too). */
965
+ const centralDirectoryOf = async (file) => {
966
+ const { size } = await file.stat();
967
+ const start = Math.max(0, size - EOCD_SEARCH_LENGTH);
968
+ const tail = await read(file, start, size - start);
969
+ for (let i = tail.length - 22; i >= 0; i -= 1) {
970
+ if (tail.readUInt32LE(i) !== EOCD_SIGNATURE) continue;
971
+ const entries = tail.readUInt16LE(i + 10);
972
+ let directorySize = tail.readUInt32LE(i + 12);
973
+ let directoryOffset = tail.readUInt32LE(i + 16);
974
+ if (directoryOffset === UINT32_MAX || directorySize === UINT32_MAX) {
975
+ const locator = i - 20;
976
+ if (locator < 0 || tail.readUInt32LE(locator) !== ZIP64_EOCD_LOCATOR_SIGNATURE) throw new Error("The archive's ZIP64 end record is missing.");
977
+ const record = await read(file, Number(tail.readBigUInt64LE(locator + 8)), 56);
978
+ if (record.readUInt32LE(0) !== ZIP64_EOCD_SIGNATURE) throw new Error("The archive's ZIP64 end record is invalid.");
979
+ directorySize = Number(record.readBigUInt64LE(40));
980
+ directoryOffset = Number(record.readBigUInt64LE(48));
981
+ }
982
+ return {
983
+ entries,
984
+ directoryOffset,
985
+ directorySize
986
+ };
987
+ }
988
+ throw new Error("The artifact is not a zip archive.");
989
+ };
990
+ /** One entry's location, from the central directory, without reading the rest of the archive. */
991
+ const findEntry = async (file, name) => {
992
+ const { directoryOffset, directorySize } = await centralDirectoryOf(file);
993
+ const directory = await read(file, directoryOffset, directorySize);
994
+ for (let i = 0; i + 46 <= directory.length;) {
995
+ if (directory.readUInt32LE(i) !== CENTRAL_HEADER_SIGNATURE) break;
996
+ const method = directory.readUInt16LE(i + 10);
997
+ let compressedSize = directory.readUInt32LE(i + 20);
998
+ const uncompressedSize = directory.readUInt32LE(i + 24);
999
+ const nameLength = directory.readUInt16LE(i + 28);
1000
+ const extraLength = directory.readUInt16LE(i + 30);
1001
+ const commentLength = directory.readUInt16LE(i + 32);
1002
+ let localHeaderOffset = directory.readUInt32LE(i + 42);
1003
+ if (directory.toString("utf8", i + 46, i + 46 + nameLength) === name) {
1004
+ const extra = directory.subarray(i + 46 + nameLength, i + 46 + nameLength + extraLength);
1005
+ for (let j = 0; j + 4 <= extra.length;) {
1006
+ const id = extra.readUInt16LE(j);
1007
+ const length = extra.readUInt16LE(j + 2);
1008
+ if (id === ZIP64_EXTRA_ID) {
1009
+ let k = j + 4;
1010
+ if (uncompressedSize === UINT32_MAX) k += 8;
1011
+ if (compressedSize === UINT32_MAX) {
1012
+ compressedSize = Number(extra.readBigUInt64LE(k));
1013
+ k += 8;
1014
+ }
1015
+ if (localHeaderOffset === UINT32_MAX) localHeaderOffset = Number(extra.readBigUInt64LE(k));
1016
+ }
1017
+ j += 4 + length;
1018
+ }
1019
+ return {
1020
+ method,
1021
+ compressedSize,
1022
+ localHeaderOffset
1023
+ };
1024
+ }
1025
+ i += 46 + nameLength + extraLength + commentLength;
1026
+ }
1027
+ return null;
1028
+ };
1029
+ /**
1030
+ * Copies the JavaScript bundle React Native's Gradle build packed into an
1031
+ * `.apk` (`assets/index.android.bundle`) or an `.aab`
1032
+ * (`base/assets/index.android.bundle`) to `outputPath`: the built-in bundle,
1033
+ * byte for byte, which no rebuild reproduces. It reads only the archive's
1034
+ * directory and that entry, streamed, so a large artifact stays on disk.
1035
+ */
1036
+ const extractBuiltInBundle = async ({ artifactPath, outputPath }) => {
1037
+ const name = path.default.extname(artifactPath) === ".aab" ? "base/assets/index.android.bundle" : "assets/index.android.bundle";
1038
+ const file = await fs.default.promises.open(artifactPath, "r");
1039
+ let entry;
1040
+ let dataOffset;
1041
+ try {
1042
+ entry = await findEntry(file, name);
1043
+ if (entry === null) throw new Error(`${path.default.basename(artifactPath)} has no ${name}. A release build packs its JavaScript bundle there unless the app's react { bundleAssetName } names another file.`);
1044
+ const local = await read(file, entry.localHeaderOffset, 30);
1045
+ if (local.readUInt32LE(0) !== LOCAL_HEADER_SIGNATURE) throw new Error(`${path.default.basename(artifactPath)} has a corrupt ${name}.`);
1046
+ dataOffset = entry.localHeaderOffset + 30 + local.readUInt16LE(26) + local.readUInt16LE(28);
1047
+ } finally {
1048
+ await file.close();
1049
+ }
1050
+ if (entry.method !== 0 && entry.method !== 8) throw new Error(`${path.default.basename(artifactPath)} stores ${name} with an unsupported compression method (${entry.method}).`);
1051
+ await fs.default.promises.mkdir(path.default.dirname(outputPath), { recursive: true });
1052
+ if (entry.compressedSize === 0) {
1053
+ await fs.default.promises.writeFile(outputPath, "");
548
1054
  return;
549
1055
  }
550
- const deviceId = device.deviceId;
551
- const adbArgs = [
552
- "-s",
553
- deviceId,
554
- "install",
555
- "-r",
556
- "-d"
557
- ];
558
- adbArgs.push(apkPath);
559
- const adbPath = Device.getAdbPath();
560
- const spinner = _hot_updater_cli_tools.p.spinner();
561
- spinner.start(`Installing the app on ${device.readableName} (id: ${deviceId})`);
1056
+ const source = fs.default.createReadStream(artifactPath, {
1057
+ start: dataOffset,
1058
+ end: dataOffset + entry.compressedSize - 1
1059
+ });
1060
+ await (entry.method === 8 ? (0, stream_promises.pipeline)(source, (0, zlib.createInflateRaw)(), fs.default.createWriteStream(outputPath)) : (0, stream_promises.pipeline)(source, fs.default.createWriteStream(outputPath)));
1061
+ };
1062
+ //#endregion
1063
+ //#region src/google/googleApi.ts
1064
+ /** A Google Cloud or Google Play API request that failed, with Google's error. */
1065
+ var GoogleApiError = class extends Error {
1066
+ status;
1067
+ /** Google's canonical status, such as `PERMISSION_DENIED`. */
1068
+ apiStatus;
1069
+ apiMessage;
1070
+ /** The `ErrorInfo` reasons, such as `SERVICE_DISABLED`. */
1071
+ reasons;
1072
+ /** The `PreconditionFailure` violation types, such as an org policy constraint. */
1073
+ violations;
1074
+ /** The `ErrorInfo` consumer of a disabled service, such as `projects/123`. */
1075
+ consumer;
1076
+ constructor(action, status, body = {}) {
1077
+ const apiMessage = body.apiMessage ?? "";
1078
+ super(`${action} failed (HTTP ${status})${apiMessage ? `: ${apiMessage}` : "."}`);
1079
+ this.name = "GoogleApiError";
1080
+ this.status = status;
1081
+ this.apiStatus = body.apiStatus;
1082
+ this.apiMessage = apiMessage;
1083
+ this.reasons = body.reasons ?? [];
1084
+ this.violations = body.violations ?? [];
1085
+ this.consumer = body.consumer;
1086
+ }
1087
+ };
1088
+ /** Google's error body, `{ error: { code, status, message, details } }`. */
1089
+ const readError = async (action, response) => {
1090
+ let error;
562
1091
  try {
563
- await (0, execa.execa)(adbPath, adbArgs);
564
- spinner.stop(`Installed the app on ${device.readableName} (id: ${deviceId}).`);
565
- } catch (error) {
566
- const errorMessage = error.stdout || error.stderr || "Unknown error";
567
- if (typeof errorMessage === "string" && errorMessage.includes("INSTALL_FAILED_INSUFFICIENT_STORAGE")) spinner.message("Installation failed due to insufficient storage");
568
- spinner.error(`Failed: Installing the app on ${device.readableName} (id: ${deviceId})`);
569
- throw new Error(typeof errorMessage === "string" ? errorMessage : "Installation failed");
1092
+ error = (await response.json()).error;
1093
+ } catch {}
1094
+ const details = Array.isArray(error?.details) ? error.details : [];
1095
+ const info = details.filter((detail) => typeof detail.reason === "string");
1096
+ return new GoogleApiError(action, response.status, {
1097
+ ...typeof error?.status === "string" ? { apiStatus: error.status } : {},
1098
+ ...typeof error?.message === "string" ? { apiMessage: error.message } : {},
1099
+ reasons: info.map((detail) => detail.reason),
1100
+ violations: details.flatMap((detail) => (detail.violations ?? []).flatMap((violation) => typeof violation.type === "string" ? [violation.type] : [])),
1101
+ ...info.find((detail) => detail.metadata?.["consumer"]) ? { consumer: info.find((detail) => detail.metadata?.["consumer"]).metadata["consumer"] } : {}
1102
+ });
1103
+ };
1104
+ /** Whether Google answered that a service is disabled in a project. */
1105
+ const isServiceDisabled = (error) => error instanceof GoogleApiError && (error.reasons.includes("SERVICE_DISABLED") || /has not been used in project .* or it is disabled/i.test(error.apiMessage));
1106
+ /** Requests with the signed-in user's access token, throwing `GoogleApiError`. */
1107
+ const createGoogleRequest = ({ accessToken, fetch }) => async (action, method, url, body) => {
1108
+ const response = await fetch(url, {
1109
+ method,
1110
+ headers: {
1111
+ authorization: `Bearer ${accessToken}`,
1112
+ ...body === void 0 ? {} : { "content-type": "application/json" }
1113
+ },
1114
+ ...body === void 0 ? {} : { body: JSON.stringify(body) }
1115
+ });
1116
+ if (!response.ok) throw await readError(action, response);
1117
+ const text = await response.text();
1118
+ return text ? JSON.parse(text) : {};
1119
+ };
1120
+ /**
1121
+ * Polls `operation` at `origin` until it is done, returning its response or
1122
+ * throwing its error as a `GoogleApiError` with the operation's code.
1123
+ */
1124
+ const waitForOperation = async ({ request, origin, operation, action, sleep, intervalMs = 2e3, attempts = 60 }) => {
1125
+ let current = operation;
1126
+ for (let attempt = 0; !current.done; attempt++) {
1127
+ if (attempt >= attempts || !current.name) throw new Error(`${action} did not finish in time.`);
1128
+ await sleep(intervalMs);
1129
+ current = await request(action, "GET", `${origin}/${current.name}`);
570
1130
  }
1131
+ if (current.error) throw new GoogleApiError(action, 400, {
1132
+ apiMessage: current.error.message ?? "",
1133
+ ...current.error.code === 7 ? { apiStatus: "PERMISSION_DENIED" } : {},
1134
+ ...current.error.code === 8 ? { apiStatus: "RESOURCE_EXHAUSTED" } : {}
1135
+ });
1136
+ return current.response;
571
1137
  };
572
1138
  //#endregion
573
- //#region src/runner/tryLaunchAppOnDevice.ts
574
- async function tryLaunchAppOnDevice({ device, port, mainActivity, applicationId, packageName }) {
575
- if (!device.deviceId) {
576
- _hot_updater_cli_tools.p.log.warn(`No device with id "${device.deviceId}", skipping launching the app.`);
577
- return;
1139
+ //#region src/google/types.ts
1140
+ /** A named failure of `issuePlayServiceAccount`, with what to do in its message. */
1141
+ var PlayServiceAccountError = class extends Error {
1142
+ code;
1143
+ constructor(code, message, options) {
1144
+ super(message, options);
1145
+ this.name = "PlayServiceAccountError";
1146
+ this.code = code;
578
1147
  }
579
- const deviceId = device.deviceId;
580
- await Device.tryRunAdbReverse({
581
- deviceId,
582
- port
1148
+ };
1149
+ /** The environment variables that replace Hot Updater's Google OAuth client. */
1150
+ const GOOGLE_OAUTH_CLIENT_ENV_NAMES = {
1151
+ clientId: "HOT_UPDATER_GOOGLE_OAUTH_CLIENT_ID",
1152
+ clientSecret: "HOT_UPDATER_GOOGLE_OAUTH_CLIENT_SECRET"
1153
+ };
1154
+ /** The service account Hot Updater creates, `hot-updater-play@<project>.iam.gserviceaccount.com`. */
1155
+ const PLAY_SERVICE_ACCOUNT_ID = "hot-updater-play";
1156
+ /** Where a Play Console admin finds the developer account ID. */
1157
+ const PLAY_CONSOLE_URL = "https://play.google.com/console";
1158
+ //#endregion
1159
+ //#region src/google/googleSignIn.ts
1160
+ const AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth";
1161
+ const TOKEN_URL = "https://oauth2.googleapis.com/token";
1162
+ const REVOKE_URL = "https://oauth2.googleapis.com/revoke";
1163
+ /** How long the browser has to come back with the sign-in. */
1164
+ const SIGN_IN_TIMEOUT_MS = 3e5;
1165
+ const base64url = (bytes) => bytes.toString("base64url");
1166
+ const page = (message) => `<!doctype html><meta charset="utf-8"><title>Hot Updater</title><body style="font-family:system-ui;margin:3rem"><p>${message}</p></body>`;
1167
+ /**
1168
+ * Signs in with Google as an installed app does (RFC 8252): the browser
1169
+ * opens Google's own consent screen, so the password and the second factor
1170
+ * stay with Google, and the authorization code comes back to a loopback
1171
+ * server on 127.0.0.1, bound to a random port for this sign-in only. The
1172
+ * code is exchanged with PKCE (S256), and `state` ties the answer to this
1173
+ * request.
1174
+ *
1175
+ * @param onUrl Sees the sign-in URL, to print and open in a browser.
1176
+ */
1177
+ const signInWithGoogle = async ({ client, scopes, onUrl, fetch = globalThis.fetch, timeoutMs = SIGN_IN_TIMEOUT_MS }) => {
1178
+ const verifier = base64url(node_crypto.default.randomBytes(32));
1179
+ const challenge = base64url(node_crypto.default.createHash("sha256").update(verifier).digest());
1180
+ const state = base64url(node_crypto.default.randomBytes(16));
1181
+ const server = node_http.default.createServer();
1182
+ await new Promise((resolve, reject) => {
1183
+ server.once("error", reject);
1184
+ server.listen(0, "127.0.0.1", () => resolve());
583
1185
  });
584
- const activity = mainActivity || ".MainActivity";
585
- const adbArgs = [
586
- "shell",
587
- "am",
588
- "start",
589
- "-n",
590
- `${applicationId}/${activity.startsWith(packageName) || !activity.startsWith(".") && activity.includes(".") ? activity : activity.startsWith(".") ? [packageName, activity].join("") : [packageName, activity].filter(Boolean).join(".")}`,
591
- "-a",
592
- "android.intent.action.MAIN",
593
- "-c",
594
- "android.intent.category.LAUNCHER"
595
- ];
596
- adbArgs.unshift("-s", deviceId);
597
- const adbPath = Device.getAdbPath();
598
- console.debug(`Running ${adbPath} ${adbArgs.join(" ")}.`);
599
- const spinner = _hot_updater_cli_tools.p.spinner();
600
- spinner.start(`Launching the app on ${device.readableName} (id: ${deviceId})`);
601
1186
  try {
602
- await (0, execa.execa)(adbPath, adbArgs);
603
- spinner.stop(`Launched the app on ${device.readableName} (id: ${deviceId}) and listening on port ${port}.`);
1187
+ const { port } = server.address();
1188
+ const redirectUri = `http://127.0.0.1:${port}`;
1189
+ const code = new Promise((resolve, reject) => {
1190
+ const timer = setTimeout(() => reject(new PlayServiceAccountError("sign-in-cancelled", "The Google sign-in did not finish in time.")), timeoutMs);
1191
+ timer.unref();
1192
+ server.on("request", (req, res) => {
1193
+ const query = new URL(req.url ?? "/", redirectUri).searchParams;
1194
+ if (query.get("state") !== state) {
1195
+ res.writeHead(404).end();
1196
+ return;
1197
+ }
1198
+ const error = query.get("error");
1199
+ const answer = query.get("code");
1200
+ res.writeHead(error || !answer ? 400 : 200, { "content-type": "text/html; charset=utf-8" }).end(page(error || !answer ? "The sign-in did not finish. Return to the terminal." : "Signed in. Close this tab and return to the terminal."));
1201
+ clearTimeout(timer);
1202
+ if (error || !answer) reject(new PlayServiceAccountError("sign-in-cancelled", error === "access_denied" ? "The Google sign-in was cancelled." : `The Google sign-in failed: ${error ?? "no authorization code"}.`));
1203
+ else resolve(answer);
1204
+ });
1205
+ });
1206
+ code.catch(() => {});
1207
+ const url = new URL(AUTHORIZATION_URL);
1208
+ url.search = new URLSearchParams({
1209
+ client_id: client.clientId,
1210
+ redirect_uri: redirectUri,
1211
+ response_type: "code",
1212
+ scope: scopes.join(" "),
1213
+ code_challenge: challenge,
1214
+ code_challenge_method: "S256",
1215
+ state,
1216
+ prompt: "select_account"
1217
+ }).toString();
1218
+ await onUrl(url.toString());
1219
+ const authorizationCode = await code;
1220
+ (0, _hot_updater_cli_tools.registerSecretValue)(authorizationCode);
1221
+ const response = await fetch(TOKEN_URL, {
1222
+ method: "POST",
1223
+ headers: { "content-type": "application/x-www-form-urlencoded" },
1224
+ body: new URLSearchParams({
1225
+ code: authorizationCode,
1226
+ client_id: client.clientId,
1227
+ ...client.clientSecret ? { client_secret: client.clientSecret } : {},
1228
+ redirect_uri: redirectUri,
1229
+ grant_type: "authorization_code",
1230
+ code_verifier: verifier
1231
+ }).toString()
1232
+ });
1233
+ const token = await response.json().catch(() => ({}));
1234
+ if (!response.ok || typeof token.access_token !== "string") throw new PlayServiceAccountError(token.error === "invalid_client" ? "sign-in-not-configured" : "sign-in-cancelled", `Google refused the sign-in (HTTP ${response.status})${typeof token.error_description === "string" ? `: ${token.error_description}` : typeof token.error === "string" ? `: ${token.error}` : "."}`);
1235
+ (0, _hot_updater_cli_tools.registerSecretValue)(token.access_token);
1236
+ return {
1237
+ accessToken: token.access_token,
1238
+ scopes: typeof token.scope === "string" ? token.scope.split(" ") : []
1239
+ };
1240
+ } finally {
1241
+ server.closeAllConnections();
1242
+ await new Promise((resolve) => server.close(() => resolve()));
1243
+ }
1244
+ };
1245
+ /** Ends a sign-in: Google revokes its access token. Whether Google did. */
1246
+ const revokeGoogleToken = async (accessToken, fetch = globalThis.fetch) => {
1247
+ try {
1248
+ return (await fetch(REVOKE_URL, {
1249
+ method: "POST",
1250
+ headers: { "content-type": "application/x-www-form-urlencoded" },
1251
+ body: new URLSearchParams({ token: accessToken }).toString()
1252
+ })).ok;
604
1253
  } catch {
605
- spinner.error("Failed to launch the app.");
606
- throw new Error(`Failed to launch the app on ${device.readableName}`);
1254
+ return false;
607
1255
  }
608
- }
1256
+ };
609
1257
  //#endregion
610
- //#region src/runAndroid.ts
611
- const runAndroid = async ({ schemeConfig, runOption }) => {
612
- const { interactive, device: deviceOption } = runOption;
613
- const androidProjectPath = path.default.join((0, _hot_updater_cli_tools.getCwd)(), "android");
614
- if (schemeConfig.aab) {
615
- _hot_updater_cli_tools.p.log.error("aab scheme can't not be run");
616
- process.exit(1);
617
- }
618
- const device = await Device.selectTargetDevice({
619
- deviceOption,
620
- interactive
1258
+ //#region src/google/googleKeyIssuer.ts
1259
+ const CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform";
1260
+ const ANDROID_PUBLISHER_SCOPE = "https://www.googleapis.com/auth/androidpublisher";
1261
+ const SCOPES = [CLOUD_PLATFORM_SCOPE, ANDROID_PUBLISHER_SCOPE];
1262
+ const PUBLISHER_API = "https://androidpublisher.googleapis.com/androidpublisher/v3";
1263
+ const RESOURCE_MANAGER_API = "https://cloudresourcemanager.googleapis.com";
1264
+ const SERVICE_USAGE_API = "https://serviceusage.googleapis.com";
1265
+ const IAM_API = "https://iam.googleapis.com/v1";
1266
+ /** IAM creates the service account; the Play Developer API is what it calls. */
1267
+ const SERVICES = ["iam.googleapis.com", "androidpublisher.googleapis.com"];
1268
+ /**
1269
+ * What the service account may do in Play Console, for the app only: read
1270
+ * it, and release to testing tracks, which `--submit play-internal` does.
1271
+ */
1272
+ const APP_PERMISSIONS = ["CAN_VIEW_NON_FINANCIAL_DATA", "CAN_MANAGE_TRACK_APKS"];
1273
+ const KEY_CREATION_CONSTRAINT = "constraints/iam.disableServiceAccountKeyCreation";
1274
+ /** Retries while Google enables an API or creates a service account. */
1275
+ const PROPAGATION_ATTEMPTS = 12;
1276
+ const PROPAGATION_DELAY_MS = 1e4;
1277
+ const sleepFor = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1278
+ /** The developer account ID in `input`, a number or a Play Console URL. */
1279
+ const parseDeveloperId = (input) => {
1280
+ const trimmed = input.trim();
1281
+ if (/^\d+$/u.test(trimmed)) return trimmed;
1282
+ return trimmed.match(/\/developers\/(\d+)(?:[/?#]|$)/u)?.[1];
1283
+ };
1284
+ /** A project ID nobody has: Google Cloud's are global. */
1285
+ const newProjectId = () => `${PLAY_SERVICE_ACCOUNT_ID}-${node_crypto.default.randomBytes(3).toString("hex")}`;
1286
+ const isNotFound = (error) => error instanceof GoogleApiError && error.status === 404;
1287
+ const isConflict = (error) => error instanceof GoogleApiError && error.status === 409;
1288
+ /** The API is disabled in the OAuth client's own project: waiting does not help. */
1289
+ const clientApiDisabled = (error) => new PlayServiceAccountError("api-not-enabled", `${error.apiMessage} The Google OAuth client Hot Updater signs in with belongs to that project, which must enable the API (see ${GOOGLE_OAUTH_CLIENT_ENV_NAMES.clientId}).`, { cause: error });
1290
+ /** Google's own words, after a message that names the likely cause. */
1291
+ const googleSays = (error) => error instanceof GoogleApiError && error.apiMessage ? ` (Google: ${error.apiMessage})` : "";
1292
+ /** A Play Console refusal, as `not-play-admin`. */
1293
+ const notPlayAdmin = (developerId, error) => new PlayServiceAccountError("not-play-admin", `The Google Account you signed in with is not an admin of the Play Console developer account ${developerId}. Sign in with an account that has the Admin permission there (Users and permissions in ${PLAY_CONSOLE_URL})${googleSays(error)}.`, { cause: error });
1294
+ const isDenied = (error) => error instanceof GoogleApiError && (error.status === 403 || error.apiStatus === "PERMISSION_DENIED");
1295
+ /**
1296
+ * `IssuePlayServiceAccount` with Google's own APIs, after a sign-in on
1297
+ * Google's consent screen (OAuth for installed apps, with PKCE and a
1298
+ * loopback redirect):
1299
+ *
1300
+ * 1. the Play Console side is checked first, with no change anywhere: the
1301
+ * developer account answers to the account as its admin, and has the app;
1302
+ * 2. the chosen Google Cloud project, or a new one, gets the IAM and Play
1303
+ * Developer APIs enabled, and the service account `hot-updater-play`;
1304
+ * 3. the service account becomes a Play Console user that may release the
1305
+ * app to testing tracks;
1306
+ * 4. only then is its JSON key created, the last step, so a failure before
1307
+ * it leaves no key behind.
1308
+ *
1309
+ * Each step reuses what an earlier run created: the project, the service
1310
+ * account, and its Play Console access.
1311
+ */
1312
+ const createGoogleKeyIssuer = ({ fetch = globalThis.fetch, sleep = sleepFor } = {}) => async (request) => {
1313
+ const { packageName, oauthClient, onStep = () => {} } = request;
1314
+ if (!oauthClient?.clientId) throw new PlayServiceAccountError("sign-in-not-configured", `This build of Hot Updater has no Google OAuth client to sign in with. Set ${GOOGLE_OAUTH_CLIENT_ENV_NAMES.clientId} and ${GOOGLE_OAUTH_CLIENT_ENV_NAMES.clientSecret} to a "Desktop app" OAuth client.`);
1315
+ const signIn = await signInWithGoogle({
1316
+ client: oauthClient,
1317
+ scopes: SCOPES,
1318
+ onUrl: request.onSignInUrl,
1319
+ fetch
621
1320
  });
622
- if (device) {
623
- if (!(await Device.getConnectedDevices()).includes(device.deviceId || "")) {
624
- if (device.type === "emulator") device.deviceId = await Device.tryLaunchEmulator(device.readableName);
1321
+ const dispose = async () => {
1322
+ await revokeGoogleToken(signIn.accessToken, fetch);
1323
+ };
1324
+ try {
1325
+ const missing = SCOPES.filter((scope) => !signIn.scopes.includes(scope));
1326
+ if (missing.length > 0) throw new PlayServiceAccountError("scope-not-granted", `The sign-in did not allow ${missing.map((scope) => scope === ANDROID_PUBLISHER_SCOPE ? "managing Google Play" : "managing Google Cloud").join(" or ")}. Run this again and keep every box on Google's consent screen ticked.`);
1327
+ const google = createGoogleRequest({
1328
+ accessToken: signIn.accessToken,
1329
+ fetch
1330
+ });
1331
+ const developerInput = await request.askDeveloperId();
1332
+ const developerId = parseDeveloperId(developerInput);
1333
+ if (developerId === void 0) throw new PlayServiceAccountError("developer-not-found", `"${developerInput.trim()}" is no developer account ID. Open ${PLAY_CONSOLE_URL}: it is the number after /developers/ in the address.`);
1334
+ onStep("Checking the Play Console developer account");
1335
+ await checkPlayConsole({
1336
+ google,
1337
+ developerId,
1338
+ packageName
1339
+ });
1340
+ const project = await chooseProject({
1341
+ google,
1342
+ request,
1343
+ sleep,
1344
+ onStep
1345
+ });
1346
+ const { projectId } = project;
1347
+ onStep(`Enabling the Google Play Developer API in ${projectId}`);
1348
+ await enableServices({
1349
+ google,
1350
+ projectId,
1351
+ sleep
1352
+ });
1353
+ onStep(`Creating the service account in ${projectId}`);
1354
+ const email = await ensureServiceAccount({
1355
+ google,
1356
+ project,
1357
+ sleep
1358
+ });
1359
+ onStep(`Giving ${email} release access to ${packageName}`);
1360
+ await grantPlayAccess({
1361
+ google,
1362
+ developerId,
1363
+ email,
1364
+ packageName
1365
+ });
1366
+ onStep("Creating the service account's JSON key");
1367
+ const key = await createKey({
1368
+ google,
1369
+ project,
1370
+ email,
1371
+ sleep
1372
+ });
1373
+ return {
1374
+ json: key.json,
1375
+ keyId: key.keyId,
1376
+ email,
1377
+ projectId,
1378
+ developerId,
1379
+ revoke: async () => {
1380
+ try {
1381
+ await google("Deleting the key", "DELETE", `${IAM_API}/${key.name}`);
1382
+ return true;
1383
+ } catch (error) {
1384
+ return isNotFound(error);
1385
+ }
1386
+ },
1387
+ dispose
1388
+ };
1389
+ } catch (error) {
1390
+ await dispose();
1391
+ throw error;
1392
+ }
1393
+ };
1394
+ /**
1395
+ * Whether the signed-in account administers `developerId`, and Google Play
1396
+ * has `packageName` there, asked without changing anything: listing the
1397
+ * account's users takes the admin permission, and an edit opened on the app
1398
+ * is deleted again.
1399
+ */
1400
+ const checkPlayConsole = async ({ google, developerId, packageName }) => {
1401
+ try {
1402
+ await google("Listing the Play Console users", "GET", `${PUBLISHER_API}/developers/${developerId}/users?pageSize=1`);
1403
+ } catch (error) {
1404
+ if (isServiceDisabled(error)) throw clientApiDisabled(error);
1405
+ if (isNotFound(error)) throw new PlayServiceAccountError("developer-not-found", `Play Console has no developer account ${developerId}. Open ${PLAY_CONSOLE_URL}: the developer account ID is the number after /developers/ in the address${googleSays(error)}.`, { cause: error });
1406
+ if (isDenied(error)) throw notPlayAdmin(developerId, error);
1407
+ throw error;
1408
+ }
1409
+ let edit;
1410
+ try {
1411
+ edit = await google(`Opening an edit of ${packageName}`, "POST", `${PUBLISHER_API}/applications/${encodeURIComponent(packageName)}/edits`);
1412
+ } catch (error) {
1413
+ if (isServiceDisabled(error)) throw clientApiDisabled(error);
1414
+ if (isNotFound(error) || isDenied(error)) throw appNotFound(packageName, developerId, error);
1415
+ throw error;
1416
+ }
1417
+ if (edit.id) await google("Deleting the edit", "DELETE", `${PUBLISHER_API}/applications/${encodeURIComponent(packageName)}/edits/${edit.id}`).catch(() => {});
1418
+ };
1419
+ const appNotFound = (packageName, developerId, cause) => new PlayServiceAccountError("app-not-found", `Google Play has no app ${packageName} in the developer account ${developerId}. Google takes the first .aab of a new app only in Play Console: create the app there, upload one .aab by hand (for example to internal testing), then run this again${googleSays(cause)}.`, { cause });
1420
+ /** The account's active projects, every page of them. */
1421
+ const listProjects = async (google) => {
1422
+ const projects = [];
1423
+ let pageToken;
1424
+ do {
1425
+ const page = await google("Listing the Google Cloud projects", "GET", `${RESOURCE_MANAGER_API}/v3/projects:search?${new URLSearchParams({
1426
+ query: "state:ACTIVE",
1427
+ ...pageToken ? { pageToken } : {}
1428
+ })}`);
1429
+ for (const project of page.projects ?? []) if (project.projectId) projects.push({
1430
+ projectId: project.projectId,
1431
+ ...project.displayName ? { displayName: project.displayName } : {},
1432
+ ...projectNumberOf(project.name)
1433
+ });
1434
+ pageToken = page.nextPageToken || void 0;
1435
+ } while (pageToken);
1436
+ return projects;
1437
+ };
1438
+ /** The project the developer picks, or a new one when there is none or they ask. */
1439
+ const chooseProject = async ({ google, request, sleep, onStep }) => {
1440
+ const projects = await listProjects(google);
1441
+ const chosen = projects.length > 0 ? await request.chooseProject(projects.map(({ projectId, displayName }) => ({
1442
+ projectId,
1443
+ ...displayName ? { displayName } : {}
1444
+ }))) : void 0;
1445
+ if (chosen !== void 0) return projects.find((project) => project.projectId === chosen) ?? { projectId: chosen };
1446
+ const projectId = newProjectId();
1447
+ onStep(`Creating the Google Cloud project ${projectId}`);
1448
+ try {
1449
+ const operation = await google("Creating the Google Cloud project", "POST", `${RESOURCE_MANAGER_API}/v3/projects`, {
1450
+ projectId,
1451
+ displayName: "Hot Updater Play"
1452
+ });
1453
+ const created = await waitForOperation({
1454
+ request: google,
1455
+ origin: `${RESOURCE_MANAGER_API}/v3`,
1456
+ operation,
1457
+ action: "Creating the Google Cloud project",
1458
+ sleep
1459
+ });
1460
+ return {
1461
+ projectId,
1462
+ ...projectNumberOf(created?.name)
1463
+ };
1464
+ } catch (error) {
1465
+ if (!(error instanceof GoogleApiError)) throw error;
1466
+ if (error.status === 429 || error.apiStatus === "RESOURCE_EXHAUSTED" || /quota/iu.test(error.apiMessage)) throw new PlayServiceAccountError("project-quota", `Google Cloud has no project quota left for this account: ${error.apiMessage || "quota exceeded"}. Delete a project you no longer use (https://console.cloud.google.com/cloud-resource-manager), or run this again and pick an existing project.`, { cause: error });
1467
+ if (/terms of service/iu.test(error.apiMessage)) throw new PlayServiceAccountError("project-create-denied", "This Google Account has not accepted the Google Cloud Terms of Service. Open https://console.cloud.google.com once to accept them, then run this again.", { cause: error });
1468
+ if (isDenied(error) || error.apiStatus === "FAILED_PRECONDITION") throw new PlayServiceAccountError("project-create-denied", `This Google Account may not create Google Cloud projects: ${error.apiMessage || "permission denied"}. Ask your organization's admin for the Project Creator role, or run this again and pick an existing project.`, { cause: error });
1469
+ throw error;
1470
+ }
1471
+ };
1472
+ /** `projectNumber` from a resource name `projects/<number>`. */
1473
+ const projectNumberOf = (name) => {
1474
+ const number = name?.match(/^projects\/(\d+)$/u)?.[1];
1475
+ return number === void 0 ? {} : { projectNumber: number };
1476
+ };
1477
+ /**
1478
+ * Runs `run` again while Google is still enabling an API in `project`,
1479
+ * or, with `notFound`, still creating what it reads.
1480
+ */
1481
+ const whilePropagating = async ({ run, project, sleep, notFound = false }) => {
1482
+ for (let attempt = 1;; attempt++) try {
1483
+ return await run();
1484
+ } catch (error) {
1485
+ const disabled = isServiceDisabled(error);
1486
+ if (disabled && !consumerIs(error, project)) throw clientApiDisabled(error);
1487
+ if (!disabled && !(notFound && isNotFound(error)) || attempt >= PROPAGATION_ATTEMPTS) {
1488
+ if (disabled) throw new PlayServiceAccountError("api-not-enabled", `Google is still enabling the APIs in ${project.projectId}: ${error.apiMessage} Wait a few minutes, then run this again.`, { cause: error });
1489
+ throw error;
625
1490
  }
626
- if (!device.deviceId) throw new Error("Failed to run on any device");
627
- } else if ((await Device.getConnectedDevices()).length === 0) {
628
- _hot_updater_cli_tools.p.log.info("No devices found. Launching first available emulator.");
629
- await Device.tryLaunchEmulator();
1491
+ await sleep(PROPAGATION_DELAY_MS);
630
1492
  }
631
- const task = `assemble${schemeConfig.variant}`;
632
- const result = await runGradle({
633
- args: { extraParams: [`-PMIN_BUNDLE_ID=${(0, _hot_updater_plugin_core.generateMinBundleId)()}`] },
634
- appModuleName: schemeConfig.appModuleName,
635
- logPrefix: `android-${schemeConfig.hotUpdaterSchemeName}-run`,
636
- tasks: [task],
637
- androidProjectPath
638
- });
639
- const targetDevices = device ? [device] : (await Device.listDevices()).filter((d) => d.connected);
640
- for (const targetDevice of targetDevices) {
641
- await tryInstallAppOnDevice({
642
- apkPath: result.buildArtifactPath,
643
- device: targetDevice
1493
+ };
1494
+ /**
1495
+ * Whether a disabled-service error is about `project` itself, which waiting
1496
+ * fixes: Google names the consumer by number, and an error naming none
1497
+ * counts as the project's own.
1498
+ */
1499
+ const consumerIs = (error, project) => error.consumer === void 0 || error.consumer === `projects/${project.projectId}` || (project.projectNumber === void 0 ? true : error.consumer === `projects/${project.projectNumber}`);
1500
+ const enableServices = async ({ google, projectId, sleep }) => {
1501
+ try {
1502
+ const operation = await google("Enabling the APIs", "POST", `${SERVICE_USAGE_API}/v1/projects/${projectId}/services:batchEnable`, { serviceIds: SERVICES });
1503
+ await waitForOperation({
1504
+ request: google,
1505
+ origin: `${SERVICE_USAGE_API}/v1`,
1506
+ operation,
1507
+ action: "Enabling the APIs",
1508
+ sleep
1509
+ });
1510
+ } catch (error) {
1511
+ if (isDenied(error) && !isServiceDisabled(error)) throw new PlayServiceAccountError("project-access-denied", `This Google Account may not enable APIs in ${projectId}: ${error.apiMessage || "permission denied"}. Pick a project you own, or ask its owner for the Editor role.`, { cause: error });
1512
+ throw error;
1513
+ }
1514
+ };
1515
+ /** The `hot-updater-play` service account's email, created unless it exists. */
1516
+ const ensureServiceAccount = async ({ google, project, sleep }) => {
1517
+ const { projectId } = project;
1518
+ const email = `${PLAY_SERVICE_ACCOUNT_ID}@${projectId}.iam.gserviceaccount.com`;
1519
+ try {
1520
+ return (await whilePropagating({
1521
+ project,
1522
+ sleep,
1523
+ run: () => google("Creating the service account", "POST", `${IAM_API}/projects/${projectId}/serviceAccounts`, {
1524
+ accountId: "hot-updater-play",
1525
+ serviceAccount: {
1526
+ displayName: "Hot Updater Play uploads",
1527
+ description: "Uploads Android App Bundles to Google Play for hot-updater native-build android --submit play-internal."
1528
+ }
1529
+ })
1530
+ })).email ?? email;
1531
+ } catch (error) {
1532
+ if (isConflict(error)) return email;
1533
+ if (isDenied(error) && !isServiceDisabled(error)) throw new PlayServiceAccountError("project-access-denied", `This Google Account may not create service accounts in ${projectId}: ${error.apiMessage || "permission denied"}. Pick a project you own, or ask its owner for the Service Account Admin role.`, { cause: error });
1534
+ throw error;
1535
+ }
1536
+ };
1537
+ /**
1538
+ * Makes `email` a Play Console user that may release `packageName`, keeping
1539
+ * any permission it has: the user and the app's grant are created, or the
1540
+ * grant gains the release permissions.
1541
+ */
1542
+ const grantPlayAccess = async ({ google, developerId, email, packageName }) => {
1543
+ const developer = `${PUBLISHER_API}/developers/${developerId}`;
1544
+ const user = `${developer}/users/${encodeURIComponent(email)}`;
1545
+ try {
1546
+ await google("Adding the Play Console user", "POST", `${developer}/users`, {
1547
+ name: `developers/${developerId}/users/${email}`,
1548
+ email
644
1549
  });
645
- await tryLaunchAppOnDevice({
646
- applicationId: schemeConfig.applicationId,
647
- device: targetDevice,
648
- mainActivity: runOption.mainActivity,
649
- packageName: schemeConfig.packageName,
650
- port: runOption.port
1550
+ } catch (error) {
1551
+ if (isDenied(error)) throw notPlayAdmin(developerId, error);
1552
+ if (!isConflict(error)) throw error;
1553
+ }
1554
+ try {
1555
+ await google("Granting access to the app", "POST", `${user}/grants`, {
1556
+ name: `developers/${developerId}/users/${email}/grants/${packageName}`,
1557
+ packageName,
1558
+ appLevelPermissions: APP_PERMISSIONS
651
1559
  });
1560
+ return;
1561
+ } catch (error) {
1562
+ if (isNotFound(error)) throw appNotFound(packageName, developerId, error);
1563
+ if (isDenied(error)) throw notPlayAdmin(developerId, error);
1564
+ if (!isConflict(error)) throw error;
652
1565
  }
653
- _hot_updater_cli_tools.p.outro("Success 🎉");
654
- return result;
1566
+ const granted = await findGrant({
1567
+ google,
1568
+ developer,
1569
+ email,
1570
+ packageName
1571
+ });
1572
+ const missing = APP_PERMISSIONS.filter((permission) => !granted.includes(permission));
1573
+ if (missing.length === 0) return;
1574
+ await google("Granting access to the app", "PATCH", `${user}/grants/${encodeURIComponent(packageName)}?updateMask=appLevelPermissions`, { appLevelPermissions: [...granted, ...missing] });
655
1575
  };
656
- //#endregion
657
- //#region src/utils/enrichNativeBuildAndroidScheme.ts
658
- const enrichNativeBuildAndroidScheme = async ({ schemeConfig, hotUpdaterSchemeName }) => {
1576
+ /** The app permissions `email` holds on `packageName`, from the user list. */
1577
+ const findGrant = async ({ google, developer, email, packageName }) => {
1578
+ let pageToken;
1579
+ do {
1580
+ const page = await google("Listing the Play Console users", "GET", `${developer}/users?${new URLSearchParams({
1581
+ pageSize: "100",
1582
+ ...pageToken ? { pageToken } : {}
1583
+ })}`);
1584
+ const found = page.users?.find((candidate) => candidate.email === email);
1585
+ if (found) return found.grants?.find((grant) => grant.packageName === packageName)?.appLevelPermissions ?? [];
1586
+ pageToken = page.nextPageToken || void 0;
1587
+ } while (pageToken);
1588
+ return [];
1589
+ };
1590
+ /** A new JSON key of the service account, checked and masked in the CLI's output. */
1591
+ const createKey = async ({ google, project, email, sleep }) => {
1592
+ const { projectId } = project;
1593
+ let key;
1594
+ try {
1595
+ key = await whilePropagating({
1596
+ project,
1597
+ sleep,
1598
+ notFound: true,
1599
+ run: () => google("Creating the key", "POST", `${IAM_API}/projects/${projectId}/serviceAccounts/${email}/keys`, {
1600
+ privateKeyType: "TYPE_GOOGLE_CREDENTIALS_FILE",
1601
+ keyAlgorithm: "KEY_ALG_RSA_2048"
1602
+ })
1603
+ });
1604
+ } catch (error) {
1605
+ if (error instanceof GoogleApiError && (error.violations.includes(KEY_CREATION_CONSTRAINT) || /disableServiceAccountKeyCreation/u.test(error.apiMessage))) throw new PlayServiceAccountError("key-creation-disabled", `An organization policy (iam.disableServiceAccountKeyCreation) forbids service account keys in ${projectId}; organizations created since 2024 enforce it by default. Ask an organization policy admin to allow keys for this project, or run this again with a project outside the organization. The service account ${email} and its Play Console access remain for the next run.`, { cause: error });
1606
+ throw error;
1607
+ }
1608
+ if (!key.name || !key.privateKeyData) throw new Error("Google Cloud created the key without its JSON file.");
1609
+ (0, _hot_updater_cli_tools.registerSecretValue)(key.privateKeyData);
1610
+ const json = Buffer.from(key.privateKeyData, "base64").toString("utf-8");
1611
+ (0, _hot_updater_cli_tools.registerSecretValue)(json);
1612
+ let parsed;
1613
+ try {
1614
+ parsed = JSON.parse(json);
1615
+ } catch {
1616
+ parsed = {};
1617
+ }
1618
+ if (typeof parsed.private_key !== "string") {
1619
+ await google("Deleting the key", "DELETE", `${IAM_API}/${key.name}`).catch(() => {});
1620
+ throw new Error("Google Cloud created a key that is no JSON key file.");
1621
+ }
1622
+ (0, _hot_updater_cli_tools.registerSecretValue)(parsed.private_key);
659
1623
  return {
660
- aab: true,
661
- variant: "Release",
662
- appModuleName: "app",
663
- applicationId: schemeConfig.packageName,
664
- ...schemeConfig,
665
- hotUpdaterSchemeName
1624
+ json,
1625
+ name: key.name,
1626
+ keyId: key.name.split("/").pop()
666
1627
  };
667
1628
  };
668
1629
  //#endregion
1630
+ //#region src/playServiceAccount.ts
1631
+ /**
1632
+ * Hot Updater's Google OAuth client, a "Desktop app" client the sign-in
1633
+ * opens Google's consent screen for. Empty in a build that has none, where
1634
+ * `HOT_UPDATER_GOOGLE_OAUTH_CLIENT_ID` and `_SECRET` name one.
1635
+ */
1636
+ const HOT_UPDATER_GOOGLE_OAUTH_CLIENT = {
1637
+ clientId: "",
1638
+ clientSecret: ""
1639
+ };
1640
+ /**
1641
+ * Hot Updater's issuer, the one place that picks how it is done: Google's
1642
+ * OAuth for installed apps, then the Cloud Resource Manager, Service Usage,
1643
+ * IAM, and Play Developer APIs. Another implementation of
1644
+ * `IssuePlayServiceAccount` replaces it here without touching its callers.
1645
+ */
1646
+ const issuePlayServiceAccount = (request) => createGoogleKeyIssuer()({
1647
+ ...request,
1648
+ oauthClient: request.oauthClient ?? HOT_UPDATER_GOOGLE_OAUTH_CLIENT
1649
+ });
1650
+ //#endregion
1651
+ exports.GOOGLE_OAUTH_CLIENT_ENV_NAMES = GOOGLE_OAUTH_CLIENT_ENV_NAMES;
1652
+ exports.HOT_UPDATER_GOOGLE_OAUTH_CLIENT = HOT_UPDATER_GOOGLE_OAUTH_CLIENT;
1653
+ exports.PLAY_CONSOLE_URL = PLAY_CONSOLE_URL;
1654
+ exports.PLAY_SERVICE_ACCOUNT_ID = PLAY_SERVICE_ACCOUNT_ID;
1655
+ exports.PlayServiceAccountError = PlayServiceAccountError;
1656
+ exports.PlayVersionCodeUsedError = PlayVersionCodeUsedError;
1657
+ exports.assertPlaySigned = assertPlaySigned;
669
1658
  exports.buildAndroid = buildAndroid;
670
1659
  exports.enrichNativeBuildAndroidScheme = enrichNativeBuildAndroidScheme;
671
- exports.runAndroid = runAndroid;
1660
+ exports.extractBuiltInBundle = extractBuiltInBundle;
1661
+ exports.findPlayInternalRelease = findPlayInternalRelease;
1662
+ exports.issuePlayServiceAccount = issuePlayServiceAccount;
1663
+ exports.readBuiltApp = readBuiltApp;
1664
+ exports.readBuiltPackageName = readBuiltPackageName;
1665
+ exports.readBuiltVersionCode = readBuiltVersionCode;
1666
+ exports.resolvePlayUploadServiceAccount = resolvePlayUploadServiceAccount;
1667
+ exports.uploadToPlay = uploadToPlay;