openuispec 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. package/README.md +42 -3
  2. package/cli/init.ts +2 -0
  3. package/examples/social-app/AGENTS.md +1 -1
  4. package/examples/social-app/CLAUDE.md +1 -1
  5. package/examples/social-app/generated/android/social-app/app/.paparazzi-hashes.json +3 -0
  6. package/examples/social-app/generated/android/social-app/app/build.gradle.kts +2 -0
  7. package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/screens/HomeFeedScreen.kt +12 -0
  8. package/examples/social-app/generated/android/social-app/app/src/test/kotlin/com/social/app/screenshots/HomeFeedScreenshotTest.kt +34 -0
  9. package/examples/social-app/generated/android/social-app/build.gradle.kts +1 -0
  10. package/examples/social-app/generated/android/social-app/gradle/libs.versions.toml +2 -0
  11. package/examples/social-app/generated/android/social-app/gradle/wrapper/gradle-wrapper.jar +0 -0
  12. package/examples/social-app/generated/android/social-app/gradlew +239 -16
  13. package/examples/social-app/generated/android/social-app/settings.gradle.kts +4 -0
  14. package/examples/todo-orbit/generated/ios/Todo Orbit/.screenshot-uitest/Sources/ScreenshotUITest.swift +36 -0
  15. package/examples/todo-orbit/generated/ios/Todo Orbit/TodoOrbit.xcodeproj/project.pbxproj +204 -212
  16. package/examples/todo-orbit/generated/ios/Todo Orbit/project.yml +1 -0
  17. package/mcp-server/index.ts +64 -1
  18. package/mcp-server/screenshot-android.ts +462 -0
  19. package/mcp-server/screenshot-ios.ts +541 -0
  20. package/mcp-server/screenshot-shared.ts +200 -0
  21. package/mcp-server/screenshot.ts +15 -1
  22. package/package.json +3 -2
  23. package/examples/todo-orbit/generated/ios/Todo Orbit/TodoOrbit.xcodeproj/xcshareddata/xcschemes/TodoOrbit.xcscheme +0 -79
@@ -26,6 +26,8 @@ import { readFileSync as fsReadFileSync, existsSync, readdirSync } from "node:fs
26
26
  import { relative, resolve } from "node:path";
27
27
  import YAML from "yaml";
28
28
  import { takeScreenshot } from "./screenshot.js";
29
+ import { takeAndroidScreenshot } from "./screenshot-android.js";
30
+ import { takeIOSScreenshot } from "./screenshot-ios.js";
29
31
 
30
32
  // ── resolve project cwd ──────────────────────────────────────────────
31
33
 
@@ -149,6 +151,12 @@ VISUAL VERIFICATION:
149
151
  - openuispec_screenshot(route, viewport?, theme?) — screenshot the generated web app at a route.
150
152
  Starts the dev server automatically. Use after generation to visually verify UI matches the spec.
151
153
  Requires puppeteer (npm install -g puppeteer).
154
+ - openuispec_screenshot_android(screen?, theme?, wait_for?) — screenshot the generated Android app.
155
+ Builds APK, installs on emulator, and captures via adb screencap.
156
+ Shows the real app with navigation, images, and themes. Requires a running emulator.
157
+ - openuispec_screenshot_ios(screen?, device?, nav?, theme?, wait_for?) — screenshot the generated iOS app.
158
+ Builds with xcodebuild, installs on simulator, and captures via xcrun simctl.
159
+ Shows the real app with navigation, images, and themes. Requires Xcode.
152
160
 
153
161
  Skip these tools ONLY when the request is purely non-UI (API logic, database, infrastructure, etc.)
154
162
  or explicitly platform-specific polish that doesn't affect shared UI semantics.`,
@@ -696,9 +704,10 @@ server.registerTool(
696
704
  wait_for: z.number().optional().default(1000).describe("Milliseconds to wait after page load before screenshotting (default 1000)"),
697
705
  full_page: z.boolean().optional().default(false).describe("Capture the full scrollable page instead of just the viewport"),
698
706
  selector: z.string().optional().describe("CSS selector to screenshot a specific element instead of the full page"),
707
+ output_dir: z.string().optional().describe("Directory to save the screenshot PNG (relative to web app root). E.g. 'screenshots'. If omitted, only returns base64 in response."),
699
708
  },
700
709
  },
701
- async ({ route, viewport, theme, wait_for, full_page, selector }) => {
710
+ async ({ route, viewport, theme, wait_for, full_page, selector, output_dir }) => {
702
711
  try {
703
712
  return await takeScreenshot(projectCwd, {
704
713
  route,
@@ -707,6 +716,7 @@ server.registerTool(
707
716
  wait_for,
708
717
  full_page,
709
718
  selector,
719
+ output_dir,
710
720
  });
711
721
  } catch (err) {
712
722
  return toolError(err);
@@ -714,6 +724,59 @@ server.registerTool(
714
724
  }
715
725
  );
716
726
 
727
+ // ── tool: openuispec_screenshot_android ───────────────────────────────
728
+
729
+ server.registerTool(
730
+ "openuispec_screenshot_android",
731
+ {
732
+ description: "Take a screenshot of an Android app on an emulator. Builds the APK, installs it, launches the app, and captures via adb screencap. Shows the real app with navigation, images, and themes. Requires a running Android emulator. Works with any Android project — use project_dir to point directly at a project, or uses OpenUISpec manifest discovery if available.",
733
+ inputSchema: {
734
+ screen: z.string().optional().describe("Screen name for metadata and filename (e.g. 'home_feed')."),
735
+ route: z.string().optional().describe("Deep link URI to navigate to a specific screen (e.g. 'myapp://profile/123'). If omitted, launches the main activity."),
736
+ nav: z.array(z.string()).optional().describe("UI navigation steps — tap elements by visible text in order (e.g. ['Profile', 'Settings']). Executed after the app launches and loads."),
737
+ theme: z.enum(["light", "dark"]).optional().describe("Force light or dark mode via system UI mode"),
738
+ wait_for: z.number().optional().default(3000).describe("Milliseconds to wait after launch for content to load (default 3000)"),
739
+ output_dir: z.string().optional().describe("Directory to save the screenshot PNG (relative to Android project root). E.g. 'screenshots'. If omitted, only returns base64 in response."),
740
+ project_dir: z.string().optional().describe("Direct path to the Android project root (containing gradlew). Skips OpenUISpec manifest lookup. Use this for standalone Android projects."),
741
+ module: z.string().optional().describe("App module name (e.g. 'app', 'mobile'). If omitted, auto-detects by scanning settings.gradle for the module with com.android.application plugin."),
742
+ },
743
+ },
744
+ async ({ screen, route, nav, theme, wait_for, output_dir, project_dir, module }) => {
745
+ try {
746
+ return await takeAndroidScreenshot(projectCwd, { screen, route, nav, theme, wait_for, output_dir, project_dir, module });
747
+ } catch (err) {
748
+ return toolError(err);
749
+ }
750
+ }
751
+ );
752
+
753
+ // ── tool: openuispec_screenshot_ios ───────────────────────────────────
754
+
755
+ server.registerTool(
756
+ "openuispec_screenshot_ios",
757
+ {
758
+ description: "Take a screenshot of an iOS app on a Simulator. Builds with xcodebuild, installs on simulator, launches the app, and captures via xcrun simctl. Shows the real app with navigation, images, and themes. Requires Xcode. Works with any iOS project — use project_dir to point directly at a project, or uses OpenUISpec manifest discovery if available.",
759
+ inputSchema: {
760
+ screen: z.string().optional().describe("Screen name for metadata and filename (e.g. 'settings')."),
761
+ device: z.string().optional().describe("Simulator device name (e.g. 'iPhone 15', 'iPad Pro 11-inch (M4)'). If omitted, uses any booted iPhone or the first available one."),
762
+ nav: z.array(z.string()).optional().describe("UI navigation steps — tap elements by visible text in order (e.g. ['Profile', 'Settings']). Executed after the app launches and loads."),
763
+ theme: z.enum(["light", "dark"]).optional().describe("Force light or dark appearance on the simulator"),
764
+ wait_for: z.number().optional().default(3000).describe("Milliseconds to wait after launch for content to load (default 3000)"),
765
+ output_dir: z.string().optional().describe("Directory to save the screenshot PNG (relative to iOS project root). E.g. 'screenshots'. If omitted, only returns base64 in response."),
766
+ project_dir: z.string().optional().describe("Direct path to the iOS project root (containing .xcodeproj/.xcworkspace). Skips OpenUISpec manifest lookup. Use this for standalone iOS projects."),
767
+ scheme: z.string().optional().describe("Xcode scheme name to build. If omitted, auto-detects from xcshareddata/xcschemes or uses the project name."),
768
+ bundle_id: z.string().optional().describe("App bundle identifier (e.g. 'com.example.myapp'). If omitted, auto-detects from project.pbxproj."),
769
+ },
770
+ },
771
+ async ({ screen, device, nav, theme, wait_for, output_dir, project_dir, scheme, bundle_id }) => {
772
+ try {
773
+ return await takeIOSScreenshot(projectCwd, { screen, device, nav, theme, wait_for, output_dir, project_dir, scheme, bundle_id });
774
+ } catch (err) {
775
+ return toolError(err);
776
+ }
777
+ }
778
+ );
779
+
717
780
  // ── start server ─────────────────────────────────────────────────────
718
781
 
719
782
  export async function startMcpServer() {
@@ -0,0 +1,462 @@
1
+ /**
2
+ * Android screenshot tool — builds the app, installs on an emulator,
3
+ * and captures real screenshots via adb screencap.
4
+ *
5
+ * Gives pixel-perfect results with real navigation, images, themes,
6
+ * and all runtime behavior. Requires a running Android emulator.
7
+ */
8
+
9
+ import { exec as execCb, execSync } from "node:child_process";
10
+ import { promisify } from "node:util";
11
+ import { existsSync, readFileSync, unlinkSync, mkdirSync, copyFileSync } from "node:fs";
12
+ import { join, resolve } from "node:path";
13
+ import {
14
+ type ScreenshotResult,
15
+ findPlatformAppDir,
16
+ buildScreenshotResponse,
17
+ } from "./screenshot-shared.js";
18
+
19
+ const exec = promisify(execCb);
20
+
21
+ // ── types ───────────────────────────────────────────────────────────
22
+
23
+ export interface AndroidScreenshotOptions {
24
+ screen?: string;
25
+ route?: string;
26
+ nav?: string[];
27
+ theme?: "light" | "dark";
28
+ wait_for?: number;
29
+ output_dir?: string;
30
+ project_dir?: string;
31
+ module?: string;
32
+ }
33
+
34
+ // ── constants ───────────────────────────────────────────────────────
35
+
36
+ const ADB_SCREENSHOT_PATH = "/sdcard/openuispec_screenshot.png";
37
+
38
+ // ── Android app directory discovery ─────────────────────────────────
39
+
40
+ function isAndroidProject(dir: string): boolean {
41
+ return existsSync(join(dir, "gradlew")) ||
42
+ existsSync(join(dir, "app", "build.gradle.kts")) ||
43
+ existsSync(join(dir, "app", "build.gradle"));
44
+ }
45
+
46
+ export function findAndroidAppDir(projectCwd: string, directDir?: string): string {
47
+ return findPlatformAppDir(projectCwd, "android", isAndroidProject, directDir);
48
+ }
49
+
50
+ // ── app module auto-detection ────────────────────────────────────────
51
+
52
+ export function detectAppModule(androidDir: string): string {
53
+ // Read settings.gradle.kts or settings.gradle to find included modules
54
+ for (const settingsFile of ["settings.gradle.kts", "settings.gradle"]) {
55
+ const settingsPath = join(androidDir, settingsFile);
56
+ if (!existsSync(settingsPath)) continue;
57
+
58
+ try {
59
+ const content = readFileSync(settingsPath, "utf-8");
60
+ // Match include(":app"), include(":module1", ":module2"), include ":app"
61
+ const modules: string[] = [];
62
+ const includeMatches = content.matchAll(/include\s*\(?([^)\n]+)\)?/g);
63
+ for (const m of includeMatches) {
64
+ const args = m[1];
65
+ const moduleNames = args.matchAll(/[":]+([^",:)\s]+)/g);
66
+ for (const mn of moduleNames) {
67
+ modules.push(mn[1]);
68
+ }
69
+ }
70
+
71
+ // For each module, check if its build.gradle.kts/.gradle has com.android.application
72
+ for (const mod of modules) {
73
+ const modDir = join(androidDir, mod);
74
+ for (const buildFile of ["build.gradle.kts", "build.gradle"]) {
75
+ const buildPath = join(modDir, buildFile);
76
+ if (!existsSync(buildPath)) continue;
77
+ try {
78
+ const buildContent = readFileSync(buildPath, "utf-8");
79
+ if (buildContent.includes("com.android.application")) {
80
+ return mod;
81
+ }
82
+ } catch { /* skip */ }
83
+ }
84
+ }
85
+ } catch { /* skip */ }
86
+ }
87
+
88
+ return "app"; // fallback
89
+ }
90
+
91
+ // ── app package / activity extraction ───────────────────────────────
92
+
93
+ export interface AppInfo {
94
+ applicationId: string;
95
+ launchActivity: string;
96
+ moduleName: string;
97
+ }
98
+
99
+ function readBuildFile(androidDir: string, moduleName: string): string | null {
100
+ for (const filename of ["build.gradle.kts", "build.gradle"]) {
101
+ try { return readFileSync(join(androidDir, moduleName, filename), "utf-8"); } catch { /* skip */ }
102
+ }
103
+ return null;
104
+ }
105
+
106
+ export function extractAppInfo(androidDir: string, moduleOverride?: string): AppInfo {
107
+ const moduleName = moduleOverride ?? detectAppModule(androidDir);
108
+
109
+ // Try to get applicationId from build.gradle.kts or build.gradle
110
+ let applicationId = "";
111
+ const buildContent = readBuildFile(androidDir, moduleName);
112
+ if (buildContent) {
113
+ // Kotlin DSL: applicationId = "..."
114
+ const appIdMatch = buildContent.match(/applicationId\s*=\s*"([^"]+)"/);
115
+ if (appIdMatch) applicationId = appIdMatch[1];
116
+ if (!applicationId) {
117
+ // Groovy DSL: applicationId "..." or applicationId '...'
118
+ const groovyMatch = buildContent.match(/applicationId\s+['"]([^'"]+)['"]/);
119
+ if (groovyMatch) applicationId = groovyMatch[1];
120
+ }
121
+ if (!applicationId) {
122
+ const nsMatch = buildContent.match(/namespace\s*=?\s*['"]([^'"]+)['"]/);
123
+ if (nsMatch) applicationId = nsMatch[1];
124
+ }
125
+ }
126
+
127
+ // Get launch activity from AndroidManifest.xml
128
+ let launchActivity = ".MainActivity";
129
+ const manifestPath = join(androidDir, moduleName, "src", "main", "AndroidManifest.xml");
130
+ try {
131
+ const manifest = readFileSync(manifestPath, "utf-8");
132
+ // Find activity with MAIN/LAUNCHER intent filter
133
+ const activityMatch = manifest.match(
134
+ /<activity[^>]*android:name="([^"]+)"[^]*?action android:name="android\.intent\.action\.MAIN"/,
135
+ );
136
+ if (activityMatch) launchActivity = activityMatch[1];
137
+
138
+ // If applicationId still empty, try manifest package
139
+ if (!applicationId) {
140
+ const pkgMatch = manifest.match(/package="([^"]+)"/);
141
+ if (pkgMatch) applicationId = pkgMatch[1];
142
+ }
143
+ } catch { /* use defaults */ }
144
+
145
+ if (!applicationId) {
146
+ throw new Error(
147
+ `Could not determine applicationId from ${moduleName}/build.gradle.kts (or .gradle) or AndroidManifest.xml`,
148
+ );
149
+ }
150
+
151
+ // Resolve relative activity name
152
+ const fullActivity = launchActivity.startsWith(".")
153
+ ? `${applicationId}${launchActivity}`
154
+ : launchActivity;
155
+
156
+ return { applicationId, launchActivity: fullActivity, moduleName };
157
+ }
158
+
159
+ // ── adb helpers ─────────────────────────────────────────────────────
160
+
161
+ export function findAdb(): string {
162
+ // Check ANDROID_HOME first
163
+ const androidHome = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT;
164
+ if (androidHome) {
165
+ const adbPath = join(androidHome, "platform-tools", "adb");
166
+ if (existsSync(adbPath)) return adbPath;
167
+ }
168
+ // Fall back to PATH
169
+ try {
170
+ execSync("which adb", { stdio: "pipe" });
171
+ return "adb";
172
+ } catch {
173
+ throw new Error(
174
+ "adb not found. Set ANDROID_HOME or add platform-tools to PATH.\n" +
175
+ "Install via Android Studio or: sdkmanager 'platform-tools'",
176
+ );
177
+ }
178
+ }
179
+
180
+ export async function getConnectedEmulator(adb: string): Promise<string> {
181
+ try {
182
+ const { stdout } = await exec(`${adb} devices`);
183
+ const lines = stdout.trim().split("\n").slice(1); // skip header
184
+ for (const line of lines) {
185
+ const [serial, state] = line.split("\t");
186
+ if (state === "device" && (serial.startsWith("emulator-") || serial.includes(":"))) {
187
+ return serial;
188
+ }
189
+ }
190
+ // Also accept physical devices if no emulator
191
+ for (const line of lines) {
192
+ const [serial, state] = line.split("\t");
193
+ if (state === "device") return serial;
194
+ }
195
+ } catch { /* fall through */ }
196
+
197
+ throw new Error(
198
+ "No connected Android device or emulator found.\n" +
199
+ "Start an emulator from Android Studio or run: emulator -avd <avd_name>",
200
+ );
201
+ }
202
+
203
+ export async function adbShell(adb: string, serial: string, cmd: string): Promise<string> {
204
+ const { stdout } = await exec(`${adb} -s ${serial} shell ${cmd}`, { timeout: 30_000 });
205
+ return stdout.trim();
206
+ }
207
+
208
+ export async function adbExec(adb: string, serial: string, args: string): Promise<string> {
209
+ const { stdout } = await exec(`${adb} -s ${serial} ${args}`, { timeout: 60_000 });
210
+ return stdout.trim();
211
+ }
212
+
213
+ // ── build APK ───────────────────────────────────────────────────────
214
+
215
+ export async function buildApk(androidDir: string, moduleName: string): Promise<string> {
216
+ if (!existsSync(join(androidDir, "gradlew"))) {
217
+ throw new Error(`No gradlew found in ${androidDir}. Initialize the Gradle wrapper first.`);
218
+ }
219
+
220
+ try {
221
+ await exec(`./gradlew :${moduleName}:assembleDebug`, {
222
+ cwd: androidDir,
223
+ timeout: 300_000,
224
+ env: { ...process.env, JAVA_OPTS: "-Xmx2g" },
225
+ });
226
+ } catch (err: any) {
227
+ const output = ((err.stderr ?? "") + "\n" + (err.stdout ?? "")).slice(-500);
228
+ throw new Error(`APK build failed:\n${output}`);
229
+ }
230
+
231
+ // Find the built APK
232
+ const apkCandidates = [
233
+ join(androidDir, moduleName, "build", "outputs", "apk", "debug", `${moduleName}-debug.apk`),
234
+ join(androidDir, moduleName, "build", "outputs", "apk", "debug", `${moduleName}-debug-unsigned.apk`),
235
+ // Common fallback names
236
+ join(androidDir, moduleName, "build", "outputs", "apk", "debug", "app-debug.apk"),
237
+ join(androidDir, moduleName, "build", "outputs", "apk", "debug", "app-debug-unsigned.apk"),
238
+ ];
239
+ for (const apk of apkCandidates) {
240
+ if (existsSync(apk)) return apk;
241
+ }
242
+
243
+ throw new Error(`APK not found after build in ${moduleName}/build/outputs/. Check Gradle output.`);
244
+ }
245
+
246
+ // ── install & launch ────────────────────────────────────────────────
247
+
248
+ export async function installAndLaunch(
249
+ adb: string,
250
+ serial: string,
251
+ apkPath: string,
252
+ appInfo: AppInfo,
253
+ route?: string,
254
+ ): Promise<void> {
255
+ // Install (replace existing)
256
+ await adbExec(adb, serial, `install -r "${apkPath}"`);
257
+
258
+ // Force-stop any existing instance
259
+ await adbShell(adb, serial, `am force-stop ${appInfo.applicationId}`);
260
+
261
+ if (route) {
262
+ // Navigate via deep link
263
+ await adbShell(adb, serial, `am start -a android.intent.action.VIEW -d "${route}" ${appInfo.applicationId}`);
264
+ } else {
265
+ // Launch the main activity
266
+ await adbShell(adb, serial, `am start -n ${appInfo.applicationId}/${appInfo.launchActivity}`);
267
+ }
268
+ }
269
+
270
+ // ── theme control ───────────────────────────────────────────────────
271
+
272
+ export async function setTheme(adb: string, serial: string, theme: "light" | "dark"): Promise<void> {
273
+ const mode = theme === "dark" ? "yes" : "no";
274
+ await adbShell(adb, serial, `cmd uimode night ${mode}`);
275
+ }
276
+
277
+ // ── UI navigation via tap ───────────────────────────────────────────
278
+
279
+ export async function tapByText(adb: string, serial: string, text: string): Promise<void> {
280
+ // Dump UI hierarchy
281
+ await adbShell(adb, serial, `uiautomator dump /sdcard/ui_dump.xml`);
282
+ const xml = await adbShell(adb, serial, `cat /sdcard/ui_dump.xml`);
283
+ await adbShell(adb, serial, `rm /sdcard/ui_dump.xml`);
284
+
285
+ const lowerText = text.toLowerCase();
286
+
287
+ // Parse each <node .../> extracting text, content-desc, and bounds
288
+ const nodeRegex = /<node\s[^>]+>/g;
289
+ interface UiNode { text: string; desc: string; cx: number; cy: number }
290
+ const nodes: UiNode[] = [];
291
+
292
+ let nodeMatch;
293
+ while ((nodeMatch = nodeRegex.exec(xml)) !== null) {
294
+ const attrs = nodeMatch[0];
295
+ const textMatch = attrs.match(/\btext="([^"]*)"/);
296
+ const descMatch = attrs.match(/\bcontent-desc="([^"]*)"/);
297
+ const boundsMatch = attrs.match(/\bbounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/);
298
+ if (!boundsMatch) continue;
299
+
300
+ nodes.push({
301
+ text: textMatch?.[1] ?? "",
302
+ desc: descMatch?.[1] ?? "",
303
+ cx: Math.round((parseInt(boundsMatch[1]) + parseInt(boundsMatch[3])) / 2),
304
+ cy: Math.round((parseInt(boundsMatch[2]) + parseInt(boundsMatch[4])) / 2),
305
+ });
306
+ }
307
+
308
+ // Exact match on text or content-desc
309
+ for (const node of nodes) {
310
+ if (node.text.toLowerCase() === lowerText || node.desc.toLowerCase() === lowerText) {
311
+ await adbShell(adb, serial, `input tap ${node.cx} ${node.cy}`);
312
+ return;
313
+ }
314
+ }
315
+
316
+ // Partial/contains match
317
+ for (const node of nodes) {
318
+ if (node.text.toLowerCase().includes(lowerText) || node.desc.toLowerCase().includes(lowerText)) {
319
+ await adbShell(adb, serial, `input tap ${node.cx} ${node.cy}`);
320
+ return;
321
+ }
322
+ }
323
+
324
+ throw new Error(`UI element with text "${text}" not found on screen.`);
325
+ }
326
+
327
+ export async function navigateByTaps(
328
+ adb: string,
329
+ serial: string,
330
+ steps: string[],
331
+ ): Promise<void> {
332
+ for (const step of steps) {
333
+ await tapByText(adb, serial, step);
334
+ // Wait for navigation animation
335
+ await new Promise(r => setTimeout(r, 800));
336
+ }
337
+ }
338
+
339
+ // ── screenshot capture ──────────────────────────────────────────────
340
+
341
+ export async function captureScreenshot(
342
+ adb: string,
343
+ serial: string,
344
+ localPath: string,
345
+ ): Promise<void> {
346
+ await adbShell(adb, serial, `screencap -p ${ADB_SCREENSHOT_PATH}`);
347
+ await adbExec(adb, serial, `pull ${ADB_SCREENSHOT_PATH} "${localPath}"`);
348
+ await adbShell(adb, serial, `rm ${ADB_SCREENSHOT_PATH}`);
349
+ }
350
+
351
+ // ── wait for app ready ──────────────────────────────────────────────
352
+
353
+ async function waitForAppReady(
354
+ adb: string,
355
+ serial: string,
356
+ applicationId: string,
357
+ waitMs: number,
358
+ ): Promise<void> {
359
+ // Wait for the activity to be in resumed state
360
+ const startTime = Date.now();
361
+ const timeout = Math.min(waitMs, 15_000);
362
+
363
+ while (Date.now() - startTime < timeout) {
364
+ try {
365
+ const output = await adbShell(adb, serial,
366
+ `dumpsys activity activities | grep -E "mResumedActivity|topResumedActivity"`,
367
+ );
368
+ if (output.includes(applicationId)) {
369
+ // App is in foreground, wait the remaining time for content to load
370
+ const elapsed = Date.now() - startTime;
371
+ const remaining = Math.max(waitMs - elapsed, 500);
372
+ await new Promise(r => setTimeout(r, remaining));
373
+ return;
374
+ }
375
+ } catch { /* activity not ready yet */ }
376
+ await new Promise(r => setTimeout(r, 500));
377
+ }
378
+
379
+ // Fallback: just wait the full duration
380
+ await new Promise(r => setTimeout(r, waitMs));
381
+ }
382
+
383
+ // ── main entry point ────────────────────────────────────────────────
384
+
385
+ export async function takeAndroidScreenshot(
386
+ projectCwd: string,
387
+ options: AndroidScreenshotOptions,
388
+ ): Promise<ScreenshotResult> {
389
+ const {
390
+ screen,
391
+ route,
392
+ nav,
393
+ theme,
394
+ wait_for = 3000,
395
+ output_dir,
396
+ project_dir,
397
+ module,
398
+ } = options;
399
+
400
+ // 1. Find Android project
401
+ const androidDir = findAndroidAppDir(projectCwd, project_dir);
402
+ const appInfo = extractAppInfo(androidDir, module);
403
+
404
+ // 2. Find adb and emulator
405
+ const adb = findAdb();
406
+ const serial = await getConnectedEmulator(adb);
407
+
408
+ // 3. Build APK
409
+ const apkPath = await buildApk(androidDir, appInfo.moduleName);
410
+
411
+ // 4. Set theme if requested
412
+ if (theme) {
413
+ await setTheme(adb, serial, theme);
414
+ }
415
+
416
+ // 5. Install and launch
417
+ await installAndLaunch(adb, serial, apkPath, appInfo, route);
418
+
419
+ // 6. Wait for app to be ready and content to load
420
+ await waitForAppReady(adb, serial, appInfo.applicationId, wait_for);
421
+
422
+ // 7. Navigate via UI taps if specified
423
+ if (nav && nav.length > 0) {
424
+ await navigateByTaps(adb, serial, nav);
425
+ }
426
+
427
+ // 8. Capture screenshot
428
+ const screenLabel = screen ?? "main";
429
+ const themeLabel = theme ?? "default";
430
+ const filename = `${screenLabel}_${themeLabel}.png`;
431
+ const tmpPath = join(androidDir, ".openuispec-screenshot.png");
432
+ await captureScreenshot(adb, serial, tmpPath);
433
+
434
+ // 9. Save to output_dir if specified
435
+ let savedPath: string | undefined;
436
+ if (output_dir) {
437
+ const outDir = resolve(androidDir, output_dir);
438
+ mkdirSync(outDir, { recursive: true });
439
+ savedPath = join(outDir, filename);
440
+ copyFileSync(tmpPath, savedPath);
441
+ }
442
+
443
+ // 10. Read and return
444
+ try {
445
+ const data = readFileSync(tmpPath).toString("base64");
446
+ const snapshots = [{
447
+ screen: screenLabel,
448
+ path: savedPath ?? filename,
449
+ data,
450
+ }];
451
+
452
+ return buildScreenshotResponse(snapshots, (s) => ({
453
+ screen: s.screen,
454
+ path: savedPath ?? null,
455
+ emulator: serial,
456
+ theme: themeLabel,
457
+ applicationId: appInfo.applicationId,
458
+ }));
459
+ } finally {
460
+ try { unlinkSync(tmpPath); } catch { /* ignore */ }
461
+ }
462
+ }