Skip to content

Commit b42b80e

Browse files
committed
fix: harden android snapshot helper packaging
1 parent 55e7da9 commit b42b80e

8 files changed

Lines changed: 222 additions & 173 deletions

File tree

.fallowrc.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"src/remote-config.ts",
1010
"src/install-source.ts",
1111
"src/android-apps.ts",
12+
"src/android-snapshot-helper.ts",
1213
"src/contracts.ts",
1314
"src/selectors.ts",
1415
"src/finders.ts",

.github/workflows/ci.yml

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,17 @@ jobs:
103103

104104
- name: Install Android SDK packages
105105
run: |
106-
yes | sdkmanager --licenses >/dev/null
107-
sdkmanager "platforms;android-36" "build-tools;36.0.0"
106+
SDK_ROOT="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-/usr/local/lib/android/sdk}}"
107+
SDKMANAGER="$SDK_ROOT/cmdline-tools/latest/bin/sdkmanager"
108+
if [ ! -x "$SDKMANAGER" ]; then
109+
SDKMANAGER="$SDK_ROOT/cmdline-tools/bin/sdkmanager"
110+
fi
111+
if [ ! -x "$SDKMANAGER" ]; then
112+
echo "sdkmanager not found under $SDK_ROOT" >&2
113+
exit 1
114+
fi
115+
yes | "$SDKMANAGER" --licenses >/dev/null
116+
"$SDKMANAGER" "platforms;android-36" "build-tools;36.0.0"
108117
109118
- name: Check Java toolchain
110119
run: |

.github/workflows/release-android-snapshot-helper.yml

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,17 @@ jobs:
3636

3737
- name: Install Android SDK packages
3838
run: |
39-
yes | sdkmanager --licenses >/dev/null
40-
sdkmanager "platforms;android-36" "build-tools;36.0.0"
39+
SDK_ROOT="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-/usr/local/lib/android/sdk}}"
40+
SDKMANAGER="$SDK_ROOT/cmdline-tools/latest/bin/sdkmanager"
41+
if [ ! -x "$SDKMANAGER" ]; then
42+
SDKMANAGER="$SDK_ROOT/cmdline-tools/bin/sdkmanager"
43+
fi
44+
if [ ! -x "$SDKMANAGER" ]; then
45+
echo "sdkmanager not found under $SDK_ROOT" >&2
46+
exit 1
47+
fi
48+
yes | "$SDKMANAGER" --licenses >/dev/null
49+
"$SDKMANAGER" "platforms;android-36" "build-tools;36.0.0"
4150
4251
- name: Check Java toolchain
4352
run: |
2.65 KB
Binary file not shown.

android-snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/SnapshotInstrumentation.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,15 @@ private static void appendEscaped(StringBuilder xml, String value) {
208208
case '\'':
209209
xml.append("'");
210210
break;
211+
case '\n':
212+
xml.append("
");
213+
break;
214+
case '\r':
215+
xml.append("
");
216+
break;
217+
case '\t':
218+
xml.append("	");
219+
break;
211220
default:
212221
xml.append(character);
213222
break;

scripts/build-android-snapshot-helper.sh

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ VERSION_CODE="$(
4848
BUILD_DIR="$HELPER_DIR/build"
4949
CLASSES_DIR="$BUILD_DIR/classes"
5050
DEX_DIR="$BUILD_DIR/dex"
51-
KEYSTORE="$BUILD_DIR/debug.keystore"
51+
KEYSTORE="$HELPER_DIR/debug.keystore"
5252
UNSIGNED_APK="$BUILD_DIR/helper-unsigned.apk"
5353
ALIGNED_APK="$BUILD_DIR/helper-aligned.apk"
5454
APK_PATH="$OUTPUT_DIR/$APK_BASENAME"
@@ -81,15 +81,10 @@ zip -q -j "$UNSIGNED_APK" "$DEX_DIR/classes.dex"
8181

8282
"$BUILD_TOOLS_DIR/zipalign" -f 4 "$UNSIGNED_APK" "$ALIGNED_APK"
8383

84-
keytool -genkeypair \
85-
-keystore "$KEYSTORE" \
86-
-storepass android \
87-
-keypass android \
88-
-alias androiddebugkey \
89-
-keyalg RSA \
90-
-keysize 2048 \
91-
-validity 10000 \
92-
-dname "CN=Android Debug,O=Android,C=US" >/dev/null 2>&1
84+
if [ ! -f "$KEYSTORE" ]; then
85+
echo "Missing Android snapshot helper signing keystore: $KEYSTORE" >&2
86+
exit 1
87+
fi
9388

9489
"$BUILD_TOOLS_DIR/apksigner" sign \
9590
--ks "$KEYSTORE" \

src/platforms/android/__tests__/snapshot.test.ts

Lines changed: 51 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -92,96 +92,53 @@ test('screenshotAndroid waits for transient UI to settle before capture', async
9292
});
9393

9494
test('screenshotAndroid writes a valid PNG when output is clean', async () => {
95-
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'screenshot-clean-'));
96-
try {
97-
const outPath = path.join(tmpDir, 'out.png');
95+
await withTempScreenshot('screenshot-clean-', async (outPath) => {
9896
await screenshotAndroid(device, outPath);
9997
const written = await fs.readFile(outPath);
10098
assert.deepEqual(written, VALID_PNG);
101-
} finally {
102-
await fs.rm(tmpDir, { recursive: true, force: true });
103-
}
99+
});
104100
});
105101

106102
test('screenshotAndroid strips warning text before PNG signature', async () => {
107103
const warning =
108104
'[Warning] Multiple displays were found, but no display id was specified! Defaulting to the first display found.';
109-
const payload = Buffer.concat([Buffer.from(warning), VALID_PNG]);
110-
mockRunCmd.mockImplementation(async (_cmd, args) => {
111-
if (args.includes('exec-out')) {
112-
return { exitCode: 0, stdout: '', stderr: '', stdoutBuffer: payload };
113-
}
114-
return { exitCode: 0, stdout: '', stderr: '' };
115-
});
105+
mockScreenshotPayload(Buffer.concat([Buffer.from(warning), VALID_PNG]));
116106

117-
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'screenshot-warning-'));
118-
try {
119-
const outPath = path.join(tmpDir, 'out.png');
107+
await withTempScreenshot('screenshot-warning-', async (outPath) => {
120108
await screenshotAndroid(device, outPath);
121109
const written = await fs.readFile(outPath);
122110
assert.deepEqual(written, VALID_PNG);
123-
} finally {
124-
await fs.rm(tmpDir, { recursive: true, force: true });
125-
}
111+
});
126112
});
127113

128114
test('screenshotAndroid strips trailing garbage after PNG payload', async () => {
129-
const payload = Buffer.concat([VALID_PNG, Buffer.from('\ntrailing-warning\n')]);
130-
mockRunCmd.mockImplementation(async (_cmd, args) => {
131-
if (args.includes('exec-out')) {
132-
return { exitCode: 0, stdout: '', stderr: '', stdoutBuffer: payload };
133-
}
134-
return { exitCode: 0, stdout: '', stderr: '' };
135-
});
115+
mockScreenshotPayload(Buffer.concat([VALID_PNG, Buffer.from('\ntrailing-warning\n')]));
136116

137-
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'screenshot-trailing-'));
138-
try {
139-
const outPath = path.join(tmpDir, 'out.png');
117+
await withTempScreenshot('screenshot-trailing-', async (outPath) => {
140118
await screenshotAndroid(device, outPath);
141119
const written = await fs.readFile(outPath);
142120
assert.deepEqual(written, VALID_PNG);
143-
} finally {
144-
await fs.rm(tmpDir, { recursive: true, force: true });
145-
}
121+
});
146122
});
147123

148124
test('screenshotAndroid throws when output contains no PNG signature', async () => {
149-
mockRunCmd.mockImplementation(async (_cmd, args) => {
150-
if (args.includes('exec-out')) {
151-
return { exitCode: 0, stdout: '', stderr: '', stdoutBuffer: Buffer.from('not a png') };
152-
}
153-
return { exitCode: 0, stdout: '', stderr: '' };
154-
});
125+
mockScreenshotPayload(Buffer.from('not a png'));
155126

156-
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'screenshot-nopng-'));
157-
try {
158-
const outPath = path.join(tmpDir, 'out.png');
127+
await withTempScreenshot('screenshot-nopng-', async (outPath) => {
159128
await assert.rejects(() => screenshotAndroid(device, outPath), {
160129
message: 'Screenshot data does not contain a valid PNG header',
161130
});
162-
} finally {
163-
await fs.rm(tmpDir, { recursive: true, force: true });
164-
}
131+
});
165132
});
166133

167134
test('screenshotAndroid throws when PNG payload is truncated', async () => {
168-
const payload = VALID_PNG.subarray(0, VALID_PNG.length - 3);
169-
mockRunCmd.mockImplementation(async (_cmd, args) => {
170-
if (args.includes('exec-out')) {
171-
return { exitCode: 0, stdout: '', stderr: '', stdoutBuffer: payload };
172-
}
173-
return { exitCode: 0, stdout: '', stderr: '' };
174-
});
135+
mockScreenshotPayload(VALID_PNG.subarray(0, VALID_PNG.length - 3));
175136

176-
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'screenshot-truncated-'));
177-
try {
178-
const outPath = path.join(tmpDir, 'out.png');
137+
await withTempScreenshot('screenshot-truncated-', async (outPath) => {
179138
await assert.rejects(() => screenshotAndroid(device, outPath), {
180139
message: 'Screenshot data does not contain a complete PNG payload',
181140
});
182-
} finally {
183-
await fs.rm(tmpDir, { recursive: true, force: true });
184-
}
141+
});
185142
});
186143

187144
function helperOutput(xml: string): string {
@@ -209,6 +166,39 @@ function helperOutput(xml: string): string {
209166
].join('\n');
210167
}
211168

169+
function mockScreenshotPayload(payload: Buffer): void {
170+
mockRunCmd.mockImplementation(async (_cmd, args) => {
171+
if (args.includes('exec-out')) {
172+
return { exitCode: 0, stdout: '', stderr: '', stdoutBuffer: payload };
173+
}
174+
return { exitCode: 0, stdout: '', stderr: '' };
175+
});
176+
}
177+
178+
async function withTempScreenshot(
179+
name: string,
180+
callback: (outPath: string) => Promise<void>,
181+
): Promise<void> {
182+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), name));
183+
try {
184+
await callback(path.join(tmpDir, 'out.png'));
185+
} finally {
186+
await fs.rm(tmpDir, { recursive: true, force: true });
187+
}
188+
}
189+
190+
function mockAndroidSnapshotXml(xml: string, activityDump = ''): void {
191+
mockRunCmd.mockImplementation(async (_cmd, args) => {
192+
if (args.includes('exec-out')) {
193+
return { exitCode: 0, stdout: xml, stderr: '' };
194+
}
195+
if (args.includes('dumpsys') && args.includes('activity') && args.includes('top')) {
196+
return { exitCode: 0, stdout: activityDump, stderr: '' };
197+
}
198+
throw new Error(`unexpected args: ${args.join(' ')}`);
199+
});
200+
}
201+
212202
test('dumpUiHierarchy returns streamed XML even when exec-out exits non-zero', async () => {
213203
const xml =
214204
'<?xml version="1.0" encoding="UTF-8"?><hierarchy><node text="streamed"/></hierarchy>';
@@ -456,15 +446,7 @@ test('snapshotAndroid preserves hidden scroll content hints in interactive snaps
456446
' com.facebook.react.views.view.ReactViewGroup{c V.E...... ........ 0,636-390,804 #3}',
457447
].join('\n');
458448

459-
mockRunCmd.mockImplementation(async (_cmd, args) => {
460-
if (args.includes('exec-out')) {
461-
return { exitCode: 0, stdout: xml, stderr: '' };
462-
}
463-
if (args.includes('dumpsys') && args.includes('activity') && args.includes('top')) {
464-
return { exitCode: 0, stdout: dump, stderr: '' };
465-
}
466-
throw new Error(`unexpected args: ${args.join(' ')}`);
467-
});
449+
mockAndroidSnapshotXml(xml, dump);
468450

469451
const result = await snapshotAndroid(device, { interactiveOnly: true });
470452
const scrollArea = result.nodes.find((node) => node.type === 'android.widget.ScrollView');
@@ -487,15 +469,7 @@ test('snapshotAndroid keeps generic-id scroll containers in interactive snapshot
487469
</node>
488470
</hierarchy>`;
489471

490-
mockRunCmd.mockImplementation(async (_cmd, args) => {
491-
if (args.includes('exec-out')) {
492-
return { exitCode: 0, stdout: xml, stderr: '' };
493-
}
494-
if (args.includes('dumpsys') && args.includes('activity') && args.includes('top')) {
495-
return { exitCode: 0, stdout: '', stderr: '' };
496-
}
497-
throw new Error(`unexpected args: ${args.join(' ')}`);
498-
});
472+
mockAndroidSnapshotXml(xml);
499473

500474
const result = await snapshotAndroid(device, { interactiveOnly: true });
501475
const scrollArea = result.nodes.find(
@@ -544,15 +518,7 @@ test('snapshotAndroid derives hidden content hints for interactive snapshots fro
544518
</node>
545519
</hierarchy>`;
546520

547-
mockRunCmd.mockImplementation(async (_cmd, args) => {
548-
if (args.includes('exec-out')) {
549-
return { exitCode: 0, stdout: xml, stderr: '' };
550-
}
551-
if (args.includes('dumpsys') && args.includes('activity') && args.includes('top')) {
552-
return { exitCode: 0, stdout: '', stderr: '' };
553-
}
554-
throw new Error(`unexpected args: ${args.join(' ')}`);
555-
});
521+
mockAndroidSnapshotXml(xml);
556522

557523
const result = await snapshotAndroid(device, { interactiveOnly: true });
558524
const scrollArea = result.nodes.find((node) => node.type === 'android.widget.ScrollView');
@@ -587,15 +553,7 @@ test('snapshotAndroid preserves bottomed-out hidden-above hints in interactive s
587553
' com.facebook.react.views.view.ReactViewGroup{c V.E...... ........ 0,636-390,804 #3}',
588554
].join('\n');
589555

590-
mockRunCmd.mockImplementation(async (_cmd, args) => {
591-
if (args.includes('exec-out')) {
592-
return { exitCode: 0, stdout: xml, stderr: '' };
593-
}
594-
if (args.includes('dumpsys') && args.includes('activity') && args.includes('top')) {
595-
return { exitCode: 0, stdout: dump, stderr: '' };
596-
}
597-
throw new Error(`unexpected args: ${args.join(' ')}`);
598-
});
556+
mockAndroidSnapshotXml(xml, dump);
599557

600558
const result = await snapshotAndroid(device, { interactiveOnly: true });
601559
const scrollArea = result.nodes.find(

0 commit comments

Comments
 (0)