mirror of
https://github.com/yume-chan/ya-webadb.git
synced 2025-10-03 09:49:24 +02:00
parent
721e14fa7a
commit
2abec924e8
12 changed files with 587 additions and 103 deletions
|
@ -1,11 +1,7 @@
|
||||||
module.exports = {
|
module.exports = {
|
||||||
"extends": [
|
extends: ["@yume-chan"],
|
||||||
"@yume-chan"
|
|
||||||
],
|
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
tsconfigRootDir: __dirname,
|
tsconfigRootDir: __dirname,
|
||||||
project: [
|
project: ["./tsconfig.test.json"],
|
||||||
"./tsconfig.build.json"
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
14
libraries/android-bin/jest.config.js
Normal file
14
libraries/android-bin/jest.config.js
Normal file
|
@ -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",
|
||||||
|
},
|
||||||
|
};
|
|
@ -27,6 +27,7 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -b tsconfig.build.json",
|
"build": "tsc -b tsconfig.build.json",
|
||||||
"build:watch": "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",
|
"lint": "eslint src/**/*.ts --fix && prettier src/**/*.ts --write --tab-width 4",
|
||||||
"prepublishOnly": "npm run build"
|
"prepublishOnly": "npm run build"
|
||||||
},
|
},
|
||||||
|
@ -34,13 +35,17 @@
|
||||||
"@yume-chan/adb": "workspace:^0.0.20",
|
"@yume-chan/adb": "workspace:^0.0.20",
|
||||||
"@yume-chan/stream-extra": "workspace:^0.0.20",
|
"@yume-chan/stream-extra": "workspace:^0.0.20",
|
||||||
"@yume-chan/struct": "workspace:^0.0.20",
|
"@yume-chan/struct": "workspace:^0.0.20",
|
||||||
"tslib": "^2.5.2"
|
"tslib": "^2.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@jest/globals": "^29.6.1",
|
||||||
"@yume-chan/eslint-config": "workspace:^1.0.0",
|
"@yume-chan/eslint-config": "workspace:^1.0.0",
|
||||||
"@yume-chan/tsconfig": "workspace:^1.0.0",
|
"@yume-chan/tsconfig": "workspace:^1.0.0",
|
||||||
"eslint": "^8.41.0",
|
"cross-env": "^7.0.3",
|
||||||
"prettier": "^2.8.8",
|
"eslint": "^8.44.0",
|
||||||
"typescript": "^5.0.3"
|
"jest": "^29.5.0",
|
||||||
|
"prettier": "^3.0.0",
|
||||||
|
"ts-jest": "^29.1.1",
|
||||||
|
"typescript": "^5.1.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,26 +1,74 @@
|
||||||
// cspell: ignore apks
|
|
||||||
// cspell: ignore obbs
|
|
||||||
|
|
||||||
import { AdbCommandBase } from "@yume-chan/adb";
|
import { AdbCommandBase } from "@yume-chan/adb";
|
||||||
|
import type { Consumable, ReadableStream } from "@yume-chan/stream-extra";
|
||||||
|
|
||||||
export interface AdbBackupOptions {
|
export interface AdbBackupOptions {
|
||||||
apps: string[] | "all" | "all-including-system";
|
|
||||||
apks: boolean;
|
|
||||||
obbs: boolean;
|
|
||||||
shared: boolean;
|
|
||||||
widgets: boolean;
|
|
||||||
compress: boolean;
|
|
||||||
user: number;
|
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<Consumable<Uint8Array>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AdbBackup extends AdbCommandBase {
|
export class AdbBackup extends AdbCommandBase {
|
||||||
backup(options: AdbBackupOptions): Promise<void> {
|
/**
|
||||||
void options;
|
* User must confirm backup on device within 60 seconds.
|
||||||
throw new Error("Not implemented");
|
*/
|
||||||
|
public async backup(
|
||||||
|
options: AdbBackupOptions
|
||||||
|
): Promise<ReadableStream<Uint8Array>> {
|
||||||
|
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> {
|
/**
|
||||||
void options;
|
* User must enter the password (if any) and
|
||||||
throw new Error("Not implemented");
|
* confirm restore on device within 60 seconds.
|
||||||
|
*/
|
||||||
|
public async restore(options: AdbRestoreOptions): Promise<void> {
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { AdbCommandBase } from "@yume-chan/adb";
|
import { AdbCommandBase } from "@yume-chan/adb";
|
||||||
|
|
||||||
export class DumpSys extends AdbCommandBase {
|
export class DumpSys extends AdbCommandBase {
|
||||||
async diskStats() {
|
public async diskStats() {
|
||||||
const output = await this.adb.subprocess.spawnAndWaitLegacy([
|
const output = await this.adb.subprocess.spawnAndWaitLegacy([
|
||||||
"dumpsys",
|
"dumpsys",
|
||||||
"diskstats",
|
"diskstats",
|
||||||
|
@ -33,4 +33,59 @@ export class DumpSys extends AdbCommandBase {
|
||||||
systemTotal,
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
// cspell: ignore logcat
|
// cspell: ignore logcat
|
||||||
|
|
||||||
|
export * from "./bu.js";
|
||||||
export * from "./bug-report.js";
|
export * from "./bug-report.js";
|
||||||
export * from "./cmd.js";
|
export * from "./cmd.js";
|
||||||
export * from "./demo-mode.js";
|
export * from "./demo-mode.js";
|
||||||
|
export * from "./dumpsys.js";
|
||||||
export * from "./logcat.js";
|
export * from "./logcat.js";
|
||||||
export * from "./overlay-display.js";
|
export * from "./overlay-display.js";
|
||||||
export * from "./pm.js";
|
export * from "./pm.js";
|
||||||
|
|
118
libraries/android-bin/src/overlay-display.spec.ts
Normal file
118
libraries/android-bin/src/overlay-display.spec.ts
Normal file
|
@ -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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -2,6 +2,7 @@ import type { Adb } from "@yume-chan/adb";
|
||||||
import { AdbCommandBase } from "@yume-chan/adb";
|
import { AdbCommandBase } from "@yume-chan/adb";
|
||||||
|
|
||||||
import { Settings } from "./settings.js";
|
import { Settings } from "./settings.js";
|
||||||
|
import { p } from "./string-format.js";
|
||||||
|
|
||||||
export interface OverlayDisplayDeviceMode {
|
export interface OverlayDisplayDeviceMode {
|
||||||
width: number;
|
width: number;
|
||||||
|
@ -22,94 +23,91 @@ export class OverlayDisplay extends AdbCommandBase {
|
||||||
public static readonly OVERLAY_DISPLAY_DEVICES_KEY =
|
public static readonly OVERLAY_DISPLAY_DEVICES_KEY =
|
||||||
"overlay_display_devices";
|
"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) {
|
constructor(adb: Adb) {
|
||||||
super(adb);
|
super(adb);
|
||||||
this.settings = new Settings(adb);
|
this.settings = new Settings(adb);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async get() {
|
public async get() {
|
||||||
const devices: OverlayDisplayDevice[] = [];
|
return OverlayDisplay.OverlayDisplayDevicesFormat.parse({
|
||||||
|
value: await this.settings.get(
|
||||||
const settingString = await this.settings.get(
|
"global",
|
||||||
"global",
|
OverlayDisplay.OVERLAY_DISPLAY_DEVICES_KEY
|
||||||
OverlayDisplay.OVERLAY_DISPLAY_DEVICES_KEY
|
),
|
||||||
);
|
position: 0,
|
||||||
|
}).map((device) => ({
|
||||||
for (const displayString of settingString.split(";")) {
|
modes: device.modes,
|
||||||
const [modesString, ...flagStrings] = displayString.split(",");
|
secure: device.flags.includes("secure"),
|
||||||
|
ownContentOnly: device.flags.includes("own_content_only"),
|
||||||
if (!modesString) {
|
showSystemDecorations: device.flags.includes(
|
||||||
continue;
|
"show_system_decorations"
|
||||||
}
|
),
|
||||||
|
}));
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async set(devices: OverlayDisplayDevice[]) {
|
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(
|
await this.settings.put(
|
||||||
"global",
|
"global",
|
||||||
OverlayDisplay.OVERLAY_DISPLAY_DEVICES_KEY,
|
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,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
25
libraries/android-bin/src/string-format.spec.ts
Normal file
25
libraries/android-bin/src/string-format.spec.ts
Normal file
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
213
libraries/android-bin/src/string-format.ts
Normal file
213
libraries/android-bin/src/string-format.ts
Normal file
|
@ -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<T> {
|
||||||
|
parse(reader: Reader): T;
|
||||||
|
stringify(value: T): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type UnionResult<T extends readonly Format<unknown>[]> = Exclude<
|
||||||
|
{
|
||||||
|
[K in keyof T]: T[K] extends Format<infer F> ? F : never;
|
||||||
|
}[number],
|
||||||
|
undefined
|
||||||
|
>;
|
||||||
|
|
||||||
|
type UnionToIntersection<T> = (
|
||||||
|
T extends unknown ? (x: T) => void : never
|
||||||
|
) extends (x: infer R) => void
|
||||||
|
? R
|
||||||
|
: never;
|
||||||
|
|
||||||
|
type SequenceResult<
|
||||||
|
T extends readonly (
|
||||||
|
| Format<unknown>
|
||||||
|
| { name: string; format: Format<unknown> }
|
||||||
|
)[]
|
||||||
|
> = UnionToIntersection<
|
||||||
|
{
|
||||||
|
[K in keyof T]: T[K] extends {
|
||||||
|
name: string;
|
||||||
|
format: Format<unknown>;
|
||||||
|
}
|
||||||
|
? Record<
|
||||||
|
T[K]["name"],
|
||||||
|
T[K]["format"] extends Format<infer F>
|
||||||
|
? Exclude<F, undefined>
|
||||||
|
: never
|
||||||
|
>
|
||||||
|
: never;
|
||||||
|
}[number]
|
||||||
|
>;
|
||||||
|
|
||||||
|
type Evaluate<T> = T extends infer U ? { [K in keyof U]: U[K] } : never;
|
||||||
|
|
||||||
|
export const p = {
|
||||||
|
literal: <T extends string>(value: T): Format<T> => ({
|
||||||
|
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<number> => ({
|
||||||
|
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: <const T extends readonly Format<unknown>[]>(
|
||||||
|
...args: T
|
||||||
|
): Format<UnionResult<T>> => ({
|
||||||
|
parse(reader) {
|
||||||
|
const expected: string[] = [];
|
||||||
|
for (const format of args) {
|
||||||
|
try {
|
||||||
|
return format.parse(reader) as UnionResult<T>;
|
||||||
|
} 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: <T>(
|
||||||
|
separator: string,
|
||||||
|
format: Format<T>,
|
||||||
|
min = 0,
|
||||||
|
max = Infinity
|
||||||
|
): Format<T[]> => ({
|
||||||
|
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: <T>(format: Format<T>): Format<T[]> => ({
|
||||||
|
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<unknown>
|
||||||
|
| { name: string; format: Format<unknown> }
|
||||||
|
)[]
|
||||||
|
>(
|
||||||
|
...args: T
|
||||||
|
): Format<Evaluate<SequenceResult<T>>> => ({
|
||||||
|
parse(reader: Reader) {
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
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<SequenceResult<T>>;
|
||||||
|
},
|
||||||
|
stringify: (value: Evaluate<SequenceResult<T>>) => {
|
||||||
|
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: <T, R>(
|
||||||
|
format: Format<T>,
|
||||||
|
map: (value: T) => R,
|
||||||
|
reverse: (value: R) => T
|
||||||
|
): Format<R> => ({
|
||||||
|
parse(reader: Reader) {
|
||||||
|
return map(format.parse(reader));
|
||||||
|
},
|
||||||
|
stringify(value: R) {
|
||||||
|
return format.stringify(reverse(value));
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
} satisfies Record<string, (...args: never) => Format<unknown>>;
|
|
@ -1,5 +1,8 @@
|
||||||
{
|
{
|
||||||
"references": [
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.test.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "./tsconfig.build.json"
|
"path": "./tsconfig.build.json"
|
||||||
},
|
},
|
||||||
|
|
7
libraries/android-bin/tsconfig.test.json
Normal file
7
libraries/android-bin/tsconfig.test.json
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.build.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"exclude": []
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue