Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
f74a633
feat(serve-emu): throttle emulator networks and switch to a clean sta…
lauridskern Oct 14, 2026
a87f770
feat(settings): add a clean status bar and network conditions
lauridskern Oct 14, 2026
feddccd
feat(app-tools): run an app in another language
lauridskern Oct 14, 2026
969bcaa
chore(release): bump version to 0.1.152
lauridskern Oct 14, 2026
1e6b39b
fix(serve-emu): hide the mobile icon in the clean Android status bar
lauridskern Oct 14, 2026
0caf37c
fix(ui): remove the device screen focus ring
lauridskern Oct 14, 2026
862a985
feat(settings): throttle Android networks through a local proxy
lauridskern Oct 14, 2026
6306b27
chore(release): bump version to 0.1.153
lauridskern Oct 14, 2026
188d877
feat(ui): show the network condition in a status card
lauridskern Oct 15, 2026
ac21345
chore(release): bump version to 0.1.154
lauridskern Oct 15, 2026
ca1a926
Merge branch 'feat/device-app-tools' into feat/device-conditions
lauridskern Oct 15, 2026
abb713f
fix(serve-emu): wait for demo mode before cleaning the status bar
lauridskern Oct 15, 2026
737d5f7
fix(settings): keep throttled connections intact and recover dead pro…
lauridskern Oct 15, 2026
a7cd164
chore(release): bump version to 0.1.155
lauridskern Oct 15, 2026
def3830
Merge branch 'feat/device-app-tools' into feat/device-conditions
lauridskern Oct 15, 2026
014f635
refactor(settings): split device settings by platform and the throttl…
lauridskern Oct 15, 2026
9aea0f6
chore(release): bump version to 0.1.156
lauridskern Oct 15, 2026
4809f6e
Merge branch 'feat/device-app-tools' into feat/device-conditions
lauridskern Oct 15, 2026
7dcb9a8
fix(settings): pace plain HTTP once and survive broken replies
lauridskern Oct 15, 2026
956cc7c
chore(release): bump version to 0.1.157
lauridskern Oct 15, 2026
4fb5ee6
Merge branch 'feat/device-app-tools' into feat/device-conditions
lauridskern Oct 15, 2026
81c20e8
test(settings): encode the echoed path in the proxy test server
lauridskern Oct 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "mobile-dev",
"version": "0.1.151",
"version": "0.1.157",
"description": "Keep a simulator beside the chat while building, running, or debugging iOS and Android apps. Stream devices, read native and Metro logs, render interactive CPU, memory, and FPS recordings, and control apps with Mobile Dev tools.",
"author": {
"name": "Callstack"
Expand Down
25 changes: 21 additions & 4 deletions docs/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,10 +191,27 @@ unrequested permission, or a stopped device count only through
memory cache and are returned only to the panel.

The device popover's App tab records `ui.app_tools.list`, `.icons`, `.launch`,
`.stop`, `.open_url`, `.permission`, and `.push` round trips in milliseconds with
bounded aggregate windows, and counts `ui.app_tools.failure`. Results that finish
after a surface or device change are not recorded. Recent URLs and the last picked
app stay in the panel's local storage and are never sent.
`.stop`, `.open_url`, `.permission`, `.push`, and `.language` round trips in
milliseconds with bounded aggregate windows, and counts `ui.app_tools.failure`.
Results that finish after a surface or device change are not recorded. Recent URLs
and the last picked app stay in the panel's local storage and are never sent.

Since 0.1.152, `mobile_app_language` and `mobile_set_app_language` use the same
app control coverage under `app_control.language` and `app_control.set_language`.
Language tags are not sent. The device settings tools report unexpected Baguette or
serve-emu failures under `device_settings.read` or `device_settings.<setting>` with a
fixed message. Android throttling runs through a local proxy on the Mac, so its adb
failures report under `device_settings.network`; throttling a device that already
uses its own proxy counts only through `server.error.outcome`. When the plugin first
starts an Android backend, it clears a throttle left by a plugin process that exited;
unexpected adb failures there report under `device_settings.network_recovery`, and a
device that went away counts only through `server.error.outcome`. Proxy
addresses and traffic are not sent. Since 0.1.155, expected device setting and app
control outcomes reach `server.error.outcome` as documented; earlier releases skipped
them. The Device tab
records `ui.device_settings.change` round trips in milliseconds and counts
`ui.device_settings.failure`, with the same surface and device-change exclusions as
the App tab. Setting values, throttle presets, and device IDs are not sent.

Saved chart cards use the `recording` surface and view. Existing readiness,
interaction and frame-pacing coverage is preserved. `ui.recording.process` and
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "codex-mobile-dev-plugin",
"version": "0.1.151",
"version": "0.1.157",
"private": true,
"type": "module",
"engines": {
Expand Down
66 changes: 65 additions & 1 deletion runtimes/serve-emu/src/adb.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// Modified for Mobile Dev: status bar demo mode and Wi-Fi-only network toggling.
import { spawn } from "node:child_process";
import { execBuffer, execText, type ExecResult } from "./exec.ts";

Expand Down Expand Up @@ -32,6 +33,8 @@ export type NetworkStatus = {
mobileData: string;
};
};
export type StatusBarMode = "clean" | "live";
export type StatusBarStatus = { mode: StatusBarMode };

function execFailed(result: ExecResult<string | Buffer>): boolean {
return result.status !== 0 || result.error !== null;
Expand Down Expand Up @@ -325,11 +328,72 @@ export async function setNetworkEnabled(
const r = await runExec("adb", ["-s", serial, "shell", ...args], {
timeout: ADB_MUTATION_TIMEOUT_MS,
});
if (execFailed(r)) {
// Wi-Fi-only devices have no telephony service to toggle mobile data on.
const noTelephony = service === "data" && /Can't find service: phone/.test(`${r.stdout}${r.stderr}`);
if (execFailed(r) && !noTelephony) {
throw new Error(
`adb shell ${args.join(" ")} failed: ${execFailure(r)}`,
);
}
}
return getNetworkStatus(serial, runExec);
}

const DEMO_BROADCAST = "am broadcast -a com.android.systemui.demo -e command";
// A clean marketing status bar: 9:41, full battery, full connected Wi-Fi, no notification icons.
// Android 16's demo mode ignores the mobile data type and level, drawing an empty "3G" icon,
// so the mobile icon is hidden. Re-entering demo mode makes the mobile command take effect.
const CLEAN_STATUS_BAR = [
"exit",
"enter",
"clock -e hhmm 0941",
"battery -e level 100 -e plugged false",
"network -e wifi show -e level 4 -e fully true",
"network -e mobile hide",
"notifications -e visible false",
].map((command) => `${DEMO_BROADCAST} ${command}`);
// Demo mode cannot be queried and ends on reboot, so entering it records the boot it belongs to.
const STATUS_BAR_MARKER = "mobile_dev_status_bar_boot";
const BOOT_ID = "cat /proc/sys/kernel/random/boot_id";

export async function getStatusBar(
serial: string,
runExec: typeof execText = execText,
): Promise<StatusBarStatus> {
const r = await runExec(
"adb",
["-s", serial, "shell", `settings get global ${STATUS_BAR_MARKER}; ${BOOT_ID}`],
{ timeout: ADB_QUERY_TIMEOUT_MS },
);
if (execFailed(r)) {
throw new Error(`reading the status bar mode failed: ${execFailure(r)}`);
}
const [marker, boot] = r.stdout.trim().split(/\s+/);
return { mode: marker && marker === boot ? "clean" : "live" };
}

export async function setStatusBar(
serial: string,
mode: StatusBarMode,
runExec: typeof execText = execText,
): Promise<StatusBarStatus> {
const command = mode === "clean"
? [
// SystemUI ignores demo broadcasts until its settings observer sees demo mode allowed.
'[ "$(settings get global sysui_demo_allowed)" = 1 ] || { settings put global sysui_demo_allowed 1 && sleep 1; }',
...CLEAN_STATUS_BAR,
`settings put global ${STATUS_BAR_MARKER} $(${BOOT_ID})`,
]
: [
`${DEMO_BROADCAST} exit`,
"settings put global sysui_demo_allowed 0",
`settings delete global ${STATUS_BAR_MARKER}`,
];
const r = await runExec("adb", ["-s", serial, "shell", command.join(" && ")], {
timeout: ADB_MUTATION_TIMEOUT_MS,
});
if (execFailed(r)) {
throw new Error(`setting the status bar to ${mode} failed: ${execFailure(r)}`);
}
return getStatusBar(serial, runExec);
}
59 changes: 59 additions & 0 deletions runtimes/serve-emu/src/api/routes/devices.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
// Modified for Mobile Dev: status bar demo mode.
import {
getFontScale,
getNetworkStatus,
getNightMode,
getStatusBar,
getUserRotation,
setFontScale,
setNetworkEnabled,
setNightMode,
setStatusBar,
setUserRotation,
type NightMode,
type OrientationMode,
Expand Down Expand Up @@ -447,5 +450,61 @@ export function deviceRoutes(): ApiRoute<ApiDependencies>[] {
}
},
},
{
method: "GET",
path: "/api/status-bar",
handler: async ({ deps }) => {
const { runForContext, requestContext, errorResponse } = deps;
try {
return Response.json({
ok: true,
statusBar: await runForContext(requestContext, (context) =>
getStatusBar(context.serial),
),
});
} catch (err) {
return errorResponse(err);
}
},
},
{
method: "POST",
path: "/api/status-bar",
handler: async ({ request: req, deps }) => {
const {
runForContext,
requestContext,
errorResponse,
readJsonBody,
MAX_JSON_BODY_BYTES,
} = deps;
try {
const payload = await readJsonBody(
req,
MAX_JSON_BODY_BYTES,
requestContext,
);
if (
typeof payload !== "object" ||
payload === null ||
Array.isArray(payload)
) {
throw new Error("status bar payload must be an object");
}
const mode = (payload as Record<string, unknown>).mode;
if (mode !== "clean" && mode !== "live") {
throw new Error("mode must be clean or live");
}
return Response.json({
ok: true,
statusBar: await runForContext(requestContext, (context) =>
setStatusBar(context.serial, mode),
),
});
} catch (err) {
return errorResponse(err);
}
},
},
];
}
2 changes: 2 additions & 0 deletions runtimes/serve-emu/src/server/api-boundary.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// Modified for Mobile Dev: status bar demo mode.
export const MAX_JSON_BODY_BYTES = 8 * 1024;
export const MAX_ROUTE_BODY_BYTES = 2 * 1024 * 1024;

Expand Down Expand Up @@ -29,6 +30,7 @@ export const API_ROUTE_METHODS = {
"/api/night-mode": ["GET", "POST"],
"/api/font-scale": ["GET", "POST"],
"/api/network": ["GET", "POST"],
"/api/status-bar": ["GET", "POST"],
"/api/logcat": ["GET"],
"/api/screenshot": ["GET", "POST"],
"/api/foreground": ["GET"],
Expand Down
16 changes: 16 additions & 0 deletions runtimes/serve-emu/src/shared/api-contracts.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// Modified for Mobile Dev: status bar demo mode.
import { parseGesture, type Gesture } from "./control-contracts";

/** Stable error codes sent by every JSON API failure. */
Expand Down Expand Up @@ -98,6 +99,9 @@ export type NetworkStatus = {
};
export type NetworkResponse = ApiSuccess<{ network: NetworkStatus }>;

export type StatusBarMode = "clean" | "live";
export type StatusBarResponse = ApiSuccess<{ statusBar: { mode: StatusBarMode } }>;

export type ForegroundApp = {
packageName: string | null;
activity: string | null;
Expand Down Expand Up @@ -352,6 +356,10 @@ export type ApiContractMap = {
GET: EndpointContract<undefined, NetworkResponse>;
POST: EndpointContract<{ enabled: boolean }, NetworkResponse>;
};
"/api/status-bar": {
GET: EndpointContract<undefined, StatusBarResponse>;
POST: EndpointContract<{ mode: StatusBarMode }, StatusBarResponse>;
};
"/api/logcat": { GET: EndpointContract<undefined, never> };
"/api/screenshot": {
GET: EndpointContract<undefined, ScreenshotBase64Response | BinaryPngResponse>;
Expand Down Expand Up @@ -679,6 +687,13 @@ export function parseNetworkResponse(value: unknown): NetworkResponse {
};
}

export function parseStatusBarResponse(value: unknown): StatusBarResponse {
const root = record(value, "status bar response");
if (root.ok !== true) fail("status bar response.ok must be true");
const status = record(root.statusBar, "statusBar");
return { ok: true, statusBar: { mode: oneOf(status.mode, ["clean", "live"] as const, "statusBar.mode") } };
}

function parseForegroundApp(value: unknown): ForegroundApp {
const item = record(value, "foreground app");
return {
Expand Down Expand Up @@ -1065,6 +1080,7 @@ export const API_SUCCESS_PARSERS = {
"/api/night-mode": { GET: parseNightModeResponse, POST: parseNightModeResponse },
"/api/font-scale": { GET: parseFontScaleResponse, POST: parseFontScaleResponse },
"/api/network": { GET: parseNetworkResponse, POST: parseNetworkResponse },
"/api/status-bar": { GET: parseStatusBarResponse, POST: parseStatusBarResponse },
"/api/logcat": { GET: unsupportedStreamingResponse },
"/api/screenshot": { GET: parseScreenshotResponse, POST: parseScreenshotResponse },
"/api/foreground": { GET: parseForegroundResponse },
Expand Down
3 changes: 2 additions & 1 deletion skills/mobile-dev/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,9 @@ These tools work on iOS simulators and Android devices with `{ platform, deviceI
- `mobile_open_url` opens a deep link or web URL. Prefer it to step-by-step navigation when the app declares a route for the changed screen, then read the screen to confirm it opened.
- `mobile_set_app_permission` allows, denies, or resets (`ask`) a permission so system prompts do not interrupt a flow; `permission: "all"` with `state: "ask"` restores first-launch prompts. iOS simulators cannot change camera or notification access, and changes can restart the app.
- `mobile_send_push` sends an APNs payload to an app on an iOS simulator. Android push needs Firebase Cloud Messaging and is not supported.
- `mobile_set_app_language` runs one app in another language and region, such as `de`, `pt-BR`, or `ar` for right-to-left layout, then relaunches it. `language: null` returns the app to the device language. The device language does not change. Android needs version 13 or later.

The panel's settings button opens the same controls under its App tab.
The panel's settings button opens the same controls under its App tab. Its Device tab also switches to a clean status bar (9:41, full battery and signal) for screenshots and simulates LTE, 3G, Edge, or offline networks. Those device settings are panel-only, so ask the user to set them. Android throttling applies right away to standard app networking such as fetch and OkHttp, leaves local dev servers like Metro at full speed, and is unavailable while the device uses its own proxy. iOS throttling applies to URLSession and fetch requests in apps launched after it was turned on, so relaunch the app after the user enables it.

### Choosing a task's devices

Expand Down
30 changes: 28 additions & 2 deletions src/server/app-control/android.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,18 @@ export function pickAndroidIconFile(resources: string, iconPath: string): string
return entries.filter(entry => /\.(?:png|webp)$/i.test(entry.path)).sort((a, b) => rank(b.config) - rank(a.config))[0]?.path ?? null;
}

// Android 12 and older have no locale service. cmd says so with exit status 20, or 0 when chained.
const localeUnavailable = /Can't find service: locale|Unknown command/;

// "Locales for com.example for user 0 are [de-AT,en]"; an empty list follows the device.
export function parseAndroidAppLocales(output: string): string | null {
if (/Unknown package/.test(output)) throw new ExpectedOperationError("invalid_input", "The app is not installed on this device.");
if (localeUnavailable.test(output)) throw new ExpectedOperationError("unsupported_platform", "Per-app languages need Android 13 or later.");
const list = /are \[([^\]]*)\]/.exec(output);
if (!list) throw new Error("Reading the app language failed: unexpected cmd locale output.");
return list[1].split(",").map(entry => entry.trim()).find(Boolean) ?? null;
}

const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;

export class AndroidDriver implements AppDriver {
Expand All @@ -136,14 +148,19 @@ export class AndroidDriver implements AppDriver {
}

// am and pm report rejected intents and permissions on stdout, sometimes with exit status 0.
private async shell(action: string, command: string, timeout = 15000) {
// Output matching `tolerate` is returned even when the command fails, for callers that parse it.
private async shell(action: string, command: string, timeout = 15000, tolerate?: RegExp) {
const rejected = (output: string) => {
const failure = /^(?:Error: |Exception occurred while executing)[^\n]*/m.exec(output) ?? /^Error[^\n]*/m.exec(output);
return failure ? new ExpectedOperationError("invalid_input", `${action} failed: ${failure[0]}`) : undefined;
};
let output: string;
try { output = await this.commands.run(await this.commands.adb(), ["-s", this.deviceId, "shell", command], { timeout }); }
catch (error) { throw rejected(commandOutput(error)) ?? commandError(error, action); }
catch (error) {
const failed = commandOutput(error);
if (tolerate?.test(failed)) return failed;
throw rejected(failed) ?? commandError(error, action);
}
const failure = rejected(output);
if (failure) throw failure;
return output;
Expand Down Expand Up @@ -222,6 +239,15 @@ export class AndroidDriver implements AppDriver {
return this.permissions(bundleId);
}

async language(bundleId: string) { return parseAndroidAppLocales(await this.shell("Reading the app language", `cmd locale get-app-locales ${bundleId}`, 15000, localeUnavailable)); }

async setLanguage(bundleId: string, language: string | null) {
// set-app-locales reports an unknown package or missing service on stdout with exit status 0.
const output = await this.shell("Changing the app language", `cmd locale set-app-locales ${bundleId}${language === null ? "" : ` --locales ${language}`}; cmd locale get-app-locales ${bundleId}`, 15000, localeUnavailable);
const applied = parseAndroidAppLocales(output);
if (applied?.toLowerCase() !== language?.toLowerCase()) throw new Error("Changing the app language failed: Android kept a different language.");
}

async push(): Promise<void> {
throw new ExpectedOperationError("unsupported_platform", "Test push notifications are available on iOS simulators. Android needs Firebase Cloud Messaging.");
}
Expand Down
3 changes: 3 additions & 0 deletions src/server/app-control/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ export interface AppDriver {
permissions(bundleId: string): Promise<AppPermissionEntry[]>;
setPermission(bundleId: string, permission: AppPermission | "all", state: PermissionState): Promise<AppPermissionEntry[]>;
push(bundleId: string, payload: Record<string, unknown>): Promise<void>;
// The app's own language, or null when it follows the device. Apps pick it up on their next launch.
language(bundleId: string): Promise<string | null>;
setLanguage(bundleId: string, language: string | null): Promise<void>;
}

export async function listApps(driver: AppDriver): Promise<InstalledApps> {
Expand Down
Loading
Loading