feat(adb): support ls_v2 and fixed_push_mkdir features

This commit is contained in:
Simon Chan 2022-04-22 23:50:42 +08:00
parent 90b0a82d92
commit 5f1fa1192d
No known key found for this signature in database
GPG key ID: A8B69F750B9BCEDD
14 changed files with 216 additions and 53 deletions

View file

@ -16,6 +16,7 @@
"genymobile", "genymobile",
"Genymobile's", "Genymobile's",
"getprop", "getprop",
"griffel",
"keyof", "keyof",
"laggy", "laggy",
"localabstract", "localabstract",

View file

@ -4,9 +4,18 @@ import { withDisplayName } from "../utils";
const useClasses = makeStyles({ const useClasses = makeStyles({
}); });
export const HexViewer = withDisplayName('HexViewer')(({ }) => { export interface HexViewer {
data: Uint8Array;
}
export const HexViewer = withDisplayName('HexViewer')(({
}) => {
const classes = useClasses(); const classes = useClasses();
// Because ADB packets are usually small,
// so don't add virtualization now.
return ( return (
<div> <div>

View file

@ -9,11 +9,11 @@ import { globalState } from '../state';
import { Icons, RouteStackProps } from "../utils"; import { Icons, RouteStackProps } from "../utils";
const KNOWN_FEATURES: Record<string, string> = { const KNOWN_FEATURES: Record<string, string> = {
'shell_v2': `"shell" command now supports separating child process's stdout and stderr, and returning exit code`, [AdbFeatures.ShellV2]: `"shell" command now supports separating child process's stdout and stderr, and returning exit code`,
// 'cmd': '', // 'cmd': '',
[AdbFeatures.StatV2]: '"sync" command now supports "STA2" (returns more information of a file than old "STAT") and "LST2" (returns information of a directory) sub command', [AdbFeatures.StatV2]: '"sync" command now supports "STA2" (returns more information of a file than old "STAT") and "LST2" (returns information of a directory) sub command',
'ls_v2': '"sync" command now supports "LST2" sub command which returns more information when listing a directory than old "LIST"', [AdbFeatures.ListV2]: '"sync" command now supports "LST2" sub command which returns more information when listing a directory than old "LIST"',
// 'fixed_push_mkdir': '', [AdbFeatures.FixedPushMkdir]: 'Android 9 (P) introduced a bug that pushing files to a non-existing directory would fail. This feature indicates it\'s fixed (Android 10)',
// 'apex': '', // 'apex': '',
// 'abb': '', // 'abb': '',
// 'fixed_push_symlink_timestamp': '', // 'fixed_push_symlink_timestamp': '',

View file

@ -2,7 +2,7 @@ import { Breadcrumb, concatStyleSets, ContextualMenu, ContextualMenuItem, Detail
import { FileIconType, getFileTypeIconProps, initializeFileTypeIcons } from "@fluentui/react-file-type-icons"; import { FileIconType, getFileTypeIconProps, initializeFileTypeIcons } from "@fluentui/react-file-type-icons";
import { useConst } from '@fluentui/react-hooks'; import { useConst } from '@fluentui/react-hooks';
import { getIcon } from '@fluentui/style-utilities'; import { getIcon } from '@fluentui/style-utilities';
import { AdbSyncEntryResponse, ADB_SYNC_MAX_PACKET_SIZE, ChunkStream, LinuxFileType, ReadableStream } from '@yume-chan/adb'; import { AdbFeatures, ADB_SYNC_MAX_PACKET_SIZE, ChunkStream, LinuxFileType, type AdbSyncEntry } from '@yume-chan/adb';
import { action, autorun, makeAutoObservable, observable, runInAction } from "mobx"; import { action, autorun, makeAutoObservable, observable, runInAction } from "mobx";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { NextPage } from "next"; import { NextPage } from "next";
@ -12,15 +12,15 @@ import path from 'path';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { CommandBar, NoSsr } from '../components'; import { CommandBar, NoSsr } from '../components';
import { globalState } from '../state'; import { globalState } from '../state';
import { asyncEffect, formatSize, formatSpeed, Icons, pickFile, ProgressStream, RouteStackProps, saveFile } from '../utils'; import { asyncEffect, createFileStream, formatSize, formatSpeed, Icons, pickFile, ProgressStream, RouteStackProps, saveFile } from '../utils';
initializeFileTypeIcons(); initializeFileTypeIcons();
interface ListItem extends AdbSyncEntryResponse { interface ListItem extends AdbSyncEntry {
key: string; key: string;
} }
function toListItem(item: AdbSyncEntryResponse): ListItem { function toListItem(item: AdbSyncEntry): ListItem {
return { ...item, key: item.name! }; return { ...item, key: item.name! };
} }
@ -139,7 +139,7 @@ class FileManagerState {
const item = this.selectedItems[0]; const item = this.selectedItems[0];
const itemPath = path.resolve(this.path, item.name); const itemPath = path.resolve(this.path, item.name);
await sync.read(itemPath) await sync.read(itemPath)
.pipeTo(saveFile(item.name, item.size)); .pipeTo(saveFile(item.name, Number(item.size)));
} catch (e) { } catch (e) {
globalState.showErrorDialog(e instanceof Error ? e.message : `${e}`); globalState.showErrorDialog(e instanceof Error ? e.message : `${e}`);
} finally { } finally {
@ -226,7 +226,7 @@ class FileManagerState {
minWidth: ICON_SIZE, minWidth: ICON_SIZE,
maxWidth: ICON_SIZE, maxWidth: ICON_SIZE,
isCollapsible: true, isCollapsible: true,
onRender(item: AdbSyncEntryResponse) { onRender(item: AdbSyncEntry) {
let iconName: string; let iconName: string;
switch (item.type) { switch (item.type) {
@ -254,7 +254,7 @@ class FileManagerState {
name: 'Name', name: 'Name',
minWidth: 0, minWidth: 0,
isRowHeader: true, isRowHeader: true,
onRender(item: AdbSyncEntryResponse) { onRender(item: AdbSyncEntry) {
return ( return (
<span className={classNames.name} data-selection-invoke> <span className={classNames.name} data-selection-invoke>
{item.name} {item.name}
@ -267,7 +267,7 @@ class FileManagerState {
name: 'Permission', name: 'Permission',
minWidth: 0, minWidth: 0,
isCollapsible: true, isCollapsible: true,
onRender(item: AdbSyncEntryResponse) { onRender(item: AdbSyncEntry) {
return `${(item.mode >> 6 & 0b100).toString(8)}${(item.mode >> 3 & 0b100).toString(8)}${(item.mode & 0b100).toString(8)}`; return `${(item.mode >> 6 & 0b100).toString(8)}${(item.mode >> 3 & 0b100).toString(8)}${(item.mode & 0b100).toString(8)}`;
} }
}, },
@ -276,9 +276,9 @@ class FileManagerState {
name: 'Size', name: 'Size',
minWidth: 0, minWidth: 0,
isCollapsible: true, isCollapsible: true,
onRender(item: AdbSyncEntryResponse) { onRender(item: AdbSyncEntry) {
if (item.type === LinuxFileType.File) { if (item.type === LinuxFileType.File) {
return formatSize(item.size); return formatSize(Number(item.size));
} }
return ''; return '';
} }
@ -288,12 +288,35 @@ class FileManagerState {
name: 'Last Modified Time', name: 'Last Modified Time',
minWidth: 150, minWidth: 150,
isCollapsible: true, isCollapsible: true,
onRender(item: AdbSyncEntryResponse) { onRender(item: AdbSyncEntry) {
return new Date(item.mtime * 1000).toLocaleString(); return new Date(Number(item.mtime) * 1000).toLocaleString();
}, },
} }
]; ];
if (globalState.device?.features?.includes(AdbFeatures.ListV2)) {
list.push(
{
key: 'ctime',
name: 'Creation Time',
minWidth: 150,
isCollapsible: true,
onRender(item: AdbSyncEntry) {
return new Date(Number(item.ctime!) * 1000).toLocaleString();
},
},
{
key: 'atime',
name: 'Last Access Time',
minWidth: 150,
isCollapsible: true,
onRender(item: AdbSyncEntry) {
return new Date(Number(item.atime!) * 1000).toLocaleString();
},
},
);
}
for (const item of list) { for (const item of list) {
item.onColumnClick = (e, column) => { item.onColumnClick = (e, column) => {
if (this.sortKey === column.key) { if (this.sortKey === column.key) {
@ -368,7 +391,7 @@ class FileManagerState {
const sync = await globalState.device.sync(); const sync = await globalState.device.sync();
const items: ListItem[] = []; const items: ListItem[] = [];
const linkItems: AdbSyncEntryResponse[] = []; const linkItems: AdbSyncEntry[] = [];
const intervalId = setInterval(() => { const intervalId = setInterval(() => {
if (signal.aborted) { if (signal.aborted) {
return; return;
@ -401,7 +424,7 @@ class FileManagerState {
if (!await sync.isDirectory(path.resolve(currentPath, entry.name!))) { if (!await sync.isDirectory(path.resolve(currentPath, entry.name!))) {
entry.mode = (LinuxFileType.File << 12) | entry.permission; entry.mode = (LinuxFileType.File << 12) | entry.permission;
entry.size = 0; entry.size = 0n;
} }
items.push(toListItem(entry)); items.push(toListItem(entry));
@ -440,7 +463,7 @@ class FileManagerState {
}), 1000); }), 1000);
try { try {
await (file.stream() as unknown as ReadableStream<Uint8Array>) await createFileStream(file)
.pipeThrough(new ChunkStream(ADB_SYNC_MAX_PACKET_SIZE)) .pipeThrough(new ChunkStream(ADB_SYNC_MAX_PACKET_SIZE))
.pipeThrough(new ProgressStream(action((uploaded) => { .pipeThrough(new ProgressStream(action((uploaded) => {
this.uploadedSize = uploaded; this.uploadedSize = uploaded;
@ -535,7 +558,7 @@ const FileManager: NextPage = (): JSX.Element | null => {
setPreviewUrl(undefined); setPreviewUrl(undefined);
}, []); }, []);
const handleItemInvoked = useCallback((item: AdbSyncEntryResponse) => { const handleItemInvoked = useCallback((item: AdbSyncEntry) => {
switch (item.type) { switch (item.type) {
case LinuxFileType.Link: case LinuxFileType.Link:
case LinuxFileType.Directory: case LinuxFileType.Directory:
@ -564,7 +587,7 @@ const FileManager: NextPage = (): JSX.Element | null => {
})); }));
const showContextMenu = useCallback(( const showContextMenu = useCallback((
item?: AdbSyncEntryResponse, item?: AdbSyncEntry,
index?: number, index?: number,
e?: Event e?: Event
) => { ) => {

View file

@ -5,7 +5,7 @@ import { observer } from "mobx-react-lite";
import { NextPage } from "next"; import { NextPage } from "next";
import Head from "next/head"; import Head from "next/head";
import { globalState } from "../state"; import { globalState } from "../state";
import { pickFile, ProgressStream, RouteStackProps } from "../utils"; import { createFileStream, pickFile, ProgressStream, RouteStackProps } from "../utils";
enum Stage { enum Stage {
Uploading, Uploading,
@ -56,7 +56,7 @@ class InstallPageState {
}; };
}); });
await (file.stream() as unknown as ReadableStream<Uint8Array>) await createFileStream(file)
.pipeThrough(new ChunkStream(ADB_SYNC_MAX_PACKET_SIZE)) .pipeThrough(new ChunkStream(ADB_SYNC_MAX_PACKET_SIZE))
.pipeThrough(new ProgressStream(action((uploaded) => { .pipeThrough(new ProgressStream(action((uploaded) => {
if (uploaded !== file.size) { if (uploaded !== file.size) {

View file

@ -1,5 +1,5 @@
import getConfig from "next/config"; import getConfig from "next/config";
import type { WritableStream } from '@yume-chan/adb'; import { WrapReadableStream, WritableStream, type ReadableStream } from '@yume-chan/adb';
interface PickFileOptions { interface PickFileOptions {
accept?: string; accept?: string;
@ -47,3 +47,10 @@ export function saveFile(fileName: string, size?: number | undefined) {
{ size } { size }
) as unknown as WritableStream<Uint8Array>; ) as unknown as WritableStream<Uint8Array>;
} }
export function createFileStream(file: File) {
// `@types/node` typing messed things up
// https://github.com/DefinitelyTyped/DefinitelyTyped/discussions/58079
// TODO: demo: remove the wrapper after switching to native stream implementation.
return new WrapReadableStream<Uint8Array>(file.stream() as unknown as ReadableStream<Uint8Array>);
}

View file

@ -78,12 +78,12 @@ export class Adb {
try { try {
// https://android.googlesource.com/platform/packages/modules/adb/+/79010dc6d5ca7490c493df800d4421730f5466ca/transport.cpp#1252 // https://android.googlesource.com/platform/packages/modules/adb/+/79010dc6d5ca7490c493df800d4421730f5466ca/transport.cpp#1252
// There are more feature constants, but some of them are only used by ADB server, not devices. // There are some other feature constants, but some of them are only used by ADB server, not devices.
const features = [ const features = [
'shell_v2', AdbFeatures.ShellV2,
'cmd', AdbFeatures.Cmd,
AdbFeatures.StatV2, AdbFeatures.StatV2,
'ls_v2', AdbFeatures.ListV2,
'fixed_push_mkdir', 'fixed_push_mkdir',
'apex', 'apex',
'abb', 'abb',

View file

@ -2,7 +2,11 @@ import Struct from '@yume-chan/struct';
import type { AdbBufferedStream, WritableStreamDefaultWriter } from '../../stream/index.js'; import type { AdbBufferedStream, WritableStreamDefaultWriter } from '../../stream/index.js';
import { AdbSyncRequestId, adbSyncWriteRequest } from './request.js'; import { AdbSyncRequestId, adbSyncWriteRequest } from './request.js';
import { AdbSyncDoneResponse, adbSyncReadResponse, AdbSyncResponseId } from './response.js'; import { AdbSyncDoneResponse, adbSyncReadResponse, AdbSyncResponseId } from './response.js';
import { AdbSyncLstatResponse } from './stat.js'; import { AdbSyncLstatResponse, AdbSyncStatResponse, type AdbSyncStat } from './stat.js';
export interface AdbSyncEntry extends AdbSyncStat {
name: string;
}
export const AdbSyncEntryResponse = export const AdbSyncEntryResponse =
new Struct({ littleEndian: true }) new Struct({ littleEndian: true })
@ -13,22 +17,64 @@ export const AdbSyncEntryResponse =
export type AdbSyncEntryResponse = typeof AdbSyncEntryResponse['TDeserializeResult']; export type AdbSyncEntryResponse = typeof AdbSyncEntryResponse['TDeserializeResult'];
const ResponseTypes = { export const AdbSyncEntry2Response =
new Struct({ littleEndian: true })
.fields(AdbSyncStatResponse)
.uint32('nameLength')
.string('name', { lengthField: 'nameLength' })
.extra({ id: AdbSyncResponseId.Entry2 as const });
export type AdbSyncEntry2Response = typeof AdbSyncEntry2Response['TDeserializeResult'];
const LIST_V1_RESPONSE_TYPES = {
[AdbSyncResponseId.Entry]: AdbSyncEntryResponse, [AdbSyncResponseId.Entry]: AdbSyncEntryResponse,
[AdbSyncResponseId.Done]: new AdbSyncDoneResponse(AdbSyncEntryResponse.size), [AdbSyncResponseId.Done]: new AdbSyncDoneResponse(AdbSyncEntryResponse.size),
}; };
const LIST_V2_RESPONSE_TYPES = {
[AdbSyncResponseId.Entry2]: AdbSyncEntry2Response,
[AdbSyncResponseId.Done]: new AdbSyncDoneResponse(AdbSyncEntry2Response.size),
};
export async function* adbSyncOpenDir( export async function* adbSyncOpenDir(
stream: AdbBufferedStream, stream: AdbBufferedStream,
writer: WritableStreamDefaultWriter<Uint8Array>, writer: WritableStreamDefaultWriter<Uint8Array>,
path: string, path: string,
): AsyncGenerator<AdbSyncEntryResponse, void, void> { v2: boolean,
await adbSyncWriteRequest(writer, AdbSyncRequestId.List, path); ): AsyncGenerator<AdbSyncEntry, void, void> {
let requestId: AdbSyncRequestId.List | AdbSyncRequestId.List2;
let responseType: typeof LIST_V1_RESPONSE_TYPES | typeof LIST_V2_RESPONSE_TYPES;
if (v2) {
requestId = AdbSyncRequestId.List2;
responseType = LIST_V2_RESPONSE_TYPES;
} else {
requestId = AdbSyncRequestId.List;
responseType = LIST_V1_RESPONSE_TYPES;
}
await adbSyncWriteRequest(writer, requestId, path);
while (true) { while (true) {
const response = await adbSyncReadResponse(stream, ResponseTypes); const response = await adbSyncReadResponse(stream, responseType);
switch (response.id) { switch (response.id) {
case AdbSyncResponseId.Entry: case AdbSyncResponseId.Entry:
yield {
mode: response.mode,
size: BigInt(response.size),
mtime: BigInt(response.mtime),
get type() { return response.type; },
get permission() { return response.permission; },
name: response.name,
};
break;
case AdbSyncResponseId.Entry2:
// `LST2` can return error codes for failed `lstat` calls.
// `LIST` just ignores them.
// But they only contain `name` so still pretty useless.
if (response.error !== 0) {
continue;
}
yield response; yield response;
break; break;
case AdbSyncResponseId.Done: case AdbSyncResponseId.Done:

View file

@ -25,9 +25,6 @@ export function adbSyncPush(
return pipeFrom( return pipeFrom(
new WritableStream<Uint8Array>({ new WritableStream<Uint8Array>({
async start() { async start() {
// TODO: AdbSyncPush: support create directory using `mkdir`
// if device doesn't support `fixed_push_mkdir` feature.
const pathAndMode = `${filename},${mode.toString()}`; const pathAndMode = `${filename},${mode.toString()}`;
await adbSyncWriteRequest(writer, AdbSyncRequestId.Send, pathAndMode); await adbSyncWriteRequest(writer, AdbSyncRequestId.Send, pathAndMode);
}, },

View file

@ -4,6 +4,7 @@ import { encodeUtf8 } from "../../utils/index.js";
export enum AdbSyncRequestId { export enum AdbSyncRequestId {
List = 'LIST', List = 'LIST',
List2 = 'LIS2',
Send = 'SEND', Send = 'SEND',
Lstat = 'STAT', Lstat = 'STAT',
Stat = 'STA2', Stat = 'STA2',

View file

@ -4,6 +4,7 @@ import { decodeUtf8 } from "../../utils/index.js";
export enum AdbSyncResponseId { export enum AdbSyncResponseId {
Entry = 'DENT', Entry = 'DENT',
Entry2 = 'DNT2',
Lstat = 'STAT', Lstat = 'STAT',
Stat = 'STA2', Stat = 'STA2',
Lstat2 = 'LST2', Lstat2 = 'LST2',
@ -43,7 +44,12 @@ export const AdbSyncFailResponse =
export async function adbSyncReadResponse<T extends Record<string, StructLike<any>>>( export async function adbSyncReadResponse<T extends Record<string, StructLike<any>>>(
stream: AdbBufferedStream, stream: AdbBufferedStream,
types: T, types: T,
): Promise<StructValueType<T[keyof T]>> { // When `T` is a union type, `T[keyof T]` only includes their common keys.
// For example, let `type T = { a: string, b: string } | { a: string, c: string}`,
// `keyof T` is `'a'`, not `'a' | 'b' | 'c'`.
// However, `T extends unknown ? keyof T : never` will distribute `T`,
// so returns all keys.
): Promise<StructValueType<T extends unknown ? T[keyof T] : never>> {
const id = decodeUtf8(await stream.read(4)); const id = decodeUtf8(await stream.read(4));
if (id === AdbSyncResponseId.Fail) { if (id === AdbSyncResponseId.Fail) {
@ -54,5 +60,5 @@ export async function adbSyncReadResponse<T extends Record<string, StructLike<an
return types[id]!.deserialize(stream); return types[id]!.deserialize(stream);
} }
throw new Error('Unexpected response id'); throw new Error(`Expected '${Object.keys(types).join(', ')}', but got '${id}'`);
} }

View file

@ -10,6 +10,19 @@ export enum LinuxFileType {
Link = 0o12, Link = 0o12,
} }
export interface AdbSyncStat {
mode: number;
size: bigint;
mtime: bigint;
get type(): LinuxFileType;
get permission(): number;
uid?: number;
gid?: number;
atime?: bigint;
ctime?: bigint;
}
export const AdbSyncLstatResponse = export const AdbSyncLstatResponse =
new Struct({ littleEndian: true }) new Struct({ littleEndian: true })
.int32('mode') .int32('mode')
@ -32,6 +45,7 @@ export const AdbSyncLstatResponse =
export type AdbSyncLstatResponse = typeof AdbSyncLstatResponse['TDeserializeResult']; export type AdbSyncLstatResponse = typeof AdbSyncLstatResponse['TDeserializeResult'];
export enum AdbSyncStatErrorCode { export enum AdbSyncStatErrorCode {
SUCCESS = 0,
EACCES = 13, EACCES = 13,
EEXIST = 17, EEXIST = 17,
EFAULT = 14, EFAULT = 14,
@ -80,15 +94,15 @@ export const AdbSyncStatResponse =
export type AdbSyncStatResponse = typeof AdbSyncStatResponse['TDeserializeResult']; export type AdbSyncStatResponse = typeof AdbSyncStatResponse['TDeserializeResult'];
const StatResponseType = { const STAT_RESPONSE_TYPES = {
[AdbSyncResponseId.Stat]: AdbSyncStatResponse, [AdbSyncResponseId.Stat]: AdbSyncStatResponse,
}; };
const LstatResponseType = { const LSTAT_RESPONSE_TYPES = {
[AdbSyncResponseId.Lstat]: AdbSyncLstatResponse, [AdbSyncResponseId.Lstat]: AdbSyncLstatResponse,
}; };
const Lstat2ResponseType = { const LSTAT_V2_RESPONSE_TYPES = {
[AdbSyncResponseId.Lstat2]: AdbSyncStatResponse, [AdbSyncResponseId.Lstat2]: AdbSyncStatResponse,
}; };
@ -97,20 +111,33 @@ export async function adbSyncLstat(
writer: WritableStreamDefaultWriter<Uint8Array>, writer: WritableStreamDefaultWriter<Uint8Array>,
path: string, path: string,
v2: boolean, v2: boolean,
): Promise<AdbSyncLstatResponse | AdbSyncStatResponse> { ): Promise<AdbSyncStat> {
let requestId: AdbSyncRequestId.Lstat | AdbSyncRequestId.Lstat2; let requestId: AdbSyncRequestId.Lstat | AdbSyncRequestId.Lstat2;
let responseType: typeof LstatResponseType | typeof Lstat2ResponseType; let responseType: typeof LSTAT_RESPONSE_TYPES | typeof LSTAT_V2_RESPONSE_TYPES;
if (v2) { if (v2) {
requestId = AdbSyncRequestId.Lstat2; requestId = AdbSyncRequestId.Lstat2;
responseType = Lstat2ResponseType; responseType = LSTAT_V2_RESPONSE_TYPES;
} else { } else {
requestId = AdbSyncRequestId.Lstat; requestId = AdbSyncRequestId.Lstat;
responseType = LstatResponseType; responseType = LSTAT_RESPONSE_TYPES;
} }
await adbSyncWriteRequest(writer, requestId, path); await adbSyncWriteRequest(writer, requestId, path);
return adbSyncReadResponse(stream, responseType); const response = await adbSyncReadResponse(stream, responseType);
switch (response.id) {
case AdbSyncResponseId.Lstat:
return {
mode: response.mode,
size: BigInt(response.size),
mtime: BigInt(response.mtime),
get type() { return response.type; },
get permission() { return response.permission; },
};
default:
return response;
}
} }
export async function adbSyncStat( export async function adbSyncStat(
@ -119,5 +146,5 @@ export async function adbSyncStat(
path: string, path: string,
): Promise<AdbSyncStatResponse> { ): Promise<AdbSyncStatResponse> {
await adbSyncWriteRequest(writer, AdbSyncRequestId.Stat, path); await adbSyncWriteRequest(writer, AdbSyncRequestId.Stat, path);
return await adbSyncReadResponse(stream, StatResponseType); return await adbSyncReadResponse(stream, STAT_RESPONSE_TYPES);
} }

View file

@ -4,11 +4,28 @@ import { AdbFeatures } from '../../features.js';
import type { AdbSocket } from '../../socket/index.js'; import type { AdbSocket } from '../../socket/index.js';
import { AdbBufferedStream, ReadableStream, WrapReadableStream, WrapWritableStream, WritableStream, WritableStreamDefaultWriter } from '../../stream/index.js'; import { AdbBufferedStream, ReadableStream, WrapReadableStream, WrapWritableStream, WritableStream, WritableStreamDefaultWriter } from '../../stream/index.js';
import { AutoResetEvent } from '../../utils/index.js'; import { AutoResetEvent } from '../../utils/index.js';
import { AdbSyncEntryResponse, adbSyncOpenDir } from './list.js'; import { escapeArg } from "../index.js";
import { adbSyncOpenDir, type AdbSyncEntry } from './list.js';
import { adbSyncPull } from './pull.js'; import { adbSyncPull } from './pull.js';
import { adbSyncPush } from './push.js'; import { adbSyncPush } from './push.js';
import { adbSyncLstat, adbSyncStat } from './stat.js'; import { adbSyncLstat, adbSyncStat } from './stat.js';
/**
* A simplified `dirname` function that only handles absolute unix paths.
* @param path an absolute unix path
* @returns the directory name of the input path
*/
export function dirname(path: string): string {
const end = path.lastIndexOf('/');
if (end === -1) {
throw new Error(`Invalid path`);
}
if (end === 0) {
return '/';
}
return path.substring(0, end);
}
export class AdbSync extends AutoDisposable { export class AdbSync extends AutoDisposable {
protected adb: Adb; protected adb: Adb;
@ -22,6 +39,19 @@ export class AdbSync extends AutoDisposable {
return this.adb.features!.includes(AdbFeatures.StatV2); return this.adb.features!.includes(AdbFeatures.StatV2);
} }
public get supportsList2(): boolean {
return this.adb.features!.includes(AdbFeatures.ListV2);
}
public get fixedPushMkdir(): boolean {
return this.adb.features!.includes(AdbFeatures.FixedPushMkdir);
}
public get needPushMkdirWorkaround(): boolean {
// https://android.googlesource.com/platform/packages/modules/adb/+/91768a57b7138166e0a3d11f79cd55909dda7014/client/file_sync_client.cpp#1361
return this.adb.features!.includes(AdbFeatures.ShellV2) && !this.fixedPushMkdir;
}
public constructor(adb: Adb, socket: AdbSocket) { public constructor(adb: Adb, socket: AdbSocket) {
super(); super();
@ -65,18 +95,18 @@ export class AdbSync extends AutoDisposable {
public async *opendir( public async *opendir(
path: string path: string
): AsyncGenerator<AdbSyncEntryResponse, void, void> { ): AsyncGenerator<AdbSyncEntry, void, void> {
await this.sendLock.wait(); await this.sendLock.wait();
try { try {
yield* adbSyncOpenDir(this.stream, this.writer, path); yield* adbSyncOpenDir(this.stream, this.writer, path, this.supportsList2);
} finally { } finally {
this.sendLock.notify(); this.sendLock.notify();
} }
} }
public async readdir(path: string) { public async readdir(path: string) {
const results: AdbSyncEntryResponse[] = []; const results: AdbSyncEntry[] = [];
for await (const entry of this.opendir(path)) { for await (const entry of this.opendir(path)) {
results.push(entry); results.push(entry);
} }
@ -117,6 +147,18 @@ export class AdbSync extends AutoDisposable {
return new WrapWritableStream({ return new WrapWritableStream({
start: async () => { start: async () => {
await this.sendLock.wait(); await this.sendLock.wait();
if (this.needPushMkdirWorkaround) {
// It may fail if the path is already existed.
// Ignore the result.
// TODO: test this
await this.adb.subprocess.spawnAndWait([
'mkdir',
'-p',
escapeArg(dirname(filename)),
]);
}
return adbSyncPush( return adbSyncPush(
this.stream, this.stream,
this.writer, this.writer,

View file

@ -1,5 +1,9 @@
// The order follows
// https://android.googlesource.com/platform/packages/modules/adb/+/79010dc6d5ca7490c493df800d4421730f5466ca/transport.cpp#1252
export enum AdbFeatures { export enum AdbFeatures {
StatV2 = 'stat_v2', ShellV2 = "shell_v2",
Cmd = 'cmd', Cmd = 'cmd',
ShellV2 = "shell_v2" StatV2 = 'stat_v2',
ListV2 = 'ls_v2',
FixedPushMkdir = 'fixed_push_mkdir',
} }