Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions packages/android/src/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1421,6 +1421,55 @@ ${Object.keys(size)
return result;
}

/**
* Read the system clipboard via `dumpsys clipboard`. Useful for a value only exposed
* through a native "Copy" action (e.g. a share sheet's "Copy Link"), which has no other
* on-screen representation to read.
*
* There is no public `cmd clipboard get` on stock Android, so this parses `dumpsys`
* output, whose exact wording differs across Android versions/OEMs (e.g. `ClipData {
* text/plain "..." }` vs `ClipData.Item { T:"..." }`). Several known shapes are tried;
* returns an empty string if the clipboard is genuinely empty or its content is not text.
*/
async getClipboardText(): Promise<string> {
const adb = await this.getAdb();
const stdout = await adb.shell('dumpsys clipboard');

const patterns = [
/text\/plain["\s]*[:{]?\s*"([^"]*)"/, // ClipData { text/plain "..." }
/\bT:"([^"]*)"/, // ClipData.Item { T:"..." }
];
for (const pattern of patterns) {
const match = stdout.match(pattern);
if (match) {
return match[1];
}
}
return '';
}

/**
* Write plain text to the system clipboard via yadb's `-writeClipboard`, which
* goes through the platform `IClipboard` binder.
*
* Unlike reading, there is no `dumpsys` equivalent for writing and no public
* `cmd clipboard set` on stock Android, so yadb -- already required here for IME
* input and forced screenshots -- is the mechanism. Text is escaped the same way
* as yadb keyboard input, so quotes and newlines survive transport.
*/
async setClipboardText(text: string): Promise<void> {
this.warnYadbOnNonDefaultDisplay('clipboard write');
await this.ensureYadb();

const adb = await this.getAdb();
await adb.shell(
// `app_process` (ART launcher) does not accept the `-d <displayId>` flag.
`app_process -Djava.class.path=/data/local/tmp/yadb /data/local/tmp com.ysbing.yadb.Main -writeClipboard '${escapeForShell(
text,
)}'`,
);
}

async clearInput(element?: ElementInfo): Promise<void> {
if (element) {
await this.tapPoint({ x: element.center[0], y: element.center[1] });
Expand Down Expand Up @@ -2393,12 +2442,28 @@ export type DeviceActionRunAdbShell = DeviceAction<RunAdbShellParam, string>;
export type DeviceActionLaunch = DeviceAction<LaunchParam, void>;
export type DeviceActionTerminate = DeviceAction<TerminateParam, void>;

const setClipboardParamSchema = z.object({
text: z
.string()
.describe('Plain text to write to the Android system clipboard'),
});

type SetClipboardParam = z.infer<typeof setClipboardParamSchema>;

export type DeviceActionAndroidGetClipboard = DeviceAction<undefined, string>;
export type DeviceActionAndroidSetClipboard = DeviceAction<
SetClipboardParam,
void
>;

const createPlatformActions = (
device: AndroidDevice,
): {
RunAdbShell: DeviceActionRunAdbShell;
Launch: DeviceActionLaunch;
Terminate: DeviceActionTerminate;
AndroidGetClipboard: DeviceActionAndroidGetClipboard;
AndroidSetClipboard: DeviceActionAndroidSetClipboard;
} => {
return {
RunAdbShell: defineAction<
Expand Down Expand Up @@ -2461,6 +2526,32 @@ const createPlatformActions = (
await device.terminate(param.uri);
},
}),
AndroidGetClipboard: defineAction<undefined, undefined, string>({
name: 'AndroidGetClipboard',
description:
'Read the Android system clipboard and return its text. Use this to obtain a value that an app only exposes through a native "Copy" action -- e.g. a share sheet\'s "Copy Link" -- and that is therefore not rendered anywhere on screen.',
interfaceAlias: 'getClipboardText',
call: async () => {
return await device.getClipboardText();
},
}),
AndroidSetClipboard: defineAction<
typeof setClipboardParamSchema,
SetClipboardParam,
void
>({
name: 'AndroidSetClipboard',
description:
'Write plain text to the Android system clipboard so it can be pasted into an app.',
interfaceAlias: 'setClipboardText',
paramSchema: setClipboardParamSchema,
sample: {
text: 'https://example.com/invite/abc123',
},
call: async (param) => {
await device.setClipboardText(param.text);
},
}),
} as const;
};

Expand Down
78 changes: 78 additions & 0 deletions packages/android/tests/unit-test/page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,84 @@ describe('AndroidDevice', () => {
});
});

describe('getClipboardText', () => {
it('should parse a `ClipData { text/plain "..." }` dumpsys shape', async () => {
mockAdb.shell.mockResolvedValue(
'Clipboard state\n mPrimaryClip: ClipData { text/plain "https://example.com/j/123" }',
);
await expect(device.getClipboardText()).resolves.toBe(
'https://example.com/j/123',
);
expect(mockAdb.shell).toHaveBeenCalledWith('dumpsys clipboard');
});

it('should parse a `ClipData.Item { T:"..." }` dumpsys shape', async () => {
mockAdb.shell.mockResolvedValue(
'mPrimaryClip: ClipData.Item { T:"copied text here" }',
);
await expect(device.getClipboardText()).resolves.toBe('copied text here');
});

it('should return an empty string when the clipboard has no recognizable text', async () => {
mockAdb.shell.mockResolvedValue('Clipboard state\n (no primary clip)');
await expect(device.getClipboardText()).resolves.toBe('');
});
});

describe('setClipboardText', () => {
it('should write the clipboard through yadb', async () => {
await device.setClipboardText('https://example.com/j/123');

expect(mockAdb.push).toHaveBeenCalled();
expect(mockAdb.shell).toHaveBeenCalledWith(
"app_process -Djava.class.path=/data/local/tmp/yadb /data/local/tmp com.ysbing.yadb.Main -writeClipboard 'https://example.com/j/123'",
);
});

it('should escape quotes and newlines the same way as yadb keyboard input', async () => {
await device.setClipboardText("it's\ntwo lines");

expect(mockAdb.shell).toHaveBeenCalledWith(
expect.stringContaining("-writeClipboard 'it'\\''s\\ntwo lines'"),
);
});
});

describe('clipboard action space', () => {
it('should expose both clipboard actions', () => {
const actionNames = device.actionSpace().map((action) => action.name);
expect(actionNames).toContain('AndroidGetClipboard');
expect(actionNames).toContain('AndroidSetClipboard');
});

it('should read the clipboard through AndroidGetClipboard', async () => {
mockAdb.shell.mockResolvedValue(
'mPrimaryClip: ClipData { text/plain "from-action-space" }',
);

const action = device
.actionSpace()
.find((candidate) => candidate.name === 'AndroidGetClipboard');
expect(action).toBeDefined();

await expect(action!.call(undefined as never)).resolves.toBe(
'from-action-space',
);
});

it('should write the clipboard through AndroidSetClipboard', async () => {
const action = device
.actionSpace()
.find((candidate) => candidate.name === 'AndroidSetClipboard');
expect(action).toBeDefined();

await action!.call({ text: 'written-via-action' } as never);
expect(mockAdb.shell).toHaveBeenCalledWith(
expect.stringContaining("-writeClipboard 'written-via-action'"),
);
});
});

// Cross-platform contract for https://github.com/web-infra-dev/midscene/issues/2313:
// Launch/Terminate on every mobile platform must expose the SAME `uri` field.
// The shared tool-generator already rejects non-object schemas, but a future
Expand Down