android-agent-bridge 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AI_SETUP.md ADDED
@@ -0,0 +1,10 @@
1
+ # Setting up android-agent-bridge (for AI agents)
2
+
3
+ Follow these steps when the user asks you to give yourself access to their Android phone.
4
+
5
+ 1. In the user's project, run `npx -y android-agent-bridge init`. It writes `.mcp.json` (server name `phone`) and an agent guide. Tell the user to restart the agent so the `phone_*` tools load.
6
+ 2. The phone needs the **Agent Bridge** app. If the phone is plugged in with USB debugging on, `npx -y android-agent-bridge usb` installs and sets everything up. Otherwise the user installs `apk/agent-bridge.apk` from the package.
7
+ 3. Pairing over Wi-Fi: the user runs `npx -y android-agent-bridge start`, opens the app, taps **Scan QR code** and turns on the accessibility service. With the MCP server running, the pairing page is at http://localhost:8350/pair.
8
+ 4. Call `phone_status` to confirm. Then follow `skill/SKILL.md`.
9
+
10
+ Without MCP, use the CLI from the shell: `npx android-agent-bridge screen --out shot.jpg`, `tap 12`, `type "hello" --into 3 --submit`, and so on (`npx android-agent-bridge help`). Read the saved screenshot file to see it.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 android-agent-bridge contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,119 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # android-agent-bridge
2
+
3
+ Give your AI agent (Claude Code, Cursor, Antigravity, or anything that speaks MCP or runs shell commands) your **whole Android phone**: it sees the screen, taps, types, scrolls and opens apps — in any app, not just your own.
4
+
5
+ - **Pair once by QR code** over Wi-Fi, or plug in **USB**. Both work, and the phone switches to Wi-Fi when you unplug.
6
+ - **Screenshots with numbered elements** so the agent says "tap 12" instead of guessing pixels, plus a **lettered grid** ("tap C7") for maps, games and images.
7
+ - **Typing without the keyboard**: text goes straight into the field.
8
+ - **Android 5.0 and newer** (phones from 2015 on).
9
+
10
+ ```
11
+ ┌──────────────┐ MCP / CLI ┌───────────────────┐ Wi-Fi or USB ┌──────────────────┐
12
+ │ Claude Code, │ ───────────▶ │ android-agent- │ ◀────────────▶ │ Agent Bridge app │
13
+ │ Cursor, … │ │ bridge (computer) │ WebSocket │ (phone) │
14
+ └──────────────┘ └───────────────────┘ └──────────────────┘
15
+ ```
16
+
17
+ ## 1. Install the app on the phone
18
+
19
+ **Install it over USB (step 2b).** `usb` installs the app for you.
20
+
21
+ Opening the APK from WhatsApp, a browser or a file manager often fails with Google Play Protect's **"App blocked to protect your device"**. In some countries (India and others), Play Protect blocks sideloaded apps that use accessibility, with no "install anyway" option. Installs over USB with adb aren't blocked, so use `usb` once. After that the phone also works over Wi-Fi.
22
+
23
+ ## 2a. Pair over Wi-Fi
24
+
25
+ ```bash
26
+ npx android-agent-bridge start
27
+ ```
28
+
29
+ Open **Agent Bridge** on the phone → **Scan QR code** → point it at the QR code in the terminal (or the bigger one at http://localhost:8350/pair). Then turn on the app's **accessibility service** with the button in the app.
30
+
31
+ Phone and computer must be on the same Wi-Fi. On Windows, allow Node.js through the firewall when asked.
32
+
33
+ ## 2b. Or set up over USB
34
+
35
+ Turn on USB debugging (Settings → About phone → tap Build number 7 times → Developer options → USB debugging), plug in, then:
36
+
37
+ ```bash
38
+ npx android-agent-bridge usb
39
+ ```
40
+
41
+ This installs the app if needed, turns on its accessibility service, pairs it and starts the bridge. No QR code.
42
+
43
+ ## 3. Connect your agent
44
+
45
+ ```bash
46
+ npx android-agent-bridge init
47
+ ```
48
+
49
+ Run it in your project: it adds the MCP server to `.mcp.json` (and `.cursor/mcp.json` with `--cursor`), plus a short guide for the agent. For other agents, add an MCP server with the command `npx -y android-agent-bridge`.
50
+
51
+ Then ask: *"Open WhatsApp on my phone and read the last message from Mom."*
52
+
53
+ ## Tools
54
+
55
+ | MCP tool | CLI | What it does |
56
+ |---|---|---|
57
+ | `phone_status` | `status` | connected phone, Android version, capabilities |
58
+ | `phone_screen` | `screen [--mode marks\|grid\|both\|plain]` | screenshot with numbered elements / grid + element list |
59
+ | `phone_elements` | `elements [--all] [--ocr]` | element list only (cheap, text); `--all` includes covered elements, `--ocr` adds screen text |
60
+ | `phone_ocr` | `ocr` | text read from the screen pixels with positions (on-device ML Kit, offline) — finds controls apps hide from accessibility |
61
+ | `phone_tap` | `tap <target> [--long]` | tap or long-press |
62
+ | `phone_type` | `type <text> [--into t] [--submit]` | type into a field |
63
+ | `phone_scroll` | `scroll <dir> [--in t]` | scroll the screen or a list |
64
+ | `phone_swipe` | `swipe <from> <to>` | drag between two points |
65
+ | `phone_key` | `key <name>` | back, home, recents, notifications, quick_settings, enter, lock |
66
+ | `phone_open_app` | `open <app>` | open an app by name or package |
67
+ | `phone_apps` | `apps [filter]` | list installed apps |
68
+ | `phone_wait_for` | `wait <text>` | wait for text to appear |
69
+ | `phone_open_app` `store` | `launch <app> --store` | open an app, or its Play Store page if it isn't installed |
70
+ | `phone_volume` | `volume [stream] [up\|down\|mute\|40%\|…]` | media, ring, notification, alarm, call volume |
71
+ | `phone_brightness` | `brightness [0-100\|auto]` | screen brightness |
72
+ | `phone_toggle` | `flashlight` · `rotation` · `dnd` · `ringer` | quick switches |
73
+ | `phone_settings` | `settings <page>` | open wifi, bluetooth, display, battery… or an app's info page |
74
+ | `phone_open_link` | `url <link>` | web pages and deep links |
75
+ | `phone_dial` | `dial <number>` | dialer with the number filled in (never calls) |
76
+ | `phone_web_search` | `search <query>` | web search on the phone |
77
+ | `phone_device_info` | `battery` · `current` · `permissions` | battery, app in front, what the app may do |
78
+ | `phone_notifications` | `notifications` | notifications received since the service started |
79
+ | `phone_clipboard` | `clipboard [set <text>]` | read / set the clipboard |
80
+ | `phone_wake` | `wake` | turn the screen on (stays PIN-locked) |
81
+ | `phone_permissions` | `permissions grant` | switch on the one-time permissions over USB |
82
+ | `phone_stop_app` | `stop <package>` | force-stop an app (USB) |
83
+
84
+ A **target** is an element number (`12`), visible text (`Sign in`), a grid cell (`C7`) or pixels (`540,1200`). Text is matched in this order: exact or starts-with in the app → exact or starts-with in the screen text (OCR) → contains → the Android system bar.
85
+
86
+ The CLI commands need a running bridge: `start`, `usb`, or an agent's MCP session. All of them share one bridge.
87
+
88
+ ## What works on which Android
89
+
90
+ | | 5.0–6.0 | 7.0–10 | 11+ |
91
+ |---|---|---|---|
92
+ | Element list, typing, clicking elements, back/home | ✅ | ✅ | ✅ |
93
+ | Tap anywhere (grid, pixels), swipes | over USB | ✅ | ✅ |
94
+ | Screenshots | tap "Allow screen capture" once per session, or USB | same | ✅ built in |
95
+ | Enter key | over USB | over USB | ✅ |
96
+
97
+ "Over USB" means the bridge does it through adb when the phone is plugged in with USB debugging.
98
+
99
+ Apps that block screen capture (banking, Netflix and similar) show up black in screenshots; their element list usually still works.
100
+
101
+ ## Security
102
+
103
+ - Only a computer holding the pairing secret from the QR code can connect. `npx android-agent-bridge pair --reset` makes a new secret; every phone then has to scan again.
104
+ - Traffic on your Wi-Fi is not encrypted (plain WebSocket), so use it on networks you trust. Over USB it never leaves the cable.
105
+ - Turn off the accessibility service in the app (or Android settings) to cut access at once.
106
+ - The agent guide tells agents to ask before sending, paying or deleting, and never to type passwords.
107
+
108
+ ## Building the app
109
+
110
+ ```bash
111
+ cd android
112
+ ./gradlew assembleRelease
113
+ ```
114
+
115
+ Needs JDK 17 and the Android SDK (`ANDROID_HOME` or `android/local.properties`). The APK lands in `android/app/build/outputs/apk/release/`; copy it to `apk/agent-bridge.apk`.
116
+
117
+ ## License
118
+
119
+ MIT
Binary file
package/package.json CHANGED
@@ -1,6 +1,53 @@
1
- {
2
- "name": "android-agent-bridge",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "name": "android-agent-bridge",
3
+ "version": "0.1.0",
4
+ "description": "Give AI agents (Claude Code, Cursor, Antigravity) your whole Android phone: screenshots with numbered elements and a tap grid, tap, type, scroll, open apps. Pair once by QR over Wi-Fi or plug in USB. Android 5.0+.",
5
+ "keywords": [
6
+ "android",
7
+ "mcp",
8
+ "model-context-protocol",
9
+ "ai-agent",
10
+ "claude-code",
11
+ "cursor",
12
+ "antigravity",
13
+ "phone-control",
14
+ "accessibility",
15
+ "automation",
16
+ "adb",
17
+ "mobile-testing"
18
+ ],
19
+ "license": "MIT",
20
+ "author": "Rudra Samadhiya",
21
+ "homepage": "https://github.com/Rudrasamadhiya/android-agent-bridge#readme",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/Rudrasamadhiya/android-agent-bridge.git"
25
+ },
26
+ "bugs": {
27
+ "url": "https://github.com/Rudrasamadhiya/android-agent-bridge/issues"
28
+ },
29
+ "bin": {
30
+ "android-agent-bridge": "server/cli.mjs",
31
+ "phonectl": "server/cli.mjs"
32
+ },
33
+ "files": [
34
+ "server",
35
+ "skill",
36
+ "apk",
37
+ "README.md",
38
+ "LICENSE",
39
+ "AI_SETUP.md"
40
+ ],
41
+ "scripts": {
42
+ "test": "node --test test/bridge.test.mjs"
43
+ },
44
+ "engines": {
45
+ "node": ">=20"
46
+ },
47
+ "dependencies": {
48
+ "@modelcontextprotocol/sdk": "^1.32.1",
49
+ "qrcode": "^1.5.4",
50
+ "ws": "^8.22.0",
51
+ "zod": "^4.6.7"
52
+ }
53
+ }
package/server/adb.mjs ADDED
@@ -0,0 +1,55 @@
1
+ // adb over USB (or `adb connect`): used for setup (`usb` command) and as a
2
+ // fallback for what the app cannot do on old Android (taps by coordinate
3
+ // before Android 7, Enter key before Android 11, screenshots without capture).
4
+ import { execFile } from 'node:child_process';
5
+ import { existsSync } from 'node:fs';
6
+ import path from 'node:path';
7
+
8
+ export const KEYCODES = {
9
+ back: 4, home: 3, recents: 187, enter: 66, tab: 61, delete: 67, escape: 111,
10
+ volume_up: 24, volume_down: 25, mute: 164, power: 26, menu: 82, search: 84,
11
+ };
12
+
13
+ export function adbPath() {
14
+ const exe = process.platform === 'win32' ? 'adb.exe' : 'adb';
15
+ for (const root of [process.env.ANDROID_HOME, process.env.ANDROID_SDK_ROOT,
16
+ process.env.LOCALAPPDATA && path.join(process.env.LOCALAPPDATA, 'Android', 'Sdk'),
17
+ process.env.HOME && path.join(process.env.HOME, 'Library', 'Android', 'sdk'),
18
+ process.env.HOME && path.join(process.env.HOME, 'Android', 'Sdk')]) {
19
+ if (root && existsSync(path.join(root, 'platform-tools', exe))) return path.join(root, 'platform-tools', exe);
20
+ }
21
+ return 'adb';
22
+ }
23
+
24
+ export function adb(args, { timeout = 15000, binary = false, serial = process.env.ANDROID_SERIAL } = {}) {
25
+ const full = serial ? ['-s', serial, ...args] : args;
26
+ return new Promise((resolve, reject) => {
27
+ execFile(adbPath(), full, { timeout, windowsHide: true, encoding: binary ? 'buffer' : 'utf8', maxBuffer: 64 * 1024 * 1024 }, (err, stdout, stderr) => {
28
+ if (err) {
29
+ reject(new Error(err.code === 'ENOENT'
30
+ ? 'adb not found. Install Android platform-tools (https://developer.android.com/tools/releases/platform-tools) or set ANDROID_HOME.'
31
+ : String(stderr || err.message).trim()));
32
+ } else resolve(binary ? stdout : String(stdout).trim());
33
+ });
34
+ });
35
+ }
36
+
37
+ /** Serials of phones attached and authorized. */
38
+ export async function devices() {
39
+ const out = await adb(['devices', '-l'], { serial: null });
40
+ return out.split('\n').slice(1).map((l) => l.trim().split(/\s+/)).filter((p) => p[1] === 'device')
41
+ .map((p) => ({ serial: p[0], model: (p.find((x) => x.startsWith('model:')) || '').slice(6) }));
42
+ }
43
+
44
+ /** Pick the adb serial for the connected phone (matching model when several are plugged in). */
45
+ export async function pickSerial(model) {
46
+ if (process.env.ANDROID_SERIAL) return process.env.ANDROID_SERIAL;
47
+ let list;
48
+ try { list = await devices(); } catch { return null; }
49
+ if (!list.length) return null;
50
+ if (list.length === 1 || !model) return list[0].serial;
51
+ const norm = (s) => String(s).replace(/[\s_]/g, '').toLowerCase();
52
+ return (list.find((d) => norm(d.model) === norm(model)) || list[0]).serial;
53
+ }
54
+
55
+ export const shell = (serial, ...cmd) => adb(['shell', ...cmd], { serial });
@@ -0,0 +1,207 @@
1
+ // Bridge: what the CLI and the MCP server talk to. It either owns the hub
2
+ // (first process to start) or forwards commands to the process that does.
3
+ import { setTimeout as sleep } from 'node:timers/promises';
4
+ import { Hub, pairLink, qrTerminal } from './hub.mjs';
5
+ import { loadConfig, APP_ID } from './config.mjs';
6
+ import { adb, pickSerial, KEYCODES } from './adb.mjs';
7
+
8
+ // commands that change the screen: report the new screen afterwards
9
+ const ACTIONS = new Set(['tap', 'swipe', 'scroll', 'type', 'key', 'open_app']);
10
+
11
+ /** Best OCR line for a query: exact, then starts-with, then contains (case-insensitive). */
12
+ export function findText(lines, query) {
13
+ const q = query.toLowerCase().trim();
14
+ const t = (l) => l.text.toLowerCase().trim();
15
+ const strong = lines.find((l) => t(l) === q) || lines.find((l) => t(l).startsWith(q));
16
+ if (strong) return { ...strong, strong: true };
17
+ const weak = lines.find((l) => t(l).includes(q));
18
+ return weak ? { ...weak, strong: false } : null;
19
+ }
20
+
21
+ async function hubAlive(cfg) {
22
+ try {
23
+ const r = await fetch(`http://127.0.0.1:${cfg.port}/status`, { signal: AbortSignal.timeout(800) });
24
+ return r.ok;
25
+ } catch { return false; }
26
+ }
27
+
28
+ async function rpc(cfg, cmd, args, timeout) {
29
+ let res;
30
+ try {
31
+ res = await fetch(`http://127.0.0.1:${cfg.port}/rpc`, {
32
+ method: 'POST',
33
+ headers: { 'content-type': 'application/json', 'x-aab-token': cfg.token },
34
+ body: JSON.stringify({ cmd, args, timeout }),
35
+ signal: AbortSignal.timeout((timeout || 20000) + 15000),
36
+ });
37
+ } catch (err) {
38
+ const e = new Error('hub not reachable: ' + err.message);
39
+ e.hubGone = true;
40
+ throw e;
41
+ }
42
+ const body = await res.json();
43
+ if (!body.ok) throw new Error(body.error);
44
+ return body.result;
45
+ }
46
+
47
+ export class Bridge {
48
+ /** host: start the hub here when none is running (MCP server, `start`); false for one-shot CLI commands. */
49
+ constructor({ log = () => {}, host = true } = {}) {
50
+ this.cfg = loadConfig();
51
+ this.log = log;
52
+ this.host = host;
53
+ this.hub = null;
54
+ }
55
+
56
+ /** Concurrent callers share one attempt, so only one hub is ever started. */
57
+ ensure() {
58
+ if (!this.ensuring) this.ensuring = this.doEnsure().finally(() => { this.ensuring = null; });
59
+ return this.ensuring;
60
+ }
61
+
62
+ async doEnsure() {
63
+ if (this.hub) return;
64
+ if (await hubAlive(this.cfg)) return;
65
+ if (!this.host) {
66
+ throw new Error(`No bridge is running. Start one with \`npx android-agent-bridge start\` (or let your AI agent's MCP server start it).`);
67
+ }
68
+ const hub = new Hub({ cfg: this.cfg, log: this.log, onRpc: (c, a, t) => this.runLocal(c, a, t) });
69
+ hub.files.onDrop = (paths) => this.sendFiles(paths);
70
+ if (await hub.start()) { this.hub = hub; return; }
71
+ if (!(await hubAlive(this.cfg))) throw new Error(hub.startError + ' Another program is using it; set AAB_PORT to pick another port.');
72
+ }
73
+
74
+ async run(cmd, args = {}, timeout) {
75
+ await this.ensure();
76
+ if (this.hub) return this.runLocal(cmd, args, timeout);
77
+ try {
78
+ return await rpc(this.cfg, cmd, args, timeout);
79
+ } catch (err) {
80
+ if (!err.hubGone || !this.host) throw err;
81
+ await this.ensure(); // the process that owned the hub exited: take over
82
+ return this.runLocal(cmd, args, timeout);
83
+ }
84
+ }
85
+
86
+ async runLocal(cmd, args = {}, timeout) {
87
+ const hub = this.hub;
88
+ if (cmd === 'status') return hub.status();
89
+ if (cmd === 'pair') {
90
+ const link = pairLink(this.cfg);
91
+ return { link, page: `http://localhost:${this.cfg.port}/pair`, qr: await qrTerminal(link) };
92
+ }
93
+ if (cmd === 'ocr') return this.ocr(args);
94
+ if (cmd === 'grant_permissions') return this.grantPermissions();
95
+ if (cmd === 'send_files') return this.sendFiles(args.paths || []);
96
+ if (cmd === 'received_files') return hub.files.received(args.limit);
97
+ if (cmd === 'force_stop') return this.forceStop(args.package);
98
+ let r;
99
+ try {
100
+ r = await hub.call(cmd, args, timeout);
101
+ } catch (err) {
102
+ // Text the app draws but hides from accessibility (Uber's tab bar, payment row):
103
+ // find it in the screen pixels and tap there instead.
104
+ const t = cmd === 'tap' && typeof args.target === 'string' ? args.target.trim() : '';
105
+ if (!t || !/nothing on screen matches/.test(err.message) || /^#?\d+$|^\d+\s*,\s*\d+$|^[A-Za-z]{1,2}\d{1,3}$/.test(t)) throw err;
106
+ const lines = (await this.ocr({ idle: 0 })).lines || [];
107
+ const hit = findText(lines, t);
108
+ const tapAt = async (h) => ({ ...(await hub.call('tap', { ...args, target: `${h.x},${h.y}` }, timeout)), via: 'ocr', matched: h.text });
109
+ // exact / starts-with on screen beats a loose "contains" match in the element list
110
+ if (hit?.strong) r = await tapAt(hit);
111
+ else if (/closest in the app/.test(err.message)) r = await hub.call('tap', { ...args, loose: true }, timeout);
112
+ else if (hit) r = await tapAt(hit);
113
+ // only the system bar has it (e.g. Android's Home button): last resort
114
+ else if (/only the system bar/.test(err.message)) r = await hub.call('tap', { ...args, system: true }, timeout);
115
+ else throw new Error(`nothing on screen matches "${t}", not in the element list nor in the screen text (OCR).`);
116
+ }
117
+ if (r?.needsAdb) r = { ...r, ...(await this.viaAdb(r.needsAdb)), needsAdb: undefined };
118
+ if (cmd === 'screen' && !r.image && r.captureError) {
119
+ const png = await this.screencap();
120
+ if (png) {
121
+ try {
122
+ // the phone draws the numbers/grid on it, same as its own screenshots
123
+ Object.assign(r, await hub.call('annotate', { image: png, mode: args.mode, maxSide: args.maxSide }, 30000));
124
+ } catch (err) {
125
+ Object.assign(r, { image: png, mime: 'image/png', note: `Screenshot taken over USB without labels (${err.message}). Use coordinates "x,y" or element numbers from the list.` });
126
+ }
127
+ delete r.captureError;
128
+ }
129
+ }
130
+ if (ACTIONS.has(cmd) && args.after !== false) {
131
+ // wait for the screen to stop changing (page loads, animations), up to `settle` ms
132
+ await sleep(250);
133
+ const settle = args.settle ?? 2500;
134
+ r.outline = (await hub.call('elements', { idle: settle }, settle + 20000)).outline;
135
+ }
136
+ return r;
137
+ }
138
+
139
+ /** Send files from this computer to the phone: staged here, downloaded by the phone into Downloads/Agent Bridge. */
140
+ async sendFiles(paths) {
141
+ if (!paths.length) throw new Error('give at least one file');
142
+ const staged = this.hub.files.stage(paths);
143
+ const results = [];
144
+ for (const f of staged) {
145
+ // a minute, plus about 2 s per 10 MB for slow Wi-Fi
146
+ const r = await this.hub.call('receive_file', f, 60000 + Math.ceil(f.size / 5e6) * 1000);
147
+ this.log(`sent ${f.name} to the phone → ${r.saved}`);
148
+ results.push({ name: f.name, size: f.size, saved: r.saved });
149
+ }
150
+ return results;
151
+ }
152
+
153
+ /** Full-resolution screenshot over USB as base64 PNG, or null without adb. */
154
+ async screencap() {
155
+ const serial = await pickSerial(this.hub.current?.info?.model);
156
+ if (!serial) return null;
157
+ return (await adb(['exec-out', 'screencap', '-p'], { serial, binary: true, timeout: 20000 })).toString('base64');
158
+ }
159
+
160
+ /** Text in the screen pixels, read on the phone (ML Kit). Uses a USB screenshot when the phone can't capture. */
161
+ async ocr(args = {}) {
162
+ let r = await this.hub.call('ocr', args, 40000);
163
+ if (r.captureError) {
164
+ const png = await this.screencap();
165
+ if (!png) throw new Error(`Can't read the screen: ${r.captureError}. Connect USB, or allow screen capture in the app.`);
166
+ r = await this.hub.call('ocr', { ...args, image: png, idle: 0 }, 40000);
167
+ }
168
+ return r;
169
+ }
170
+
171
+ /** One-time permissions the user would otherwise switch on in Settings, granted over USB with adb. */
172
+ async grantPermissions() {
173
+ const serial = await pickSerial(this.hub.current?.info?.model);
174
+ if (!serial) throw new Error("Plug the phone in over USB (USB debugging on) to grant permissions, or switch them on in the phone's Settings.");
175
+ const sh = (...c) => adb(['shell', ...c], { serial });
176
+ const granted = [];
177
+ await sh('appops', 'set', APP_ID, 'WRITE_SETTINGS', 'allow').then(() => granted.push('modify system settings')).catch(() => {});
178
+ await sh('cmd', 'notification', 'allow_dnd', APP_ID).then(() => granted.push('do not disturb access')).catch(() => {});
179
+ await sh('pm', 'grant', APP_ID, 'android.permission.POST_NOTIFICATIONS').then(() => granted.push('notifications')).catch(() => {});
180
+ // Android 9 and older: save received files in Downloads/Agent Bridge
181
+ await sh('pm', 'grant', APP_ID, 'android.permission.WRITE_EXTERNAL_STORAGE').then(() => granted.push('storage')).catch(() => {});
182
+ return { granted, permissions: await this.hub.call('permissions', {}) };
183
+ }
184
+
185
+ async forceStop(pkg) {
186
+ if (!pkg || !/^[\w.]+$/.test(pkg)) throw new Error('give the app package, e.g. com.instagram.android (see `apps`)');
187
+ const serial = await pickSerial(this.hub.current?.info?.model);
188
+ if (!serial) throw new Error('Force-stopping an app needs the phone on USB.');
189
+ await adb(['shell', 'am', 'force-stop', pkg], { serial });
190
+ return { stopped: pkg };
191
+ }
192
+
193
+ /** The phone could not do it itself (old Android): do it through adb. */
194
+ async viaAdb(op) {
195
+ const serial = await pickSerial(this.hub.current?.info?.model);
196
+ if (!serial) throw new Error(`${op.reason} Connect the phone over USB with USB debugging on and it will be done through adb.`);
197
+ const sh = (...c) => adb(['shell', ...c.map(String)], { serial });
198
+ if (op.op === 'tap') await (op.long ? sh('input', 'swipe', op.x, op.y, op.x, op.y, 800) : sh('input', 'tap', op.x, op.y));
199
+ else if (op.op === 'swipe') await sh('input', 'swipe', op.x1, op.y1, op.x2, op.y2, op.ms || 300);
200
+ else if (op.op === 'key') await sh('input', 'keyevent', KEYCODES[op.key] ?? op.key);
201
+ else if (op.op === 'text') await sh('input', 'text', `'${String(op.text).replace(/'/g, `'\\''`).replace(/ /g, '%s')}'`);
202
+ else throw new Error('unknown adb fallback ' + op.op);
203
+ return { via: 'adb' };
204
+ }
205
+
206
+ close() { this.hub?.close(); }
207
+ }