# Android driver Status, round three (2026-11-07): everything the round asked for is built and checked on two emulators, `emulator-5560` (Android 16) and `emulator-5562` (Android 10, standing in for the Pixel). The Mi 10 Pro (LineageOS, Android 17) was used read-only and with the Calculator only, until it locked at about 10:36 with someone apparently at it ([below](#the-mi-10-pro-in-round-three)). The Pixel is still offline. Branch `claude/android-driver-1aff78`, not pushed this round. ## What was built | Package | What it does | | --- | --- | | `internal/driver/android/adb` | A client for the adb server protocol: `host:version`, `host:devices-l`, `host:track-devices-l`, `host-serial::features`, `host:transport:`, `shell:` (v1, interleaved output), `shell,v2,raw:` (stdout, stderr, exit code), `exec:` (binary-safe), `sync:` (push, pull, `STAT`/`LST2`, `LIST`/`LIS2`, several requests on one connection), local sockets, forwards, and wireless debugging: `host:mdns:check`, `host:mdns:services`, `host:pair:`, `host:connect:`. It reuses the running server and never kills one that another program started (one of its own that lost the network it restarts: [below](#an-adb-server-cut-off-from-the-local-network-2026-11-09)); when none runs, `StartServer` starts one, with Pinky's own adb when the computer has none ([below](#pinkys-own-adb)). | | `internal/driver/android/scrcpy` | The bundled scrcpy 5.0.1 server (`server/`, with its Apache-2.0 license and checksum) and its protocol: `key=value` options, the video stream headers, control messages, device messages, `start_app` results read from the server log, and `list_apps`. The options that would keep a screen awake or press POWER do not exist and are dropped from raw extras. | | `internal/driver/android` | The driver. `Provider` (also `driver.Pairer`), `Device` (also `Toucher`, `LockReporter`, `Clipboard`, `Settler`, `PersonWatcher`, `Waker`, `Unlocker`, `Files`, `Backgrounder`, `CompanionInstaller`), and `Background`, the device a background display gives (`Toucher`, `LockReporter`, `Clipboard`, `Settler`, `Files`). | | `internal/driver/android/mdns` | Pinky's own mDNS search (RFC 6762/6763), questions only: the adb server's discovery stops working after the computer's network changes. See [Wi-Fi without adb's discovery](#wi-fi-without-adbs-discovery). | | `internal/driver/android/companion` | The Go client for the companion app (owned by this session since round three; the app itself is the companion session's). Round three added `TurnOn`, `KeepEnabled`, `EnabledForTask`, `Uninstall`, `InstalledVersion`, `APKVersion`, `Info.Features` and a display for `set_text`. | | `internal/driver/android/cmd/androidctl` | A debug command for every feature, with timings. | Shared contracts: none changed by this session. `cmd/pinky/companion.go` (gateway-core's) now calls `SetUpCompanion`, because `InstallCompanion` had to take the contract's signature. ### How it works - **One scrcpy session per device** carries input, the H.264 stream and settle detection. It starts on first use (about 0.5 s on the Mi), restarts by itself on the next call after it dies, and connects to the server's abstract socket straight through the adb server, so no host port is used. Each run pushes its own jar, which the server deletes once it starts; a server that crashed before that (the Android 10 emulator's failing encoder left six) has it removed by the driver when the session ends, and `Close` waits up to 3 s for that. - **Server options Pinky always sets:** `audio=false`, `tunnel_forward=true`, `clipboard_autosync=false`, `power_on=false`. Never: `stay_awake`, `power_off_on_close`, `turn_screen_off`, `screen_off_timeout` (the options are gone from the code, and `Options.Extra` drops them). The phone's own screen timeout and lock always apply. - **When the video encoder fails,** the device runs a session without video instead. The Android 10 emulator's encoder fails at once (`Capture/encoding error: IllegalStateException`), and so does the image's own `screenrecord` (`Encoder failed (err=-38)`), with and without SwiftShader. The first video session of a device waits up to a second for its first frame; if the server reports an encoder failure instead, the driver logs it once and starts a control-only session: taps, keys, typing, the clipboard and unlocking keep working, touches go in display pixels (the size from `dumpsys window displays`, `cur=WxH`), settle uses the companion's events (300 ms quiet) or a fixed 1 s wait, and `Stream` and background displays return `driver.ErrUnsupported`. `Stream` tries video again after a minute. - **Input** (taps of 50 ms, long presses of the phone's `long_press_timeout` plus 400 ms, swipes, raw touches, keys) goes through scrcpy's control messages. **One gesture at a time per session:** a session restart (a `Stream` with other options) waits for the gesture in progress plus 150 ms for the server to read it, and a gesture that finds its session replaced before it began goes to the new one. This fixed the lost taps (below). - **Wake (`driver.Waker`).** `ScreenOn` reads display 0's power state from the display power controllers (`dumpsys display`: the `mScreenState` that follows `mDisplayId=0`; Android 10 has one controller). The overall wakefulness and the device idle controller's `mScreenOn` both say "on" while a background display keeps its own display group awake, so neither is used. `Wake` presses `KEYCODE_WAKEUP` (never POWER) when the screen is off and waits until it is on; it never unlocks. Actions keep waking the screen as before. - **Unlock (`driver.Unlocker`).** Wake, then `wm dismiss-keyguard`, which shows the PIN pad on a secure lock screen. The alternate bouncer of phones with an under-display fingerprint sensor (`alternate_bouncer`, `udfps_touch_overlay`) gets a tap above the sensor, which brings up the PIN pad. A lock screen that opens on a fingerprint page of its own (vivo's OriginOS: "请验证指纹" with 紧急拨号 / 使用密码 / 取消 links at the bottom) gets a tap on its "use password" link, found by its words (使用密码, 密码, Use password, Use PIN and the like, in Chinese, English and Japanese) and only when the screen also asks for a fingerprint (the alternate bouncer's ids, or 指纹 / fingerprint in its words), the link is the only one, enabled, not under another window, and in the same place in two reads (`passwordlink.go`); otherwise a swipe up from 70 % of the height is the fallback, but never on a screen that shows digit keys the driver cannot read (on some pads a touch on a key enters its digit). The driver enters nothing until a tree read (the companion's, or uiautomator's when the companion is off) shows one of these, and then enters the PIN through scrcpy, never in a shell command line (where other processes could read it), and never logs it: - **AOSP's PIN view or password field** (`pinEntry`, `passwordEntry`), or a focused password field in SystemUI (with digit keys for a PIN): the PIN goes in as key codes, as one gesture, 80 ms apart. A 300 ms pause follows, then Enter unless the phone already unlocked itself (Android's auto-confirm). - **Otherwise, a PIN pad read from its keys' labels and layout** (`pinpad.go`), for OEM lock screens and SystemUI's Compose bouncer: ten keys in SystemUI or a keyguard package (MIUI's is `com.android.keyguard`), each labelled with one digit in its text or description ("2", "2 ABC", "2\nABC", the word "Two" as on vivo's pads, Arabic-Indic or full-width digits), laid out as a phone keypad (three evenly spaced columns, 1 to 9 in three evenly spaced rows and one key below the middle column; the digits may be scrambled), no key bigger than its cell, all on one window, none under a window above it, exactly one such grid on the screen, and in the same place in two reads in a row: a pad still sliding in reads a few dozen pixels off, and gets 2 s from when it first shows to settle. A key without a label may take its digit from its resource id (`key0`, `VivoPinkey0`) only when every key with both a label and such an id agrees, because LineageOS's scrambled pad keeps the ids in place and moves the labels. The PIN goes in as taps on the keys' centers, 150 ms apart, with a lock check before each digit after the fourth (a pad that checks the PIN as it is typed may have unlocked, and further taps would land on the screen behind). If the phone is still locked 0.6 s after the last digit, the driver reads the pad again and, when it is unchanged and shows a confirm key in its bottom row or just below it (`key_enter`, `btn_letter_ok`, "OK", "Enter", "确定", "✓" and the like), taps it after one more lock check; pads without one check the PIN by themselves. Then up to 4 s for the lock screen to go. Still locked: `driver.ErrLocked`, never retried; an error after the first digit went in is `ErrLocked` too, since those digits would spoil another try. Nothing entered: `driver.ErrPINNotEntered` (no PIN pad, a pad the driver cannot read with confidence, keys disabled after too many attempts, a saved PIN that is not all digits on a PIN pad). A pattern lock is `ErrUnsupported`. Which lock screens are checked on a device and which only in tests: see [Unlocking other Android phones](integration.md#unlocking-other-android-phones). - **The Pinky folder (`driver.Files`)** is `/sdcard/Pinky`, over the sync protocol: `ListFiles` walks it with `LIS2` (or `LIST` on phones without `ls_v2`), folders included, sorted, and creates it when missing; `Pull` and `Push` take paths relative to it. Paths are cleaned and refused with `driver.ErrNotFound` when absolute, with any `..`, a backslash, control characters or nothing left. Every component is checked with `LST2`/`STAT` (lstat), so a symbolic link is never followed. `/sdcard` refuses to create links on both emulators anyway. `Push` creates folders, keeps the modification time, checks the size, and on Android 10 tells the media scanner (Android 11 and later index files written through `/sdcard` by themselves). - **Background displays (`driver.Backgrounder`).** `OpenBackground` opens a scrcpy virtual display at the main display's size without system decorations, with the keyboard kept on it, and starts the app. The device it returns streams, observes (through the companion), takes input and settles on that display only, and closes it on Close. **The person's tasks are never destroyed:** starting an app the person already has a task for moves that task to the virtual display (Android reuses it), so before every start the driver notes which tasks are elsewhere (`cmd activity stack list`), and Close moves those back to the main display (`cmd activity display move-stack 0`) before the display goes and takes only the agent's own tasks with it. A task given back lands on top of the main display: Android has no shell command to put it lower. `DisplayOptions.KeepContent` (`androidctl bg -keep`) moves every task to the main display instead, also on top. From Android 13 the display is always unlocked and has its own display group, so it keeps working while the phone is off and locked ([below](#background-displays-while-the-phone-is-off-and-locked)); there `Background.Locked` is false, and the display gets a key that does nothing (`KEYCODE_UNKNOWN`) every 4 s without other input, which keeps its display group awake through the lock screen's 10 s timeout. Its reads turn the companion on again at once after a failure (there is no uiautomator to fall back on there) and retry once. `Type` uses the companion's `set_text` on that display when the app has the `displays` feature (with its input connection and paste fallbacks, and the field read back afterwards: [below](#typing-into-fields-that-refuse-set_text-github-issue-31-2026-11-09)), and settle waits for that display's events too. - **Input on a background display of a Xiaomi phone (GitHub issue #48, 2026-11-11).** MIUI and HyperOS drop what adb injects into a display other than the main one: on HyperOS 1.0.9.0 (Android 13, with "USB debugging (Security settings)" on) taps, swipes and back on a background display did nothing, while text through the companion's `set_text` landed. The driver tells these systems by `ro.mi.os.version.name` or `ro.miui.ui.version.name` (`deviceProps.XiaomiROM`; LineageOS on a Xiaomi phone has neither) and sends that display's input through the companion's accessibility service instead (`displayinput.go`): taps, long presses, swipes and pinches as gestures Android plays on that display (`dispatchGesture` with `setDisplayId`), drags and the mirror's touches as `segments` of one finger the app holds down between calls, back as the service's global action while that display has the phone's focus, and enter as the focused field's action key ([companion-protocol.md](companion-protocol.md#commands-that-act)). Back that answers `not_focused` (the phone's own screen was touched after the background display, so Android would send back there), enter without a field and other keys still go through scrcpy, which such a phone drops: the agent then gets a note with the result that the key may not have arrived and what to do instead (`droppedKeyNote`; the gateway takes the driver's `InputNoter` note after `key` as after `type`). `type`'s `submit` presses enter the same way and keeps the note the typing left, such as a field the driver could not read back, unless the enter itself may not have arrived: that note replaces it. Gestures move at an even speed, so a drag's easing is lost; two-finger touches from the mirror stay on scrcpy. Where the companion cannot stand in (Android 10, the app missing, or too old for `display_input`), `OpenBackground` fails and the gateway runs the task on the phone's own screen, telling the agent why; Pinky updates an outdated app once the phone is free. `DisplayOptions.Input` (`androidctl bg -input scrcpy|companion`) forces either way. Checked with `-input companion` on the Android 17 emulator: taps, long presses, swipes, pinches, drags (also one held down halfway, `androidctl` step `dragr:`, as drags that follow the page are), typing with enter, and back (one level, on the background display only, with the launcher on the main display untouched). There a drag on the background display also goes on while the main display is tapped (`input tap`), and back after such a tap answers `not_focused` and goes through scrcpy, with the note when the phone counts as one that drops adb's input. Checked on the Mi 10 Pro (LineageOS, Android 17, 2026-11-12) next to the accessibility service the owner keeps on: a tap, back, a swipe, both kinds of drag and a pinch in Settings, nothing through scrcpy, the main display untouched and `enabled_accessibility_services` as before afterwards. Not checked on HyperOS itself, which no phone here runs: only there does adb's input fail, so only the people who reported it can say whether the gestures get through. - **Companion app.** Its accessibility service is on only while an agent works on the phone (since 2026-11-08): the phone's apps can see an accessibility service that is on, and banking apps refuse to sign in next to one that is not from an app store. The gateway tells the driver when an agent works (`driver.WorkWatcher`: a lease is held and the person is not in control). `AgentWorking(true)` turns the service on in the background (`Enable`), so the task's first read finds it ready; `AgentWorking(false)` turns it off 5 s after the last driver call (`companionRest`), and while an agent works a minute without driver calls does; Close does too. Turning it off restores the setting exactly when the driver turned it on, and otherwise removes only Pinky's entry: a service an older Pinky kept on for good (`idle_disable_ms` 0) goes off as well, at the first `AgentWorking(false)` after the device opens. While no agent works, Observe and Type do without the companion (uiautomator, the keyboard) and never turn it on; unlocking from the computer (`readLockNodes`) and background displays still do. Callers that never call `AgentWorking` (androidctl, tests) turn it on at the first read and off after a minute. Several drivers (two Pinky processes, Pinky and `androidctl`) can share it: one leaving keeps the service on while another client is connected, and the last one to leave turns it off. On a phone that ignores Pinky's change to its accessibility settings (vivo's OriginOS 6, issue #94), only the person can turn the service on, and what they turned on stays on until they turn it off on the phone (companion.md). The build used is the one Pinky carries (`companionApp`): the release build `app.pinky.companion` in releases, the development build `app.pinky.companion.dev` in builds from the source. Events are tracked per display (`display` in each event; -1 counts for every display). - **The companion installer (`driver.CompanionInstaller`).** `CompanionState`: `missing` (or `no_apk` when Pinky has no APK either); `public_key` (`driver.CompanionPublicKey`) when the installed build is signed with the public key of the builds until 2026-11-08 (`companion.OldPublicSigner`) and the bundled APK with another, which clients treat as `outdated` and explain; `outdated` when the installed versionCode is lower than the bundled APK's (`assets/pinky-companion.json` in the APK; versionCode counts commits up to the last change to the companion, see companion.md) or the installed build is signed with another key (`dumpsys package` names its signer, `signatures:[dd80ed25]`, Java's hash code of the certificate, which `companion.APKVersion` computes for the bundled APK from its signing block); `on` while the service is on (an agent works, another client has it on, or the person turned it on); `manual_on` (`driver.CompanionManualOn`) instead on a phone where only the person can turn it on, where Pinky leaves what they turned on on after tasks, updates and restarts and stops the app's idle backstop (companion.md, issue #94); `manual` (`driver.CompanionManual`) when the phone took the driver's last change to `enabled_accessibility_services` without an error and did not keep it (vivo's OriginOS 6, GitHub issue #94; see companion.md), so only the person can turn the service on, in the phone's accessibility settings: the checkup's item fails with `turn_on_companion_by_hand` instead of offering Turn on, and a background display that cannot be read tells the agent so; the driver remembers it, also once the person turns the service on and across Pinky's restarts (`Config.CompanionManualFile`, `manual.go`), and forgets it when a change of its own is kept; `off` when it did not come on the last time the driver tried (the phone refuses settings changes over USB with an error, say); else `ready`: Pinky turns it on while an agent works. `InstallCompanion` installs when the phone has none, an older build or one signed with another key, which cannot be updated in place: the driver turns it off, uninstalls Pinky's own package and installs the new one (also when `pm install -r` says `INSTALL_FAILED_UPDATE_INCOMPATIBLE`). It then turns the service on through the driver's own link to check that it comes on, and it goes off again 5 s later unless an agent works. Until 2026-11-08, Install turned the service on for good (`TurnOn`, `idle_disable_ms` 0). Play Protect may make the phone ask the person; the call waits up to its context. - **Pairing (`driver.Pairer`, on the Provider).** `StartPairing` makes a random service name (`pinky-xxxxxxxxxx`) and password, `QR()` returns `WIFI:T:ADB;S:;P:;;`, and `Wait` looks for the phone's `_adb-tls-pairing._tcp` service of that name both in the adb server's mDNS services and in the driver's own search (`Config.Browser`, package `mdns`), pairs with `host:pair::`, and waits up to 30 s for the phone to come online. A server whose discovery lists the phone gets a second to connect by itself (serial `adb--._adb-tls-connect._tcp`; a server Pinky started never does, see [Is it the same phone?](#is-it-the-same-phone-2026-11-09)), then the driver connects by that service name, which gives the connection the same serial; when the server's discovery does not list the phone, the driver connects at once to the phone's `_adb-tls-connect._tcp` service in its own search, found by the guid `host:pair` reports, or by the address it paired from. `PairWithCode` does the same with the address and six-digit code the phone shows. A pairing reports its progress (`driver.PairingReporter`: found, paired, and whether the system blocks the search). When the phone paired but its connect service shows up nowhere, the error wraps `driver.ErrPairedNotFound`, and `ConnectWiFi` (`driver.WiFiConnector`) connects at the address Wireless debugging shows. A real adb server (platform-tools 37) answers `host:pair` with OKAY and then a second OKAY or FAIL once the pairing is over; `Pair` reads both forms. Pairing returns the phone by the id `List` gives it (over Wi-Fi its hardware serial); before 2026-11-07 it looked it up by the connection's serial and failed with not found after a real pairing. Why pairing failed, as far as adb tells (`pairError`; checked 2026-11-08 against the platform-tools 37 server on this Mac with local addresses, no phone): a refused port answers `Unable to start pairing client.` at once (`driver.ErrPairNotListening`); a port that accepts and closes, or a TLS port that is not a pairing service (adbd's connect port), answers `Wrong password or connection was dropped.` at once, as a wrong code does (`driver.ErrPairRejected`); an address that does not answer gives the same words after 5 s (`driver.ErrPairUnreachable`, from the time it took); a server without `host:pair` answers `unknown host service` (`driver.ErrPairOldTool`). Before, every adb failure read "a wrong code", and the client showed "The pairing code didn't match" even for a QR code. The gateway logs each failure at info with adb's words (`pairing over Wi-Fi failed`), so the diagnostics show them. The waits for a paired or connected phone (`connectPaired`, `ConnectWiFi`, 30 s) end the same whether their timeout falls between two adb requests or during one (`waitRanOut`, by the clock, since the connection's deadline comes a moment before the context says so): before 2026-11-09 one that fell during a request returned the request's `i/o timeout`, so the page said the phone did not answer instead of asking for its address; Linux CI hit it now and then (run 42496615234). - **Reconnecting over Wi-Fi (`Config.ReconnectWiFi`, on in the app unless `PINKY_DEVICES` is set).** `List` starts a pass in the background when one is due: the guids in the adb server's `~/.android/adb_known_hosts.pb` (the phones it paired with) that have no connection in use and that the server's discovery does not list are searched for on the network for up to 6 s, and the ones found are connected by address (`host:connect`). The paired phones the server's discovery does list are connected by their service name (`host:connect:._adb-tls-connect._tcp`), as the server's own auto-connect would, since a server Pinky starts has that off (since 2026-11-09). An address that refused is tried again after 5 minutes, then 15, 45 and so on up to 6 hours (at once at a new address); when the server is gone after the attempt, after 6 hours. The adb server of platform-tools 37 can crash when a TLS handshake fails, and every other tool loses its connections with it. Passes come 5 s after a phone came or went, then back off from 10 s to 2 minutes while a paired phone stays away (1 minute while one with a kept address does), and once a minute while all are there. A computer that never paired a phone over Wi-Fi never searches. - **The last address of a paired phone (GitHub issue #102, 2026-11-12).** A REDMI K90 (HyperOS OS4.0.5, Android 16) stops announcing `_adb-tls-connect._tcp` while its screen is off, though Wireless debugging still takes connections. After Pinky updated itself and the adb server restarted, it stayed offline until the person woke it, for tens of minutes to hours, since the reconnector found phones only by mDNS and kept the addresses it connected only in memory. Now the driver keeps the address each paired phone was last connected at, by guid, in `network-phones.json` (`Config.NetworkFile`, the `paired` list beside the phones moved to Wi-Fi; `netStore.pairedAddr`), and replaces it each time the phone connects: by the reconnector (by name, with the address the server's discovery lists; by an address the search found), by a pairing, or by the server itself (a named connection the server lists). While the phone is away, each pass tries that address alongside the search (`tryKept`): a 3 s TCP probe first, since `adb connect` at an address nothing answers waits for the system's TCP timeout and holds up the server, and a probe that fails is not held against the phone; then `host:connect:`, which counts as a refusal like the search's when it fails. The connection is kept only when the device there is the phone (`isPhone`): its hardware serial is the one in the guid, or, for a phone whose shell reads no serial, it has Pinky's mark ([Is it the same phone?](#is-it-the-same-phone-2026-11-09)). Another device at the address is disconnected and the address forgotten; one that does not tell within 10 s is disconnected and tried again at the next pass. The try and the search's connection to the same phone take turns (`keptTry`), so the phone is connected once, by whichever gets there first. Removing the phone (`Disconnect`) or unpairing it from the server (gone from `adb_known_hosts.pb`) forgets the address; pausing it keeps it but stops the tries. Unit tests in `reconnect_kept_test.go`; not yet tried on a HyperOS phone. - **Person input** (`PersonInput`): `getevent` on the touchscreens and hardware-key devices from `getevent -lp`: direct multi-touch devices, or, on a phone without any (the emulators' virtio touchscreens have no input properties), its multi-touch devices; devices with power, volume, home, back, menu or recents keys; never `uinput` devices. Injected input never reaches /dev/input, so it is not reported. At most one signal per 300 ms. - **Observe:** the companion's tree when it is on, else `uiautomator dump`; lock and keyboard state from the window manager in parallel; OCR where the tree misses text ([below](#text-from-pixels-ocr-2026-11-08)). The screen conversion lists an input method window's elements whenever the app sends its nodes (build 70 sends them unasked for a keyboard sheet that covers the screen, such as Gboard's stylus tutorial), and hides what lies under a higher window by its touchable `region` when the app sends one (Android 13+), else by its bounds: Gboard's stylus toolbar spans the lower half of the screen but covers only a narrow strip. When a tree is still empty while the keyboard shows (older builds, such as the Mi's), the driver reads again with the keyboard's window, then falls back to uiautomator for that read. ### Settle detection As in round two: a video packet counts as a change unless it is a refinement (85 to 125 ms after the previous capture), a key frame or configuration, or tiny; the screen has settled when no change came for 400 ms (limit 3 s). While the companion is on, its window and content events must also have been quiet for 300 ms; since round three only the events of the display being settled count (and those whose display is not known). Without video: the companion's events, or a fixed 1 s. ## Using it from the gateway ```go p := android.NewProvider(android.Config{Logf: log.Printf}) // driver.Provider and driver.Pairer dev, _ := p.Open(ctx, id) // *android.Device dev.(driver.Waker).Wake(ctx) // before an agent's step on the main screen bg, _ := dev.(driver.Backgrounder).OpenBackground(ctx, driver.BackgroundOptions{App: "com.android.settings"}) ``` - **Do not wake the phone for an agent working on a background display:** that would turn the screen on, which is what background work avoids. `Background` does not implement `Waker` on purpose. - `Background.Locked()` is false from Android 13, and so is its `Screen.Locked`; the phone's own `Device.Locked()` still says whether the phone is locked. Agents on a background display are not stopped by the lock screen: the gateway's policy (blocked apps, approvals) is all that stands between them and the phone's apps. - `Unlock(ctx, pin)` returns nil, `ErrLocked` (the PIN went in, all or part of it, and the phone is still locked: do not try again), `ErrPINNotEntered` (nothing was entered: no PIN pad, one the driver cannot read with confidence, attempts refused), `ErrUnsupported` (pattern, characters it cannot type) or `ErrOffline`. Errors never contain the PIN. - `Files` paths are slash-separated and relative to `/sdcard/Pinky`; `ErrNotFound` for anything outside or missing. `Background` serves the same folder. - `CompanionState` costs one `dumpsys package` (7 to 80 ms), plus `settings get` and a ping when the app is current. `InstallCompanion` may take minutes on a phone where Play Protect asks. - `func (d *android.Device) SetUpCompanion(ctx context.Context, force bool) (android.CompanionInstall, error)` does what `InstallCompanion` does and reports `APK`, `Bundled` and `Before` (versions), `Installed` and `Replaced`; `force` also installs over the same versionCode when the file differs. `android.FindCompanionAPK()` looks at `$PINKY_COMPANION_APK`, `pinky-android.apk` next to the executable, `../Resources/pinky-android.apk` (macOS bundle), then the debug build under the working directory. - `Stream` returns `ErrUnsupported` on a phone whose encoder fails; input still works there. - **`cmd/pinky`'s `onlyProvider` (`PINKY_DEVICES`) hides `driver.Pairer`:** it embeds `driver.Provider` only, so `Gateway.StartPairing` finds no pairer while that variable is set. gateway-core may want to forward it. (Since forwarded, with `driver.WiFiConnector` and the pairing's progress.) - **The app sets `ReconnectWiFi`,** except while `PINKY_DEVICES` limits it to some phones (the others belong to other tools on the shared server). Other providers (`androidctl`, `cmd/pinky/companion.go`, tests) leave it off, so they never search the network unasked; pairing searches whatever the setting. - **`PINKY_DEVICES` reaches the driver (`Provider.Limit`, `limit.go`, 2026-11-09).** `onlyProvider` hid the phones it leaves out, but the driver still asked every device adb listed for its properties (`getprop`), the owner's phones and other tools' emulators too. Now `cmd/pinky` passes its filter to the provider, and the driver takes the server's list through `devices`, which drops every connection whose phone it cannot tell is allowed from the adb serial alone: a cable's serial is the phone's id, a Wireless debugging connection's name holds its serial, a paired phone's guid too (the reconnector's addresses), and an address the driver connected to is kept with its phone (`network.go`). The dropped ones get no request at all (`deviceProps` refuses them as well), and neither the network passes nor the reconnector connect to a phone left out. The connection to a phone the person adds (a pairing, an address typed in Connect by address or for Tailscale) is let in by its serial (`allowConn`). An emulator Pinky starts runs on a console port Pinky picks (even, 5554 to 5682, not used by any emulator adb lists and free on 127.0.0.1) and is used until it quits (`avdLauncher.started`). Left out under the limit, unless `PINKY_DEVICES` names the serial: a connection at an address the driver did not make, and an emulator another tool or an earlier run of Pinky started; such an AVD shows as not running in Add phone, and starting it fails as the emulator refuses an AVD that runs. Tests: `limit_test.go`, `TestDeviceFilterReachesProvider`. ## How to run ```sh go build -o /tmp/pinky-android/androidctl ./internal/driver/android/cmd/androidctl A=/tmp/pinky-android/androidctl scripts/devlock.sh emulator-5560 $A -s emulator-5560 screen # true or false scripts/devlock.sh emulator-5560 $A -s emulator-5560 wake scripts/devlock.sh emulator-5560 env ANDROIDCTL_PIN=1234 $A -s emulator-5560 unlock # or the PIN on stdin scripts/devlock.sh emulator-5560 $A -s emulator-5560 files scripts/devlock.sh emulator-5560 $A -s emulator-5560 push notes.txt docs/notes.txt scripts/devlock.sh emulator-5560 $A -s emulator-5560 pull docs/notes.txt /tmp/pinky-android/notes.txt scripts/devlock.sh emulator-5560 $A -s emulator-5560 companion-state scripts/devlock.sh emulator-5560 env PINKY_COMPANION_APK=app-debug.apk $A -s emulator-5560 companion-install scripts/devlock.sh emulator-5560 $A -s emulator-5560 bg -app com.google.android.deskclock state sleep:5000 state observe ``` `bg` runs steps on a background display; its `state` step prints whether the phone's own screen is on and locked. Never pass a PIN on the command line. Wi-Fi, against a second adb server whose discovery is off, which stands in for one whose discovery stopped and leaves the shared server (and its emulators) alone: ```sh ADB=~/Library/Android/sdk/platform-tools/adb ADB_MDNS=0 ADB_USB=0 ADB_EMU=0 $ADB -P 5038 start-server # no discovery, no USB, no emulators export ADB_SERVER_SOCKET=tcp:127.0.0.1:5038 $A wifi-search -seconds 8 # what Pinky's own search sees scripts/devlock.sh 3e8be48e $A -v pair-qr /tmp/pinky-android/qr.png # scan it on the phone scripts/devlock.sh 3e8be48e $A -v pair-code 192.168.5.137:37109 123456 scripts/devlock.sh 3e8be48e $A wifi-connect 192.168.5.137:38161 scripts/devlock.sh 3e8be48e $A -v wifi-reconnect -seconds 60 # $ADB -P 5038 disconnect … meanwhile $ADB -P 5038 kill-server ``` On macOS the binary needs Local Network access ([below](#macos-local-network-privacy)); sign it once with an identifier (`codesign -s - -f --identifier app.pinky.androidctl $A`) and keep that build. Tests: ```sh go test ./internal/driver/android/... # unit tests, fake adb servers and fixtures scripts/devlock.sh emulator-5560 env PINKY_DEVICE=emulator-5560 PINKY_TEST_PIN=1234 \ PINKY_COMPANION_APK=/path/to/app-debug.apk go test -tags device -v ./internal/driver/android/ ``` On a phone the device tests use only the home screen, Settings search and the Calculator, and skip what needs an emulator. On emulators they also press power, unlock with `PINKY_TEST_PIN`, use the phone app's keypad where there is no Calculator, install the companion and change the accessibility setting (put back afterwards). `PINKY_TAPS` sets the taps per batch of `TestDeviceTapsLand` (40). ## Wi-Fi without adb's discovery 2026-11-07. The owner's Homebrew Pinky 2026.1107.1 sat on "Pairing…" on both the Mac and the Mi: the adb server listed no mDNS service at all, while `dns-sd -B _adb-tls-pairing._tcp` showed the phone's. Restarting the server fixed it at once, as it had that morning for a server running since 5 November. **Why the server stops seeing the network.** Its log (`$TMPDIR/adb.501.log`) shows the server of 15:24 reconnecting the Mi over Wi-Fi at 15:24, 15:28 and 16:07, then losing it at 21:18 and never finding it again. At 21:08:40 an iPhone was plugged in: macOS attached its USB network interfaces (`en10`, `anri0`) and configd logged "network changed" at 21:09:20. The server's discovery (openscreen, `mdns daemon version [adb discovery 0.0.0]`) does not survive such a change, and stays blind until it restarts. Plugging in an iPhone, a VPN or a sleep is enough, so on a computer that runs for days it is the normal case. **What Pinky does now.** It searches for `_adb-tls-pairing._tcp` and `_adb-tls-connect._tcp` itself (package `mdns`) whenever it pairs, and in the background while a paired phone is away, and connects by address; see How it works. It never restarts the server for that (it does for a server of its own that lost the network altogether: [below](#an-adb-server-cut-off-from-the-local-network-2026-11-09)). In the logs: "the adb server's discovery does not list …; Pinky found it at …". **A record is no proof of an address** (2026-11-09, with the iPhone's; see "A record is no proof of an address" in `docs/notes/ios.md`). The search skips A and AAAA records that name an address no device can have: unspecified, loopback, multicast or broadcast, or IPv4 written as IPv6 (`mdns.Usable`). Such a record is neither dialed nor drops a phone's addresses from the cache with its cache-flush bit (`TestUnusableAddresses`). A replayed record does not move a connected phone: the reconnector connects only phones the server does not list, and the server keeps the connection it has. But adb trusts whoever answers at an address. Its TLS client accepts any server certificate (`transport.cpp`, "Allow any server certificate"), its known hosts keep only each phone's guid (`adb_known_hosts.proto`), and the server's discovery connects by itself to a `_adb-tls-connect` service named after a known guid (`transport_mdns.cpp`), as Pinky's reconnector does. So while a paired phone is away, a record with its guid and another address gets the server, and Pinky through it, connected to whatever answers there, which then sees the text and commands Pinky sends. Checks on the address cannot close that, since the server connects by itself. It needs a check that the phone is the phone, with the server's own auto-connect off where Pinky starts it: done on 2026-11-09 with a mark Pinky leaves on each phone rather than with the phone's certificate, see [Is it the same phone?](#is-it-the-same-phone-2026-11-09). **How the Mi answers mDNS** (LineageOS 24, Android 17, measured from the Mac): - A question with the unicast-response bit from another port than 5353 gets its answer straight back, with the SRV, TXT, A, AAAA and NSEC records in the additional section. A plain question gets a multicast answer (which the group socket reads); a plain question sent straight to the phone gets a multicast answer too. It does not answer a legacy unicast question (`dig @224.0.0.251 -p 5353`) with its id. - Asleep, it misses the first questions: answers came from the second to the fourth (1–5 s), and at times it answered nothing for a minute. The search asks at 0, 1, 2, 4, 6 and 10 s, then every 5 s. A search that runs for long, as the one for iPhones on Linux (`docs/notes/ios.md`), sets `Options.MaxEvery` instead: 1 s, then twice as long each time up to it (RFC 6762 §5.2), from the start again when the computer's addresses change. The Android searches keep the quick schedule: they last seconds. - After the pairing dialog closes, its pairing service says goodbye (TTL 0) and leaves the search's list at once. ### macOS Local Network privacy Searching needs Local Network access (macOS 15 and later), and the system gives no error when it is missing: sends succeed and nothing comes back. What was seen on this Mac (macOS 27.2): - It is decided per executable. Go builds carry the ad-hoc identifier `a.out`; a binary that searched fine stopped receiving anything when copied to another path or rebuilt, while `/usr/bin/python3`, the `dig` binary and a build re-signed with `--identifier app.pinky.androidctl` were let through. TCP connections to the phone worked from every binary. Test binaries built by `go test` are blocked, so `TestDeviceBrowse` reports `mdns: not allowed…` here; the unit tests use the loopback only. - A process the app starts (the adb server, when Pinky starts it) counts as the app. - `nehelper` keeps a rule for `app.pinky.desktop`; whether the owner's Pinky is allowed was not checked. Pinky.app now explains its prompt (`NSLocalNetworkUsageDescription`, English in `cmd/pinky/mygo.json`, Chinese in `cmd/pinky/resources/darwin/zh-Hans.lproj/InfoPlist.strings`). - The search tells a blocked process by its own questions: they loop back to its group socket within milliseconds when allowed (multicast loopback) and never when blocked. After 1.5 s without one, `Err` wraps `mdns.ErrNotAllowed`, the pairing reports `NotAllowed`, and Add phone offers Privacy & Security › Local Network. ### Checked on the Mi (owner at the phone) With a second server on port 5038 with discovery off (How to run), `androidctl` from this branch: | What | Result | | --- | --- | | `wifi-search` | The Mi's connect service in 1.4–5.2 s | | `wifi-reconnect`, nothing connected | Connected the Mi by address in 6.2 s, listed as `3e8be48e` (its hardware serial) | | The same, connection dropped (`adb -P 5038 disconnect`) | Back 10 s and 18 s later (5 s to the pass, then the search; a dozing Mi missed one search) | | `pair-qr`, scanned on the Mi | Found the pairing service, paired in under 0.25 s, connected 0.6 s later; returned as `3e8be48e` | | `pair-code` with the dialog's code and address | Paired and connected in 2.4 s | | `wifi-connect` | The right address at once; a closed port: "failed to connect to '192.168.5.137:5555': Connection refused" | The shared server on 5037 kept its connections to the Mi and `emulator-5560` throughout; the server on 5038 was stopped afterwards. Once the server on 5038 crashed when a reconnect's TLS handshake failed (`Handshake failed in SSL_accept/SSL_connect`, then `libc++abi: … mutex lock failed`), an adb bug: hence the long waits after a refusal. Run every `adb -P 5038` command with the three variables: a bare one starts an ordinary server there, which connects the emulators and the Mi too (it happened once, for three seconds). The Mi was paired with this Mac twice more (QR, code), which only adds this Mac's key to its paired devices. ## Pinky's own adb Since 2026-11-08 every package of Pinky carries an adb of its own, so that a person with an Android phone installs nothing else (branch `claude/bundle-adb-3b0832`). **What it is.** `scripts/adb.sh` builds adb, and nothing else, from [android-tools](https://github.com/nmeum/android-tools) 37.0.0p1, AOSP's `android-17.0.0_r1` with a CMake build (the same version as Google's platform-tools 37.0.0), with `scripts/adb/CMakeLists.txt`. It links protobuf 3.21.12 (the last without Abseil), BoringSSL, libusb, fmt, brotli, lz4, zstd and zlib statically, so it needs only the system's libraries; every source is checked against its SHA-256. `adb version` says `Version 37.0.0p1-pinky`. It has no mDNS (android-tools builds that with Rust): Pinky finds the phones on the network itself ([above](#wi-fi-without-adbs-discovery)) and pairs and connects by address, and a server without discovery answers `host:mdns:check` with a failure, which pairing already handles. | Package | Where | Built | | --- | --- | --- | | Pinky.app (arm64, amd64) | `Contents/Helpers/adb`, signed with the app (with the hardened runtime and a timestamp under a Developer ID) | Apple's clang for macOS 12 and later (Pinky needs 13; see [platform.md](platform.md)), 6.3 MB, the system's zlib; USB through libusb, adb's default on macOS | | Windows installer and zip | `adb.exe`, `AdbWinApi.dll` and `AdbWinUsbApi.dll` next to `Pinky.exe` | a 32-bit `adb.exe` (8.4 MB), as Google's: AOSP builds AdbWinApi for 32-bit Windows only, and the two DLLs are AOSP's own (platform/development, `host/windows/prebuilt/usb`, Apache-2.0, checked by SHA-256 and the git blob). Cross-compiled with Ubuntu 24.04's mingw-w64 (posix threads, `-static`, SSE2) in Docker, with MSYS2's mingw fixes (`scripts/adb/windows.patch`). It imports Windows' own DLLs and AdbWinApi.dll only | | Linux `.tar.gz` and `.deb` | `adb` next to `pinky` (`/opt/pinky/adb`) | a static musl executable (8.8 MB) built in Alpine 3.22, which runs on any x86-64 Linux | Licenses and build notes go with it (`adb-NOTICE.txt`, `adb-BUILD.txt`; in Pinky.app under `Contents/Resources`): Apache-2.0 for AOSP's code, BoringSSL's, the BSD licenses of protobuf, lz4 and zstd, MIT for brotli and fmt, zlib's, and the LGPL 2.1 of libusb, which is linked statically; its source is in android-tools' release and `scripts/adb.sh` builds and links it again. **Which adb starts the server.** Pinky uses whatever adb server runs and never kills or restarts one that another program started (since 2026-11-09 it restarts one of its own that cannot reach the network: [below](#an-adb-server-cut-off-from-the-local-network-2026-11-09)). When none answers, `adb.StartServer` starts one with the first adb that runs and is platform-tools 30 or later (an older one, such as Ubuntu 22.04's 29.0.6, lacks Wireless debugging: it is passed over for a newer one, and used only when none works): 1. the one `PINKY_ADB` names, and only that one: a path, or `pinky` for Pinky's own (no version check); 2. the Android SDK's: `ANDROID_HOME`, `ANDROID_SDK_ROOT`, then where Android Studio puts it (`~/Library/Android/sdk`, `%LOCALAPPDATA%\Android\Sdk`, `~/Android/Sdk`), so that Pinky and Android Studio share one server of one version; 3. Homebrew's (`$HOMEBREW_PREFIX/bin`, `/opt/homebrew/bin`, `/usr/local/bin`, Linuxbrew's): an app opened from the Finder or the Dock has the system's PATH, without them; 4. those on PATH, in order (a system package's `/usr/bin/adb`, Scoop's, Chocolatey's); 5. Pinky's own, last, also when its folder happens to be on PATH. An adb the person installed comes first so that their own adb commands find a server of their version. Pinky's own runs from a copy in the user's cache folder (`~/Library/Caches/Pinky/adb//`, `%LOCALAPPDATA%\Pinky\adb\\` with the two DLLs, `~/.cache/pinky/adb//`; `$PINKY_HOME/cache/adb` when that is set), named after the files' SHA-256, and other versions' copies go: the server can outlive Pinky ([below](#stopping-pinkys-adb-when-pinky-quits-2026-11-10)), and running from the app's folder it would keep the Windows installer from updating Pinky (MyGo's installer stops every process started from the install folder, which would take every tool's adb connections down) and a disk image from being ejected. **How the server starts.** Pinky runs the server process itself, `adb -L tcp: fork-server server --reply-fd ` (what `adb start-server` runs once it has found no server), detached (its own session; on Windows its own process group, a console without a window and an inherited pipe handle), and waits for the `OK` it writes once it has looked for devices. On Windows it started with `DETACHED_PROCESS`, as adb's own `launch_server` does, until 2026-11-09: the adb that scoop puts on the PATH is a shim without a console that runs the real `adb.exe`, and Windows gives a console program started by one without a console a console of its own, with a window, which stayed open as long as the server ran (DIYgod/pinky#28). With `CREATE_NO_WINDOW` the shim gets a console without a window, and `adb.exe` shares it. `TestServerHasNoWindow` plays such a shim and checks that the program it runs has a console and no visible window, and that it had a visible one with `DETACHED_PROCESS`. `adb start-server` would check the version of a server that came up meanwhile and kill it when it differs; a server process that cannot listen because another one does only exits. The port is `ServerAddr()`'s, so `ANDROID_ADB_SERVER_PORT` and `ADB_SERVER_SOCKET` hold as before; a server address that is not this computer's is never started. **When the server goes away.** The Android provider now exists without a server, even without any adb (a development build on a computer without one: it then lists no phones, without an error), and `List` starts one (`Config.StartServer`): at once the first time, and when a server that ran went away, after it has stayed away for 3 s, so that whoever restarts it comes first. An adb client of another version (an old `adb` on PATH, a newer SDK) prints "adb server version (41) doesn't match this client (39); killing..." and starts its own at once; Pinky then uses that one, and pairing and other features an old server lacks fail as they would. After a failure `List` waits 5 s, doubling to two minutes. Since every platform-tools from 29 (2019) speaks protocol 41, as Pinky's does, mismatches come only from much older or much newer adbs. **USB on Windows and Linux.** Windows needs a driver for a phone's USB debugging interface; adb cannot see a phone without one. `List` reads the USB devices from SetupAPI (`usbdriver_windows.go`) and lists a phone whose debugging interface (compatible id `USB\Class_ff&SubClass_42&Prot_01`) has no driver (problem 28 or 1, or no service) as `StateNeedsSetup`, id `usbdriver:`, named as the phone names itself (the bus-reported description of the composite device), with the driver to install: the Google USB Driver for Google's vendor id, Samsung's for Samsung's, the makers' page otherwise. A phone online over Wi-Fi by the serial its USB device reports is left out. Linux needs udev rules for adb to open the USB device; a phone adb reports with "no permissions" says which package has them. Both reach the client as any phone that needs setup does (Add phone's "Found …, but …", the phone's page, the checkup), with `Info.Setup` `usb_driver` or `usb_permission` (the onboarding branch's `driver.SetupUSBDriver` and `driver.SetupUSBPermission`). **What the computer lacks (`driver.HostChecker`).** `Provider.CheckHost` answers from what the last `List` found, for the checkup page: `adb` when there is no adb to start a server with, `adb_server` when no server runs and starting one failed (Detail has the error), `adb_old` when the server that runs speaks a protocol older than 41 (platform-tools before 29), `adb_restarts` when the server went away three times or more within ten minutes, as when another program keeps restarting it with an adb of another version, `adb_no_network` when the server cannot reach phones on the network that Pinky reaches and Pinky could not restart it ([below](#an-adb-server-cut-off-from-the-local-network-2026-11-09)), and, when none of those holds, `adb_other_server` when another program's adb server, on another port, holds a phone on a cable that the server Pinky uses cannot reach ([below](#another-adb-server-holds-the-phone-github-issue-95-2026-11-12)). `List` asks a server that came for its version once. **How it was checked (2026-11-08).** - `make check`; unit tests for the search order on all three systems, version parsing and the platform-tools 30 limit, the cache copy, starting a server with the test binary as a fake adb (on Windows through the inherited handle, in CI), that a second server where one listens quits and leaves the first, `List` starting a server and waiting after one went away, the Windows driver listing from data shaped as SetupAPI gives it, and SetupAPI itself on the Windows runner (`TestSystemUSBNodes`). - This Mac, under `scripts/devlock.sh emulator-5560`: `pinky daemon` with an empty `HOME` (no SDK), PATH `/usr/bin:/bin:/usr/sbin:/sbin` (no adb), `PINKY_ADB=pinky` (Homebrew's adb is passed over too), a server port of its own (5039) with `ADB_USB=0 ADB_EMU=0`, only emulator-5560 registered (`host:emulator:5561`) and `ADB_VENDOR_KEYS` pointing at the usual key. The daemon logged `android: started an adb server adb=…/cache/adb/64df412b2df9aab8/adb from=Pinky version=37.0.0p1-pinky`, `pinky devices` listed `sdk_gphone64_arm64 emulator-5560 android usb Idle`, and `androidctl` took a 1080×2400 screenshot (652 ms) and read the home screen through that server. Without `PINKY_ADB`, the same environment started Homebrew's adb (37.0.2). The shared server on 5037 and its devices were the same before and after. - `scripts/dist.sh darwin` on this Mac (`SIGN=- NOTARIZE=0`) put adb into both apps, with its notes, and `TestPackagedADB` passed with the Apple silicon app unpacked from its zip. - `TestPackagedADB` (`PINKY_ADB_APP=`) starts a server with a package's adb on a port of its own and asks it for its version and devices; the release workflow runs it for the Linux, Windows and Mac packages (the Intel app's where the runner has Rosetta). The Intel Mac adb could not run here (no Rosetta on this Mac); the Windows adb ran under Wine (`adb.exe version`). - GitHub, a release run that published nothing (`release.yml` on a temporary branch, as `v2026.1108.90`, run 42401184322): every package was built with its adb, both Pinky.apps were signed and notarized with it, and `TestPackagedADB` passed with the Linux package's (server up in 46 ms), the Windows zip's on the Windows runner (423 ms: the 32-bit adb.exe with AOSP's DLLs, started detached with the inherited pipe), and both Mac apps' (Apple silicon, and Intel under the runner's Rosetta; about 3 s each, as the server waits for its USB scan). CI passed the new tests on Linux and Windows; there, four tests of rules and settings fail on main as well. **Not done.** mDNS in Pinky's adb (Android Studio's "Pair devices using Wi-Fi" through a server Pinky started fails; it works once that server is gone and Android Studio starts its own); the Windows driver listing has not met a real phone without a driver; no Windows ARM64 or Linux arm64 adb (Pinky has no such packages either). ## Stopping Pinky's adb when Pinky quits (2026-11-10) An adb server outlives whoever started it, so that every adb tool shares it. People on Windows took the adb.exe left in Task Manager for Pinky that would not quit (inbox #1396). Settings › Advanced › **Stop adb when Pinky quits** (`ui.Settings.StopADBOnQuit`, on by default) has Pinky stop, as it quits, the server it started for itself with its own adb, and only that one (`adb.Client.StopPinkyADB`, `quit*.go`; called by `stopADBAtQuit` in `cmd/pinky/adbquit.go`, from both the app and `pinky daemon`, once the gateway has closed). **Which server is Pinky's.** One that runs Pinky's own adb, the copy in the cache folder (`inCache`: the program is `adb`/`adb.exe` under `cacheDir()`; case ignored on macOS and Windows). Pinky's adb comes last in the search order, so such a server means the person has no adb of their own that works; a server of an adb they installed (SDK, Homebrew, PATH) is never stopped, whoever started it. - macOS and Linux: the one process `ps` lists with the server's command line on the port (`serverProcesses`, `ownCommandLine` of #52), and the program before `-L tcp:` in the cache. - Windows: the process that listens on the port in `GetExtendedTcpTable` (exactly one, on a loopback or any address), whose image (`QueryFullProcessImageName`) is in the cache. Command lines of other processes are not readable there without more rights, which is why #52's `OwnServer` stays false on Windows; the image path tells as much. **Is another program using it.** Connections to any port the server listens on (5037 and forwarded ports) whose client is neither the server nor Pinky: macOS `lsof -b -w -nP -a -p , -iTCP -FpnT` (`-b -w` because a stale network mount made a plain `lsof` hang for minutes; with `-p` it takes about 30 ms, under a 2 s limit), Linux `/proc/net/tcp{,6}` with owners from `/proc//fd`, Windows the TCP table. A client counts when the server's end of the connection has a remote port that is not one of Pinky's own sockets. Android Studio, scrcpy and `adb logcat` hold such connections while they work; a one-off `adb devices` does not, and the server then stops (as `adb kill-server` would), to start again with the next adb command. When Pinky cannot tell (another OS, `lsof` failing), it stops the server. **The stop.** `host:kill`, as `adb kill-server` sends, then up to 3 s until the server no longer answers; the whole call has 4 s. Measured: 69–92 ms on this Mac. **Updates and takeovers keep it.** Quitting to start again at once (an update's restart, or the app taking over from a daemon) leaves the restart marker (`update.Restarting`, written by `MarkRestart`); the quitting Pinky reads it as the quit begins, before the next one removes it, and keeps the server, which the next Pinky uses, so phones on a cable stay connected through the update. **Why on by default.** It stops only Pinky's own adb, which nobody else asked for, and nothing in use; the next Pinky starts it again in about 100 ms. Stopping it also helps Android Studio's "Pair devices using Wi-Fi", which fails through Pinky's adb (no mDNS) until Android Studio starts its own. Turn it off to keep phones on adb between runs of Pinky (an `adb shell` afterwards would otherwise start a fresh server). **How it was checked.** Unit tests for the parsers (`lsof`, `/proc/net`, the Windows table built from bytes), `listener`, `otherClients` and `inCache` (Windows paths too); `TestStopPinkyADB` on macOS, Linux (Docker, real `/proc`) and Windows (CI) with the test binary as a fake adb in the cache: a held connection from another process keeps it (`ErrServerInUse`) while the test's own does not, it stops once that connection goes, and a server of an adb outside the cache is `ErrNotOwnServer`. On this Mac, under `scripts/devlock.sh emulator-5730`, `pinky daemon` with `PINKY_ADB=pinky` on port 5041: with an `adb track-devices` attached the quit took 0.40 s and left the server ("another program uses it"); with nobody else it stopped it (92 ms); with the restart marker it kept it and the next daemon used the same process; with the setting off it kept it; a server Homebrew's adb started on that port was left alone. ## Round three results ### Background displays while the phone is off and locked This is the question behind the owner's keep-awake worry. **On the Android 16 emulator with a PIN, an app on a background display keeps running, drawing, taking taps and text, and streaming while the phone's own screen is off and locked.** Each run used the Clock's stopwatch or Settings on a scrcpy virtual display: | Run | Result | | --- | --- | | Locked before the display opened (screen off, keyguard showing) | Stopwatch reset, started, read 7 s then 11 s, paused at 11 s, by taps on the display. 624 frames in 17.8 s (35 fps). The phone stayed screen off and locked throughout. | | Locked while the work ran (power key 12 s in) | The stopwatch read 3 s before the lock and 15 s and 18 s after it, and paused on a tap after it. 794 frames in 25 s. | | Idle past the screen timeout (timeout set to 15 s, no input for 35 s), phone locked before the display opened | The display's power group stayed awake (`wakefulness 1`, display 0 dozing), the stopwatch read 38 s, a tap still paused it, 1747 frames in 44 s (40 fps). | | Idle while the phone locks with the display open (later, from main's report) | **Broke without a fix:** about 10 s after the last input, reads of the display turned empty (no app, no elements, 3 ms) and the app slept. With the keep-awake key, reads 8 s and 14 s into a pause and 35 s after the lock still showed the app; through the gateway, `TestEmulatorBackground` passes, and fails at its 8 s read without the key. | | Typing (`TestDeviceBackgroundLocked`) | Settings on the display, search tapped, "pinky" typed with the companion's `set_text` on that display, read back, video flowing; the phone off and locked. | Why: from Android 13 scrcpy creates its display with `VIRTUAL_DISPLAY_FLAG_ALWAYS_UNLOCKED` and `OWN_DISPLAY_GROUP`, so the keyguard does not cover it and the display has its own power state. `dumpsys power` shows a second power group that stays awake while group 0 dozes, and `dumpsys display` shows display 0 OFF and the virtual display ON. **The lock screen's timeout applies to every display group.** While the keyguard shows and has focus, Android sets a 10 s user-activity timeout for all groups (`PowerManagerService: Going to sleep due to timeout ... activityTimeoutWM=10000`; `PowerGroup: Powering off display group due to timeout (groupId= 1, ... millisSinceLastUserActivity=10003)`). The background display's group then dozes, its activities go to sleep (`isSleeping=true`, `visibleRequested=false`, the app at `TOP_SLEEPING`), and accessibility still lists the window but cannot fetch its root, so the companion's tree is empty. The phone locked before the display opened kept working because focus was then on the display's app, not on the keyguard. Input on the virtual display counts as activity for its group only, so the driver sends `KEYCODE_UNKNOWN` there every 4 s when nothing else did; apps ignore that key. `KEYCODE_WAKEUP` sent to the virtual display does not help: it wakes the phone's own screen (group 0) and leaves group 1 dozing. What it costs and means: - **The phone's own screen stays off** (no burn-in), but the phone as a whole stays awake while the display is open: its CPU, the encoder while content changes (it sends nothing for a still screen), and the keep-awake key every 4 s. Close the display when the task ends; then the phone sleeps as usual. - **The lock screen does not protect apps from an agent on a background display.** That is the point of the feature, and it moves all of the protection to the gateway's policy. - **Android 10 to 12: not verified.** scrcpy sets neither flag before Android 13, so the display most likely sleeps with the phone and the keyguard may cover it. The Android 10 emulator cannot show it (its encoder fails, and a virtual display needs one) and the Pixel is offline. - **The Mi:** a background display with the Calculator worked again (8 x 9 = 72 by taps, main screen untouched, the launcher still in front, the accessibility setting exactly as before). The locked case could not be tried: the Mi keeps its screen on while charging (`stay_on_while_plugged_in=15`, Developer options > Stay awake, not set by Pinky), and the rules forbid pressing its power key or changing settings. **Recommendation:** make background work the default for Android 13 and later, with the display closed at the end of each task, and keep the main screen for Android 12 and earlier until a phone shows otherwise. ### The lost taps Reproduced and fixed. `TestDeviceTapsLand` taps the phone app's 5 key in batches and counts the digits that arrive: | Batch | Before the fix | After | | --- | --- | --- | | Paced 300 ms, back to back, video reset every 400 ms, a stream nobody reads, the companion turned off and on every 1.5 s, screenshots and reads meanwhile, the session restarted right before each tap | 0 lost | 0 lost | | Another caller reopening the stream with other options every 350 ms while taps go on | **9 of 40 lost** (each failed with "session restarted with new options"; a write that beat the close could also vanish without an error) | 0 lost | | Android 10: a stream request on a phone without video | 1 of 40 lost (the request closed the session before refusing) | 0 lost | Totals after the fix: 320 taps on Android 16 and 280 on Android 10, none lost. The Mi's two lost taps (7 and = through the app) fit this: a session restart, or a session that died, while the taps were on their way. Which restart happened that night is not known; the fix covers restarts the driver makes, and taps on a session that dies return an error. ### Android 10 (emulator-5562, standing in for the Pixel) - **scrcpy:** the server runs, but the emulator image's video encoder fails (so does its `screenrecord`), so the driver runs it without video. The real Pixel streamed fine in round one. - **Companion:** builds from round three install and run (`set_text`, events; Android 10 has no IME action, so Enter goes through scrcpy). Typing ASCII and CJK passes through `set_text`. - **uiautomator fallback:** used for the PIN pad while the companion was off. - **Person watcher:** watches the virtio touchscreens (see above); a finger through the emulator console gives one signal, Pinky's taps none. - **Unlock:** wrong PIN `ErrLocked`, right PIN 2.4 s with the companion. - **Device tests:** all pass, or skip what needs video (stream, background displays) or a calculator. ### Timings (emulators unless said) | What | Time | | --- | --- | | `ScreenOn` | 10 to 30 ms (Mi 11 to 170 ms) | | `Wake` from asleep | 0.46 s | | `Unlock` with the right PIN | 1.5 s with the companion (Android 16), 2.4 s (Android 10), 4.2 to 5.0 s with uiautomator reading the PIN pad | | `Unlock` with a wrong PIN, until `ErrLocked` | about 7 s (the 4 s wait for the lock screen to go included) | | `Unlock` by taps on a pad read from its labels, from the screen off | 3.3 to 3.8 s with the companion, 8 to 12.5 s with uiautomator; a wrong PIN, until `ErrLocked`, about 6 s (Android 10, 16 and 17 emulators, up to four running) | | `ListFiles` (4 entries), push 300 KB, pull it back | 3 to 14 ms, 12 to 25 ms, 4 to 7 ms | | `CompanionState` | 7 to 33 ms (Mi, read-only: 79 ms) | | `InstallCompanion`: turn on only; replace a build signed with another key | 0.18 s; 0.39 s (no Play Protect on the emulators) | | Background display ready | 0.35 to 0.6 s (Mi 1.1 s) | | Control-only fallback after a failed encoder | one extra session start, about 0.13 s | ## What works where | | Mi 10 Pro, Android 17 | Emulator, Android 16 | Emulator, Android 10 | Pixel, Android 10 | | --- | --- | --- | --- | --- | | Observe, Screenshot, input, launch, settle | Yes (rounds one and two; Calculator tests again today) | Yes | Yes, without video | Partly (round one) | | Stream | Yes | Yes | No: the image's encoder fails | Yes (round one) | | Wake, ScreenOn | ScreenOn read-only | Yes | Yes | | | Unlock | Yes, by the owner from the computer (key events, past the fingerprint screen) | Yes, right and wrong PIN; also by taps on the pad read from its labels (so is an Android 17 emulator) | Yes; also by taps | Not tried | | Files | Not tried | Yes | Yes | | | Companion state and install | State only: `outdated` (build 1 vs 69) | Yes, all states | Yes, incl. replacing a build signed with another key | | | Background display | Yes, Calculator, unlocked | Yes, also off and locked | No (needs video) | | | Person input | Pinky's input gives no signal (round two) | Finger: one signal; Pinky: none | Same | | | Pairing | Yes (2026-11-07, owner at the phone): QR code and pairing code on a server with discovery off, found by Pinky's own search; connect by address; reconnect after a drop | Fake servers | | | ## The Mi 10 Pro in round three - 09:25: read-only. Screen on and unlocked on the launcher, AC powered, `stay_on_while_plugged_in=15` (`mStayOn=true`): its screen never times out while charging. Other tools' files are in `/data/local/tmp` (AndroMeldAgent, frida-server, Mika), so the owner's own tooling may have set it. Reported to the main session at once. - 09:40: `CompanionState` = `outdated`. A background display with the Calculator, 8 x 9 = 72; main screen untouched. - 10:35: `TestDeviceCalculator`, `TestDeviceBackground` and `TestDevicePersonQuietWhileInjecting` (Calculator). The first two passed. The third counted **5 person signals** while Pinky gave input (round two: 0), and afterwards the Mi was **locked with its screen off** (`showing=true`, dozing), although Stay awake is on. The input dispatcher's recent queue had key events and two resets of input device 4 at that time. Pinky never sends the power key (no key code for it, no scrcpy option for it), so someone most likely pressed it and touched the screen: the watcher saw them. As the rules ask, the Mi was left as it was and not used again. The accessibility setting was back to the owner's service, and no `getevent`, `app_process` or Pinky file was left on it. ## Emulator state left behind - Both: PIN `1234` (`locksettings set-pin`), `stay_on_while_plugged_in=0`, the accessibility setting as found (`null`), the companion session's build 73. `emulator-5560` is unlocked on its home screen; the companion session left `emulator-5562` locked and asleep. `screen_off_timeout` is 600000 on `emulator-5560`; on `emulator-5562` it went back to the image's maximum after the restart. Gboard's stylus tutorial was dismissed on `emulator-5560`. - `emulator-5562` runs with `-gpu swiftshader_indirect` since a restart at 09:11 (it did not help the encoder). - `/sdcard/Pinky` exists on both and is empty. No Pinky jar is left in `/data/local/tmp`. ## Removed phones (`driver.Forgetter`, 2026-11-08) When the person removes a phone from Pinky (gateway notes, "Removing a phone"), the provider hears `Forget(id)` and `Disconnect(ctx, id)`, at start `Forget` again for each phone removed earlier, and `Remember(id)` once it is added back (`forget.go`). The driver hides nothing itself; the gateway keeps the phone off its list. - `Forget` keeps the Wi-Fi reconnector (Config.ReconnectWiFi) away from the phone: it skips the paired phones whose guid, `adb--`, holds a removed phone's hardware serial, and neither searches for them nor connects them. `Disconnect` also forgets the address each such phone was last connected at, which the reconnector would otherwise try. - `Disconnect` drops the phone's Wi-Fi connections that were made by address (by the reconnector, by `ConnectWiFi` and pairings, or by `adb connect`), found by their serial, by the guid in an mDNS name, or by the hardware serial in their properties; `adb.Client.Disconnect` sends `host:disconnect:` and never an empty serial, which would drop every connection. A USB connection stays. A connection the adb server's own discovery made cannot be dropped: platform-tools 37 reads `adb disconnect adb-3e8be48e-kIY2HE._adb-tls-connect._tcp` as an address, adds port 5555 and answers `no such device 'adb-…._tcp:5555'` (checked on this Mac). It stays connected to the server, which reconnects it after a drop anyway, and Pinky does not use it. - adb's own files stay as they are: the phone is still in `~/.android/adb_known_hosts.pb` and still trusts this computer's key, for the other tools that share the server. Pairing it again in Add phone, or plugging it in, adds it back. Checked on `pinky-api36` (`emulator-5560`) through an adb server of the test's own on port 5097 (`ADB_MDNS=0 ADB_USB=0 ADB_EMU=0`, so it found nothing by itself), with the emulator connected by address as a phone on Wi-Fi: `TestRemoveEmulatorOverWiFi` (tag `device`) and `TestRemovePhoneOnEmulator` (tag `e2e`) in `cmd/pinky`. Removing it dropped `127.0.0.1:5561`; connected again with `adb -P 5097 connect`, it stayed off the list, after a restart too; `ConnectWiFi` brought it back. Over a TCP connection the emulator is known by its hardware serial (`EMULATOR37X2X13X0`), and on its own transport by its adb serial (`emulator-5560`), so it cannot stand in for a phone removed over Wi-Fi and then plugged in; the gateway's tests cover that. ## Away from home and Wi-Fi without Wireless debugging (2026-11-08) What it is and how it works: [docs/away-from-home.md](../away-from-home.md). Code: `network.go` (the kept phones, `network-phones.json`, the reconnect pass, offline listing), `tailnet.go` (`driver.Tailnet`), `wifi.go` (`driver.WiFiSwitch`), `adb/tcpip.go`; `internal/tailnet` reads `tailscale status --json`. Device tests in `network_device_test.go` (tag `device`): `TestDeviceWiFiSwitch`, `TestDeviceWiFiWatch`, `TestDeviceWiFiAgain`, `TestDeviceAway`, `TestDeviceAwayOff`, `TestDeviceAwayFailover`, `TestDeviceTailnetRoam` (`PINKY_ROAM_WAIT`, `_BITRATE`, `_FPS`), `TestDeviceTailnetIdle`. On macOS, sign the test binary with an identifier (`codesign -s - -f --identifier app.pinky.androidctl`) for the mDNS step. Checked on real phones (the owner switching networks and restarting the phones): - **Mi 10 Pro, Android 17**, Tailscale 1.104 from Google Play, the MacBook's Tailscale 1.104.5 (standalone app): `adb tcpip 5555` over Wireless debugging works and needs no new consent (the paired key is trusted for TCP too); adbd listens on `[::]:5555`, every interface. Over Wireless debugging adbd restarted before its reply reached the computer: an empty reply now counts as restarting. Use away from home took 2.4 s. At home Tailscale went direct over the LAN (as fast as Wi-Fi, see the doc's table). On an iPhone's 4G hotspot with a weak signal it relayed through DERP Singapore: no direct path, since this Mac's UDP leaves through the home's Surge gateway and its Linode exit (netcheck: `172.237.70.167`); 0.35–0.5 Mbit/s up, which no stream fits (hence 1 Mbit/s, 15 fps through Tailscale). Coming home the Tailscale connection paused 5 s and carried on; leaving home adb's log shows the Wireless debugging connection terminated at the switch. Android 17 kept `adb_wifi_enabled` 1 on the hotspot. - **Pixel (2016), Android 10**: Use Wi-Fi instead 1.4 s, no prompt; reconnect after a drop 134 ms; unplugged, listed over Wi-Fi at once; restarted: `network_away`, then `network_off` once it refused (about 40 s); plugged in, Use Wi-Fi again 1.6 s; off left nothing listening. The Mi found a phone at a new address with mDNS (`_adb._tcp`, instance `adb-[-]`) in 4.9 s. Another device on the home LAN (192.168.5.143) advertises `_adb._tcp` too: Pinky only connects to phones it kept. - **adb connect at an address nothing answers** waits for macOS's 75 s TCP timeout and blocks the server's other requests: the reconnect pass probes with a 3 s TCP dial first. - **Pinky Companion on the Pixel** (asked by the coordinator, main 31b1c20): the installed 0.1.0 (code 1, signer e2404fbd, neither the old public key nor the new one) was replaced by the release build 2026.1108.9 (code 486, signer a9ed84e0) through `SetUpCompanion` in 18.2 s (the Mi: 2.6 s); Play Protect checks USB installs there (`package_verifier_user_consent` 1, `package_verifier_enable` 1); whether a dialog showed was not seen. A task: on in 281 ms, first read 362 ms after the start, next read 700 ms, off 0.2 s after it; `enabled_accessibility_services` null before and after. The release build was left installed (the owner's choice). ## Text from pixels (OCR, 2026-11-08) Some screens draw text that the accessibility tree does not describe: mini-programs and other pages in super-apps' own web views (UC's U4 in Alipay, Tencent's X5 and XWeb), games and other canvases, maps, charts. A user could not read anything inside Alipay's mini-programs (Alipay itself is blocked by default; this is about the tree). OCR, which only the iOS driver had, now serves both drivers from `internal/ocr`. - **The engine** (`internal/ocr`): Vision through purego on macOS, moved from the iOS driver as it was; Windows.Media.Ocr through WinRT, called with syscall as `internal/secret` calls Windows Hello (`winrt_windows.go`): the first of Pinky's languages (zh-Hans, zh-Hant, en-US) whose OCR pack Windows has, else the person's own; pictures larger than `OcrEngine.MaxImageDimension` are scaled down; Chinese words are joined without spaces; every line has confidence 1. Linux has none: `ocr.Supported` says why, wrapping `driver.ErrUnsupported`. `ocr.Warm` loads the models in the background when an agent starts working on an Android phone (`AgentWorking(true)`): Vision takes 0.1 to 0.2 s for that while macOS keeps its compiled models, and took 13 to 15 s twice on this Mac when it had dropped them. - **When** (`textareas.go`): reads that show the agent the screen ask for OCR (`ObserveOptions.OCR`). The driver reads the tree first and runs OCR only where the tree misses text: a web view (a class named `…WebView`, or one of Chromium's, UC's, X5's or XWeb's packages) of at least 1/8 of the screen with fewer than 3 labelled nodes in it, or whose labelled nodes smaller than half of it cover less than 20 % of it; a leaf view of at least 1/6 that is unlabelled and not an image, a field or a list (a SurfaceView, a TextureView or a custom view reports `android.view.View` or its layout, so class names do not tell canvases apart); an application window of at least 1/6 with fewer than 3 labelled nodes in all. Only lines in those areas are added, and not where a window above covers them (the status bar, the keyboard). `ObserveOptions.OCRAll` (`phone_observe { ocr: true }`, `phone_wait_for`, the gateway's check of a tap where the tree says little) adds all lines but the bars' and the keyboard's. Lines an element already shows are left out (`ocr.Extras`, as on the iPhone), and OCR elements are tapped by x,y. - **The picture** (`text.go`): over USB `screencap -p`; over Wi-Fi (and Tailscale) a frame of the live video (`ObserveOptions.Frame`, through `frameShot`), since a screenshot's PNG of 1 to 2 MB takes seconds there and tens of seconds over a phone's mobile uplink; on a background display always a frame, as it has no screenshots. Over USB the frame is not the cheaper one: without a mirror open, getting one waits for the encoder to restart (1.1 to 1.5 s on the Android 16 emulator, against 0.13 s for `screencap`). A frame whose shape is not the screen's (2 % slack for the encoder's rounding; a frame from before a rotation) is not used, and the wait for one is bounded at 1.5 s (`frameWait`: the encoder restarts at most once a second, and a screen that keeps moving gives no pause); then a screenshot where there is one. A frame reaches Vision as an uncompressed BMP, which takes a few milliseconds to write and read against tens for a JPEG. Nothing is captured of a locked phone or of a screen the gateway's `MayCapture` refuses: a blocked app the person has not allowed for the task, an app it cannot name. A blocked app the person allowed for the task is read too (the person's choice on 2026-11-08): the picture stays in memory, the agent gets the text, and screenshots, history and recordings still take none of it. When a window event came after the tree was read (the companion's `window` events, per display), or without the companion the window manager's focused app is another, the lines are dropped: the picture may show another app. - **Without OCR** (Linux): those areas carry the state `no_ocr` instead, on every read whether OCR was asked for or not, so the state does not change between reads (an approval compares states before it acts): the area's own element when the tree lists one with its bounds, else an element of role `other` of its own. - `androidctl observe -ocr` (or `-all`) prints what OCR did and its times; it reads from screenshots, as it has no video decoder. - Shared contracts (additive): `driver.ObserveOptions` gains `OCRAll` and `MayCapture`; `driver.TextReader` (`ReadsText`), which `Device` and `Background` implement. - **Checked on the Android 16 emulator** (`pinky-api36`, booted on port 5600 against a private adb server so that nothing else saw it; Chrome showing a `data:` page whose heading is HTML and whose three lines are drawn in a canvas: "Balance 4,321.09", "立即还款 Repay now", "Order 20261108"). Chrome exposes no page at all to the companion: its content is one leaf `FrameLayout` described "Web View", which is why a described leaf counts as an area. Through the gateway (`phone_connect`, `phone_observe`, read-only task): on the main screen every read added the three canvas lines, in the right places, in 0.21 to 0.30 s a read (`screencap` 0.13 s, Vision 0.1 to 0.26 s); on a background display (frames through `frameShot`) the same lines in 0.27 to 0.30 s a read. `phone_observe { ocr: true }` added the address bar's text as well. Settings (a native screen) ran no OCR: 0.23 s a read, as before. While Chrome's notification dialog covered the page, the automatic reads ran none (the dialog's tree is complete), and all the text gave only the page's lines left visible around it. A frame through a stream opened for it took 1.1 to 1.5 s on the emulator (the encoder restarts), hence screenshots over USB. - **Vision's first recognition in a process** took 13.7, 14.1 and 15.7 s in three processes of about a dozen on this Mac (and most likely the 16 s of one gateway's first `phone_connect`), and 0.1 to 0.3 s in the others. `ocr.Warm` starts it when an agent starts working; a first read that needs OCR before it is done waits for it, as the iPhone's first read does. - **Windows**, on GitHub's `windows-latest` runner (CI run 42484462479, dispatched on this branch): `Supported` found Windows.Media.Ocr with the en-US pack (no Chinese one there), `MaxImageDimension` 10000, and `TestRecognize` read both drawn lines with their boxes, the first recognition in 44 ms. Linux reports no OCR and skips. Not tried on a Windows computer with a phone. - **Checked on the Mi 10 Pro** (LineageOS, Android 17, over Wi-Fi, so OCR read frames of the video; the owner present; a scratch harness drove the real gateway; nothing kept): the same canvas page in LineageOS's browser (Jelly, a WebView): every read added the three canvas lines, Chinese included, at the right places; a read with OCR took 0.43 to 0.74 s (a frame 0.25 to 0.44 s, Vision 0.1 to 0.16 s). A tap at an OCR line's x,y took 1.4 to 2.1 s, of which the tap check's read of all the text about 0.45 s. The home screen (Lawnchair) first counted as a canvas: its icons sit over a full-screen unlabelled leaf, and every home screen read paid a frame (0.79 s). Hence areas are judged by all the labelled nodes over them, not only their own (`sparse`); after that no read of the home screen or the Calculator ran OCR. Calculator: taps on keys by ref or x,y 1.48 to 1.83 s with no OCR (as before); a tap where no element is 1.47 to 1.76 s, with a read of all the text for the tap check (a frame 0.36 to 0.42 s, Vision 0.12 to 0.19 s), against 0.8 to 1.0 s for a tap on the display that changes nothing. No approval came up in any of it. The Calculator blocked in the harness's own rules: not allowed, it was hidden and not captured (`MayCapture` false); allowed for the task, the agent got its display's text as `ocr` lines, no screenshot (with the message), and history kept no picture or text of its steps. A locked phone: no capture at all. ## Decisions Rounds one and two (kept): 1. **Direct abstract-socket connections, no `adb forward`.** 2. **A jar per session, deleted by the server.** 3. **Wake with `KEYCODE_WAKEUP`, never POWER.** Round three removed every scrcpy option that could press POWER or keep a screen awake. 4. **`clipboard_autosync=false`.** 5. **Settle on capture cadence, not size,** 400 ms quiet. 6. **Typing:** the companion's `set_text` first; keys when they reach the field, paste otherwise. 7. **Rows merged into one element,** the way TalkBack reads them. 8. **`App` is the read window's package.** 9. **Background displays without system decorations, keyboard kept local.** 10. **Companion settle: video and events both quiet, tracked locally.** 11. **`set_text` falls back only after a refusal.** 12. **Person input from real devices only** (no `uinput`), 300 ms apart. 13. **Notification text stays out of logs.** Round three: 14. **Read display 0's power state, not the wakefulness:** a background display keeps the phone awake with its own screen off. 15. **Unlock only after seeing the PIN pad.** Typing digits at a lock screen that has not shown the pad could land some of them in it and count as a failed attempt, so the driver reads the tree first, even at 2 s with uiautomator. `wm dismiss-keyguard` brings up the pad on AOSP-style lock screens without a swipe that vendors place differently; the swipe is the fallback. 16. **The PIN goes as key codes through scrcpy.** Not `input text` (the PIN would be in a process list) and not `set_text` (it would pass through the companion app). 17. **Background `Locked` is false from Android 13,** so the gateway does not stop an agent on a background display because the phone is locked. 18. **A control-only session when the encoder fails,** rather than a driver that cannot tap at all on such a phone. 19. **Restarts wait for the gesture in progress** (one gesture at a time per session), instead of a second session for streams with other options, which would double the encoder's work on the phone. 20. **Replacing a build signed with another key is part of Install:** the person asked for the current companion, the old one cannot be updated, and it is Pinky's own app. Its service is turned off before the uninstall, so Android does not rewrite the person's accessibility setting. 21. **`CompanionState` reports `off` while the service is on only for the driver's reads,** so the client's Install card reflects what the person chose, not the driver's last minute. 22. **The bundled APK's version comes from `assets/pinky-companion.json`,** which the companion build writes, rather than parsing the binary manifest. 23. **Pairing finds the phone by its guid,** and connects by hand only when the adb server has not connected by itself after a second. 24. **Multi-touch devices count as touchscreens on a phone without a direct one** (the emulators): otherwise the watcher would see nothing there. 25. **A background display gives the person's tasks back and destroys only the agent's.** Destroying everything (scrcpy's default) lost the person's task when the agent used an app they had open; moving everything back put the agent's last app on top of the person's screen. Given back, the person's task also lands on top, which is the smaller harm. 26. **A no-op key keeps a background display awake** while it is open, rather than a wake lock (which would need a change to the bundled scrcpy server or the companion app) or `KEYCODE_WAKEUP` (which wakes the phone's own screen). It is sent only after 4 s without other input on the display. 27. **The last driver to leave turns the companion off,** so two Pinky processes (or Pinky and `androidctl`) do not cut each other off and the setting still goes back: the app's three-minute backstop did not turn the service off on `emulator-5560` in a four-minute wait with no client: build 73 restarted its count whenever Android bound the service again, which every `uiautomator dump` causes. Build 74 fixed that, but older builds (the Mi's) still have it, so the driver does not rely on the backstop. Wi-Fi without adb's discovery (2026-11-07): 28. **Pinky searches the network itself instead of restarting the adb server.** A restart fixes the server's discovery but drops every other tool's connections and the emulators', and the next network change breaks it again. The server still pairs and connects; only finding the phone moved to the driver. 29. **Questions with the unicast-response bit from a port of Pinky's own, answers also read on a socket bound to the group address.** The Mi answers such questions straight back and multicasts answers to plain ones; binding the wildcard address on 5353, as most libraries do, would take the unicast answers meant for mDNSResponder. Pinky never answers or announces anything. Follow-up questions for a missing SRV or address record come at most once a second. 30. **Reconnect what adb would:** the phones in its known hosts, when their connect service is on the network and the server does not list it. A phone the server lists is left to the server. 31. **A search the system blocks is told by its own questions not coming back** (multicast loopback to the group socket), since macOS drops a blocked app's traffic without an error. Pinky's own adb (2026-11-08): 32. **Built from AOSP's source rather than Google's platform-tools,** which come under Google's SDK terms, which forbid passing them on. android-tools' CMake build, cut down to adb, is the shortest way there; the Windows adb is 32-bit like Google's because AOSP's AdbWinApi.dll, which Samsung's and the other makers' older drivers need, is. 33. **The person's adb before Pinky's own,** so that their tools and Pinky share one server of one version and neither kills the other's. 34. **The server process started directly** (`fork-server server`), never through `adb start-server`, which kills a server of another version. 35. **Pinky's own adb from a copy in the cache,** not from the app's folder, so that updates and disk images are never held by the server, which outlives Pinky. Is it the same phone? (2026-11-09): 36. **A mark Pinky leaves on the phone, not the phone's TLS certificate.** adbd makes a new key at every start, adb shows the certificate to nobody, and connections at port 5555 and over Tailscale have no TLS. The mark lasts across restarts and only adb's shell user, or a computer the phone trusts with adb, can read it. 37. **One mark file per computer** (`.pinky-id-`, `` random and kept in `phone-identities.json`), so that two computers with Pinky do not take each other's mark for a reset. 38. **Check a connection once while adb keeps it, and again whenever adb made it again,** with a watch on the server's list and a guard on the device's client, rather than before every request: a check is a sync round trip, and taps and frames come many times a second. 39. **The phones Pinky knew before the check came in are marked at their first check, unasked.** Asking about every phone the person already uses would teach them to click Trust; the price is one check per phone during which an impostor would be taken for the phone. 40. **The server Pinky starts does not connect by itself; Pinky connects by the same names.** The connections keep the serials they had, so everything keyed by serial works as before, and a device advertising a paired phone's name is connected only when Pinky asks for that phone, and then checked. ## Not done, and open questions - **Android 10 to 12 with a background display while locked:** expected not to work (see above); needs a real phone. - **A wake lock instead of the keep-awake key:** the companion session could add a `keep_awake` command that puts a 1-pixel, untouchable accessibility overlay with `FLAG_KEEP_SCREEN_ON` on a given display, which recent Android versions turn into a wake lock for that display. Not built or tested; the key works. - **The app's own Local Network prompt** was not seen: the verification ran through `androidctl`. A Pinky.app without Local Network access shows "Pinky isn't allowed to look for phones on this network" in Add phone after 30 s. - **The Mi:** locked since about 10:36, probably by someone at it; Stay awake keeps its screen on while charging, which the owner may want off. It still has companion build 1; `InstallCompanion` there will replace it and Play Protect will ask. - **The Pixel:** still offline. - **Screenshot on a virtual display** returns `ErrUnsupported` (`screencap` rejects virtual display ids); decode a frame from the display's stream. - **Screens that never stop moving** reach the settle limit; the phone app's favorites page on the Android 16 emulator is one. - **uiautomator unbinds every accessibility service** while it dumps; with the companion on, the driver never runs it. - **The identity marks on a real phone** (2026-11-09): checked on the emulator only. Still to see on the Pixel or the Mi over Wireless debugging, with the owner's yes: the mark after a QR pairing, the phone listed online after a restart of Wireless debugging and of the phone (a new TLS key, the same mark), its Wireless debugging connection made by name after a sleep, and whether the Mi's LineageOS keeps `/data/local/tmp` across reboots. See [Is it the same phone?](#is-it-the-same-phone-2026-11-09). - **Typing on HyperOS (GitHub issue #31):** whether WeChat's chat input takes text through the companion's input connection on a background display, whether Gboard's keys answer taps there, and whether HyperOS lets an accessibility service use `flagInputMethodEditor` at all. The emulators answer yes to all three on stock Android; the Mi runs LineageOS, so it cannot stand in for a Xiaomi phone ([below](#typing-into-fields-that-refuse-set_text-github-issue-31-2026-11-09)). ## The phone's notifications (`driver.NotificationLister`, 2026-11-09) `Device.Notifications` lists the notifications the phone holds, each with its package and texts, so the gateway can find a blocked app's rows in the notification shade, which name the app only while expanded (docs/notes/gateway.md, "Blocked apps on other screens"). It runs `dumpsys notification --noredact` and keeps on the phone only the section headings, the `NotificationRecord(… pkg=…)` lines and the `android.title`, `text`, `bigText`, `subText`, `summaryText`, `infoText`, `conversationTitle` and `title.big` extras (`notificationsCmd`), then reads the "Notification List" section (`parseNotifications`). A text over several lines keeps its first; without `--noredact` the dump gives only lengths, which are skipped. About 70 ms on the Android 16 emulator. Not checked on the Pixel (Android 10) or the Mi 10 Pro: the test emulators hold no one's notifications, the phones do. ## App clones (应用分身, Dual apps, App Twin; GitHub issue #18, 2026-11-09) A clone is a second copy of an installed app with its own sign-in and data, which the phone keeps in another Android user. scrcpy's `list_apps` and `start_app` see user 0 only, so before this agents could neither find nor open a clone, and on a background display (no launcher) they could not tap its icon either. `clones.go` finds them; `Apps` lists them as apps of their own and `Launch` (main screen and background displays) starts them. **Which users count.** `pm list users` (all versions) and `cmd user list -v` (Android 11+, adds the user type) in one shell command. A user counts as a clone space when it is running, not a guest, not disabled, not paused (`QUIET_MODE`), not a private space, has no profile owner, and is either Android 14's clone profile (`profile.CLONE`) or one the makers use for clones: user 999 (Xiaomi's XSpace, 双开 / Dual apps; OPPO, OnePlus, realme and vivo, 应用分身 / App Cloner), user 128 (Huawei's and Honor's 应用分身 / App Twin), or a user named `DUAL_APP` (Samsung's Dual Messenger, user 95, which carries the managed-profile flag). The profile owners come from `dumpsys device_policy` (`Profile Owner (User N)`), in the second shell command, which runs only when a candidate exists. Ids and names are from public reports, not from these phones: Xiaomi's `UserInfo{999:XSpace:…}`, OPPO's `UserInfo{999:应用分身:20001010}`, Samsung's `UserInfo{95:DUAL_APP:20001030}`, Huawei's user 128 named ツイン with flags 0x4000010 (EMUI 11). **Not exposed, on purpose:** work profiles (they have a profile owner, the employer's device policy app; their data is the employer's and their screens may be blocked from capture), Samsung's Secure Folder (user 150; the shell may not even read it), Android 15's private space, Xiaomi's Second space and other people's users and guests (full users, which `am start --user` would start in the background). A phone that does not show its clone space by one of these marks simply lists no clones, as before. **What is listed.** For each clone space, `cmd package query-activities --components --user N -a android.intent.action.MAIN -c android.intent.category.LAUNCHER`, first launcher activity per package. Only packages that are apps of the person's own in user 0's list and not system apps count: makers install some system apps in the clone space for the clones' use, and those are not clones the person made. A clone is `driver.App{ID: "com.tencent.mm@999" (driver.CloneID), Name: "微信(分身)", CloneOf: &{com.tencent.mm 微信}}`. The suffix follows the phone's language (`persist.sys.locale`, else `ro.product.locale`): `(分身)` for Chinese, `(クローン)` for Japanese, ` (clone)` otherwise, like the app names scrcpy reads in the phone's language; a second clone of the same app (two clone spaces) gets a number, `(分身 2)`. The scan runs next to scrcpy's list and costs one shell command on a phone without a clone space, two with one. When it fails, the clones listed before stay. **Starting a clone.** `am start --user N --display D -a android.intent.action.MAIN -c android.intent.category.LAUNCHER -f 0x10200000 -n ''`, as a launcher tap would (new task, reset if needed). `D` is 0 on the main screen and the virtual display's id on a background display; `am start` runs as the shell user, which also owns scrcpy's virtual display, so Android lets it start there. Without `--display` (Android 7) it retries without. "does not exist" / "unable to resolve" is `driver.ErrNotFound`; other errors (a security exception, a maker's refusal) come back with `am`'s words. On a background display the person's tasks are given back as for any other app (`theirs`, `returnTheirs`). Reports say HarmonyOS refuses `am start --user` on Huawei phones; there the clone is listed but may not open. **Policy (gateway).** A clone reads like its original on the screen (the tree names the package, not the user), so the rules, the agent's scope and the task's app list judge it as the app it copies (`driver.App.Original`): a clone of a blocked app is blocked, launching it asks or is refused like its original, and a task whose list names the clone lets in the package (the clone and the original alike). The policy engine also reads a clone id as its package (`clonePackage`). Settings › Safety › Add app does not offer clones (they are blocked with their original), and `phone_apps` marks them "clone of …". For the same reason, a person who allows a blocked app's clone for a task (the unblock card, "Open Google Wallet (clone)?") allows the package: the original opens for that task as well, and keeping the clone blocked keeps the original blocked. The launch approval and the history name the clone ("Open 微信(分身)?"); steps inside it name the app as the screen does (微信). **Checked on an emulator, not on a phone.** On `pinky-api37` (Android 17, Google APIs), booted with `-read-only` as `emulator-5590` so nothing it did persists: `pm create-user --profileOf 0 --user-type android.os.usertype.profile.CLONE Clone` (user 10), a launcher-only test app (`app.pinky.clonetest`, no code: its activity is `android.app.Activity`) installed and `pm install-existing --user 10`. `pm list users` and `cmd user list -v` print what the parser expects (`UserInfo{10:Clone:1000} running`; `id=10, name=Clone, type=profile.CLONE, flags=PROFILE (parentId=0) (running) (visible)`). `androidctl apps` listed `app.pinky.clonetest@10 Clone Test (clone)` and the companion's clone, and none of the Google system apps the clone profile also holds with launcher entries (Gmail, YouTube, Gemini, Files and others), which is why system apps are left out. `launch "Clone Test (clone)"` started it in user 10 on display 0 (`cmd activity stack list`: `userId=10 displayId=0`); `bg -app app.pinky.clonetest@10` started it on the virtual display, the companion read its screen, and on close the person's clone task went back to display 0; with no task of the person's, the agent's clone task went away with the display. A clone space that is `RUNNING_LOCKED` (credential storage not unlocked) resolves only direct-boot-aware apps, all system apps, so it lists nothing until the phone is unlocked. The test phones have no clones: the Mi 10 Pro runs LineageOS, which has no Dual apps, and the Pixel runs Android 10. Open: whether MIUI/HyperOS lists the clones' launcher activities in user 999 to `query-activities` (its launcher draws the clone icons itself), whether HarmonyOS lets `am start --user` through, and naming the app in front as a clone (it would take a `cmd activity stack list` or focused-window read per observe while a cloned package is in front). ## A reset the encoder never answers (2026-11-09) To reset (Pinky's reset-video, sent when a stream attaches and for a key frame), the scrcpy server signals the end of the encoder's input and encodes until the encoder drains it. An encoder that never does leaves the server stuck for good: alive and connected, its video thread waiting in `MediaCodec.dequeueOutputBuffer`, nothing sent again. Pinky saw a healthy session, the mirror showed "Connecting…" while the phone's screen was on and unlocked, and Checkup passed. The server's log ends in `Video capture reset`, and the next reset adds `GraphicBufferSource: EOS was already signaled`. **Where it was seen.** Only on a Pixel 9a (Android 17, over Wi-Fi), the one phone tested; whether other encoders do it is not known, and nothing in the fix depends on the phone. There it never happened on resets a few seconds apart (550, alone or three within 300 ms, all answered within 0.9 s), but often after a still screen of a minute or two: with pauses of up to 3 minutes the server stuck at the 2nd reset with H.265 and at the 4th with H.264, so another codec does not help. A temporary `device` test that mirrors the main display with Pinky's Wi-Fi options and sends reset-video after random pauses shows it (not committed: it fails on such a phone by design). **What the driver does.** The main display's session (`watchResets`) is replaced when a reset-video goes unanswered for 5 s (`resetStall`); a server answers within a second, a still or dark screen included, since it writes the session header before it encodes anything. The replacement waits for the gesture in progress, as for new options, since input still works on a session whose video stopped. Closing the session ends the stuck server too: after each stuck run none was left on the phone. The mirror opens the stream again by itself, as after any session end. One reset is in flight at a time (`resetVideo`): another caller waits for its answer, which serves everyone, so each session header answers the reset that watchResets times. Background displays are not watched: replacing one closes the agent's apps on it. ## One phone listed many times (2026-11-09) A Galaxy S10+ showed some 40 times under Phones in the menu bar flyout, every row "Galaxy S10+ · Idle": about 40 ids, all listed online in one pass, so the adb server listed that many connections of the phone in the `device` state at once. Over Wi-Fi a phone's id is its hardware serial (`phoneID`), so its connections are one phone however many there are; for them to be 40 phones, the shell must have read no `ro.serialno` (each connection at an address then kept its address as its id). Some vendors keep the serial properties from the shell. What made adb hold that many connections is not known yet (a new port each time Wireless debugging restarts is the likeliest; the person's `adb devices -l` would say). - `deviceProps` also reads `ro.boot.serialno`, and the android_id (`settings get secure android_id`, only when the shell reads neither serial property, as the settings command takes a moment). - Without a hardware serial, a connection that Wireless debugging's discovery made takes the serial in its name (`adb--._adb-tls-connect._tcp`), a cable's keeps adb's serial as before, and a connection at an address takes the serial that one of those had with the same android_id (`Provider.phoneID`, which remembers it for the provider's life), else `android-`. List works out the ids of the connections that name the serial first, so the addresses take it in the same pass. - `TestPhoneHidingItsSerial` lists three connections at addresses of such a phone, alone, with the discovery's connection, with a cable, and with `ro.boot.serialno` only: one phone each time (with the old ids: three, four and three online phones). - Where the connections came from. Of the driver's own connections, only the Wi-Fi reconnector's come again and again (`pair.go`, `ConnectWiFi`, the move to Wi-Fi and Tailscale connect once, when asked). It took a paired phone for missing when no connection was named after its guid and none was at an address it had connected itself, so a phone connected some other way got one more connection: at adb tcpip's port 5555, by another tool, by a Pinky that ran before (`byAddr` lives in memory), or at its other address (IPv6 where the reconnector found IPv4). That is one more connection each time, not dozens, but each was one more phone for a phone that hides its serial. Now `onWiFi` counts any connection on the computer's network whose phone is the paired one (`phoneID` against the serial in the guid; a cable's and Tailscale's do not count, as the server's discovery would connect the phone at home all the same), and an address the reconnector connected gives the serial in the phone's guid to `phoneID` (`guidAt`), so such a phone is listed under its serial. `TestReconnectPhoneOnWiFiAlready` and `TestReconnectHiddenSerial` (both fail without the change). - Not found: a way for the driver to keep adding live connections. Whatever held some 40 at once (the adb server, another tool, or connections that stayed up across Wireless debugging's restarts), Pinky now lists the phone once. ## An app just started on a background display reads as empty (GitHub issue #30, 2026-11-09) The report: on a Pixel (Android and Pinky versions not given), `phone_launch` of Files by Google on the background display returned `settled · settle: timeout` with only the header line (no app, no elements), `phone_observe` with `wait_ms: 3000` or `ocr: true` the same, a screenshot "the screen changed while it was taken", and one `phone_key back` the full screen; every fresh launch. **Not reproduced.** `TestEmulatorBackgroundColdLaunch` (driver, tag `device`, `bglaunch_device_test.go`) opens a display as a task does and starts an app cold on it (force-stopped first), reading every 250 ms: on the Android 16 emulator (unlocked, locked with the screen off, with Settings in front on the phone's own screen, with the display idle 15 s before the launch) and the Android 17 emulator (unlocked, and locked, where Pinky keeps the phone awake and dark), Clock, Settings and the emulator's Files were readable 0.6 to 3.7 s after the start, every time. The gateway's `TestEmulatorBackgroundColdLaunch` (tag `device`) returns each app's screen from `phone_launch`, on main as well. The Mi was locked and asleep, so it was left alone. **What the reads show before the app is readable**, and what the report's empty header fits: the companion's tree for the display has no window (accessibility leaves out the splash screen's starting window), or a window whose root has one node, or the companion answers that the display "is not open to accessibility services" (on Android 17, for up to 3 s after a start). With no app window, `foreground()` names no app, which is the report's header without `app:`. The activity was `RESUMED`, visible, its display not sleeping. On the Pixel the state lasted until the first key on the display; the emulators leave it by themselves. A dozing display group (above) gives the same empty read, but the keep-awake key prevents that on Android 13 to 16, and Android 17 keeps the phone awake and dark. **What changed:** - The driver (`background.go`): a read that finds the display untracked ("not open to accessibility services") within 10 s of a start returns the display's empty screen instead of failing, and no such answer counts as the companion's failure any more. Before, it turned the companion off and fell back to uiautomator (seen on the Android 17 emulator), and the gateway, taking the error for a display it can never read, moved the task to the phone's own screen. - The driver nudges a display whose app shows no window after its start, once each: 1.5 s after the start the keep-awake key (`KEYCODE_UNKNOWN`, which apps ignore; it counts as input on the display and as activity for its power group), and 4 s after it a second start of the app, which brings its task to the front as a tap on its icon does. Each nudge is logged with the display's windows and `dumpsys activity -p activities`' states (`TestEmulatorBackgroundNudges` checks both on the Android 17 emulator), so feedback with diagnostics from an affected phone says which state it was in and whether a nudge helped. - The gateway (`pipeline.go`, `awaitApp`): after a launch on a background display that reads no app, it reads again for up to 6 s; if the app is still not there, the launch returns `uncertain` with " was started on the background display, but Android does not show its screen there yet, so the screen is empty. Call phone_observe in a few seconds; if it stays empty, launch the app again." `phone_observe` of a background display with no app says so too, and a screenshot of a screen with no app gives "Pinky cannot tell which app is on the screen yet, …" instead of "the screen changed while it was taken" (`errNoAppShot`). Tests: `TestLaunchWaitsForTheBackgroundApp`, `TestLaunchSaysTheBackgroundAppCannotBeRead`. Open: the cause on the reporter's Pixel. Their diagnostics with this version would show the windows and the activity's state at the nudges, and whether the key or the second start brought the app. ## Is it the same phone? (2026-11-09) The hole: adb trusts whoever answers ("A record is no proof of an address", above). A connection that the server's discovery makes goes to whatever advertises the phone's `_adb-tls-connect` name, one made by address goes to whatever has the address now, and adb makes a dropped connection by address again in place, under the same serial and transport id, to whatever answers there. A device on the network could so stand in for a paired phone while it is away, and get the commands, text and taps Pinky sends, and show agents screens of its own. **Why not the phone's certificate.** The plan was to pin each phone's TLS certificate on first trusted contact. It would not hold: - adbd makes a new key and certificate each time Wireless debugging starts, so a phone's certificate changes at every restart of the phone or of Wireless debugging; a pin would call the person's own phone an impostor every day. - The adb server's TLS client accepts any certificate (`transport.cpp`) and no host service tells what a connection presented. - Pinky dialing the phone's TLS port itself before handing it to adb sees a certificate that says nothing (above), and under TLS 1.3 a passive proxy sees no certificate at all. An active proxy would have to finish the phone's handshake, which asks for the computer's adb key: Pinky would have to use the person's `~/.android/adbkey` and put itself between adb and every phone. - Connections at port 5555 (Use Wi-Fi instead, `adb tcpip`) and over Tailscale have no TLS. **The mark.** Pinky leaves 32 random bytes on each phone it trusts, in `/data/local/tmp/.pinky-id-`, owned by adb's shell user with mode 0600, and keeps their SHA-256 with the phone's id (its hardware serial over Wi-Fi) in `phone-identities.json` in its config folder, with ``, random and made once. Apps on the phone cannot read the file (the folder belongs to the shell user and lets others only pass through); a computer the phone trusts with adb can, and could do anything else to the phone too. A connection over the network is the phone when the file it serves (sync `RECV`, at most 4 KB read) hashes to the record, compared in constant time. adbd gives a pushed file the owner's permissions for the group and others too (0600 arrived as 0666 on the Android 17 emulator), so the driver runs `chmod 600` after the push, and removes a file it cannot narrow. Code: `identity.go`; on in the app (`Config.VerifyWiFi`, `Config.IdentityFile`). **When a phone is trusted, and marked.** A connection on a cable, an emulator's and one on this computer (loopback) are the phone and are never checked; on such a connection Pinky marks the phone once the gateway says it is the person's (each new connection, so a phone plugged in after a reset is trusted again). Over the network: the phone the person paired in Pinky, when the connection goes to the host the pairing went to (a connection by name, to the address the server's discovery has for it); the address the person typed (Connect by address, Tailscale); a phone the person trusts on its page (`TrustPhone`); and, at its first check, a phone Pinky knew before this check came in (one with no record that the gateway knows, as for iPhones: opened before, or seen on a cable). Any other phone on the network with no record waits for the person to say it is theirs (`driver.SetupConfirmWiFi`, the found-phone card, as for iPhones). **The checks.** `List` checks each connection over the network once, and lists one without the phone's mark as `needs_setup` with `driver.SetupPhoneChanged` ("This may not be {phone}"), and a check that fails (the phone stops answering) as offline. `OpenDevice` refuses such a connection, and the device's adb client runs a guard (`adb.Client.Guard`) before each `Dial` and `Forward`: a request reaches a connection over the network only while it is the phone, as checked since adb last made the connection. A watch on the server's `host:track-devices-l` gives each connection a stamp that changes whenever it leaves the list or changes state (the server reports a dropped connection offline before it makes it again), and a check counts only under the stamp it was made under; checks of one connection wait for each other. The gateway shows the status "Not the same phone", tells an agent `device_needs_setup` with `phone_changed` ("…trusts it on its page in Pinky or plugs the phone in…"), and the phone's page explains it with "Trust this phone…" (after a confirmation) and a note that plugging the phone in does the same. **Auto-connect off.** A server Pinky starts gets `ADB_MDNS_AUTO_CONNECT=0` (`adb.ServerOptions.NoAutoConnect`): its discovery still lists the phones (`host:mdns:services`), but it connects to none of them by itself. The variable lists the services it may auto-connect, and `0` turns all of them off: adb 37's binary logs "Disabling all auto-connecting" for it (`adb_mdns.cpp`). The driver connects instead: the reconnector and a pairing connect a paired phone by its service name, `._adb-tls-connect._tcp`, which gives the connection the serial the server's own would have. So people's Wireless debugging phones connect as before, under the same serials, but a device advertising a paired phone's name is connected only when Pinky asks for that phone, and then checked. A phone forgotten in Pinky is no longer connected by the server behind its back. Under `PINKY_DEVICES` the driver connects nothing by itself, so the server keeps auto-connect; a server the person or another tool started keeps it too. The checks stand between such a server's connections and Pinky either way. **Tests.** `identity_test.go` against a fake adb server that keeps files per phone and can swap the device behind a connection in place, as adb does: a new phone waits for the person, a known phone is marked at its first check (32 bytes, mode 0600, the hash recorded), the same phone passes, another device under the phone's serial is refused and gets no mark, the guard refuses after a reconnect to another device and passes after a reconnect to the phone, `TrustPhone` marks anew, a cable marks again, a pairing marks only at the address it paired from, a file that is not Pinky's is replaced, a failed check is offline; `TestReadMarkADB`, `TestIdentityStore`, `TestTrustedConnections`. The gateway's `TestWiFiPhoneChanged` (status, the agent's answer, no open before the trust, the waiting task gets the phone after it) and `TestTrustPhoneUnsupported`; the server's environment in `adb/tools_test.go`; the connections by name in `TestQRPairing` and `TestReconnectByName`. **On the emulator** (`TestEmulatorIdentity`, tag `device`, `identity_device_test.go`; Android 17 emulator `pinky-api37` on a private adb server on port 5099 started with `ADB_MDNS_AUTO_CONNECT=0`, reached through a TCP proxy on the Mac's LAN address, since a connection on this computer is not checked): listed as a new phone (`confirm_wifi`); marked at its first check once known (`-rw------- 1 2000 2000 32`, after the chmod; before it, `-rw-rw-rw-`); a shell through the guard worked; with its mark removed and the proxy's connections cut, adb made the connection again in place (transport id 3 before and after), the shell was refused (`ErrOffline`), the phone listed `phone_changed` and `OpenDevice` refused it; after `TrustPhone` it was online and the shell worked. The whole run takes 2 s. The same server listed a phone paired with this Mac advertising `_adb-tls-connect` on the LAN for minutes, and did not connect it. Run it with `PINKY_EMULATOR=emulator- ANDROID_ADB_SERVER_PORT= go test -tags device -run TestEmulatorIdentity ./internal/driver/android`; it removes its mark and connection afterwards. **What it does not cover.** - The first check of a phone Pinky knew before this version trusts what answers (decision 39). - A pairing marks the connection that follows it only when it goes to the host the phone paired from; a connection elsewhere waits for the person. - A device that advertises a paired phone's name can still keep Pinky from connecting the phone by name, or get connected and refused: it can deny the phone, not use it. - Anyone with adb on the phone (a computer it trusts) can read the mark. The mark proves "the phone this computer marked", not the hardware. - A reset, or a ROM or cleaner app that empties `/data/local/tmp`, shows the person's phone as changed: they trust it or plug it in. - The guard follows the server's list through the watch, which hears of a change a moment after the server makes it. The server reports a dropped connection offline before it makes it again, and making it again takes a TCP connection and a TLS handshake, so the watch has nearly always heard of the drop first; a request sent after the reconnect but before the watch read the drop would go out under the old check. ## Typing into fields that refuse `set_text` (GitHub issue #31, 2026-11-09) The report: a Redmi K70 (HyperOS, Android 16), Pinky 2026.1109.12 on Windows over Tailscale, a task on the background display. In WeChat's chat input `phone_type` returned `settled · no change` and nothing appeared; ASCII once ended up only as a composition in Gboard's suggestion strip, Chinese never. Taps on Gboard's keys did nothing, taps and swipes in apps did. WeChat's search box took the text once while the keyboard was hidden. No error anywhere. **Cause.** WeChat's chat input is not a TextView: it refuses `ACTION_SET_TEXT`, so the driver fell back to what it does without the companion: scrcpy's keys, or the clipboard and the paste key. A pinyin keyboard composes the letters instead of passing them on, and the paste did not reach the field either. Nothing read the field afterwards, so the agent got the settle's "no change" rather than an error; only the log said "the companion could not set the text … typing it instead". **What changed.** - **The companion** (build with `set_text_fallback` in ping, [companion.md](companion.md#fields-that-refuse-set_text-github-issue-31-2026-11-09)) tries, after `ACTION_SET_TEXT`, the focused field's input connection (`AccessibilityService.getInputMethod()`, Android 13+, `flagInputMethodEditor`), which writes into the app's editor without the keyboard, on any display, whatever keyboard is set; then `ACTION_PASTE` with a clip marked sensitive (`ClipDescription.EXTRA_IS_SENSITIVE`, so a one-time code is not shown in the clipboard's preview), cleared afterwards. Each way counts only once the node reads the text; the result says which (`method`), and a refusal lists every reason. An app on another display cannot read the clipboard while the phone is locked, so a paste is refused there and says so; the input connection works locked. - **The driver reads the field back** (`typecheck.go`). With a companion that has `set_text_fallback`, a `set_text` result that is not `verified` is an error: "after typing, the field reads otherwise than the text (4 characters where 7 were typed; it was not submitted): the app may format, cut or change what it takes; read the field before going on". When the companion refuses and the driver types keys or pastes, it reads the focused field before and up to 1.5 s after (`fieldCheck`, every 150 ms): a field that reads as before is "the text did not reach the field, which reads as it did before typing. Pinky's companion could not write it (), and typing it by pasting did not work either: the keyboard composes what it is given, …" (`notLanded`); one that reads otherwise is the error above, with "as keys" or "by pasting". Enter for `submit` goes only after the check passes, so text that did not land is never sent. A password field, or a field the driver cannot read, gives a note instead (`driver.InputNoter`, as on iOS since 2026.1109.14): "The field hides its text, so Pinky could not check what was typed." or "Pinky could not read the field back …". `androidctl type` prints it. - **Cost.** A refusal costs the companion's attempts: up to about 1.6 s each for the input connection and the paste to show in the node, and the driver asks twice (the focused field, then the field at the last tap). The fallback after it adds up to 1.5 s of reads when nothing lands. A field that takes `ACTION_SET_TEXT` costs what it did. **The test app** (`android/inputtest`, `app.pinky.inputtest`) has a field for each case: `plain` (an EditText), `no_set_text` (refuses `ACTION_SET_TEXT`), `no_commit` (also drops what its input connection commits; only a paste fills it), `chat` (a View with its own input connection, built like WeChat's: refuses `ACTION_SET_TEXT` and `ACTION_PASTE`, reports its hint as its text while empty, sends on the keyboard's action), `short_field` (keeps 4 characters) and `dead` (takes nothing), and a line that echoes what each holds. `TestEmulatorBackgroundTypeFallback` (tag `device`, `typing_device_test.go`) types into each on a background display; the unit tests (`typecheck_test.go`) cover the read-back with a fake companion. **Checked on the Android 16 emulator** (`emulator-5640`, zh-CN, Gboard with Chinese (Pinyin) as its only language, background display), through `TestEmulatorBackgroundTypeFallback` and `androidctl bg`: | Field | Text | How it landed | Result | | --- | --- | --- | --- | | plain | hello | `set_text` | landed | | no_set_text | 你好 | `input_connection` | landed | | no_commit | 粘贴 | `paste` (phone unlocked) | landed | | chat | 微信 | `input_connection` | landed | | chat | 发送, submit | `input_connection`, then the editor action | sent | | short_field | abcdefg | `set_text` | error: 4 characters where 7 were typed | | dead | dead | refused, then a paste | error: did not reach the field | | no_commit, phone locked | 粘贴 | refused ("apps cannot paste while the phone is locked") | error: did not reach the field | Before the change, the same app reproduced the report: with Gboard's Pinyin, `no_set_text` and `chat` stayed empty after `world`, `chat`, 你好 and 微信, and `Type` returned no error. **Gboard's keys on a background display** answer taps on the Android 16 emulator: the keyboard's window is on the virtual display (the IME policy is "local") with a touchable region over the keys, and tapping n, i and the space bar typed 你 into the field. `companionctl tree -ime -display N` lists the keys with their bounds. Why they did nothing on the reporter's HyperOS phone was not found; a HyperOS phone would show it with `dumpsys window windows` (the InputMethod window's `mDisplayId` and touchable region), `dumpsys input` (the keyboard window's input channel and flags) and `logcat -s InputDispatcher` while a key is tapped. Agents no longer need the keyboard for text: the input connection writes without it. ## Pinching (`driver.Pincher`, `driver.MultiToucher`, 2026-11-09) `pinch.go`. A pinch goes through the scrcpy session like a swipe, with two pointers: `scrcpy.PointerGenericFinger` (-2) and `scrcpy.PointerVirtualFinger` (-3). The server turns the second pointer's down and up into `ACTION_POINTER_DOWN` and `ACTION_POINTER_UP` and injects every event on the session's display, so the same code pinches on the phone's own screen and on a background display. The agent's `Pinch` moves both fingers every 8 ms along `driver.Pinch.At` (eased in and out, so the content keeps the scale it reached instead of flinging on); the mirror's `Touch2` sends what the person's fingers do, mapped from the picture to the screen like `Touch`. `androidctl` has a `pinch:CX,CY,FROM,TO[,MS[,ANGLE[,ROTATE]]]` step. **Checked on emulator-5900 (Android 16, 1080x2400, 420 dpi)** with a small test app built locally (a `ScaleGestureDetector` and the fingers' angle, its readings in a label), not committed: | What | Result | | --- | --- | | `Pinch` on the main display, 400→1200 px, 1200→400 px, 500→1500 px, 1500→500 px | ×2.50, ×0.41, ×2.64, ×0.35 | | `Pinch` turning 90° and -60° | 90° and -60° | | `Pinch` on a background display (virtual display, `bg -app`) | ×2.72; zooming out with -60° gave -60° | | `Touch2` as the mirror sends it (30 moves 16 ms apart), 500→1500 px and back | ×2.65, then ×0.34 | | `phone_pinch` through the gateway (a temporary device test), main display: `ref` with scale 2, x,y with scale 0.5 and rotation 45 | ×1.91, ×0.51 and 45°; history "Zoomed in on “Zoom area”", "Zoomed out on “Zoom area”" | | The same on a background display ("in the background") | ×1.9, ×0.52 and 45° | | `phone_pinch` at 20,20 | `invalid_argument` (no room for two fingers), nothing sent | **Android's `ScaleGestureDetector` stops below a minimum span** (`config_minScalingSpan`, 27 mm: about 446 px at 420 dpi) and starts only after a span slop (about 42 px), so a pinch whose fingers come closer than that zooms less than asked: scale 2 from the gateway (fingers 990 to 1976 px apart, wide enough) gave ×1.9, and narrow elements or scale 4 give less. Apps decide how far to follow anyway; `phone_pinch`'s description and SKILL.md tell agents to observe and pinch again. ## Links (`driver.LinkOpener`, `links.go`, 2026-11-09) `phone_launch` opens web links and apps' own links (`myapp://…`) on Android, on the main screen and on a background display. The gateway judges the app a link opens in before anything opens (docs/notes/gateway.md, "Links"), so the driver has two halves: `LinkApp` names the app the phone would pick for a tap on the link, and `OpenLink` opens the link in that app and nowhere else. **Which app.** One shell command, in the person's user: `cmd package resolve-activity --brief --user current -d ''`, then a `::pinky-candidates` marker, then `cmd package query-activities --brief …` with the same intent. `viewIntent` is `-a android.intent.action.VIEW -c android.intent.category.DEFAULT -c android.intent.category.BROWSABLE`, what a browser starts for a tap on a link: an app gets a link only when its filter has both categories, so links meant for other apps' internals do not resolve. `parseLinkQuery` reads both halves (`package/class` lines, short `.Class` names expanded with the package); `pickLinkHandler` trusts the resolved activity only when it is one of the candidates (the person's default, or an app that verified the link's site), else takes the only package that can open it. Anything else is the phone's chooser or resolver (`android/com.android.internal.app.ResolverActivity`, or a maker's): `*driver.LinkChoiceError` with the packages it would offer, which the gateway refuses with their names. No candidate at all is `driver.ErrNotFound`. A link with a control character is `driver.ErrUnsupported`; the link is passed quoted (`adb.Quote`), never through a shell parser of its own. **Opening.** `openLink` asks the candidates again, in the app's user, and starts the activity of the judged package by name: `am start --user U --display D -f 0x10000000 -d '' -n ''` (new task, as a browser starts an app for a link). Naming the activity is what keeps a link from reaching an app the rules did not see: if the phone's choice changed between the approval and the start, the start still goes to the judged app (and the gateway marks the approval stale first, `snapshot.opener`). A package that no longer opens the link is `driver.ErrNotFound`. `amStart` is the clones' `am start`, with its retry without `--display` for Android 7. **Background display and clones.** `Background.OpenLink` starts the same command on the virtual display, through `startWith`, so the person's own tasks are given back as for an app started there. An app clone (`com.tencent.mm@999`) opens the link in its user (`--user 999`); `LinkApp` itself looks only at the person's user, and the gateway picks the clone when the agent opened it last and its app is in front. **Checked on the Android 16 emulator** (emulator-5720, `TestDeviceLinks`, driver; `TestEmulatorLinks`, gateway, both tag `device`): `https://example.com/?from=pinky` opens in Chrome and `pinkytest://order/42` in a small local test app (`cc.pinky.linktest`, one activity whose filter has the scheme; it logs the link it was opened with, which the test reads), on the main screen and on a background display, where the task stays. Through the gateway each asks under `Installs and links` as "Open “link” in App?", and history reads "Opened “link” in App". With a second test app that declares the same scheme installed and neither set as the default, `PINKY_CHOICE_LINK=pinkytest://order/42` gets "several apps open pinkytest://order/42: Link Test, Link Test Two" (`LinkChoiceError`), and nothing opens; `intent:#Intent;component=com.android.settings/.Settings;end` is refused by the policy before any shell command. go test -c -tags device -o /tmp/pinky-and/android.test ./internal/driver/android scripts/devlock.sh emulator-5720 env PINKY_DEVICE=emulator-5720 PINKY_TEST_PIN=1234 PINKY_DEEP_LINK=pinkytest://order/42 /tmp/pinky-and/android.test -test.run TestDeviceLinks -test.v go test -c -tags device -o /tmp/pinky-gw/gateway.test ./internal/gateway scripts/devlock.sh emulator-5720 env PINKY_EMULATOR=emulator-5720 PINKY_TEST_PIN=1234 PINKY_DEEP_LINK=pinkytest://order/42 /tmp/pinky-gw/gateway.test -test.run TestEmulatorLinks -test.v Not checked on a real phone: makers' resolvers (MIUI and HyperOS show their own chooser; `resolve-activity` should name it, which is not a candidate, so the link is refused, but not seen), app clones opening links (no clone space on the emulators), and Android 10's `cmd package` output on the Pixel. ## Dragging (`driver.Dragger`, `drag.go`, 2026-11-10) `phone_drag` (docs/notes/gateway.md, "Dragging") goes through the scrcpy session like a swipe, with one finger (`scrcpy.PointerGenericFinger`): down at the press point, a move every 8 ms (`swipeStep`) along `driver.Drag.Steps`, no events while the finger rests (the launcher and the system see a finger held still), and up at the drop point. Each step keeps to the drag's own clock, and the finger always lifts, also when the call is cancelled. `Device` and `Background` both implement it, so it works on the phone's own screen and on a background display. Both also implement `driver.DragReplanner`: `DragReplan` plays the stops up to `at`, calls back with the finger down and still (holding the input lock, so the callback must not send input or open a stream), and plays the stops the callback returns, or the rest as they were. The gateway uses it to follow Pixel Launcher's shrinking page (docs/notes/gateway.md, "Dragging"), which the measurements below are about. **Checked on the pinky-api36 emulator (Android 16, Pixel Launcher)** through a temporary device test that ran `phone_drag` actions through the gateway (not committed): | What | Result | | --- | --- | | Main screen: hold 800, rest 800 ms near the right edge, drop | the launcher made a new page and the app landed there | | Hold 800, drop over the middle of another app's icon (above its label) with the default 300 ms rest | a folder of the two | | The same with an 800 ms rest at the drop | the launcher reordered the icons instead of making a folder (put down to the long rest at the time; below, a 2.5 s rest at the shrunk point made a folder) | | Hold 800, rest 800 ms at the edge, then drop over a folder on that page | the app went into the folder | | Background display (Settings › Sound): drag the media volume slider's thumb right, then left | 4 → 15 → 1 | | History | "Dragged “Calculator”" and the like; on another language's app, the localised lines | **Dropping onto an app on Pixel Launcher** (same emulator, the launcher reset with `pm clear` between runs; Gmail listed `@416,1619` dragged onto Photos listed `@663,1619`, hold 800): - As soon as the icon is picked up, the launcher shrinks the workspace page by about 0.78 toward about 540,990 (the middle across, 41% down) and shows a Remove bar; the dock does not move. A `uiautomator dump` taken while the finger rests has no workspace at all, only the drag layer, a drop-target node ("Move to row 4 column 2 in Home screen 1 of 0"), the Remove bar, the page indicator and the dock, so the gateway cannot look the target up again mid-drag: it compares pictures instead. - The picked-up icon keeps where the finger holds it, so the 32 px between a home screen ref's point (icon and label together) and its icon picture cancels out between two refs. - Letting go where Photos was before the drag (`663,1619`, or its icon picture's middle `663,1587`) never made a folder: Photos moved up a row and Gmail took its place, with rests of 300, 800, 1500 and 2000 ms and moves of 200 and 1500 ms. - Letting go where Photos was during the drag always made one: `416,1619 > 634,1500` (from ref points, 300 ms and 2.5 s rests), `416,1587 > 634,1468` (icon middles), and YouTube onto Gmail at `910,1619 > 443,1481` (`centre + 0.78 × (target − centre)` from Gmail's ref point). Two things seen on the way, not about dragging: the gateway updated the companion app in the middle of the session (to 2026.1110.3), after which the screen's tree came back empty until the harness reconnected, so a drag by `ref` was `not_found` and history read "Failed: Dragged" (which is why the bare form now has its own message); and a folder the launcher had just made was missing from the tree for a moment. ## Android emulators from Add phone (2026-11-09) The person starts the Android Virtual Devices (AVDs) that Android Studio made from **Add phone › Virtual phone**, and a running emulator is a phone like any other, by its adb serial (`emulator-5554`), with `Info.Virtual` set ("Emulator" where a phone says USB) and `Info.VirtualID` its AVD's name (`avd.go`, `driver.VirtualLauncher` on the provider). Agents have no tool to start or stop one. Pinky creates no AVDs and downloads no system images. - **The SDK**, found as Android Studio finds it (`adb.SDKs`): `ANDROID_HOME`, then `ANDROID_SDK_ROOT`, then `~/Library/Android/sdk` on macOS, `%LOCALAPPDATA%\Android\Sdk` on Windows, `~/Android/Sdk` on Linux; the first that has `emulator/emulator` wins. Without an SDK, or with one that has no emulator, Add phone says which (`Missing` `android_sdk` or `emulator`, with the folder). - **The AVDs**: `emulator -list-avds`, then each one's `.ini` and `config.ini` in the AVD folder (`ANDROID_AVD_HOME`, else `avd` in `ANDROID_USER_HOME`, `ANDROID_EMULATOR_HOME` or `ANDROID_SDK_HOME`'s `.android`, else `~/.android/avd`, as the emulator looks) for the name shown, the model (`hw.device.name`), the Android version (`target`, else the system image's folder) and the system image. An AVD whose system image is not in the SDK is listed as unable to start, and Pinky offers no download. - **Which AVD runs where**: each emulator's `ro.boot.qemu.avd_name` (`ro.kernel.qemu.avd_name` before Android 12), read with the properties Pinky reads anyway, of the emulators it may use (under `PINKY_DEVICES`, see `Provider.Limit` above). - **Starting**: `emulator -avd -no-audio`, detached from Pinky (its own session, so quitting Pinky leaves it running), its output in the temp folder's `pinky-emulator-.log`; an emulator that quits within the first seconds gives that log's last line as the error. Within one run of Pinky, an AVD started again gets the console port it ran on before (`-port`) while no other emulator holds it, so its serial, and with it its phone id, settings and saved PIN, stay the same; when the port is taken after all, any port. Under `PINKY_DEVICES` Pinky always picks the port, and the next free one when the emulator cannot take it, so that it knows the emulator's serial. It counts as starting for 3 minutes or until adb lists it, or until it quits: then it no longer counts as starting, and Add phone and its page say why with the last error line of its log (`VirtualPhone.Failed`) until it runs or is started again. - **No sound**: without `-no-audio` the emulator opens the computer's default microphone while it boots, though it passes the guest only silence unless started with `-allow-host-audio`. On this Mac that microphone was a Bluetooth headset (RayNeo glasses): opening it switches the headset to its call mode, which took over 15 s, and the QEMU main loop waited in CoreAudio (`AUD_open_in` → `AudioDeviceCreateIOProcID`, seen with `sample`) until the emulator's hang detector ended it (SIGSEGV, "detected a hanging thread 'QEMU2 main loop'. No response for 15015 ms"), every time, about 30 s after Start. The same AVD started from Android Studio would do the same; AirPods would only drop to call quality. The emulator has no switch for the microphone alone, so Pinky starts emulators without sound. An emulator started elsewhere keeps its sound and is a phone in Pinky all the same. - **After a crash** the emulator writes a crash report (`/tmp/android-/emu-crash-.db`, or `ANDROID_EMU_CRASH_REPORTING_DATABASE`), and the next windowed emulator first asks whether to send it: until the person answers, its main loop waits in `CrashConsentProvider::requestConsent` and adb never lists it. Pinky leaves that alone; the e2e test gives the emulator a crash folder of its own. - **Booting**: adb lists an emulator as `device` well before Android has started, about 12 s after Start on a cold boot of an Android 16 AVD, when `settings` still answers "cmd: Can't find service: settings" and `wm size` "cmd: Can't find service: window". Pinky took that answer for the phone's name, kept it for the connection, and the mirror's first try found no screen size. Now the properties are read with `sys.boot_completed`, and until it is 1 the phone is offline with "The phone is still starting." (`errStarting`, not kept), the AVD still counts as starting in Add phone, and Open says it is offline. A phone restarting on its cable is the same. A quick boot from the AVD's snapshot has `sys.boot_completed` set already. - **Shutting down**: the emulator console's `kill` on its console port on 127.0.0.1, with the token from `~/.emulator_console_auth_token` when the console asks for it. Its phone goes offline, as an unplugged one does. Tests: `avd_test.go` (the list, versions and models, the AVD folder, missing SDK and emulator, unavailable images, starting on the same port and on another, the emulator's arguments, the console's `kill` against a fake console, an emulator that quits while it starts and its log's last error line, one listed before it has booted), `TestLimitEmulators` in `limit_test.go` (under `PINKY_DEVICES`); `ui/addvirtual_test.go` for what Add phone and the phone's page show then. On this Mac, with the AVDs the sibling sessions use running, see "Virtual phones end to end" in `ios.md` for the app. ## How smooth the stream is (2026-11-09) Measured with `cmd/pinky-mirrorbench` on `pinky-api36` (Android 16, 1080×2400); the numbers and the method are in [mirror-smoothness.md](mirror-smoothness.md). - With `-gpu host`, scrcpy's defaults over USB (H.264, 8 Mbit/s, no frame cap) give 60 changed pictures a second while a list moves, on the main display and on a background display, 65 to 70 ms from the finger's first move to the picture, and 80 ms to the first picture of a stream opened again. - At 16 Mbit/s the encoder uses the bits and sends the same 60 frames: the bit rate does not hold frames back on an emulator. - The Mi 10 Pro over USB (90 Hz) streams 89 to 90 changed pictures a second while a list moves, 56 ms from the finger's first move to the picture, using 5.8 of its 8 Mbit/s. On a still screen it went on sending 64 pictures a second at 1 Mbit/s; what changes in them is still to be found. 16 Mbit/s was not tried on it. - A software-rendered emulator (swangle, the headless default without `-gpu host`) gives 22 to 30 fps, because the app itself draws at most about 44 frames a second and the guest's software encoder keeps up with less. The emulator's own gRPC screen stream gives the same rates (60 and about 30) and is not worth a driver path of its own. ## An adb server cut off from the local network (2026-11-09) **What happened.** On the owner's Mac (macOS 27.2) every phone on Wi-Fi (the Pixel 9a, the TV, the Galaxy A12) dropped one minute after Pinky updated itself from 2026.1109.8 to 2026.1109.10 (21:37 on 2026-11-08), and never came back; phones on a cable worked. Every pairing failed with "nothing at that address takes pairing requests (adb: pairing failed: Unable to start pairing client.)", which sent the person to the phone. The server that ran was the one 2026.1109.8 had started (`~/Library/Android/sdk/platform-tools/adb -L tcp:5037 fork-server server --reply-fd 3`, its parent launchd by then): its `adb mdns services` was empty, and `adb connect 192.168.0.150:` failed at once with "No route to host", while `nc` from a shell and a server started anew on another port (`adb -P 5038 start-server`) found the phone and connected with the pairing it had. Restarting the server fixed it. **Why.** macOS lets a process on the local network only while the app responsible for it may ([above](#macos-local-network-privacy)), and the server Pinky starts counts as Pinky. Once that Pinky was gone and its bundle replaced, the server it left behind had no app with Local Network access: sends fail with `EHOSTUNREACH` ("No route to host") at once, which is what macOS gives for a blocked process (`mdns.refusedByPrivacy`). The same was seen with an adb started by launchd, for itself, while macOS's "adb would like to find devices on your local network" prompt was open, and again with a copy of adb re-signed under a new identifier: "failed to connect to '192.168.0.150:38865': No route to host" in 60 ms, while a program from the shell connected to the same address. **Only the server Pinky started loses it.** The owner's log reads like a natural experiment: through the updates at 10:51, 11:30, 12:39 and 19:04 on 2026-11-08 the phones on Wi-Fi stayed, and new connections came after each (the TV at 11:51, the A12 at 12:10); the server then was not one Pinky had started (the first "android: started an adb server" is at 19:47:51, by 2026.1109.8). The next update, at 21:37, broke Wi-Fi, and it stayed broken through four more updates until the server was stopped at 19:47 on 2026-11-09. The update of 20:18 that day went through a server an `adb` client from a shell had started (`adb -L tcp:5037 fork-server server --reply-fd 4`), and nothing broke. The update itself never touches the server: the helper (`internal/update`, `swapScript`) waits for Pinky to exit, moves its bundle aside, puts the new one in place and deletes the old one once the new version is up; the server runs in a session of its own and stays, its app gone. **What Pinky does now** (`offnet.go`, `adb/own.go`). When the server answers a connection with "no route to host", or a pairing with "Unable to start pairing client" (adb says that for no route and for a refused connection alike), Pinky connects to the same address itself (TCP, 3 s, closed at once). If it gets through, the server is cut off: - A server Pinky started, in this run or an earlier one, it stops (SIGTERM) and starts again with `Config.StartServer`, so that it counts as the Pinky that runs, then asks it again: the reconnector connects the phone by address, a pairing pairs again with the same code, and every connection by address (`connectAddr`: Add phone's connect step, the reconnects of phones on adb tcpip's port and over Tailscale, turning that port on) connects. Pinky tells its own servers by their command line (`ps -A`): it runs ` -L tcp: fork-server server --reply-fd 3`, while adb's own `start-server` (Android Studio, the person's adb commands) runs `adb -L tcp:5037 fork-server server --reply-fd 4`. Every Pinky since 2026-11-08 starts them that way, so the server an update leaves behind is told too, without a note from the Pinky that started it. A restart is not counted for `adb_restarts`, and is logged at Info (`Config.Infof`). After it, the request is made again up to three times, 3 s apart, through the new server (`afterRestart`): the first TLS handshake of a new server failed once (below). A request that finds a restart under way waits for it; a phone that fails while Pinky restarts the server, or within 30 s after, is not held back for it (`justRestarted`), since the server did not quit because of the phone. - A server another program started it leaves running, as always. CheckHost reports `adb_no_network` with the address ("quit the program that started it, or stop it with `adb kill-server`"), a pairing fails with `driver.ErrPairNoNetwork` (Add phone: "This computer's adb server can't reach phones on your network, though Pinky can …"), and the phone is not held back for it, since the phone did not refuse. - Pinky restarts a server once per run. A server it started again that cannot reach the network either is never restarted again in that run, since every restart drops every connection on the server (phones on a cable, emulators, mirrors, actions under way); it is reported the same way, with Privacy & Security › Local Network to check. Another program's server is looked at again after ten minutes (`offNetRetry`). - When Pinky cannot reach the address either, nothing changes: that is the network's no route, and the phone is held back as before. Only on macOS (`cutOffChecks`), and only for a server on this computer: on Linux and Windows there is no Local Network privacy, and "No route to host" is what a phone that left gives; a server on another computer (`ANDROID_ADB_SERVER_ADDRESS`, `ADB_SERVER_SOCKET`) reaches another network, which Pinky's own connection tells nothing about, and is never Pinky's to stop (`Client.OwnServer` is false for an address that is not loopback). On Windows Pinky tells no server its own anyway (`serverProcesses` is unsupported there). These came from the owner's review of DIYgod/pinky#52. **How it was checked.** `make check`; `TestOwnCommandLine`, `TestParseProcesses` and `TestStopOwnServer` (the fake adb started by `StartServer` is Pinky's and stops; one started as `adb start-server` starts one is not, and keeps running), and the driver's `TestReconnectServerCutOff`, `…CutOffAgain`, `TestReconnectOthersServerCutOff`, `TestReconnectNoRouteForPinkyToo`, `TestReconnectFailsAfterRestart`, `TestPairServerCutOff`, `TestPairRefusedNotCutOff` against the fake server with `noRoute`. On the owner's Mac with the Pixel 9a (4B221JEBF06203, Wi-Fi 192.168.0.150), through a harness that ran the driver limited to the Pixel with reconnecting on, against a server on port 5041 (`ADB_USB=0 ADB_EMU=0 ADB_MDNS=0`, so the owner's server on 5037 kept its phones): | Server on 5041 | What the driver did | | --- | --- | | Started Pinky's way by the harness under launchd, with a copy of adb re-signed as `app.pinky.test-adb` while its Local Network prompt was open: `adb -P 5041 connect` got "No route to host" | Run 1: detected 0.55 s after the start, stopped it, started another (0.64 s); the first TLS handshake of the new server failed ("Handshake failed in SSL_accept/SSL_connect"), and the server connected the phone by itself 6 s later. Run 2: detected at 0.43 s, restarted, the Pixel online over Wi-Fi at 1.56 s | | The same copy run by launchd as `adb -L tcp:5041 server nodaemon` (another program's) | Left running (the same process), CheckHost `adb_no_network` with 192.168.0.150:38865 | | An adb launchd ran that macOS lets on the network | Connected the Pixel at once; no restart, CheckHost empty | After run 1 a failure right after a restart no longer holds the phone back for five minutes (it did then), and the request is made again through the new server (`afterRestart`). A bare TCP connection to the phone's TLS port just before a connect did not disturb the connect (six tries). Not seen: the real case after an update of Pinky.app, and pairing through a cut-off server on a real phone (it needs the person at the phone). The first launchd test left a Local Network rule for the SDK's `adb` on the owner's Mac (`/Library/Preferences/com.apple.networkextension.plist`, 19:52:56). ## Another adb server holds the phone (GitHub issue #95, 2026-11-12) **What happened.** A person on macOS 27.2 (Apple silicon, Pinky 2026.1111.19, adb 37.0.1 with its native USB backend) plugged in a HyperOS 3.0 phone (Android 15), and Pinky never listed it. Two adb servers ran, on 5037 (the one Pinky uses) and 5049 (another program's). `adb devices -l` on 5037 was empty, while `adb -P 5049 devices -l` listed the phone; the server on 5037 logged `Could not open interface: e00002c5` and `Could not find device interface` again and again. `adb -P 5049 kill-server` fixed it. The checkup said only that the phone was not connected, and pointed at the cable, unlocking and USB debugging. **Why.** A phone's USB interface goes to one adb server at a time. On macOS the server that opened it first has it exclusively (`e00002c5` is `kIOReturnExclusiveAccess`); on Linux the first to claim the interface has it, and the others get `EBUSY`. So with a second server running on another port (`adb -P start-server`, or a tool that sets `ANDROID_ADB_SERVER_PORT` for its own), a phone on a cable may belong to that one, and the server Pinky uses never lists it. **What Pinky does now** (`otherserver.go`, `adb/others.go`). - `List` has the driver look for other adb servers on this computer, at most every 30 s (`otherScanEvery`) and in the background, so that `List` never waits for it. It does so only with `Config.OtherServers` set (cmd/pinky passes `adb.Client.OtherServers`; androidctl, mirrorbench and the tests do not) and only when the server Pinky uses is on this computer. - `OtherServers` reads the servers from `ps -ww -A -o pid=,command=` (some 40 ms on a Mac with a thousand processes). It takes an adb, by name or path, with `server` and `fork-server` or `nodaemon`: `adb -L tcp: fork-server server --reply-fd ` (adb's start-server, and Pinky), `adb -L tcp: server nodaemon` (launchd) and `adb [-P ] nodaemon server`. The port comes from `-L tcp:` or `-L tcp::`, else `-P`, else 5037. Servers on another address or on a socket file are left out, and so is the port of the server Pinky uses. - Pinky sends each of them `host:devices-l` on 127.0.0.1, with 2 s to answer, and nothing else: never a request that changes what a server does. A device it lists with a `usb:` field, not in state "no permissions" and not an emulator, is held. It counts when the server Pinky uses does not list it (online or waiting to be allowed), when the driver may use it (`PINKY_DEVICES`) and when the person has not removed or paused it. - `CheckHost` then reports `adb_other_server`, after every other code, for the server on the lowest port that holds such a phone. Detail names the port and the phones' models, never their serials (people paste the checkup into public issues): "Another program's adb server, on port 5049, holds Redmi K70 over USB, so the adb server Pinky uses cannot reach it. Quit the program that started it, or, once you are sure nothing else uses it, run `adb -P 5049 kill-server`: Pinky then gets the phone." The client reads the port from that first clause (`ui/checkup.go`, `otherServerPort`), says the same in the person's language (`checkHostADBOtherServer`, on the checkup's adb item and in Add phone › Android), and puts the step first on an Android phone's "isn't connected" item (`checkConnectionAndroidStepOtherServer`). - The report goes away at the next `List` that finds the phone on the server Pinky uses. A newly found held phone is logged at Info. - Pinky never stops the other server: stopping it would take that program's phones away, as with `adb_old` and `adb_no_network`. **Not done.** - Windows. `OtherServers` returns `errors.ErrUnsupported` there: a process's command line is not at hand, and the ports an adb server process listens on include those of `adb forward`. Asking `host:devices-l` on such a port would write into another tool's connection to its phone. - Naming the program that started the server. `ps` shows `adb` for every server adb's start-server started (argv[0]), and its parent is launchd by then, so only the port can be named reliably. **How it was checked.** `USBMUXD_SOCKET_ADDRESS=127.0.0.1:9 make check`, `GOOS=windows go vet ./...`, `GOOS=linux go vet ./...` and `CGO_ENABLED=0 go build ./...`. With fakes only: - `TestServerPorts`: ps lines in every server form, among client commands, other programs, a socket file and another computer's address. - `TestOtherServerHoldsPhone`: two fake adb servers. The other holds a phone on a cable, a phone without permission, an emulator and a phone on Wi-Fi. Detail has the port and the model but not the serial, the next `List` within 30 s does not look again, and the report goes once the server Pinky uses lists the phone. - `TestOtherServersOff`. - In ui: `TestCheckupHostWords` (the words, the step on the connection item, a Detail without a port) and `TestAddAndroidOtherServer` (Add phone, in Chinese). The command lines were compared with this Mac's `ps`, where a start-server server ran on 5047 (`adb -L tcp:5047 fork-server server --reply-fd 4`) beside the SDK's on 5037. No real phone was used, and no second server was started.