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.");
}
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");
});
});
120 changes: 120 additions & 0 deletions test/current-activity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
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");

// Verify bundle ID format (at least one dot for typical apps)
// or common single-word system apps
const isBundleIdFormat = /\./.test(activity.id);
const isSystemApp = ["Health", "Maps", "Settings", "Photos"].includes(activity.id);
assert.ok(isBundleIdFormat || isSystemApp, `Bundle ID "${activity.id}" should have a reverse-DNS format or be a known system app`);

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

// Small poll to let Safari become foreground
const deadline = Date.now() + 5000;
let newActivity = await ios.getCurrentActivity();
while (newActivity.id !== testBundleId && Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 500));
newActivity = await ios.getCurrentActivity();
}

assert.strictEqual(newActivity.id, testBundleId, `Expected ${testBundleId} but got ${newActivity.id}`);
} 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;
}
}
});
});