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 }> {
try {
const dumpsysOutput = this.adb("shell", "dumpsys", "activity", "activities").toString();

// Try to find mCurrentFocus first
let focusMatch = dumpsysOutput.match(/mCurrentFocus=Window\{[^\s]+ u0 ([^\s/]+)\//);
if (focusMatch && focusMatch[1]) {
return { id: focusMatch[1] };
}

// Fallback to mResumedActivity
focusMatch = dumpsysOutput.match(/mResumedActivity=ActivityRecord\{[^\s]+ u0 ([^\s/]+)\//);
if (focusMatch && focusMatch[1]) {
return { id: focusMatch[1] };
}

// Fallback to mFocusedActivity
focusMatch = dumpsysOutput.match(/mFocusedActivity=ActivityRecord\{[^\s]+ u0 ([^\s/]+)\//);
if (focusMatch && focusMatch[1]) {
return { id: focusMatch[1] };
}

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
39 changes: 39 additions & 0 deletions src/ios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,45 @@ export class IosRobot implements Robot {
const wda = await this.wda();
return await wda.getOrientation();
}

public async getCurrentActivity(): Promise<{ id: string }> {
try {
const wda = await this.wda();
const source = await wda.getPageSource();

// The top-level element in the source tree represents the current app
// Try to extract bundle ID from the type attribute which usually contains it
const rootElement = source.value;

// Bundle ID is typically in the type field or name field
// Example: "XCUIElementTypeApplication" for the app under test
// The rawIdentifier often contains the bundle ID
if (rootElement.rawIdentifier) {
return { id: rootElement.rawIdentifier };
}

// Fallback: try to extract from type if it contains bundle info
if (rootElement.type) {
// Sometimes the type is formatted as "bundleId.ClassName"
const match = rootElement.type.match(/^([a-zA-Z0-9][a-zA-Z0-9.]*[a-zA-Z0-9])\.?/);
if (match) {
return { id: match[1] };
}
}

// Last fallback: try name
if (rootElement.name) {
return { id: rootElement.name };
}

throw new ActionableError("No app is in foreground. Please launch an app and try again.");
} 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
35 changes: 35 additions & 0 deletions src/iphone-simulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,4 +269,39 @@ export class Simctl implements Robot {
const wda = await this.wda();
return wda.getOrientation();
}

public async getCurrentActivity(): Promise<{ id: string }> {
try {
const wda = await this.wda();
const source = await wda.getPageSource();

// The top-level element in the source tree represents the current app
const rootElement = source.value;

// Bundle ID is typically in the rawIdentifier field
if (rootElement.rawIdentifier) {
return { id: rootElement.rawIdentifier };
}

// Fallback: try to extract from type if it contains bundle info
if (rootElement.type) {
const match = rootElement.type.match(/^([a-zA-Z0-9][a-zA-Z0-9.]*[a-zA-Z0-9])\.?/);
if (match) {
return { id: match[1] };
}
}

// Last fallback: try name
if (rootElement.name) {
return { id: rootElement.name };
}

throw new ActionableError("No app is in foreground. Please launch an app and try again.");
} catch (error) {
if (error instanceof ActionableError) {
throw error;
}
throw new ActionableError("Failed to get current app. Please ensure WebDriver Agent is running.");
}
}
}
17 changes: 16 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,19 @@ 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 }> {
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 };
}
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.");
}
}
}
7 changes: 7 additions & 0 deletions src/robot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,4 +143,11 @@ 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)
* @throws ActionableError if no activity is currently in focus
*/
getCurrentActivity(): Promise<{ id: string }>;
}
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;
};
13 changes: 13 additions & 0 deletions test/android.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,4 +136,17 @@ describe("android", () => {
// screen size should not have changed
assert.deepEqual(screenSize1, screenSize2);
});

it("should get current activity", async function() {
hasOneAndroidDevice || 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");
});
});
106 changes: 106 additions & 0 deletions test/current-activity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import assert from "node:assert";

import { AndroidRobot, AndroidDeviceManager } from "../src/android";
import { IosRobot, IosManager } from "../src/ios";

const androidManager = new AndroidDeviceManager();
const androidDevices = androidManager.getConnectedDevices();
const hasAndroidDevice = androidDevices.length === 1;

const iosManager = new IosManager();
const iosDevices = iosManager.listDevices();
const hasIosDevice = iosDevices.length === 1;

describe("getCurrentActivity", () => {
describe("Android", () => {
const android = new AndroidRobot(androidDevices?.[0]?.deviceId || "");

it("should return current activity as object with id field", async function() {
hasAndroidDevice || this.skip();

// First, ensure we're on the home screen
await android.pressButton("HOME");

// Get current activity
const activity = await android.getCurrentActivity();

// Should return an object with id field
assert.ok(activity, "Activity should be defined");
assert.ok("id" in activity, "Activity should have an id field");
assert.ok(typeof activity.id === "string", "Activity id should be a string");
assert.ok(activity.id.length > 0, "Activity id should not be empty");
});

it("should return correct package name after launching an app", async function() {
hasAndroidDevice || this.skip();

const testAppPackage = "com.android.settings";

// Launch the settings app
await android.launchApp(testAppPackage);

// Small delay to ensure app is focused
await new Promise(resolve => setTimeout(resolve, 500));

const activity = await android.getCurrentActivity();
assert.equal(activity.id, testAppPackage, `Expected ${testAppPackage} but got ${activity.id}`);
});

it("should throw ActionableError if no activity in focus", async function() {
// This test is skipped as it's difficult to reliably create a state
// where no activity is in focus on a real device
this.skip();
});
});

describe("iOS", () => {
const ios = new IosRobot(iosDevices?.[0]?.deviceId || "");

it("should return current activity as object with id field", async function() {
hasIosDevice || this.skip();

try {
const activity = await ios.getCurrentActivity();

// Should return an object with id field
assert.ok(activity, "Activity should be defined");
assert.ok("id" in activity, "Activity should have an id field");
assert.ok(typeof activity.id === "string", "Activity id should be a string");
assert.ok(activity.id.length > 0, "Activity id should not be empty");
} catch (error: any) {
// Skip if tunnel is not running or other setup issues
if (error.message.includes("tunnel") || error.message.includes("WebDriver")) {
this.skip();
} else {
throw error;
}
}
});

it("should return bundle ID format", async function() {
hasIosDevice || this.skip();

try {
const activity = await ios.getCurrentActivity();

// Bundle IDs typically contain dots (e.g., com.apple.mobilesafari)
// or are single words like "Health", "Maps", etc.
assert.ok(activity.id.length > 0, "Bundle ID should not be empty");

// Try to launch an app and verify we get its bundle ID
// Using com.apple.mobilesafari as it's typically available
await ios.launchApp("com.apple.mobilesafari");

const newActivity = await ios.getCurrentActivity();
assert.ok(newActivity.id.length > 0, "New activity id should not be empty");
} catch (error: any) {
// Skip if tunnel is not running or other setup issues
if (error.message.includes("tunnel") || error.message.includes("WebDriver")) {
this.skip();
} else {
throw error;
}
}
});
});
});
17 changes: 17 additions & 0 deletions test/ios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,21 @@ describe("ios", async () => {
assert.equal(Math.ceil(pngSize.width / screenSize.scale), screenSize.width);
assert.equal(Math.ceil(pngSize.height / screenSize.scale), screenSize.height);
});

it("should be able to get current activity", async function() {
hasOneDevice || this.skip();

try {
const activity = await robot.getCurrentActivity();
assert.ok(activity.id, "Activity id should be defined");
assert.ok(typeof activity.id === "string", "Activity id should be a string");
} catch (error: any) {
// Skip if tunnel is not running or WDA is not available
if (error.message.includes("tunnel") || error.message.includes("WebDriver")) {
this.skip();
} else {
throw error;
}
}
});
});