Skip to content
31 changes: 31 additions & 0 deletions src/android.ts
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,37 @@ export class AndroidRobot implements Robot {
return rotation === "0" ? "portrait" : "landscape";
}

public async getCurrentActivity(): Promise<{ id: string; isCanonical: boolean }> {
try {
const dumpsysOutput = this.adb("shell", "dumpsys", "activity", "activities").toString();

// Try to find mResumedActivity first (preferred for foreground app)
let focusMatch = dumpsysOutput.match(/mResumedActivity=ActivityRecord\{[^\s]+ u\d+ ([^\s/]+)\//);
if (focusMatch && focusMatch[1]) {
return { id: focusMatch[1], isCanonical: true };
}

// Fallback to mCurrentFocus (may point to IME/system windows)
focusMatch = dumpsysOutput.match(/mCurrentFocus=Window\{[^\s]+ u\d+ ([^\s/]+)\//);
if (focusMatch && focusMatch[1]) {
return { id: focusMatch[1], isCanonical: true };
}

// Fallback to mFocusedActivity (legacy, dead on Android 9+)
focusMatch = dumpsysOutput.match(/mFocusedActivity=ActivityRecord\{[^\s]+ u\d+ ([^\s/]+)\//);
if (focusMatch && focusMatch[1]) {
return { id: focusMatch[1], isCanonical: true };
}

throw new ActionableError("No activity is currently in focus. Please launch an app and try again.");
} catch (error) {
if (error instanceof ActionableError) {
throw error;
}
throw new ActionableError("Failed to get current activity. Please ensure the device is properly connected.");
}
}

private async getUiAutomatorDump(): Promise<string> {
for (let tries = 0; tries < 10; tries++) {
const dump = this.adb("exec-out", "uiautomator", "dump", "/dev/tty").toString();
Expand Down
18 changes: 17 additions & 1 deletion src/ios.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Socket } from "node:net";
import { execFileSync } from "node:child_process";

import { WebDriverAgent } from "./webdriver-agent";
import { WebDriverAgent, parseWdaPageSourceForAppId } from "./webdriver-agent";
import { ActionableError, Button, InstalledApp, Robot, ScreenSize, SwipeDirection, ScreenElement, Orientation } from "./robot";

const WDA_PORT = 8100;
Expand Down Expand Up @@ -229,6 +229,22 @@ export class IosRobot implements Robot {
const wda = await this.wda();
return await wda.getOrientation();
}

public async getCurrentActivity(): Promise<{ id: string; isCanonical: boolean }> {
try {
const wda = await this.wda();
const [source, sessionBundleId] = await Promise.all([
wda.getPageSource(),
wda.getActiveSessionBundleId()
]);
return parseWdaPageSourceForAppId(source, sessionBundleId);
} catch (error) {
if (error instanceof ActionableError) {
throw error;
}
throw new ActionableError("Failed to get current app. Please ensure the device is properly connected and WebDriver Agent is running.");
}
}
}

export class IosManager {
Expand Down
18 changes: 17 additions & 1 deletion src/iphone-simulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
import { join, basename, extname } from "node:path";

import { trace } from "./logger";
import { WebDriverAgent } from "./webdriver-agent";
import { WebDriverAgent, parseWdaPageSourceForAppId } from "./webdriver-agent";
import { ActionableError, Button, InstalledApp, Robot, ScreenElement, ScreenSize, SwipeDirection, Orientation } from "./robot";

export interface Simulator {
Expand Down Expand Up @@ -269,4 +269,20 @@ export class Simctl implements Robot {
const wda = await this.wda();
return wda.getOrientation();
}

public async getCurrentActivity(): Promise<{ id: string; isCanonical: boolean }> {
try {
const wda = await this.wda();
const [source, sessionBundleId] = await Promise.all([
wda.getPageSource(),
wda.getActiveSessionBundleId()
]);
return parseWdaPageSourceForAppId(source, sessionBundleId);
} catch (error) {
if (error instanceof ActionableError) {
throw error;
}
throw new ActionableError("Failed to get current app. Please ensure WebDriver Agent is running.");
}
}
}
20 changes: 19 additions & 1 deletion src/mobile-device.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Mobilecli } from "./mobilecli";
import { Button, InstalledApp, Orientation, Robot, ScreenElement, ScreenSize, SwipeDirection } from "./robot";
import { ActionableError, Button, InstalledApp, Orientation, Robot, ScreenElement, ScreenSize, SwipeDirection } from "./robot";

interface InstalledAppsResponse {
status: "ok",
Expand Down Expand Up @@ -213,4 +213,22 @@ export class MobileDevice implements Robot {
const response = JSON.parse(this.runCommand(["device", "orientation", "get"])) as OrientationResponse;
return response.data.orientation;
}

public async getCurrentActivity(): Promise<{ id: string; isCanonical: boolean }> {
try {
const response = JSON.parse(this.runCommand(["device", "get-current-activity"])) as any;
if (response.status === "ok" && response.data?.id) {
return {
id: response.data.id,
isCanonical: response.data.isCanonical ?? true
};
}
throw new ActionableError("No activity is currently in focus. Please launch an app and try again.");
} catch (error) {
if (error instanceof ActionableError) {
throw error;
}
throw new ActionableError("Failed to get current activity. Please ensure the device is properly connected.");
}
}
}
8 changes: 8 additions & 0 deletions src/robot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,4 +143,12 @@ export interface Robot {
* Get the current screen orientation.
*/
getOrientation(): Promise<Orientation>;

/**
* Get the currently running activity (Android) or foreground app (iOS).
* @returns An object with the `id` field containing the package name (Android) or bundle ID (iOS),
* and `isCanonical` indicating if the ID is a canonical identifier.
* @throws ActionableError if no activity is currently in focus
*/
getCurrentActivity(): Promise<{ id: string; isCanonical: boolean }>;
}
15 changes: 15 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -637,5 +637,20 @@ export const createMcpServer = (): McpServer => {
}
);

tool(
"mobile_get_current_activity",
"Get Current Activity",
"Get the currently running activity (Android) or foreground app (iOS). Returns the package name for Android or bundle ID for iOS.",
{
device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you.")
},
{ readOnlyHint: true },
async ({ device }) => {
const robot = getRobotFromDevice(device);
const activity = await robot.getCurrentActivity();
return JSON.stringify(activity);
}
);

return server;
};
63 changes: 63 additions & 0 deletions src/webdriver-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,4 +451,67 @@ export class WebDriverAgent {
return json.value.toLowerCase() as Orientation;
});
}

public async getActiveSessionBundleId(): Promise<string | null> {
try {
const sessionsUrl = `http://${this.host}:${this.port}/sessions`;
const sessionsResponse = await fetch(sessionsUrl);
const sessionsJson = await sessionsResponse.json();
const sessions = sessionsJson.value || [];

if (sessions[0]?.id) {
const sessionId = sessions[0].id;
const sessionUrl = `http://${this.host}:${this.port}/session/${sessionId}`;
const response = await fetch(sessionUrl);
const json = await response.json();
// Some WDA versions put it in capabilities, some in the root
return json.value?.capabilities?.bundleId || json.value?.bundleId || null;
}
} catch (error) {
// ignore and fall back to tree parsing
}
return null;
}
}

/**
* Extracts the app bundle identifier from the WDA page source.
*
* Resolution order:
* 1. `rootElement.rawIdentifier`: Canonical bundle ID if present.
* 2. `sessionBundleId`: Canonical bundle ID derived from active session capabilities.
* 3. `rootElement.type`: Fallback if it matches bundle ID format (contains dots).
* 4. `rootElement.name`: Last-resort fallback. Note that this returns the app's
* accessibility label / display name (e.g., "Safari") which is non-canonical.
*
* @param source The WDA page source tree.
* @param sessionBundleId Optional bundle ID from active session capabilities.
* @returns An object containing the resolved app identifier and whether it is canonical.
*/
export function parseWdaPageSourceForAppId(source: SourceTree, sessionBundleId?: string | null): { id: string; isCanonical: boolean } {
const rootElement = source.value;

// 1. Prefer rawIdentifier (usually contains bundle ID)
if (rootElement.rawIdentifier) {
return { id: rootElement.rawIdentifier, isCanonical: true };
}

// 2. Fallback to bundleId from active session if provided
if (sessionBundleId) {
return { id: sessionBundleId, isCanonical: true };
}

// 3. Fallback to type if it looks like a bundle ID (contains a dot)
// We check for dots to avoid generic types like "XCUIElementTypeApplication"
if (rootElement.type && rootElement.type.includes(".")) {
return { id: rootElement.type, isCanonical: true };
}

// 4. Fallback to name (accessibility label/display name) as a last resort
// NOTE: This may return "Safari" instead of "com.apple.mobilesafari"
if (rootElement.name) {
return { id: rootElement.name, isCanonical: false };
}

throw new ActionableError("No app is in foreground. Please launch an app and try again.");
}
31 changes: 22 additions & 9 deletions test/android.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ import { AndroidRobot, AndroidDeviceManager } from "../src/android";

const manager = new AndroidDeviceManager();
const devices = manager.getConnectedDevices();
const hasOneAndroidDevice = devices.length === 1;
const hasAndroidDevice = devices.length > 0;

describe("android", () => {

const android = new AndroidRobot(devices?.[0]?.deviceId || "");

it("should be able to get the screen size", async function() {
hasOneAndroidDevice || this.skip();
hasAndroidDevice || this.skip();
const screenSize = await android.getScreenSize();
assert.ok(screenSize.width > 1024);
assert.ok(screenSize.height > 1024);
Expand All @@ -21,7 +21,7 @@ describe("android", () => {
});

it("should be able to take screenshot", async function() {
hasOneAndroidDevice || this.skip();
hasAndroidDevice || this.skip();

const screenSize = await android.getScreenSize();
const screenshot = await android.getScreenshot();
Expand All @@ -35,20 +35,20 @@ describe("android", () => {
});

it("should be able to list apps", async function() {
hasOneAndroidDevice || this.skip();
hasAndroidDevice || this.skip();
const apps = await android.listApps();
const packages = apps.map(app => app.packageName);
assert.ok(packages.includes("com.android.settings"));
});

it("should be able to open a url", async function() {
hasOneAndroidDevice || this.skip();
hasAndroidDevice || this.skip();
await android.adb("shell", "input", "keyevent", "HOME");
await android.openUrl("https://www.example.com");
});

it("should be able to list elements on screen", async function() {
hasOneAndroidDevice || this.skip();
hasAndroidDevice || this.skip();
await android.terminateApp("com.android.chrome");
await android.adb("shell", "input", "keyevent", "HOME");
await android.openUrl("https://www.example.com");
Expand All @@ -68,7 +68,7 @@ describe("android", () => {
});

it("should be able to send keys and tap", async function() {
hasOneAndroidDevice || this.skip();
hasAndroidDevice || this.skip();
await android.terminateApp("com.google.android.deskclock");
await android.adb("shell", "pm", "clear", "com.google.android.deskclock");
await android.launchApp("com.google.android.deskclock");
Expand Down Expand Up @@ -97,7 +97,7 @@ describe("android", () => {
});

it("should be able to launch and terminate an app", async function() {
hasOneAndroidDevice || this.skip();
hasAndroidDevice || this.skip();

// kill if running
await android.terminateApp("com.android.chrome");
Expand All @@ -113,7 +113,7 @@ describe("android", () => {
});

it("should handle orientation changes", async function() {
hasOneAndroidDevice || this.skip();
hasAndroidDevice || this.skip();

// assume we start in portrait
const originalOrientation = await android.getOrientation();
Expand All @@ -136,4 +136,17 @@ describe("android", () => {
// screen size should not have changed
assert.deepEqual(screenSize1, screenSize2);
});

it("should get current activity", async function() {
hasAndroidDevice || this.skip();

// Go to home screen
await android.pressButton("HOME");
await new Promise(resolve => setTimeout(resolve, 500));

// Get current activity
const activity = await android.getCurrentActivity();
assert.ok(activity.id, "Activity id should be defined");
assert.ok(typeof activity.id === "string", "Activity id should be a string");
});
});
Loading