diff --git a/packages/android/src/device.ts b/packages/android/src/device.ts index 9266cec349..3d1702ba13 100644 --- a/packages/android/src/device.ts +++ b/packages/android/src/device.ts @@ -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 { + 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 { + this.warnYadbOnNonDefaultDisplay('clipboard write'); + await this.ensureYadb(); + + const adb = await this.getAdb(); + await adb.shell( + // `app_process` (ART launcher) does not accept the `-d ` 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 { if (element) { await this.tapPoint({ x: element.center[0], y: element.center[1] }); @@ -2393,12 +2442,28 @@ export type DeviceActionRunAdbShell = DeviceAction; export type DeviceActionLaunch = DeviceAction; export type DeviceActionTerminate = DeviceAction; +const setClipboardParamSchema = z.object({ + text: z + .string() + .describe('Plain text to write to the Android system clipboard'), +}); + +type SetClipboardParam = z.infer; + +export type DeviceActionAndroidGetClipboard = DeviceAction; +export type DeviceActionAndroidSetClipboard = DeviceAction< + SetClipboardParam, + void +>; + const createPlatformActions = ( device: AndroidDevice, ): { RunAdbShell: DeviceActionRunAdbShell; Launch: DeviceActionLaunch; Terminate: DeviceActionTerminate; + AndroidGetClipboard: DeviceActionAndroidGetClipboard; + AndroidSetClipboard: DeviceActionAndroidSetClipboard; } => { return { RunAdbShell: defineAction< @@ -2461,6 +2526,32 @@ const createPlatformActions = ( await device.terminate(param.uri); }, }), + AndroidGetClipboard: defineAction({ + 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; }; diff --git a/packages/android/tests/unit-test/page.test.ts b/packages/android/tests/unit-test/page.test.ts index 1217e47bfd..734d15622e 100644 --- a/packages/android/tests/unit-test/page.test.ts +++ b/packages/android/tests/unit-test/page.test.ts @@ -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