331 lines
12 KiB
Python
331 lines
12 KiB
Python
# Copyright 2026 Google LLC
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""Process-wide ADB server endpoint discovery and activation."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import subprocess
|
|
from typing import Any, Callable
|
|
|
|
from dotenv import set_key
|
|
|
|
from artemis.config import settings
|
|
from artemis.config.paths import ROOT_DIR, get_app_dir
|
|
from artemis.runtime.adb_endpoint import (
|
|
AdbEndpoint,
|
|
AdbSession,
|
|
InvalidAdbEndpoint,
|
|
current_adb_endpoint,
|
|
)
|
|
from artemis.toolchain import toolchain
|
|
from artemis.utils.logger import get_logger
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
AdbServerEndpoint = AdbEndpoint
|
|
InvalidAdbServerEndpoint = InvalidAdbEndpoint
|
|
|
|
|
|
class AdbServerConnectionManager:
|
|
"""Validate, probe, activate, and persist the selected ADB server endpoint."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
adb_resolver: Callable[[], str | None] | None = None,
|
|
env_files: list[Path] | None = None,
|
|
) -> None:
|
|
self._adb_resolver = adb_resolver or (lambda: toolchain.resolve("adb"))
|
|
self._env_files = env_files
|
|
self._activation_lock = asyncio.Lock()
|
|
|
|
@staticmethod
|
|
def validate_endpoint(host: str, port: int) -> AdbServerEndpoint:
|
|
return AdbEndpoint.create(host, port)
|
|
|
|
def current_endpoint(self) -> AdbServerEndpoint:
|
|
return current_adb_endpoint()
|
|
|
|
def status(self) -> dict[str, Any]:
|
|
return {"endpoint": self.current_endpoint().to_dict()}
|
|
|
|
def synchronize_environment(self) -> AdbServerEndpoint:
|
|
"""Align subprocess and Python ADB clients with the configured endpoint."""
|
|
socket_endpoint = self._endpoint_from_socket(os.environ.get("ADB_SERVER_SOCKET"))
|
|
has_explicit_host = bool(os.environ.get("ADB_HOST") or os.environ.get("ADB_PORT"))
|
|
endpoint = (
|
|
socket_endpoint
|
|
if socket_endpoint is not None and not has_explicit_host
|
|
else self.current_endpoint()
|
|
)
|
|
self._activate(endpoint)
|
|
return endpoint
|
|
|
|
async def connect(
|
|
self,
|
|
host: str,
|
|
port: int,
|
|
*,
|
|
persist: bool = True,
|
|
) -> dict[str, Any]:
|
|
"""Probe an endpoint, then make it the process-wide ADB server on success."""
|
|
endpoint = self.validate_endpoint(host, port)
|
|
probe_result = await self.probe(host, port)
|
|
if not probe_result["success"]:
|
|
return probe_result
|
|
|
|
persisted = False
|
|
persistence_error: str | None = None
|
|
async with self._activation_lock:
|
|
self._activate(endpoint)
|
|
if persist:
|
|
try:
|
|
await asyncio.to_thread(self._persist, endpoint)
|
|
persisted = True
|
|
except Exception as exc:
|
|
persistence_error = str(exc)
|
|
logger.warning(f"Unable to persist ADB server endpoint: {exc}")
|
|
|
|
message = probe_result["message"]
|
|
if persist and not persisted:
|
|
message = (
|
|
f"{message} The connection is active for this session, but Artemis could not "
|
|
"save it for the next launch."
|
|
)
|
|
|
|
return {
|
|
**probe_result,
|
|
"endpoint": endpoint.to_dict(),
|
|
"message": message,
|
|
"persisted": persisted,
|
|
"persistence_error": persistence_error,
|
|
}
|
|
|
|
async def probe(self, host: str, port: int) -> dict[str, Any]:
|
|
"""Test an endpoint without changing the process preference."""
|
|
endpoint = self.validate_endpoint(host, port)
|
|
return await asyncio.to_thread(self._probe_sync, endpoint)
|
|
|
|
async def use_local_server(self, *, persist: bool = True) -> dict[str, Any]:
|
|
"""Restore the standard local ADB server without killing any remote daemon."""
|
|
endpoint = AdbEndpoint.local()
|
|
persisted = False
|
|
persistence_error: str | None = None
|
|
async with self._activation_lock:
|
|
self._activate(endpoint)
|
|
if persist:
|
|
try:
|
|
await asyncio.to_thread(self._persist, endpoint)
|
|
persisted = True
|
|
except Exception as exc:
|
|
persistence_error = str(exc)
|
|
logger.warning(f"Unable to persist local ADB server endpoint: {exc}")
|
|
|
|
message = "Artemis is now using the local ADB server."
|
|
if persist and not persisted:
|
|
message += " The setting could not be saved for the next launch."
|
|
return {
|
|
"success": True,
|
|
"endpoint": endpoint.to_dict(),
|
|
"persisted": persisted,
|
|
"persistence_error": persistence_error,
|
|
"message": message,
|
|
}
|
|
|
|
def _probe_sync(self, endpoint: AdbServerEndpoint) -> dict[str, Any]:
|
|
adb_path = self._adb_resolver()
|
|
if not adb_path:
|
|
return {
|
|
"success": False,
|
|
"error_code": "adb_not_found",
|
|
"message": "Android Platform Tools (adb) is not installed or not on PATH.",
|
|
"endpoint": endpoint.to_dict(),
|
|
"devices": [],
|
|
}
|
|
|
|
session = AdbSession(endpoint, adb_path=adb_path)
|
|
command = session.command(["devices", "-l"])
|
|
try:
|
|
result = subprocess.run(
|
|
command,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
timeout=8,
|
|
env=self._probe_environment(),
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
return {
|
|
"success": False,
|
|
"error_code": "timeout",
|
|
"message": (
|
|
"The ADB server did not respond. Check that the host and port are correct "
|
|
"and accessible from this computer."
|
|
),
|
|
"endpoint": endpoint.to_dict(),
|
|
"devices": [],
|
|
}
|
|
except OSError as exc:
|
|
logger.warning(f"Failed to execute ADB endpoint probe: {exc}")
|
|
return {
|
|
"success": False,
|
|
"error_code": "adb_execution_failed",
|
|
"message": f"Unable to run adb: {exc}",
|
|
"endpoint": endpoint.to_dict(),
|
|
"devices": [],
|
|
}
|
|
|
|
stdout = result.stdout or ""
|
|
stderr = result.stderr or ""
|
|
output = "\n".join(part.strip() for part in (stdout, stderr) if part.strip())
|
|
devices = self._parse_devices(stdout)
|
|
if result.returncode != 0:
|
|
return {
|
|
"success": False,
|
|
"error_code": "server_unreachable",
|
|
"message": self._connection_error_message(output),
|
|
"output": output,
|
|
"endpoint": endpoint.to_dict(),
|
|
"devices": [],
|
|
}
|
|
|
|
ready_devices = [device for device in devices if device["state"] == "device"]
|
|
if ready_devices:
|
|
message = (
|
|
f"Connected to {endpoint.host}:{endpoint.port} and found "
|
|
f"{len(ready_devices)} ready device{'s' if len(ready_devices) != 1 else ''}."
|
|
)
|
|
elif devices:
|
|
message = (
|
|
"The ADB server is reachable, but its devices require attention. "
|
|
"Unlock the phone and approve USB debugging if prompted."
|
|
)
|
|
else:
|
|
message = (
|
|
"The ADB server is reachable, but no devices are attached to the remote computer."
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"message": message,
|
|
"output": output,
|
|
"endpoint": endpoint.to_dict(),
|
|
"devices": devices,
|
|
}
|
|
|
|
@staticmethod
|
|
def _parse_devices(output: str) -> list[dict[str, str | None]]:
|
|
devices: list[dict[str, str | None]] = []
|
|
for raw_line in output.splitlines():
|
|
line = raw_line.strip()
|
|
if not line or line.lower().startswith("list of devices") or line.startswith("*"):
|
|
continue
|
|
parts = line.split()
|
|
if len(parts) < 2:
|
|
continue
|
|
metadata = {
|
|
key: value.replace("_", " ")
|
|
for token in parts[2:]
|
|
if ":" in token
|
|
for key, value in [token.split(":", 1)]
|
|
}
|
|
devices.append(
|
|
{
|
|
"serial": parts[0],
|
|
"state": parts[1],
|
|
"model": metadata.get("model"),
|
|
"product": metadata.get("product"),
|
|
}
|
|
)
|
|
return devices
|
|
|
|
@staticmethod
|
|
def _connection_error_message(output: str) -> str:
|
|
lowered = output.lower()
|
|
if "cannot connect" in lowered or "failed to connect" in lowered:
|
|
return (
|
|
"No ADB server is listening at this address. Verify that the host and port are "
|
|
"correct and accessible from this computer."
|
|
)
|
|
if "version" in lowered and "mismatch" in lowered:
|
|
return (
|
|
"The local and remote ADB versions are incompatible. Use matching Android "
|
|
"Platform Tools versions on both computers."
|
|
)
|
|
return output or "Unable to reach the ADB server endpoint."
|
|
|
|
@staticmethod
|
|
def _probe_environment() -> dict[str, str]:
|
|
environment = os.environ.copy()
|
|
environment.pop("ADB_SERVER_SOCKET", None)
|
|
return environment
|
|
|
|
@classmethod
|
|
def _endpoint_from_socket(cls, value: str | None) -> AdbServerEndpoint | None:
|
|
if not value or not value.startswith("tcp:"):
|
|
return None
|
|
address = value.removeprefix("tcp:")
|
|
if address.startswith("["):
|
|
match = re.fullmatch(r"\[([^]]+)]:(\d+)", address)
|
|
else:
|
|
match = re.fullmatch(r"(.+):(\d+)", address)
|
|
if match is None:
|
|
return None
|
|
try:
|
|
return cls.validate_endpoint(match.group(1), int(match.group(2)))
|
|
except (InvalidAdbServerEndpoint, ValueError):
|
|
return None
|
|
|
|
@staticmethod
|
|
def _activate(endpoint: AdbServerEndpoint) -> None:
|
|
settings.ADB_HOST = endpoint.host
|
|
settings.ADB_PORT = endpoint.port
|
|
endpoint.apply_to_environment()
|
|
logger.info(
|
|
f"Activated {endpoint.mode} ADB server endpoint {endpoint.host}:{endpoint.port}"
|
|
)
|
|
|
|
def _persist(self, endpoint: AdbServerEndpoint) -> None:
|
|
for env_file in self._environment_files():
|
|
env_file.parent.mkdir(parents=True, exist_ok=True)
|
|
if not env_file.exists():
|
|
env_file.touch()
|
|
set_key(str(env_file), "ADB_HOST", endpoint.host, quote_mode="never")
|
|
set_key(str(env_file), "ADB_PORT", str(endpoint.port), quote_mode="never")
|
|
|
|
def _environment_files(self) -> list[Path]:
|
|
if self._env_files is not None:
|
|
candidates = self._env_files
|
|
else:
|
|
candidates = [ROOT_DIR / ".env", get_app_dir() / ".env"]
|
|
|
|
unique_files: list[Path] = []
|
|
seen: set[Path] = set()
|
|
for candidate in candidates:
|
|
resolved = candidate.resolve()
|
|
if resolved not in seen:
|
|
seen.add(resolved)
|
|
unique_files.append(candidate)
|
|
return unique_files
|
|
|
|
|
|
adb_server_connection = AdbServerConnectionManager()
|
|
adb_server_connection.synchronize_environment()
|