@yume-chan/android-bin 0.0.22 → 0.0.24

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/src/dumpsys.ts CHANGED
@@ -1,5 +1,36 @@
1
1
  import { AdbCommandBase } from "@yume-chan/adb";
2
2
 
3
+ const BatteryDumpFields: Record<
4
+ string,
5
+ {
6
+ type: "number" | "boolean" | "string";
7
+ field: keyof DumpSys.Battery.Info;
8
+ }
9
+ > = {
10
+ "AC powered": { type: "boolean", field: "acPowered" },
11
+ "USB powered": { type: "boolean", field: "usbPowered" },
12
+ "Wireless powered": { type: "boolean", field: "wirelessPowered" },
13
+ "Dock powered": { type: "boolean", field: "dockPowered" },
14
+ "Max charging current": {
15
+ type: "number",
16
+ field: "maxChargingCurrent",
17
+ },
18
+ "Max charging voltage": {
19
+ type: "number",
20
+ field: "maxChargingVoltage",
21
+ },
22
+ "Charge counter": { type: "number", field: "chargeCounter" },
23
+ status: { type: "number", field: "status" },
24
+ health: { type: "number", field: "health" },
25
+ present: { type: "boolean", field: "present" },
26
+ level: { type: "number", field: "level" },
27
+ scale: { type: "number", field: "scale" },
28
+ voltage: { type: "number", field: "voltage" },
29
+ temperature: { type: "number", field: "temperature" },
30
+ technology: { type: "string", field: "technology" },
31
+ current: { type: "number", field: "current" },
32
+ };
33
+
3
34
  export class DumpSys extends AdbCommandBase {
4
35
  async diskStats() {
5
36
  const output = await this.adb.subprocess.spawnAndWaitLegacy([
@@ -40,74 +71,67 @@ export class DumpSys extends AdbCommandBase {
40
71
  "battery",
41
72
  ]);
42
73
 
43
- let acPowered = false;
44
- let usbPowered = false;
45
- let wirelessPowered = false;
46
- let level: number | undefined;
47
- let scale: number | undefined;
48
- let voltage: number | undefined;
49
- let current: number | undefined;
50
- let status: DumpSys.Battery.Status | undefined;
51
- let health: DumpSys.Battery.Health | undefined;
74
+ const info: DumpSys.Battery.Info = {
75
+ acPowered: false,
76
+ usbPowered: false,
77
+ wirelessPowered: false,
78
+ dockPowered: false,
79
+ status: DumpSys.Battery.Status.Unknown,
80
+ health: DumpSys.Battery.Health.Unknown,
81
+ };
82
+
52
83
  for (const line of output.split("\n")) {
53
- const parts = line.split(":");
84
+ const parts = line.split(":").map((part) => part.trim());
54
85
  if (parts.length !== 2) {
55
86
  continue;
56
87
  }
57
88
 
58
- switch (parts[0]!.trim()) {
59
- case "AC powered":
60
- acPowered = parts[1]!.trim() === "true";
61
- break;
62
- case "USB powered":
63
- usbPowered = parts[1]!.trim() === "true";
64
- break;
65
- case "Wireless powered":
66
- wirelessPowered = parts[1]!.trim() === "true";
67
- break;
68
- case "level":
69
- level = Number.parseInt(parts[1]!.trim(), 10);
70
- break;
71
- case "scale":
72
- scale = Number.parseInt(parts[1]!.trim(), 10);
73
- break;
74
- case "voltage":
75
- voltage = Number.parseInt(parts[1]!.trim(), 10);
76
- break;
77
- case "current now":
78
- current = Number.parseInt(parts[1]!.trim(), 10);
89
+ const field = BatteryDumpFields[parts[0]!];
90
+ if (!field) {
91
+ continue;
92
+ }
93
+
94
+ switch (field.type) {
95
+ case "boolean":
96
+ info[field.field] = (parts[1]!.trim() === "true") as never;
79
97
  break;
80
- case "status":
81
- status = Number.parseInt(
98
+ case "number":
99
+ info[field.field] = Number.parseInt(
82
100
  parts[1]!.trim(),
83
101
  10,
84
- ) as DumpSys.Battery.Status;
102
+ ) as never;
85
103
  break;
86
- case "health":
87
- health = Number.parseInt(
88
- parts[1]!.trim(),
89
- 10,
90
- ) as DumpSys.Battery.Health;
104
+ case "string":
105
+ info[field.field] = parts[1]! as never;
91
106
  break;
92
107
  }
93
108
  }
94
109
 
95
- return {
96
- acPowered,
97
- usbPowered,
98
- wirelessPowered,
99
- level,
100
- scale,
101
- voltage,
102
- current,
103
- status,
104
- health,
105
- };
110
+ return info;
106
111
  }
107
112
  }
108
113
 
109
114
  export namespace DumpSys {
110
115
  export namespace Battery {
116
+ export interface Info {
117
+ acPowered: boolean;
118
+ usbPowered: boolean;
119
+ wirelessPowered: boolean;
120
+ dockPowered: boolean;
121
+ maxChargingCurrent?: number;
122
+ maxChargingVoltage?: number;
123
+ chargeCounter?: number;
124
+ status: Status;
125
+ health: Health;
126
+ present?: boolean;
127
+ level?: number;
128
+ scale?: number;
129
+ voltage?: number;
130
+ temperature?: number;
131
+ technology?: string;
132
+ current?: number;
133
+ }
134
+
111
135
  export enum Status {
112
136
  Unknown = 1,
113
137
  Charging,
package/src/logcat.ts CHANGED
@@ -5,8 +5,8 @@ import { AdbCommandBase, AdbSubprocessNoneProtocol } from "@yume-chan/adb";
5
5
  import type { ReadableStream } from "@yume-chan/stream-extra";
6
6
  import {
7
7
  BufferedTransformStream,
8
- DecodeUtf8Stream,
9
8
  SplitStringStream,
9
+ TextDecoderStream,
10
10
  WrapReadableStream,
11
11
  WritableStream,
12
12
  } from "@yume-chan/stream-extra";
@@ -353,6 +353,7 @@ export async function deserializeAndroidLogEntry(
353
353
  ): Promise<AndroidLogEntry> {
354
354
  const entry = (await LoggerEntry.deserialize(stream)) as AndroidLogEntry;
355
355
  if (entry.headerSize !== LoggerEntry.size) {
356
+ // Skip unknown fields
356
357
  await stream.readExactly(entry.headerSize - LoggerEntry.size);
357
358
  }
358
359
 
@@ -384,7 +385,7 @@ export interface LogSize {
384
385
 
385
386
  export class Logcat extends AdbCommandBase {
386
387
  static logIdToName(id: LogId): string {
387
- return LogId[id]!;
388
+ return LogId[id];
388
389
  }
389
390
 
390
391
  static logNameToId(name: string): LogId {
@@ -420,7 +421,7 @@ export class Logcat extends AdbCommandBase {
420
421
 
421
422
  const result: LogSize[] = [];
422
423
  await stdout
423
- .pipeThrough(new DecodeUtf8Stream())
424
+ .pipeThrough(new TextDecoderStream())
424
425
  .pipeThrough(new SplitStringStream("\n"))
425
426
  .pipeTo(
426
427
  new WritableStream({
package/src/pm.ts CHANGED
@@ -4,16 +4,12 @@
4
4
  // cspell:ignore versioncode
5
5
 
6
6
  import type { Adb } from "@yume-chan/adb";
7
- import {
8
- AdbCommandBase,
9
- AdbSubprocessNoneProtocol,
10
- escapeArg,
11
- } from "@yume-chan/adb";
12
- import type { Consumable, ReadableStream } from "@yume-chan/stream-extra";
7
+ import { AdbCommandBase, escapeArg } from "@yume-chan/adb";
8
+ import type { MaybeConsumable, ReadableStream } from "@yume-chan/stream-extra";
13
9
  import {
14
10
  ConcatStringStream,
15
- DecodeUtf8Stream,
16
11
  SplitStringStream,
12
+ TextDecoderStream,
17
13
  } from "@yume-chan/stream-extra";
18
14
 
19
15
  import { Cmd } from "./cmd.js";
@@ -230,6 +226,11 @@ export interface PackageManagerUninstallOptions {
230
226
  keepData: boolean;
231
227
  user: SingleUserOrAll;
232
228
  versionCode: number;
229
+ /**
230
+ * Only remove the specified splits, not the entire app
231
+ *
232
+ * On Android 10 and lower, only one split name can be specified.
233
+ */
233
234
  splitNames: string[];
234
235
  }
235
236
 
@@ -263,27 +264,48 @@ export class PackageManager extends AdbCommandBase {
263
264
  }
264
265
 
265
266
  #buildInstallArguments(
267
+ command: string,
266
268
  options: Partial<PackageManagerInstallOptions> | undefined,
267
269
  ): string[] {
268
- return buildArguments(
269
- ["pm", "install"],
270
+ const args = buildArguments(
271
+ ["pm", command],
270
272
  options,
271
273
  PACKAGE_MANAGER_INSTALL_OPTIONS_MAP,
272
274
  );
275
+ if (!options?.skipExisting) {
276
+ /*
277
+ * | behavior | previous version | modern version |
278
+ * | -------------------- | -------------------- | -------------------- |
279
+ * | replace existing app | requires `-r` | default behavior [1] |
280
+ * | skip existing app | default behavior [2] | requires `-R` |
281
+ *
282
+ * [1]: `-r` recognized but ignored)
283
+ * [2]: `-R` not recognized but ignored
284
+ *
285
+ * So add `-r` when `skipExisting` is `false` for compatibility.
286
+ */
287
+ args.push("-r");
288
+ }
289
+ return args;
273
290
  }
274
291
 
292
+ /**
293
+ * Install the apk file.
294
+ *
295
+ * @param apks Path to the apk file. It must exist on the device. On Android 10 and lower, only one apk can be specified.
296
+ */
275
297
  async install(
276
298
  apks: string[],
277
299
  options?: Partial<PackageManagerInstallOptions>,
278
300
  ): Promise<string> {
279
- const args = this.#buildInstallArguments(options);
301
+ const args = this.#buildInstallArguments("install", options);
280
302
  // WIP: old version of pm doesn't support multiple apks
281
303
  args.push(...apks);
282
304
  return await this.adb.subprocess.spawnAndWaitLegacy(args);
283
305
  }
284
306
 
285
307
  async pushAndInstallStream(
286
- stream: ReadableStream<Consumable<Uint8Array>>,
308
+ stream: ReadableStream<MaybeConsumable<Uint8Array>>,
287
309
  options?: Partial<PackageManagerInstallOptions>,
288
310
  ): Promise<void> {
289
311
  const sync = await this.adb.sync();
@@ -300,49 +322,55 @@ export class PackageManager extends AdbCommandBase {
300
322
  await sync.dispose();
301
323
  }
302
324
 
303
- // Starting from Android 7, `pm` is only a wrapper for `cmd package`,
304
- // and `cmd package` launches faster than `pm`.
305
- // But `cmd package` can't read `/data/local/tmp` folder due to SELinux policy,
306
- // so installing a file must use `pm`.
307
- const args = this.#buildInstallArguments(options);
325
+ // Starting from Android 7, `pm` becomes a wrapper to `cmd package`.
326
+ // The benefit of `cmd package` is it starts faster than the old `pm`,
327
+ // because it connects to the already running `system` process,
328
+ // instead of initializing all system components from scratch.
329
+ //
330
+ // But launching `cmd package` directly causes it to not be able to
331
+ // read files in `/data/local/tmp` (and many other places) due to SELinux policies,
332
+ // so installing files must still use `pm`.
333
+ // (the starting executable file decides which SELinux policies to apply)
334
+ const args = this.#buildInstallArguments("install", options);
308
335
  args.push(filePath);
309
- const process = await this.adb.subprocess.spawn(args.map(escapeArg), {
310
- protocols: [AdbSubprocessNoneProtocol],
311
- });
312
-
313
- const output = await process.stdout
314
- .pipeThrough(new DecodeUtf8Stream())
315
- .pipeThrough(new ConcatStringStream())
316
- .then((output) => output.trim());
317
336
 
318
- await this.adb.rm(filePath);
337
+ try {
338
+ const output = await this.adb.subprocess
339
+ .spawnAndWaitLegacy(args.map(escapeArg))
340
+ .then((output) => output.trim());
319
341
 
320
- if (output !== "Success") {
321
- throw new Error(output);
342
+ if (output !== "Success") {
343
+ throw new Error(output);
344
+ }
345
+ } finally {
346
+ await this.adb.rm(filePath);
322
347
  }
323
348
  }
324
349
 
325
350
  async installStream(
326
351
  size: number,
327
- stream: ReadableStream<Consumable<Uint8Array>>,
352
+ stream: ReadableStream<MaybeConsumable<Uint8Array>>,
328
353
  options?: Partial<PackageManagerInstallOptions>,
329
354
  ): Promise<void> {
330
355
  // Android 7 added both `cmd` command and streaming install support,
331
- // we can't detect whether `pm` supports streaming install,
332
- // so we detect `cmd` command support instead.
356
+ // It's hard to detect whether `pm` supports streaming install (unless actually trying),
357
+ // so check for whether `cmd` is supported,
358
+ // and assume `pm` streaming install support status is same as that.
333
359
  if (!this.#cmd.supportsCmd) {
360
+ // Fall back to push file then install
334
361
  await this.pushAndInstallStream(stream, options);
335
362
  return;
336
363
  }
337
364
 
338
- const args = this.#buildInstallArguments(options);
339
- // Remove `pm` from args, final command will starts with `cmd package install`
365
+ const args = this.#buildInstallArguments("install", options);
366
+ // Remove `pm` from args, `Cmd#spawn` will prepend `cmd <command>` so the final args
367
+ // will be `cmd package install <args>`
340
368
  args.shift();
341
369
  args.push("-S", size.toString());
342
370
  const process = await this.#cmd.spawn(false, "package", ...args);
343
371
 
344
372
  const output = process.stdout
345
- .pipeThrough(new DecodeUtf8Stream())
373
+ .pipeThrough(new TextDecoderStream())
346
374
  .pipeThrough(new ConcatStringStream())
347
375
  .then((output) => output.trim());
348
376
 
@@ -356,8 +384,6 @@ export class PackageManager extends AdbCommandBase {
356
384
  ]);
357
385
  }
358
386
 
359
- // TODO: install: support split apk formats (`adb install-multiple`)
360
-
361
387
  static parsePackageListItem(
362
388
  line: string,
363
389
  ): PackageManagerListPackagesResult {
@@ -369,7 +395,7 @@ export class PackageManager extends AdbCommandBase {
369
395
  let installer: string | undefined;
370
396
  let uid: number | undefined;
371
397
 
372
- // Parse backwards
398
+ // The output format is easier to parse in backwards
373
399
  let index = line.indexOf(" uid:");
374
400
  if (index !== -1) {
375
401
  uid = Number.parseInt(line.substring(index + " uid:".length), 10);
@@ -391,7 +417,9 @@ export class PackageManager extends AdbCommandBase {
391
417
  line = line.substring(0, index);
392
418
  }
393
419
 
394
- // `sourceDir` may contain `=` so use `lastIndexOf`
420
+ // `sourceDir` may contain `=` characters
421
+ // (because in newer Android versions it's a base64 string of encrypted package name),
422
+ // so use `lastIndexOf`
395
423
  index = line.lastIndexOf("=");
396
424
  if (index !== -1) {
397
425
  sourceDir = line.substring(0, index);
@@ -432,7 +460,10 @@ export class PackageManager extends AdbCommandBase {
432
460
 
433
461
  const process = await this.#cmdOrSubprocess(args);
434
462
  const reader = process.stdout
435
- .pipeThrough(new DecodeUtf8Stream())
463
+ .pipeThrough(new TextDecoderStream())
464
+ // FIXME: `SplitStringStream` will throw away some data
465
+ // if it doesn't end with a separator. So each chunk of data
466
+ // must contain several complete lines.
436
467
  .pipeThrough(new SplitStringStream("\n"))
437
468
  .getReader();
438
469
  while (true) {
@@ -460,7 +491,7 @@ export class PackageManager extends AdbCommandBase {
460
491
 
461
492
  const process = await this.#cmdOrSubprocess(args);
462
493
  const output = await process.stdout
463
- .pipeThrough(new DecodeUtf8Stream())
494
+ .pipeThrough(new TextDecoderStream())
464
495
  .pipeThrough(new ConcatStringStream())
465
496
  .then((output) => output.trim());
466
497
  if (output !== "Success") {
@@ -481,7 +512,7 @@ export class PackageManager extends AdbCommandBase {
481
512
 
482
513
  const process = await this.#cmdOrSubprocess(args);
483
514
  const output = await process.stdout
484
- .pipeThrough(new DecodeUtf8Stream())
515
+ .pipeThrough(new TextDecoderStream())
485
516
  .pipeThrough(new ConcatStringStream())
486
517
  .then((output) => output.trim());
487
518
 
@@ -491,4 +522,147 @@ export class PackageManager extends AdbCommandBase {
491
522
 
492
523
  return output;
493
524
  }
525
+
526
+ /**
527
+ * Creates a new install session.
528
+ *
529
+ * Install sessions are used to install apps with multiple splits, but it can also be used to install a single apk.
530
+ *
531
+ * Install sessions was added in Android 5.0 (API level 21).
532
+ *
533
+ * @param options Options for the install session
534
+ * @returns ID of the new install session
535
+ */
536
+ async sessionCreate(options?: Partial<PackageManagerInstallOptions>) {
537
+ const args = this.#buildInstallArguments("install-create", options);
538
+
539
+ const process = await this.#cmdOrSubprocess(args);
540
+ const output = await process.stdout
541
+ .pipeThrough(new TextDecoderStream())
542
+ .pipeThrough(new ConcatStringStream())
543
+ .then((output) => output.trim());
544
+
545
+ const sessionIdString = output.match(/.*\[(\d+)\].*/);
546
+ if (!sessionIdString) {
547
+ throw new Error("Failed to create install session");
548
+ }
549
+
550
+ return Number.parseInt(sessionIdString[1]!, 10);
551
+ }
552
+
553
+ async sessionAddSplit(sessionId: number, splitName: string, path: string) {
554
+ const args: string[] = [
555
+ "pm",
556
+ "install-write",
557
+ sessionId.toString(),
558
+ splitName,
559
+ path,
560
+ ];
561
+
562
+ const output = await this.adb.subprocess
563
+ .spawnAndWaitLegacy(args)
564
+ .then((output) => output.trim());
565
+ if (!output.startsWith("Success")) {
566
+ throw new Error(output);
567
+ }
568
+ }
569
+
570
+ async sessionAddSplitStream(
571
+ sessionId: number,
572
+ splitName: string,
573
+ size: number,
574
+ stream: ReadableStream<MaybeConsumable<Uint8Array>>,
575
+ ) {
576
+ const args: string[] = [
577
+ "pm",
578
+ "install-write",
579
+ "-S",
580
+ size.toString(),
581
+ sessionId.toString(),
582
+ splitName,
583
+ "-",
584
+ ];
585
+
586
+ const process = await this.#cmdOrSubprocess(args);
587
+ const output = process.stdout
588
+ .pipeThrough(new TextDecoderStream())
589
+ .pipeThrough(new ConcatStringStream())
590
+ .then((output) => output.trim());
591
+
592
+ await Promise.all([
593
+ stream.pipeTo(process.stdin),
594
+ output.then((output) => {
595
+ if (!output.startsWith("Success")) {
596
+ throw new Error(output);
597
+ }
598
+ }),
599
+ ]);
600
+ }
601
+
602
+ async sessionCommit(sessionId: number) {
603
+ const args: string[] = ["pm", "install-commit", sessionId.toString()];
604
+ const output = await this.adb.subprocess
605
+ .spawnAndWaitLegacy(args)
606
+ .then((output) => output.trim());
607
+ if (output !== "Success") {
608
+ throw new Error(output);
609
+ }
610
+ }
611
+
612
+ async sessionAbandon(sessionId: number) {
613
+ const args: string[] = ["pm", "install-abandon", sessionId.toString()];
614
+ const output = await this.adb.subprocess
615
+ .spawnAndWaitLegacy(args)
616
+ .then((output) => output.trim());
617
+ if (output !== "Success") {
618
+ throw new Error(output);
619
+ }
620
+ }
621
+ }
622
+
623
+ export class PackageManagerInstallSession {
624
+ static async create(
625
+ packageManager: PackageManager,
626
+ options?: Partial<PackageManagerInstallOptions>,
627
+ ) {
628
+ const id = await packageManager.sessionCreate(options);
629
+ return new PackageManagerInstallSession(packageManager, id);
630
+ }
631
+
632
+ #packageManager: PackageManager;
633
+
634
+ #id: number;
635
+ get id() {
636
+ return this.#id;
637
+ }
638
+
639
+ constructor(packageManager: PackageManager, id: number) {
640
+ this.#packageManager = packageManager;
641
+ this.#id = id;
642
+ }
643
+
644
+ addSplit(splitName: string, path: string) {
645
+ return this.#packageManager.sessionAddSplit(this.#id, splitName, path);
646
+ }
647
+
648
+ addSplitStream(
649
+ splitName: string,
650
+ size: number,
651
+ stream: ReadableStream<MaybeConsumable<Uint8Array>>,
652
+ ) {
653
+ return this.#packageManager.sessionAddSplitStream(
654
+ this.#id,
655
+ splitName,
656
+ size,
657
+ stream,
658
+ );
659
+ }
660
+
661
+ commit() {
662
+ return this.#packageManager.sessionCommit(this.#id);
663
+ }
664
+
665
+ abandon() {
666
+ return this.#packageManager.sessionAbandon(this.#id);
667
+ }
494
668
  }
@@ -1,4 +1,3 @@
1
- /* eslint-disable @typescript-eslint/no-unsafe-argument */
2
1
  export class ParseError extends Error {
3
2
  #expected: string[];
4
3