import { spawn } from "node:child_process";
export type AdbDevice = {
serial: string;
state: string;
model?: string;
device?: string;
transportId?: string;
product?: string;
};
export type ExecResult = {
code: number;
stdout: string;
stderr: string;
};
function collectOutput(proc: ReturnType<typeof spawn>): Promise<ExecResult> {
return new Promise((resolve) => {
let stdout = "";
let stderr = "";
proc.stdout?.setEncoding("utf8");
proc.stderr?.setEncoding("utf8");
proc.stdout?.on("data", (d) => (stdout += d));
proc.stderr?.on("data", (d) => (stderr += d));
proc.on("close", (code) => resolve({ code: code ?? -1, stdout, stderr }));
});
}
export async function adbExec(
adbPath: string,
args: string[],
opts?: { timeoutMs?: number }
): Promise<ExecResult> {
const proc = spawn(adbPath, args, { stdio: ["ignore", "pipe", "pipe"] });
const timeoutMs = opts?.timeoutMs ?? 0;
let timeout: NodeJS.Timeout | undefined;
const resultPromise = collectOutput(proc);
if (timeoutMs > 0) {
timeout = setTimeout(() => {
try {
proc.kill();
} catch {
// ignore
}
}, timeoutMs);
}
const result = await resultPromise;
if (timeout) clearTimeout(timeout);
return result;
}
export async function adbShell(
adbPath: string,
serial: string,
shellArgs: string[]
): Promise<ExecResult> {
return adbExec(adbPath, ["-s", serial, "shell", ...shellArgs]);
}
export async function listDevices(adbPath: string): Promise<AdbDevice[]> {
const res = await adbExec(adbPath, ["devices", "-l"]);
if (res.code !== 0) throw new Error(`adb devices failed: ${res.stderr || res.stdout}`);
const lines = res.stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
// first line is usually: "List of devices attached"
const out: AdbDevice[] = [];
for (const line of lines) {
if (line.toLowerCase().startsWith("list of devices")) continue;
const parts = line.split(/\s+/);
if (parts.length < 2) continue;
const serial = parts[0];
const state = parts[1];
const kv: Record<string, string> = {};
for (const p of parts.slice(2)) {
const m = p.match(/^([^:]+):(.+)$/);
if (m) kv[m[1]] = m[2];
}
out.push({
serial,
state,
model: kv.model,
device: kv.device,
transportId: kv.transport_id,
product: kv.product,
});
}
return out;
}
export async function getDeviceProps(adbPath: string, serial: string): Promise<Record<string, string>> {
const res = await adbShell(adbPath, serial, ["getprop"]);
if (res.code !== 0) throw new Error(`adb shell getprop failed: ${res.stderr || res.stdout}`);
const props: Record<string, string> = {};
for (const line of res.stdout.split(/\r?\n/)) {
const m = line.match(/^\[(.+?)\]: \[(.*)\]$/);
if (m) props[m[1]] = m[2];
}
return props;
}
export async function tap(adbPath: string, serial: string, x: number, y: number): Promise<void> {
const res = await adbShell(adbPath, serial, ["input", "tap", String(x), String(y)]);
if (res.code !== 0) throw new Error(`tap failed: ${res.stderr || res.stdout}`);
}
export async function swipe(
adbPath: string,
serial: string,
x1: number,
y1: number,
x2: number,
y2: number,
durationMs: number
): Promise<void> {
const res = await adbShell(adbPath, serial, [
"input",
"swipe",
String(x1),
String(y1),
String(x2),
String(y2),
String(durationMs),
]);
if (res.code !== 0) throw new Error(`swipe failed: ${res.stderr || res.stdout}`);
}
/**
* adb shell input text has its own escaping rules:
* - spaces can be sent as %s
* - many special chars need backslash escaping and vary by device/IME.
*
* We do a conservative encoding:
* - replace spaces with %s
* - strip newlines
*/
export async function inputText(adbPath: string, serial: string, text: string): Promise<void> {
const safe = text.replace(/\r?\n/g, " ").split(" ").map((t) => t).join("%s");
const res = await adbShell(adbPath, serial, ["input", "text", safe]);
if (res.code !== 0) throw new Error(`inputText failed: ${res.stderr || res.stdout}`);
}
export async function keyevent(adbPath: string, serial: string, keycode: number): Promise<void> {
const res = await adbShell(adbPath, serial, ["input", "keyevent", String(keycode)]);
if (res.code !== 0) throw new Error(`keyevent failed: ${res.stderr || res.stdout}`);
}
export async function startApp(adbPath: string, serial: string, pkg: string, activity?: string): Promise<void> {
const component = activity ? `${pkg}/${activity}` : pkg;
const res = await adbShell(adbPath, serial, ["monkey", "-p", pkg, "-c", "android.intent.category.LAUNCHER", "1"]);
// fallback to am start if monkey fails
if (res.code !== 0) {
const res2 = await adbShell(adbPath, serial, ["am", "start", "-n", component]);
if (res2.code !== 0) throw new Error(`startApp failed: ${res2.stderr || res2.stdout}`);
}
}
export async function stopApp(adbPath: string, serial: string, pkg: string): Promise<void> {
const res = await adbShell(adbPath, serial, ["am", "force-stop", pkg]);
if (res.code !== 0) throw new Error(`stopApp failed: ${res.stderr || res.stdout}`);
}
export async function screencapPng(adbPath: string, serial: string): Promise<Buffer> {
// Use exec-out to avoid \r\n mangling.
const proc = spawn(adbPath, ["-s", serial, "exec-out", "screencap", "-p"], { stdio: ["ignore", "pipe", "pipe"] });
const chunks: Buffer[] = [];
const errChunks: Buffer[] = [];
proc.stdout?.on("data", (d) => chunks.push(Buffer.isBuffer(d) ? d : Buffer.from(d)));
proc.stderr?.on("data", (d) => errChunks.push(Buffer.isBuffer(d) ? d : Buffer.from(d)));
const code: number = await new Promise((resolve) => proc.on("close", (c) => resolve(c ?? -1)));
if (code !== 0) {
throw new Error(`screencap failed: ${Buffer.concat(errChunks).toString("utf8")}`);
}
return Buffer.concat(chunks);
}
export async function dumpUiHierarchy(adbPath: string, serial: string): Promise<string> {
// Use uiautomator dump to /dev/tty to get XML output directly to stdout
const res = await adbShell(adbPath, serial, ["uiautomator", "dump", "/dev/tty"]);
if (res.code !== 0) throw new Error(`uiautomator dump failed: ${res.stderr || res.stdout}`);
return res.stdout;
}
/**
* Long press at a specific coordinate.
* Uses input swipe with the same start and end coordinates to simulate a press-and-hold.
*/
export async function longPress(
adbPath: string,
serial: string,
x: number,
y: number,
durationMs: number = 1000
): Promise<void> {
const res = await adbShell(adbPath, serial, [
"input",
"swipe",
String(x),
String(y),
String(x),
String(y),
String(durationMs),
]);
if (res.code !== 0) throw new Error(`longPress failed: ${res.stderr || res.stdout}`);
}
/**
* Pinch gesture (zoom in/out).
* Note: True multi-touch pinch requires device-specific sendevent commands.
* This implementation simulates a pinch using a single swipe gesture.
* For precise multi-touch control, consider using scrcpy's control protocol instead.
*/
export async function pinch(
adbPath: string,
serial: string,
centerX: number,
centerY: number,
startDistance: number,
endDistance: number,
durationMs: number = 500
): Promise<void> {
// Calculate touch points along a horizontal axis through center
// Pinch in: startDistance > endDistance (fingers move together)
// Pinch out: startDistance < endDistance (fingers move apart)
const startX = centerX - Math.floor(startDistance / 2);
const endX = centerX - Math.floor(endDistance / 2);
// Simulate pinch with a single swipe gesture
// Note: This is a simplified single-finger approach. True pinch requires multi-touch.
const res = await adbShell(adbPath, serial, [
"input",
"swipe",
String(startX),
String(centerY),
String(endX),
String(centerY),
String(durationMs),
]);
if (res.code !== 0) throw new Error(`pinch failed: ${res.stderr || res.stdout}`);
}
/**
* Drag and drop gesture.
* Drags from start coordinates to end coordinates over a specified duration.
* Uses draganddrop command if available (Android 7+), otherwise falls back to swipe.
*/
export async function dragDrop(
adbPath: string,
serial: string,
startX: number,
startY: number,
endX: number,
endY: number,
durationMs: number = 500
): Promise<void> {
// Try draganddrop command first (Android 7+)
let res = await adbShell(adbPath, serial, [
"input",
"draganddrop",
String(startX),
String(startY),
String(endX),
String(endY),
]);
// If draganddrop not available, fall back to swipe with longer duration
if (res.code !== 0 && res.stderr.includes("Unknown command")) {
res = await adbShell(adbPath, serial, [
"input",
"swipe",
String(startX),
String(startY),
String(endX),
String(endY),
String(durationMs),
]);
}
if (res.code !== 0) throw new Error(`dragDrop failed: ${res.stderr || res.stdout}`);
}
/**
* Execute arbitrary shell command on device.
* Returns stdout, stderr, and exit code.
* WARNING: Can execute any command on the device. Use with caution.
*/
export async function shellCommand(
adbPath: string,
serial: string,
command: string
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const res = await adbShell(adbPath, serial, [command]);
return {
stdout: res.stdout,
stderr: res.stderr,
exitCode: res.code,
};
}
/**
* Push a local file to the device.
* Uses adb push to transfer file from local path to remote device path.
* WARNING: Can modify device filesystem. Ensure paths are correct.
*/
export async function pushFile(
adbPath: string,
serial: string,
localPath: string,
remotePath: string
): Promise<void> {
const res = await adbExec(adbPath, ["-s", serial, "push", localPath, remotePath]);
if (res.code !== 0) throw new Error(`pushFile failed: ${res.stderr || res.stdout}`);
}
/**
* Pull a file from the device to local filesystem.
* Uses adb pull to transfer file from device to local path.
*/
export async function pullFile(
adbPath: string,
serial: string,
remotePath: string,
localPath: string
): Promise<void> {
const res = await adbExec(adbPath, ["-s", serial, "pull", remotePath, localPath]);
if (res.code !== 0) throw new Error(`pullFile failed: ${res.stderr || res.stdout}`);
}
/**
* List directory contents on device.
* Uses adb shell ls -la to list files and directories.
* Returns raw output from ls command.
*/
export async function listDirectory(
adbPath: string,
serial: string,
path: string
): Promise<string> {
const res = await adbShell(adbPath, serial, ["ls", "-la", path]);
if (res.code !== 0) throw new Error(`listDirectory failed: ${res.stderr || res.stdout}`);
return res.stdout;
}
/**
* Get clipboard content from device.
* Note: Clipboard access varies by Android version and may require special permissions.
* This uses dumpsys clipboard which works on most Android versions but may have limitations
* on newer versions (Android 10+) due to privacy restrictions.
*/
export async function getClipboard(adbPath: string, serial: string): Promise<string> {
const res = await adbShell(adbPath, serial, ["dumpsys", "clipboard"]);
if (res.code !== 0) throw new Error(`getClipboard failed: ${res.stderr || res.stdout}`);
// Parse the dumpsys output to extract clipboard text
// Look for "mPrimaryClip=" or "primaryClip=" patterns
const output = res.stdout;
// Try to extract text from ClipData format: text="..."
const textMatch = output.match(/text="([^"]*)"/);
if (textMatch) {
return textMatch[1];
}
// Try alternative format: ClipData.Item { T:... }
const itemMatch = output.match(/ClipData\.Item \{ T:([^}]*)\}/);
if (itemMatch) {
return itemMatch[1].trim();
}
// If no clipboard data found, return empty string
if (output.includes("mPrimaryClip=null") || output.includes("primaryClip=null")) {
return "";
}
// Return raw output if we can't parse it
return output;
}
/**
* Set clipboard content on device.
* Note: Direct clipboard setting via ADB is limited. This implementation uses
* a combination of approaches:
* 1. Try using service call (may require root or special permissions)
* 2. Fall back to using input text which pastes but doesn't truly set clipboard
*
* Limitations: May not work on all Android versions due to security restrictions.
*/
export async function setClipboard(adbPath: string, serial: string, text: string): Promise<void> {
// Escape single quotes for shell
const escaped = text.replace(/'/g, "'\\''");
// Try using am broadcast to set clipboard (works on some devices with clipper-like apps)
// If that fails, note that we cannot reliably set clipboard via ADB on most devices
const res = await adbShell(adbPath, serial, [
"am", "broadcast", "-a", "clipper.set", "-e", "text", `'${escaped}'`
]);
// If broadcast fails, it's expected - clipboard setting is restricted
// We'll just document the limitation in the tool description
if (res.code !== 0 && !res.stderr.includes("BroadcastQueue")) {
throw new Error(
`setClipboard failed: Direct clipboard setting via ADB is restricted on most Android devices. ` +
`Consider using UI automation to paste instead. Error: ${res.stderr || res.stdout}`
);
}
}
/**
* List installed packages on device.
* Returns array of package names.
*/
export async function listInstalledApps(
adbPath: string,
serial: string,
options?: { system?: boolean }
): Promise<string[]> {
const args = ["pm", "list", "packages"];
// Add filter flags
if (options?.system === true) {
args.push("-s"); // System packages only
} else if (options?.system === false) {
args.push("-3"); // Third-party packages only
}
// If system is undefined, show all packages (default)
const res = await adbShell(adbPath, serial, args);
if (res.code !== 0) throw new Error(`listInstalledApps failed: ${res.stderr || res.stdout}`);
// Parse output: each line is "package:com.example.app"
const packages: string[] = [];
for (const line of res.stdout.split(/\r?\n/)) {
const match = line.match(/^package:(.+)$/);
if (match) {
packages.push(match[1].trim());
}
}
return packages;
}
/**
* Get current notifications from device.
* Returns raw notification dump from dumpsys.
* Note: May require special permissions on newer Android versions (10+).
* Output includes notification text, package names, and metadata.
*/
export async function getNotifications(adbPath: string, serial: string): Promise<string> {
const res = await adbShell(adbPath, serial, ["dumpsys", "notification", "--noredact"]);
if (res.code !== 0) throw new Error(`getNotifications failed: ${res.stderr || res.stdout}`);
return res.stdout;
}
/**
* Get currently focused activity/app.
* Returns the package name and activity of the foreground app.
*/
export async function getCurrentActivity(
adbPath: string,
serial: string
): Promise<{ package: string; activity: string }> {
// Try modern method first (works on Android 7+)
let res = await adbShell(adbPath, serial, ["dumpsys", "activity", "activities"]);
if (res.code === 0) {
// Look for mResumedActivity or mFocusedActivity pattern
// Format: mResumedActivity: ActivityRecord{hash user/package/activity}
const resumedMatch = res.stdout.match(/mResumedActivity:.*?(\S+)\/(\S+)\s/);
if (resumedMatch) {
return {
package: resumedMatch[1],
activity: resumedMatch[2],
};
}
}
// Fallback to window dump method (older Android versions)
res = await adbShell(adbPath, serial, ["dumpsys", "window", "windows"]);
if (res.code !== 0) throw new Error(`getCurrentActivity failed: ${res.stderr || res.stdout}`);
// Look for mCurrentFocus pattern
// Format: mCurrentFocus=Window{hash u0 package/activity}
const focusMatch = res.stdout.match(/mCurrentFocus=Window\{[^}]+\s+(\S+)\/(\S+)\}/);
if (focusMatch) {
return {
package: focusMatch[1],
activity: focusMatch[2],
};
}
// Try alternative pattern
const altMatch = res.stdout.match(/mFocusedApp=.*?ActivityRecord\{.*?\s+(\S+)\/(\S+)\s/);
if (altMatch) {
return {
package: altMatch[1],
activity: altMatch[2],
};
}
throw new Error("Could not determine current activity. Device may be on home screen or lock screen.");
}
/**
* Wake the device screen.
* Uses KEYCODE_WAKEUP (224) to turn on the screen.
*/
export async function wakeScreen(adbPath: string, serial: string): Promise<void> {
const res = await adbShell(adbPath, serial, ["input", "keyevent", "224"]);
if (res.code !== 0) throw new Error(`wakeScreen failed: ${res.stderr || res.stdout}`);
}
/**
* Put the device screen to sleep.
* Uses KEYCODE_SLEEP (223) to turn off the screen.
*/
export async function sleepScreen(adbPath: string, serial: string): Promise<void> {
const res = await adbShell(adbPath, serial, ["input", "keyevent", "223"]);
if (res.code !== 0) throw new Error(`sleepScreen failed: ${res.stderr || res.stdout}`);
}
/**
* Check if the device screen is currently on.
* Uses dumpsys power to check screen state.
* Returns true if screen is on, false if off.
*/
export async function isScreenOn(adbPath: string, serial: string): Promise<boolean> {
// Try dumpsys power method first (most reliable)
let res = await adbShell(adbPath, serial, ["dumpsys", "power"]);
if (res.code === 0) {
// Look for "Display Power: state=" pattern
const powerMatch = res.stdout.match(/Display Power: state=(\w+)/i);
if (powerMatch) {
const state = powerMatch[1].toUpperCase();
return state === "ON" || state === "VR";
}
// Alternative pattern: "mScreenOn=true/false"
const screenOnMatch = res.stdout.match(/mScreenOn=(true|false)/i);
if (screenOnMatch) {
return screenOnMatch[1].toLowerCase() === "true";
}
}
// Fallback to dumpsys display method
res = await adbShell(adbPath, serial, ["dumpsys", "display"]);
if (res.code === 0) {
// Look for mScreenState pattern
const stateMatch = res.stdout.match(/mScreenState=(\w+)/i);
if (stateMatch) {
const state = stateMatch[1].toUpperCase();
return state === "ON" || state === "2";
}
}
throw new Error(`isScreenOn failed: Could not determine screen state from dumpsys output`);
}
/**
* Unlock the device screen.
* Note: Only works for devices without secure lock (no PIN/password/pattern).
* Uses KEYCODE_MENU (82) to unlock, or can use swipe gesture as alternative.
*/
export async function unlockScreen(adbPath: string, serial: string): Promise<void> {
// First wake the screen if it's off
const screenOn = await isScreenOn(adbPath, serial);
if (!screenOn) {
await wakeScreen(adbPath, serial);
// Wait a bit for screen to fully wake
await new Promise(resolve => setTimeout(resolve, 500));
}
// Try KEYCODE_MENU (82) to unlock
let res = await adbShell(adbPath, serial, ["input", "keyevent", "82"]);
// If that doesn't work, try swipe gesture from bottom to top
// This works on many devices to dismiss simple lock screens
if (res.code !== 0) {
res = await adbShell(adbPath, serial, ["input", "swipe", "540", "1800", "540", "500", "300"]);
}
if (res.code !== 0) {
throw new Error(
`unlockScreen failed: ${res.stderr || res.stdout}. ` +
`Note: This only works for devices without secure lock (no PIN/password/pattern).`
);
}
}
/**
* Connect to a device over WiFi using ADB.
* Device must already have TCP/IP mode enabled (via enableTcpip).
* Default port is 5555.
*/
export async function connectWifi(
adbPath: string,
ipAddress: string,
port: number = 5555
): Promise<void> {
const address = `${ipAddress}:${port}`;
const res = await adbExec(adbPath, ["connect", address]);
if (res.code !== 0) {
throw new Error(`connectWifi failed: ${res.stderr || res.stdout}`);
}
// Check if connection was successful
if (!res.stdout.includes("connected") && !res.stdout.includes("already connected")) {
throw new Error(`connectWifi failed: ${res.stdout || res.stderr}`);
}
}
/**
* Disconnect from a WiFi ADB connection.
* If ipAddress is provided, disconnects from that specific device.
* If ipAddress is omitted, disconnects from all devices.
*/
export async function disconnectWifi(
adbPath: string,
ipAddress?: string
): Promise<void> {
const args = ipAddress ? ["disconnect", ipAddress] : ["disconnect"];
const res = await adbExec(adbPath, args);
if (res.code !== 0) {
throw new Error(`disconnectWifi failed: ${res.stderr || res.stdout}`);
}
}
/**
* Enable TCP/IP mode on the device for WiFi debugging.
* Device must be connected via USB first.
* Default port is 5555.
* After enabling, you can connect wirelessly using connectWifi with the device's IP address.
*/
export async function enableTcpip(
adbPath: string,
serial: string,
port: number = 5555
): Promise<void> {
const res = await adbExec(adbPath, ["-s", serial, "tcpip", String(port)]);
if (res.code !== 0) {
throw new Error(`enableTcpip failed: ${res.stderr || res.stdout}`);
}
// Verify success message
if (!res.stdout.includes("restarting") && !res.stdout.includes("listening")) {
throw new Error(`enableTcpip failed: ${res.stdout || res.stderr}`);
}
}
/**
* Get the device's WiFi IP address.
* Useful for connecting to the device wirelessly after enabling TCP/IP mode.
* Returns the IP address or null if not connected to WiFi.
*/
export async function getDeviceIp(adbPath: string, serial: string): Promise<string | null> {
// Try ip route method first (works on most modern Android devices)
let res = await adbShell(adbPath, serial, ["ip", "route"]);
if (res.code === 0) {
// Look for pattern like: "192.168.1.x/24 dev wlan0 proto kernel scope link src 192.168.1.x"
const ipMatch = res.stdout.match(/src\s+([\d.]+)/);
if (ipMatch) {
return ipMatch[1];
}
}
// Fallback to ifconfig wlan0 method
res = await adbShell(adbPath, serial, ["ifconfig", "wlan0"]);
if (res.code === 0) {
// Look for inet addr pattern
const inetMatch = res.stdout.match(/inet addr:([\d.]+)/i);
if (inetMatch) {
return inetMatch[1];
}
// Alternative pattern: "inet 192.168.1.x"
const altMatch = res.stdout.match(/inet\s+([\d.]+)/i);
if (altMatch) {
return altMatch[1];
}
}
// If no WiFi IP found, return null
return null;
}