Skip to content

Commit 4df80af

Browse files
thymikeeclaude
andauthored
fix: strip pre-PNG garbage from Android screenshots on multi-display devices (#185)
* fix: strip pre-PNG garbage from Android screenshots on multi-display devices On Android emulators with multiple displays (e.g. Galaxy Z Fold), `adb exec-out screencap -p` writes a warning to stdout before the PNG data, producing corrupt files. Locate the PNG signature in the buffer and discard everything before it. https://claude.ai/code/session_01Lfb3WKqXCSqYtCg8dbS8JW * refactor: simplify PNG stripping and test scripts - Remove unnecessary ternary; subarray(0) is a no-op - Replace hex-encoded printf mock scripts with file-based payloads https://claude.ai/code/session_01Lfb3WKqXCSqYtCg8dbS8JW * fix: trim Android screenshot output to complete PNG payload --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e248c87 commit 4df80af

2 files changed

Lines changed: 132 additions & 1 deletion

File tree

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { promises as fs } from 'node:fs';
4+
import os from 'node:os';
5+
import path from 'node:path';
6+
import { screenshotAndroid } from '../index.ts';
7+
import type { DeviceInfo } from '../../../utils/device.ts';
8+
9+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
10+
const VALID_PNG = Buffer.from(
11+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+b9xkAAAAASUVORK5CYII=',
12+
'base64',
13+
);
14+
15+
async function withMockedAdb(
16+
tempPrefix: string,
17+
script: string,
18+
run: (ctx: { device: DeviceInfo; tmpDir: string }) => Promise<void>,
19+
payload?: Buffer,
20+
): Promise<void> {
21+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), tempPrefix));
22+
const adbPath = path.join(tmpDir, 'adb');
23+
await fs.writeFile(adbPath, script, 'utf8');
24+
await fs.chmod(adbPath, 0o755);
25+
if (payload) {
26+
await fs.writeFile(path.join(tmpDir, 'payload.bin'), payload);
27+
}
28+
29+
const previousPath = process.env.PATH;
30+
process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`;
31+
32+
const device: DeviceInfo = {
33+
platform: 'android',
34+
id: 'emulator-5554',
35+
name: 'Pixel',
36+
kind: 'emulator',
37+
booted: true,
38+
};
39+
40+
try {
41+
await run({ device, tmpDir });
42+
} finally {
43+
process.env.PATH = previousPath;
44+
await fs.rm(tmpDir, { recursive: true, force: true });
45+
}
46+
}
47+
48+
const catPayload = '#!/bin/bash\ncat "$(dirname "$0")/payload.bin"\n';
49+
50+
test('screenshotAndroid writes a valid PNG when output is clean', async () => {
51+
await withMockedAdb('screenshot-clean-', catPayload, async ({ device, tmpDir }) => {
52+
const outPath = path.join(tmpDir, 'out.png');
53+
await screenshotAndroid(device, outPath);
54+
const written = await fs.readFile(outPath);
55+
assert.deepEqual(written, VALID_PNG);
56+
}, VALID_PNG);
57+
});
58+
59+
test('screenshotAndroid strips warning text before PNG signature', async () => {
60+
const warning =
61+
'[Warning] Multiple displays were found, but no display id was specified! Defaulting to the first display found.';
62+
const payload = Buffer.concat([Buffer.from(warning), VALID_PNG]);
63+
64+
await withMockedAdb('screenshot-warning-', catPayload, async ({ device, tmpDir }) => {
65+
const outPath = path.join(tmpDir, 'out.png');
66+
await screenshotAndroid(device, outPath);
67+
const written = await fs.readFile(outPath);
68+
assert.deepEqual(written, VALID_PNG);
69+
}, payload);
70+
});
71+
72+
test('screenshotAndroid strips trailing garbage after PNG payload', async () => {
73+
const payload = Buffer.concat([VALID_PNG, Buffer.from('\ntrailing-warning\n')]);
74+
75+
await withMockedAdb('screenshot-trailing-', catPayload, async ({ device, tmpDir }) => {
76+
const outPath = path.join(tmpDir, 'out.png');
77+
await screenshotAndroid(device, outPath);
78+
const written = await fs.readFile(outPath);
79+
assert.deepEqual(written, VALID_PNG);
80+
}, payload);
81+
});
82+
83+
test('screenshotAndroid throws when output contains no PNG signature', async () => {
84+
const script = '#!/bin/bash\necho "not a png"\n';
85+
86+
await withMockedAdb('screenshot-nopng-', script, async ({ device, tmpDir }) => {
87+
const outPath = path.join(tmpDir, 'out.png');
88+
await assert.rejects(() => screenshotAndroid(device, outPath), {
89+
message: 'Screenshot data does not contain a valid PNG header',
90+
});
91+
});
92+
});
93+
94+
test('screenshotAndroid throws when PNG payload is truncated', async () => {
95+
const payload = VALID_PNG.subarray(0, VALID_PNG.length - 3);
96+
97+
await withMockedAdb('screenshot-truncated-', catPayload, async ({ device, tmpDir }) => {
98+
const outPath = path.join(tmpDir, 'out.png');
99+
await assert.rejects(() => screenshotAndroid(device, outPath), {
100+
message: 'Screenshot data does not contain a complete PNG payload',
101+
});
102+
}, payload);
103+
});

src/platforms/android/snapshot.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,28 @@ export async function snapshotAndroid(
1818
return parseUiHierarchy(xml, 800, options);
1919
}
2020

21+
// PNG file signature: 0x89 P N G \r \n 0x1A \n
22+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
23+
2124
export async function screenshotAndroid(device: DeviceInfo, outPath: string): Promise<void> {
2225
const result = await runCmd('adb', adbArgs(device, ['exec-out', 'screencap', '-p']), {
2326
binaryStdout: true,
2427
});
2528
if (!result.stdoutBuffer) {
2629
throw new AppError('COMMAND_FAILED', 'Failed to capture screenshot');
2730
}
28-
await fs.writeFile(outPath, result.stdoutBuffer);
31+
// On multi-display devices (e.g. Galaxy Z Fold), adb screencap may write a
32+
// warning to stdout before the PNG data. Strip any leading garbage by
33+
// locating the PNG signature and discarding everything before it.
34+
const pngOffset = result.stdoutBuffer.indexOf(PNG_SIGNATURE);
35+
if (pngOffset < 0) {
36+
throw new AppError('COMMAND_FAILED', 'Screenshot data does not contain a valid PNG header');
37+
}
38+
const pngEndOffset = findPngEndOffset(result.stdoutBuffer, pngOffset);
39+
if (!pngEndOffset) {
40+
throw new AppError('COMMAND_FAILED', 'Screenshot data does not contain a complete PNG payload');
41+
}
42+
await fs.writeFile(outPath, result.stdoutBuffer.subarray(pngOffset, pngEndOffset));
2943
}
3044

3145
export async function dumpUiHierarchy(device: DeviceInfo): Promise<string> {
@@ -83,6 +97,20 @@ function extractUiDumpXml(stdout: string, stderr: string): string | null {
8397
return xml.length > 0 ? xml : null;
8498
}
8599

100+
function findPngEndOffset(buffer: Buffer, pngStartOffset: number): number | null {
101+
let offset = pngStartOffset + PNG_SIGNATURE.length;
102+
while (offset + 8 <= buffer.length) {
103+
const chunkLength = buffer.readUInt32BE(offset);
104+
const chunkTypeOffset = offset + 4;
105+
const chunkType = buffer.toString('ascii', chunkTypeOffset, chunkTypeOffset + 4);
106+
const chunkEnd = offset + 12 + chunkLength; // len(4) + type(4) + data + crc(4)
107+
if (chunkEnd > buffer.length) return null;
108+
if (chunkType === 'IEND') return chunkEnd;
109+
offset = chunkEnd;
110+
}
111+
return null;
112+
}
113+
86114
function isRetryableAdbError(err: unknown): boolean {
87115
if (!(err instanceof AppError)) return false;
88116
if (err.code !== 'COMMAND_FAILED') return false;

0 commit comments

Comments
 (0)