""" ADB client — communicates with BlueStacks emulator via Android Debug Bridge. All screenshot data stays in memory (never written to disk). """ import subprocess import time import logging logger = logging.getLogger("sos.adb") class AdbError(Exception): """Raised when an ADB command fails.""" pass class AdbClient: """Low-level ADB wrapper over a single device.""" def __init__(self, host="127.0.0.1", port=5555): self.host = host self.port = port self.serial = None # set by connect() # ── connection ──────────────────────────────────────────── def connect(self) -> str: """Connect to the emulator. Returns device serial.""" out = self._raw(f"connect {self.host}:{self.port}") self.serial = f"{self.host}:{self.port}" lines = [l.strip() for l in out.splitlines() if l.strip()] logger.info("ADB connect: %s", lines[-1] if lines else out) return self.serial def disconnect(self): """Disconnect from the emulator.""" if self.serial: self._raw(f"disconnect {self.serial}") def is_connected(self) -> bool: """Check if device shows as 'device' in adb devices.""" try: out = self._raw("devices") return self.serial in out and "device" in out.split(self.serial)[1][:20] except Exception: return False # ── screenshots (pure memory — no files) ────────────────── def screencap(self) -> bytes: """ Capture screen as raw PNG bytes, fully in memory. Returns PNG bytes ready for OpenCV / PIL. """ raw = self._exec_out(f"shell screencap -p") if not raw: raise AdbError("screencap returned empty bytes — device disconnected?") return raw # ── input actions ───────────────────────────────────────── def tap(self, x: int, y: int): """Tap at screen coordinates (x, y).""" self._shell(f"input tap {x} {y}") def swipe(self, x1: int, y1: int, x2: int, y2: int, duration_ms: int = 300): """Swipe from (x1,y1) to (x2,y2) over duration_ms.""" self._shell(f"input swipe {x1} {y1} {x2} {y2} {duration_ms}") def keyevent(self, keycode: int): """Send a key event (e.g. 4 = BACK, 3 = HOME).""" self._shell(f"input keyevent {keycode}") def text(self, text: str): """Type text (only ASCII safe; use with care).""" # escape special chars for shell safe = text.replace(" ", "%s").replace("'", "\\'") self._shell(f"input text '{safe}'") # ── app management ──────────────────────────────────────── def force_stop(self, package: str): """Force-stop an app by package name.""" self._shell(f"am force-stop {package}") def launch_app(self, package: str, activity: str = None): """Launch an app. If activity is omitted, monkey-launch it.""" if activity: self._shell(f"am start -n {package}/{activity}") else: self._shell(f"monkey -p {package} -c android.intent.category.LAUNCHER 1") # ── helpers ─────────────────────────────────────────────── def _shell(self, cmd: str) -> str: """Run 'adb -s shell '. Returns stdout decoded.""" if not self.serial: raise AdbError("Not connected — call connect() first") return self._raw(f"-s {self.serial} shell {cmd}") def _exec_out(self, cmd: str) -> bytes: """Run 'adb -s exec-out '. Returns raw bytes (for images).""" if not self.serial: raise AdbError("Not connected — call connect() first") full = ["adb", "-s", self.serial, "exec-out"] + cmd.split() logger.debug("ADB exec-out: %s", " ".join(full)) r = subprocess.run(full, capture_output=True, timeout=30) if r.returncode != 0: raise AdbError(f"ADB exec-out failed: {r.stderr.decode(errors='replace')}") return r.stdout def _raw(self, cmd: str) -> str: """Run 'adb '. Returns decoded stdout.""" full = ["adb"] + cmd.split() logger.debug("ADB: %s", " ".join(full)) r = subprocess.run(full, capture_output=True, text=True, timeout=30) if r.returncode != 0: raise AdbError(f"ADB error: {r.stderr.strip()}") return r.stdout