""" Android Platform Adapter for Hermes Agent (Iris x Hermes). A plugin-based gateway adapter that runs a WebSocket server *inside* the ``hermes gateway`` process. The native Android / Desktop app connects to it with a pairing token and talks to the agent over a single WS transport (chat, streaming, tools, media, pairing, push-token). Zero new Python dependencies: ``websockets`` and ``httpx`` are hermes core deps. Zero hermes-core changes. Milestone M0: this is a *skeleton* adapter. It registers the ``android`` platform, resolves its configuration, and implements the abstract adapter contract as no-ops so that ``hermes gateway status`` lists ``android``. The WebSocket server, pairing, streaming, media, outbox, push, and search are wired in later milestones (see ``docs/14-milestones.md``). Configuration in config.yaml:: gateway: platforms: android: enabled: true extra: host: 127.0.0.1 port: 8790 home_channel: android:default push_backend: fcm outbox_retention_hours: 72 max_upload_bytes: 104857600 Or via environment variables (overrides config.yaml; secrets live in .env): ANDROID_TOKEN, ANDROID_WS_HOST, ANDROID_WS_PORT, ANDROID_HOME_CHANNEL, ANDROID_PUSH_BACKEND, ANDROID_FCM_SERVICE_ACCOUNT, NTFY_TOPIC, ... """ import logging import os import time import uuid from typing import Any, Dict, List, Optional from agent.secret_scope import UnscopedSecretError as _UnscopedSecretError from agent.secret_scope import get_secret as _scoped_get_secret def _get_scoped_secret(name, default=None): """Scope-aware credential read with the default-profile startup fallback. Secondary profiles construct their adapters under a profile secret scope -- the scope is authoritative and a scoped miss returns ``default`` (no cross-profile borrow from ``os.environ``, which may hold another profile's value). The DEFAULT profile's adapter constructs and sends *unscoped* under multiplexing, where a bare ``get_secret`` would raise ``UnscopedSecretError`` and crash this path; there ``os.environ`` is that profile's own value, so fall back to it. Same pattern as the IRC ``IRC_SERVER_PASSWORD`` read (``plugins/platforms/irc/adapter.py``). """ try: val = _scoped_get_secret(name, default) except _UnscopedSecretError: val = os.getenv(name) return val if val is not None else default logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Lazy import: BasePlatformAdapter and friends live in the main repo. # We import at module level (as the bundled plugins do) but guard the heavy # gateway imports so the plugin can be discovered before the gateway is fully # initialised. # --------------------------------------------------------------------------- from gateway.platforms.base import ( # noqa: E402 BasePlatformAdapter, SendResult, MessageEvent, MessageType, ) from gateway.config import Platform # noqa: E402 # --------------------------------------------------------------------------- # Defaults # --------------------------------------------------------------------------- DEFAULT_HOST = "127.0.0.1" DEFAULT_PORT = 8790 DEFAULT_HOME_CHANNEL = "android:default" DEFAULT_PUSH_BACKEND = "fcm" DEFAULT_OUTBOX_RETENTION_HOURS = 72 DEFAULT_MAX_UPLOAD_BYTES = 100 * 1024 * 1024 # 100 MB def _truthy(value: Optional[str]) -> bool: return (value or "").strip().lower() in {"1", "true", "yes", "on"} # --------------------------------------------------------------------------- # Passive / config probes (called from status displays -- no side effects) # --------------------------------------------------------------------------- def check_requirements() -> bool: """PASSIVE dependency probe: ``websockets`` importable + token set. Must be side-effect free (called from ``hermes setup`` / ``status`` / dashboard readiness). Never installs. """ try: import websockets # noqa: F401 (core dep) except Exception: return False return bool(_get_scoped_secret("ANDROID_TOKEN")) def validate_config(config) -> bool: """Given a PlatformConfig, is the platform properly configured?""" extra = getattr(config, "extra", {}) or {} token = _get_scoped_secret("ANDROID_TOKEN") or extra.get("token", "") return bool(token) def is_connected(config) -> bool: """Is the platform configured (env or config.yaml)?""" return validate_config(config) # --------------------------------------------------------------------------- # Env-driven auto-configuration (seeds PlatformConfig.extra pre-adapter) # --------------------------------------------------------------------------- def _env_enablement() -> Optional[dict]: """Seed ``PlatformConfig.extra`` from env vars during gateway config load. Called by the platform registry's env-enablement hook BEFORE adapter construction, so ``gateway status`` and ``get_connected_platforms()`` reflect env-only configuration without instantiating the adapter. Returns ``None`` when the platform isn't minimally configured (no token); the caller then skips auto-enabling. The special ``home_channel`` key in the returned dict is handled by the core hook -- it becomes a proper ``HomeChannel`` dataclass on the ``PlatformConfig`` rather than being merged into ``extra``. """ token = _get_scoped_secret("ANDROID_TOKEN", "") if not token: return None seed: Dict[str, Any] = { "host": os.getenv("ANDROID_WS_HOST", "").strip() or DEFAULT_HOST, "port": _parse_port(os.getenv("ANDROID_WS_PORT", "")), "push_backend": ( os.getenv("ANDROID_PUSH_BACKEND", "").strip().lower() or DEFAULT_PUSH_BACKEND ), } home = os.getenv("ANDROID_HOME_CHANNEL", "").strip() or DEFAULT_HOME_CHANNEL seed["home_channel"] = { "chat_id": home, "name": os.getenv("ANDROID_HOME_CHANNEL_NAME", "").strip() or "Default", } return seed def _parse_port(raw: str) -> int: try: return int((raw or "").strip()) except (ValueError, TypeError): return DEFAULT_PORT # --------------------------------------------------------------------------- # Target parsing: "android:[:]" # --------------------------------------------------------------------------- def _parse_target_ref(target_ref: str) -> Optional[tuple]: """Parse a raw target string into ``(chat_id, thread_id)`` or ``None``. Recognises the native syntax ``android:[:]``. Returns ``None`` for anything else so the target proceeds to channel-directory resolution. """ if not target_ref or not target_ref.startswith("android:"): return None body = target_ref[len("android:"):] if not body: return None if ":" in body: chat_id, thread_id = body.split(":", 1) thread_id = thread_id or None else: chat_id, thread_id = body, None chat_id = chat_id.strip() if not chat_id: return None return (chat_id, thread_id) # --------------------------------------------------------------------------- # Standalone (out-of-process) send -- best-effort, stretch for v1 # --------------------------------------------------------------------------- async def _standalone_send( pconfig, chat_id: str, message: str, *, thread_id: Optional[str] = None, media_files: Optional[List[str]] = None, force_document: bool = False, ) -> Dict[str, Any]: """Out-of-process delivery for cron jobs that run separately from the gateway. The outbox is served by the *running* gateway, so standalone delivery while the gateway process is fully down is best-effort only (see ``docs/00-overview.md`` "Out of scope"). For M0 this is a stub that reports the gateway is required; the real implementation lands with the outbox (M3/M5). """ return { "error": ( "android standalone send: the running gateway is required to serve " "the outbox (standalone delivery is best-effort only)" ) } # --------------------------------------------------------------------------- # Interactive setup (hermes gateway setup flow) -- full version in M1 # --------------------------------------------------------------------------- def interactive_setup() -> None: """Prompt for the pairing token / host / port / push backend. M0: minimal. M1 adds token generation, QR payload, and a live ``hello`` connectivity test. """ try: from hermes_cli.config import ( get_env_value, save_env_value, prompt, print_info, print_success, print_warning, ) except Exception: print("android: setup helpers unavailable; set ANDROID_TOKEN in ~/.hermes/.env") return print_info("📱 Android / Desktop (Iris x Hermes)") token = get_env_value("ANDROID_TOKEN") or "" if not token: generated = uuid.uuid4().hex + uuid.uuid4().hex # 64 hex chars save_env_value("ANDROID_TOKEN", generated) print_success(f"Generated pairing token: {generated}") print_warning("Keep this secret -- the app presents it on connect.") else: print_info("Existing ANDROID_TOKEN found (not shown).") host = prompt("WS bind host", default=get_env_value("ANDROID_WS_HOST") or DEFAULT_HOST) save_env_value("ANDROID_WS_HOST", host or DEFAULT_HOST) port = prompt("WS port", default=str(_parse_port(get_env_value("ANDROID_WS_PORT") or ""))) save_env_value("ANDROID_WS_PORT", str(_parse_port(port))) backend = prompt("Push backend (fcm/ntfy)", default=get_env_value("ANDROID_PUSH_BACKEND") or DEFAULT_PUSH_BACKEND) save_env_value("ANDROID_PUSH_BACKEND", (backend or DEFAULT_PUSH_BACKEND).strip().lower()) print_success("Android configuration saved to ~/.hermes/.env") print_info("Restart the gateway for changes to take effect: hermes gateway restart") # --------------------------------------------------------------------------- # Android Adapter # --------------------------------------------------------------------------- class AndroidAdapter(BasePlatformAdapter): """WebSocket-backed adapter for the native Iris Android / Desktop app. M0: skeleton. Implements the abstract adapter contract as no-ops and resolves configuration. The WebSocket server, connection registry, pairing, streaming, media, outbox, push, and search are added in later milestones. """ def __init__(self, config, **kwargs): platform = Platform("android") super().__init__(config=config, platform=platform) extra = getattr(config, "extra", {}) or {} # Connection settings (env vars override config.yaml) self.host = os.getenv("ANDROID_WS_HOST", "").strip() or extra.get("host", DEFAULT_HOST) self.port = _parse_port(os.getenv("ANDROID_WS_PORT", "") or str(extra.get("port", DEFAULT_PORT))) self.token = _get_scoped_secret("ANDROID_TOKEN") or extra.get("token", "") self.home_channel = extra.get("home_channel", DEFAULT_HOME_CHANNEL) self.push_backend = ( os.getenv("ANDROID_PUSH_BACKEND", "").strip().lower() or extra.get("push_backend", DEFAULT_PUSH_BACKEND) ) self.outbox_retention_hours = int( extra.get("outbox_retention_hours", DEFAULT_OUTBOX_RETENTION_HOURS) ) self.max_upload_bytes = int( extra.get("max_upload_bytes", DEFAULT_MAX_UPLOAD_BYTES) ) # TLS (optional) self.ws_cert = _get_scoped_secret("ANDROID_WS_CERT") or extra.get("ws_cert", "") self.ws_key = _get_scoped_secret("ANDROID_WS_KEY") or extra.get("ws_key", "") # Auth allowed = os.getenv("ANDROID_ALLOWED_USERS", "").strip() self.allowed_users: List[str] = ( [u.strip() for u in allowed.split(",") if u.strip()] if allowed else [] ) self.allow_all = _truthy(os.getenv("ANDROID_ALLOW_ALL_USERS")) # Runtime state (populated by the WS server in M1) self._ws_server = None self._connections: Dict[str, Any] = {} self._connected = False @property def name(self) -> str: return "Android" # ── Connection lifecycle ────────────────────────────────────────────── async def connect(self, *, is_reconnect: bool = False) -> bool: """Bring the platform up. M0: no WebSocket server yet -- just validate config and mark connected so ``hermes gateway status`` reflects the platform. M1 starts the ``websockets`` server here. """ if not self.token: logger.error("android: ANDROID_TOKEN must be set") self._set_fatal_error( "config_missing", "ANDROID_TOKEN must be set", retryable=False, ) return False # Prevent two profiles from binding the same port/identity. try: from gateway.status import acquire_scoped_lock lock_key = f"{self.host}:{self.port}" if not acquire_scoped_lock("android", lock_key): logger.error("android: %s:%s already in use by another profile", self.host, self.port) self._set_fatal_error( "lock_conflict", "WS port in use by another profile", retryable=False, ) return False self._lock_key = lock_key except ImportError: self._lock_key = None # status module not available (e.g. tests) # M1: start the websockets server on host:port (TLS if cert/key set). self._connected = True self._mark_connected() logger.info("android: connected (skeleton; WS server starts in M1) on %s:%s", self.host, self.port) return True async def disconnect(self) -> None: """Tear down the platform.""" try: from gateway.status import release_scoped_lock if getattr(self, "_lock_key", None): release_scoped_lock("android", self._lock_key) except ImportError: pass # M1: stop the server and close all device sockets. self._connected = False self._mark_disconnected() logger.info("android: disconnected") # ── Outbound (agent -> app) ─────────────────────────────────────────── async def send( self, chat_id: str, content: str, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Send a message to a chat. M0: no live devices yet -- log and report success with a minted id. M1: broadcast a ``message`` frame to connected devices, else fall to the outbox + fire push. """ message_id = f"msg_{uuid.uuid4().hex}" logger.debug("android: send to %s (%d chars) [skeleton no-op]", chat_id, len(content or "")) return SendResult(success=True, message_id=message_id) async def send_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> None: """Send a typing indicator. M0: no-op (M1 emits a ``typing`` frame).""" return None async def send_image( self, chat_id: str, image_url: str, caption: Optional[str] = None, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Send an image. M0: not implemented (M4).""" return SendResult(success=False, error="android: media not implemented yet (M4)") # ── Chat info ───────────────────────────────────────────────────────── async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: """Return ``{name, type, chat_id}`` for a chat. M0: the channel directory is not persisted yet, so report the home channel name for the default chat and a generic name otherwise. """ name = "Default" if chat_id in (self.home_channel, DEFAULT_HOME_CHANNEL) else (chat_id or "chat") return {"name": name, "type": "channel", "chat_id": chat_id} # --------------------------------------------------------------------------- # Plugin entry point # --------------------------------------------------------------------------- def register(ctx): """Plugin entry point: called by the Hermes plugin system.""" ctx.register_platform( name="android", label="Android", adapter_factory=lambda cfg: AndroidAdapter(cfg), check_fn=check_requirements, validate_config=validate_config, is_connected=is_connected, required_env=["ANDROID_TOKEN"], install_hint="No extra packages needed (websockets + httpx are core deps)", setup_fn=interactive_setup, # Env-driven auto-configuration: seeds PlatformConfig.extra with # host/port/push_backend + home_channel so env-only setups show up in # gateway status without instantiating the adapter. env_enablement_fn=_env_enablement, # Cron home-channel delivery support (deliver=android:[:]). cron_deliver_env_var="ANDROID_HOME_CHANNEL", # Out-of-process cron delivery (best-effort; outbox is gateway-served). standalone_sender_fn=_standalone_send, # Native target syntax: "android:[:]". parse_target_ref_fn=_parse_target_ref, # Auth env vars for _is_user_authorized() integration. allowed_users_env="ANDROID_ALLOWED_USERS", allow_all_env="ANDROID_ALLOW_ALL_USERS", # WS has no message-size limit. max_message_length=0, # Display. emoji="📱", pii_safe=False, allow_update_command=True, # LLM guidance. platform_hint=( "You are chatting with the user through their native Iris app " "(Android/Desktop). It renders Markdown, inline code, images, " "audio and video, and shows your reasoning and tool activity. " "Conversations are organized into channels and optional threads. " "Keep formatting rich but readable." ), )