From 2abec924e88cca2fd39a9644ff301f17555d535b Mon Sep 17 00:00:00 2001 From: Simon Chan <1330321+yume-chan@users.noreply.github.com> Date: Fri, 7 Jul 2023 18:30:01 +0800 Subject: [PATCH] feat(bin): add wrapper for bu fixes #354 --- libraries/android-bin/.eslintrc.cjs | 10 +- libraries/android-bin/jest.config.js | 14 ++ libraries/android-bin/package.json | 13 +- libraries/android-bin/src/bu.ts | 78 +++++-- libraries/android-bin/src/dumpsys.ts | 57 ++++- libraries/android-bin/src/index.ts | 2 + .../android-bin/src/overlay-display.spec.ts | 118 ++++++++++ libraries/android-bin/src/overlay-display.ts | 150 ++++++------ .../android-bin/src/string-format.spec.ts | 25 ++ libraries/android-bin/src/string-format.ts | 213 ++++++++++++++++++ libraries/android-bin/tsconfig.json | 3 + libraries/android-bin/tsconfig.test.json | 7 + 12 files changed, 587 insertions(+), 103 deletions(-) create mode 100644 libraries/android-bin/jest.config.js create mode 100644 libraries/android-bin/src/overlay-display.spec.ts create mode 100644 libraries/android-bin/src/string-format.spec.ts create mode 100644 libraries/android-bin/src/string-format.ts create mode 100644 libraries/android-bin/tsconfig.test.json diff --git a/libraries/android-bin/.eslintrc.cjs b/libraries/android-bin/.eslintrc.cjs index 40d283a4..3a906170 100644 --- a/libraries/android-bin/.eslintrc.cjs +++ b/libraries/android-bin/.eslintrc.cjs @@ -1,11 +1,7 @@ module.exports = { - "extends": [ - "@yume-chan" - ], + extends: ["@yume-chan"], parserOptions: { tsconfigRootDir: __dirname, - project: [ - "./tsconfig.build.json" - ], + project: ["./tsconfig.test.json"], }, -} +}; diff --git a/libraries/android-bin/jest.config.js b/libraries/android-bin/jest.config.js new file mode 100644 index 00000000..ff68d1cb --- /dev/null +++ b/libraries/android-bin/jest.config.js @@ -0,0 +1,14 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +export default { + preset: "ts-jest/presets/default-esm", + extensionsToTreatAsEsm: [".ts"], + transform: { + "^.+\\.tsx?$": [ + "ts-jest", + { tsconfig: "tsconfig.test.json", useESM: true }, + ], + }, + moduleNameMapper: { + "^(\\.{1,2}/.*)\\.js$": "$1", + }, +}; diff --git a/libraries/android-bin/package.json b/libraries/android-bin/package.json index dd1fa9bf..ed8c8434 100644 --- a/libraries/android-bin/package.json +++ b/libraries/android-bin/package.json @@ -27,6 +27,7 @@ "scripts": { "build": "tsc -b tsconfig.build.json", "build:watch": "tsc -b tsconfig.build.json", + "test": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --coverage", "lint": "eslint src/**/*.ts --fix && prettier src/**/*.ts --write --tab-width 4", "prepublishOnly": "npm run build" }, @@ -34,13 +35,17 @@ "@yume-chan/adb": "workspace:^0.0.20", "@yume-chan/stream-extra": "workspace:^0.0.20", "@yume-chan/struct": "workspace:^0.0.20", - "tslib": "^2.5.2" + "tslib": "^2.6.0" }, "devDependencies": { + "@jest/globals": "^29.6.1", "@yume-chan/eslint-config": "workspace:^1.0.0", "@yume-chan/tsconfig": "workspace:^1.0.0", - "eslint": "^8.41.0", - "prettier": "^2.8.8", - "typescript": "^5.0.3" + "cross-env": "^7.0.3", + "eslint": "^8.44.0", + "jest": "^29.5.0", + "prettier": "^3.0.0", + "ts-jest": "^29.1.1", + "typescript": "^5.1.6" } } diff --git a/libraries/android-bin/src/bu.ts b/libraries/android-bin/src/bu.ts index 4d0e07b0..0671ea6b 100644 --- a/libraries/android-bin/src/bu.ts +++ b/libraries/android-bin/src/bu.ts @@ -1,26 +1,74 @@ -// cspell: ignore apks -// cspell: ignore obbs - import { AdbCommandBase } from "@yume-chan/adb"; +import type { Consumable, ReadableStream } from "@yume-chan/stream-extra"; export interface AdbBackupOptions { - apps: string[] | "all" | "all-including-system"; - apks: boolean; - obbs: boolean; - shared: boolean; - widgets: boolean; - compress: boolean; user: number; + saveSharedStorage?: boolean; + saveWidgets?: boolean; + packages: string[] | "user" | "all"; + savePackageApk: boolean; + savePackageObb: boolean; + savePackageKeyValue: boolean; + compress: boolean; +} + +export interface AdbRestoreOptions { + user: number; + file: ReadableStream>; } export class AdbBackup extends AdbCommandBase { - backup(options: AdbBackupOptions): Promise { - void options; - throw new Error("Not implemented"); + /** + * User must confirm backup on device within 60 seconds. + */ + public async backup( + options: AdbBackupOptions + ): Promise> { + const args = ["bu", "backup"]; + + if (options.user !== undefined) { + args.push("--user", options.user.toString()); + } + + args.push(options.saveSharedStorage ? "--shared" : "--no-shared"); + args.push(options.saveWidgets ? "--widgets" : "--no-widgets"); + + args.push(options.savePackageApk ? "--apk" : "--no-apk"); + args.push(options.savePackageObb ? "--obb" : "--no-obb"); + args.push( + options.savePackageKeyValue ? "--key-value" : "--no-key-value" + ); + + args.push(options.compress ? "--compress" : "--no-compress"); + + if (typeof options.packages === "string") { + switch (options.packages) { + case "user": + args.push("--all", "--no-system"); + break; + case "all": + args.push("--all", "--system"); + break; + } + } else { + args.push(...options.packages); + } + + const process = await this.adb.subprocess.spawn(args); + return process.stdout; } - restore(options: AdbBackupOptions): Promise { - void options; - throw new Error("Not implemented"); + /** + * User must enter the password (if any) and + * confirm restore on device within 60 seconds. + */ + public async restore(options: AdbRestoreOptions): Promise { + const args = ["bu", "restore"]; + if (options.user !== undefined) { + args.push("--user", options.user.toString()); + } + const process = await this.adb.subprocess.spawn(args); + await options.file.pipeTo(process.stdin); + await process.exit; } } diff --git a/libraries/android-bin/src/dumpsys.ts b/libraries/android-bin/src/dumpsys.ts index 1ced1fb1..6b6a3dc6 100644 --- a/libraries/android-bin/src/dumpsys.ts +++ b/libraries/android-bin/src/dumpsys.ts @@ -1,7 +1,7 @@ import { AdbCommandBase } from "@yume-chan/adb"; export class DumpSys extends AdbCommandBase { - async diskStats() { + public async diskStats() { const output = await this.adb.subprocess.spawnAndWaitLegacy([ "dumpsys", "diskstats", @@ -33,4 +33,59 @@ export class DumpSys extends AdbCommandBase { systemTotal, }; } + + public async battery() { + const output = await this.adb.subprocess.spawnAndWaitLegacy([ + "dumpsys", + "battery", + ]); + + let acPowered = false; + let usbPowered = false; + let wirelessPowered = false; + let level: number | undefined; + let scale: number | undefined; + let voltage: number | undefined; + let current: number | undefined; + for (const line of output) { + const parts = line.split(":"); + if (parts.length !== 2) { + continue; + } + + switch (parts[0]!.trim()) { + case "AC powered": + acPowered = parts[1]!.trim() === "true"; + break; + case "USB powered": + usbPowered = parts[1]!.trim() === "true"; + break; + case "Wireless powered": + wirelessPowered = parts[1]!.trim() === "true"; + break; + case "level": + level = Number.parseInt(parts[1]!.trim(), 10); + break; + case "scale": + scale = Number.parseInt(parts[1]!.trim(), 10); + break; + case "voltage": + voltage = Number.parseInt(parts[1]!.trim(), 10); + break; + case "current now": + current = Number.parseInt(parts[1]!.trim(), 10); + break; + } + } + + return { + acPowered, + usbPowered, + wirelessPowered, + level, + scale, + voltage, + current, + }; + } } diff --git a/libraries/android-bin/src/index.ts b/libraries/android-bin/src/index.ts index 8087ef58..43917cc7 100644 --- a/libraries/android-bin/src/index.ts +++ b/libraries/android-bin/src/index.ts @@ -1,8 +1,10 @@ // cspell: ignore logcat +export * from "./bu.js"; export * from "./bug-report.js"; export * from "./cmd.js"; export * from "./demo-mode.js"; +export * from "./dumpsys.js"; export * from "./logcat.js"; export * from "./overlay-display.js"; export * from "./pm.js"; diff --git a/libraries/android-bin/src/overlay-display.spec.ts b/libraries/android-bin/src/overlay-display.spec.ts new file mode 100644 index 00000000..4b44f09f --- /dev/null +++ b/libraries/android-bin/src/overlay-display.spec.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "@jest/globals"; + +import { OverlayDisplay } from "./overlay-display.js"; + +describe("OverlayDisplay", () => { + describe("OverlayDisplayDevicesFormat", () => { + // values are from https://cs.android.com/android/platform/superproject/+/master:frameworks/base/packages/SettingsLib/res/values/arrays.xml;l=468;drc=60c1d392225bc6e1601693c7d5cfdf1d7f510015 + + it("should parse 0 device", () => { + expect( + OverlayDisplay.OverlayDisplayDevicesFormat.parse({ + value: "", + position: 0, + }), + ).toEqual([]); + }); + + it("should parse 1 mode", () => { + expect( + OverlayDisplay.OverlayDisplayDevicesFormat.parse({ + value: "720x480/142", + position: 0, + }), + ).toEqual([ + { + flags: [], + modes: [ + { + density: 142, + height: 480, + width: 720, + }, + ], + }, + ]); + }); + + it("should parse 2 modes", () => { + expect( + OverlayDisplay.OverlayDisplayDevicesFormat.parse({ + value: "1920x1080/320|3840x2160/640", + position: 0, + }), + ).toEqual([ + { + flags: [], + modes: [ + { + density: 320, + height: 1080, + width: 1920, + }, + { + density: 640, + height: 2160, + width: 3840, + }, + ], + }, + ]); + }); + + it("should parse 2 device", () => { + expect( + OverlayDisplay.OverlayDisplayDevicesFormat.parse({ + value: "1280x720/213;1920x1080/320", + position: 0, + }), + ).toEqual([ + { + flags: [], + modes: [ + { + density: 213, + height: 720, + width: 1280, + }, + ], + }, + { + flags: [], + modes: [ + { + density: 320, + height: 1080, + width: 1920, + }, + ], + }, + ]); + }); + + it("should parse flags", () => { + expect( + OverlayDisplay.OverlayDisplayDevicesFormat.parse({ + value: "1920x1080/320|3840x2160/640,secure", + position: 0, + }), + ).toEqual([ + { + flags: ["secure"], + modes: [ + { + density: 320, + height: 1080, + width: 1920, + }, + { + density: 640, + height: 2160, + width: 3840, + }, + ], + }, + ]); + }); + }); +}); diff --git a/libraries/android-bin/src/overlay-display.ts b/libraries/android-bin/src/overlay-display.ts index ab629390..ea6df92b 100644 --- a/libraries/android-bin/src/overlay-display.ts +++ b/libraries/android-bin/src/overlay-display.ts @@ -2,6 +2,7 @@ import type { Adb } from "@yume-chan/adb"; import { AdbCommandBase } from "@yume-chan/adb"; import { Settings } from "./settings.js"; +import { p } from "./string-format.js"; export interface OverlayDisplayDeviceMode { width: number; @@ -22,94 +23,91 @@ export class OverlayDisplay extends AdbCommandBase { public static readonly OVERLAY_DISPLAY_DEVICES_KEY = "overlay_display_devices"; + public static readonly OverlayDisplayDevicesFormat = p.separated( + ";", + p.sequence( + { + name: "modes", + format: p.separated( + "|", + p.sequence( + { name: "width", format: p.digits() }, + p.literal("x"), + { name: "height", format: p.digits() }, + p.literal("/"), + { name: "density", format: p.digits() } + ), + 1 + ), + }, + { + name: "flags", + format: p.map( + p.repeated( + p.sequence(p.literal(","), { + name: "flag", + format: p.union( + p.literal("secure"), + p.literal("own_content_only"), + p.literal("show_system_decorations") + ), + }) + ), + (value) => value.map((item) => item.flag), + (value) => value.map((item) => ({ flag: item })) + ), + } + ) + ); + constructor(adb: Adb) { super(adb); this.settings = new Settings(adb); } public async get() { - const devices: OverlayDisplayDevice[] = []; - - const settingString = await this.settings.get( - "global", - OverlayDisplay.OVERLAY_DISPLAY_DEVICES_KEY - ); - - for (const displayString of settingString.split(";")) { - const [modesString, ...flagStrings] = displayString.split(","); - - if (!modesString) { - continue; - } - - const device: OverlayDisplayDevice = { - modes: [], - secure: false, - ownContentOnly: false, - showSystemDecorations: false, - }; - for (const modeString of modesString.split("|")) { - const match = modeString.match(/(\d+)x(\d+)\/(\d+)/); - if (!match) { - continue; - } - device.modes.push({ - width: parseInt(match[1]!, 10), - height: parseInt(match[2]!, 10), - density: parseInt(match[3]!, 10), - }); - } - if (device.modes.length === 0) { - continue; - } - - for (const flagString of flagStrings) { - switch (flagString) { - case "secure": - device.secure = true; - break; - case "own_content_only": - device.ownContentOnly = true; - break; - case "show_system_decorations": - device.showSystemDecorations = true; - break; - } - } - devices.push(device); - } - - return devices; + return OverlayDisplay.OverlayDisplayDevicesFormat.parse({ + value: await this.settings.get( + "global", + OverlayDisplay.OVERLAY_DISPLAY_DEVICES_KEY + ), + position: 0, + }).map((device) => ({ + modes: device.modes, + secure: device.flags.includes("secure"), + ownContentOnly: device.flags.includes("own_content_only"), + showSystemDecorations: device.flags.includes( + "show_system_decorations" + ), + })); } public async set(devices: OverlayDisplayDevice[]) { - let settingString = ""; - for (const device of devices) { - if (settingString) { - settingString += ";"; - } - - settingString += device.modes - .map((mode) => `${mode.width}x${mode.height}/${mode.density}`) - .join("|"); - - if (device.secure) { - settingString += ",secure"; - } - - if (device.ownContentOnly) { - settingString += ",own_content_only"; - } - - if (device.showSystemDecorations) { - settingString += ",show_system_decorations"; - } - } - await this.settings.put( "global", OverlayDisplay.OVERLAY_DISPLAY_DEVICES_KEY, - settingString + OverlayDisplay.OverlayDisplayDevicesFormat.stringify( + devices.map((device) => { + const flags: ( + | "secure" + | "own_content_only" + | "show_system_decorations" + )[] = []; + if (device.secure) { + flags.push("secure"); + } + if (device.ownContentOnly) { + flags.push("own_content_only"); + } + if (device.showSystemDecorations) { + flags.push("show_system_decorations"); + } + return { + modes: device.modes, + flags, + }; + }) + ) ); } } diff --git a/libraries/android-bin/src/string-format.spec.ts b/libraries/android-bin/src/string-format.spec.ts new file mode 100644 index 00000000..eb55a493 --- /dev/null +++ b/libraries/android-bin/src/string-format.spec.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "@jest/globals"; + +import type { Reader } from "./string-format.js"; +import { ParseError, p } from "./string-format.js"; + +describe("StringFormat", () => { + describe("digits", () => { + it("should match 0-9", () => { + const reader: Reader = { value: "1234567890", position: 0 }; + expect(p.digits().parse(reader)).toBe(1234567890); + }); + + const digitsError = new ParseError("0123456789".split("")); + + it("should throw if input is empty", () => { + const reader: Reader = { value: "", position: 0 }; + expect(() => p.digits().parse(reader)).toThrowError(digitsError); + }); + + it("should throw error if not 0-9", () => { + const reader: Reader = { value: "a", position: 0 }; + expect(() => p.digits().parse(reader)).toThrowError(digitsError); + }); + }); +}); diff --git a/libraries/android-bin/src/string-format.ts b/libraries/android-bin/src/string-format.ts new file mode 100644 index 00000000..be13e4f3 --- /dev/null +++ b/libraries/android-bin/src/string-format.ts @@ -0,0 +1,213 @@ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +export class ParseError extends Error { + #expected: string[]; + + get expected(): string[] { + return this.#expected; + } + + public constructor(expected: string[]) { + super(`Expected ${expected.join(", ")}`); + this.#expected = expected; + } +} + +export interface Reader { + value: string; + position: number; +} + +export interface Format { + parse(reader: Reader): T; + stringify(value: T): string; +} + +type UnionResult[]> = Exclude< + { + [K in keyof T]: T[K] extends Format ? F : never; + }[number], + undefined +>; + +type UnionToIntersection = ( + T extends unknown ? (x: T) => void : never +) extends (x: infer R) => void + ? R + : never; + +type SequenceResult< + T extends readonly ( + | Format + | { name: string; format: Format } + )[] +> = UnionToIntersection< + { + [K in keyof T]: T[K] extends { + name: string; + format: Format; + } + ? Record< + T[K]["name"], + T[K]["format"] extends Format + ? Exclude + : never + > + : never; + }[number] +>; + +type Evaluate = T extends infer U ? { [K in keyof U]: U[K] } : never; + +export const p = { + literal: (value: T): Format => ({ + parse(reader) { + if (!reader.value.startsWith(value, reader.position)) { + throw new ParseError([value.charAt(0)]); + } + reader.position += value.length; + return value; + }, + stringify() { + return value; + }, + }), + digits: (): Format => ({ + parse(reader) { + const match = reader.value.substring(reader.position).match(/^\d+/); + if (!match) { + throw new ParseError("0123456789".split("")); + } + reader.position += match[0].length; + return Number.parseInt(match[0], 10); + }, + stringify(value) { + return value.toString(); + }, + }), + union: []>( + ...args: T + ): Format> => ({ + parse(reader) { + const expected: string[] = []; + for (const format of args) { + try { + return format.parse(reader) as UnionResult; + } catch (e) { + if (e instanceof ParseError) { + expected.push(...e.expected); + } else { + throw e; + } + } + } + throw new ParseError(expected); + }, + stringify(value) { + for (const format of args) { + try { + const result = format.stringify(value); + // Parse the result to make sure it is valid + format.parse({ value: result, position: 0 }); + } catch (e) { + // ignore + } + } + throw new Error("No format matches"); + }, + }), + separated: ( + separator: string, + format: Format, + min = 0, + max = Infinity + ): Format => ({ + parse(reader: Reader) { + const result: T[] = []; + while (true) { + try { + result.push(format.parse(reader)); + if (result.length === max) { + break; + } + } catch (e) { + if (result.length < min) { + throw e; + } else { + break; + } + } + + if (reader.value.startsWith(separator, reader.position)) { + reader.position += separator.length; + } else if (result.length < min) { + throw new ParseError([separator.charAt(0)]); + } else { + break; + } + } + return result; + }, + stringify(value) { + return value.map((item) => format.stringify(item)).join(separator); + }, + }), + repeated: (format: Format): Format => ({ + parse(reader: Reader) { + const result: T[] = []; + while (true) { + try { + result.push(format.parse(reader)); + } catch (e) { + break; + } + } + return result; + }, + stringify(value: T[]) { + return value.map((item) => format.stringify(item)).join(""); + }, + }), + sequence: < + const T extends readonly ( + | Format + | { name: string; format: Format } + )[] + >( + ...args: T + ): Format>> => ({ + parse(reader: Reader) { + const result: Record = {}; + for (const part of args) { + if ("name" in part) { + result[part.name] = part.format.parse(reader); + } else { + void part.parse(reader); + } + } + return result as Evaluate>; + }, + stringify: (value: Evaluate>) => { + let result = ""; + for (const part of args) { + if ("name" in part) { + result += part.format.stringify( + value[part.name as keyof typeof value] + ); + } + } + return result; + }, + }), + map: ( + format: Format, + map: (value: T) => R, + reverse: (value: R) => T + ): Format => ({ + parse(reader: Reader) { + return map(format.parse(reader)); + }, + stringify(value: R) { + return format.stringify(reverse(value)); + }, + }), +} satisfies Record Format>; diff --git a/libraries/android-bin/tsconfig.json b/libraries/android-bin/tsconfig.json index 3dce3aea..85fc5a7a 100644 --- a/libraries/android-bin/tsconfig.json +++ b/libraries/android-bin/tsconfig.json @@ -1,5 +1,8 @@ { "references": [ + { + "path": "./tsconfig.test.json" + }, { "path": "./tsconfig.build.json" }, diff --git a/libraries/android-bin/tsconfig.test.json b/libraries/android-bin/tsconfig.test.json new file mode 100644 index 00000000..57715e3a --- /dev/null +++ b/libraries/android-bin/tsconfig.test.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { + "types": [] + }, + "exclude": [] +}