screen-locker/screen_locker/_adb_transport.py
Krzysztof kuhy Rudnicki bdc405c8c9 Split _shutdown, _phone_verification and _status_data
Six new modules, all composed or re-exported so no caller changes:

  _shutdown.py (384)          -> _shutdown_sick_state.py  SickDayStateMixin
                                 _wake_alarm.py           WakeAlarmMixin
  _phone_verification.py (380)-> _adb_transport.py        AdbTransportMixin
                                 _http_workout_fetch.py   HttpWorkoutFetchMixin
  _status_data.py (370)       -> _status_types.py         the 7 dataclasses
                                 _status_projection.py    shutdown projection
                                                          + format_summary_line

Moving a name means moving where it is *read*, so 30-odd mock patch targets
move with it. Most importantly conftest.py's autouse _block_real_network
fixture: ruff correctly dropped the now-unused `socket` import from
_phone_verification, which silently removed the guard's target and would have
let subnet probes reach the real network. It now blocks socket in both modules
that actually own the probes, and both targets are asserted to exist.

976 tests pass at 100% branch coverage; pylint 8.70 -> 8.71; `screen_lock
--status` renders the real week unchanged. File-length gate: 41 -> 35.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
2026-08-15 20:11:28 +02:00

138 lines
5.2 KiB
Python

"""ADB transport for phone verification: connect, reconnect, run commands.
Split out of :mod:`screen_locker._phone_verification` to keep every file under
the 250-line cap. Composed back into ``PhoneVerificationMixin`` there, so
callers see no change.
Everything here is plumbing: reaching the phone and running a command on it.
Deciding whether what came back counts as a workout stays in
``_phone_verification``.
"""
from __future__ import annotations
from concurrent.futures import ( # pylint: disable=no-name-in-module
ThreadPoolExecutor,
as_completed,
)
import contextlib
import logging
import shutil
import socket
import subprocess
from screen_locker._constants import ADB_TIMEOUT
_logger = logging.getLogger(__name__)
class AdbTransportMixin:
"""Finds the phone over USB or wifi and runs adb commands against it."""
def _run_adb(self, args: list[str]) -> tuple[bool, str]:
"""Run an ADB command and return success flag and stdout."""
adb = shutil.which("adb") or "adb"
# When multiple devices are connected (e.g. USB + wireless), pin to
# the wireless device's serial to avoid "more than one device" errors.
_discovery_cmds = {"devices", "connect", "disconnect", "kill-server"}
serial = (
self._get_wireless_serial()
if args and args[0] not in _discovery_cmds
else None
)
serial_args = ["-s", serial] if serial else []
try:
result = subprocess.run(
[adb, *serial_args, *args],
capture_output=True,
text=True,
timeout=ADB_TIMEOUT,
check=False,
)
except (FileNotFoundError, OSError) as exc:
_logger.warning("ADB not available: %s", exc)
return False, ""
except subprocess.TimeoutExpired:
_logger.warning("ADB command timed out: %s", args)
return False, ""
return not result.returncode, result.stdout
def _adb_shell(self, command: str, *, root: bool = False) -> tuple[bool, str]:
"""Run a shell command on the connected Android device."""
if root:
return self._run_adb(["shell", "su", "-c", command])
return self._run_adb(["shell", command])
def _get_wireless_serial(self) -> str | None:
"""Return the serial (ip:port) of the first connected wireless ADB device."""
success, output = self._run_adb(["devices"])
if not success:
return None
for line in output.strip().split("\n")[1:]:
parts = line.split()
if parts and ":" in parts[0] and "device" in line and "offline" not in line:
return parts[0]
return None
def _has_adb_device(self) -> bool:
"""Return True if adb devices shows at least one connected device."""
success, output = self._run_adb(["devices"])
if not success:
return False
lines = output.strip().split("\n")[1:]
return any("device" in line and "offline" not in line for line in lines)
def _try_adb_connect(self, address: str) -> bool:
"""Run adb connect to address. Returns True on success."""
_, output = self._run_adb(["connect", address])
lower = output.lower()
return "connected" in lower and "unable" not in lower and "failed" not in lower
def _get_local_subnet_prefix(self) -> str | None:
"""Detect the local /24 network prefix (e.g. '192.168.1')."""
with (
contextlib.suppress(OSError),
socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock,
):
sock.connect(("8.8.8.8", 80))
return ".".join(sock.getsockname()[0].split(".")[:3])
return None
def _try_wireless_reconnect(self) -> bool:
"""Scan local /24 subnet on port 5555 and attempt ADB connect to phone."""
prefix = self._get_local_subnet_prefix()
if prefix is None:
_logger.info("Could not determine local subnet for wireless scan")
return False
def probe(i: int) -> bool:
ip = f"{prefix}.{i}"
with (
contextlib.suppress(OSError),
socket.create_connection((ip, 5555), timeout=0.5),
):
if self._try_adb_connect(f"{ip}:5555"):
return self._has_adb_device()
return False
_logger.info("Scanning %s.1-254:5555 for phone...", prefix)
with ThreadPoolExecutor(max_workers=64) as executor:
for future in as_completed(
executor.submit(probe, i) for i in range(1, 255)
):
if future.result():
return True
return False
def _is_phone_connected(self) -> bool:
"""Check if an Android device is connected via ADB.
If no device is visible, attempts wireless reconnection via subnet scan.
"""
if self._has_adb_device():
return True
_logger.info("No ADB device detected — attempting wireless reconnect...")
return self._try_wireless_reconnect()
# ── ADB verification ──────────────────────────────────────────────────────