HTTP transport: drop WS server, offline send queue + dead-stream watchdog
CI / Gateway plugin tests (push) Successful in 5m9s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m55s

Gateway (docs/19):
- Remove ws_server.py; frame dispatch factored into dispatch.py
- http_server: media upload/pull, pairing over HTTP
- protocol: media frames mirrored; tests + ws_probe updated for HTTP

App:
- HttpGateway: postFrame/uploadMedia/pullMedia no longer throw on
  network failure (PostResult ok=false / Result.failure) — uncaught
  SocketTimeoutException on Dispatchers.Default crashed the app
- GatewayClient: dead-stream watchdog (health probe every 10s, 2
  failures -> redial in ~20s instead of the 45s SSE read timeout);
  state flips to Reconnecting when the stream dies, restored from the
  last hello.ack on long-poll success; poke() + backoff reset on app
  resume (MainActivity.onResume)
- Offline sends: composer enabled while disconnected; a send with no
  response (status 0) stays queued (Pending) and is auto-resent on the
  next (re)connect after a 2s outbox-replay grace; gateway 4xx
  rejections fail the bubble (tap to retry, no auto-loop)
- ChatStore: echo-replace and thread-relocate also match Failed
  bubbles (POST response lost in a network drop); loadHistory dedupes
  local failed bubbles the server already has; failMessage()
- MainActivity: poke() on resume so a backgrounded app reconnects
  promptly instead of waiting out the backoff
This commit is contained in:
ARIA committed 2026-08-22 20:10:05 +02:00
1 parent 2349a95dd4
commit e6015033b6
22 files changed
+2804 -2439

No files matched your search

+78 -272
View File
@@ -1,18 +1,20 @@
"""
Android Platform Adapter for Hermes Agent (Iris x Hermes).
A plugin-based gateway adapter that runs a WebSocket server *inside* the
A plugin-based gateway adapter that runs an HTTP 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).
with a pairing token and talks to the agent over a single HTTP transport
(chat, streaming, tools, media, pairing, push-token): JSON frames via
``POST /v1/frame``, events via SSE ``GET /v1/events`` (or long-poll), and
media via ``POST /v1/media`` / ``GET /v1/media/{id}`` (docs/19).
Zero new Python dependencies: ``websockets`` and ``httpx`` are hermes core
deps. Zero hermes-core changes.
Zero new Python dependencies: ``httpx`` is a hermes core dep. Zero
hermes-core changes.
Milestone M1: the gateway core loop (text round-trip). The WS server binds
and authenticates devices (``hello`` with constant-time token check), the
adapter emits ``message`` frames from ``send()`` and turns inbound
``message.send`` frames into ``MessageEvent``s for ``handle_message()``.
Milestone M1: the gateway core loop (text round-trip). The server binds and
authenticates devices (constant-time token check), the adapter emits
``message`` frames from ``send()`` and turns inbound ``message.send`` frames
into ``MessageEvent``s for ``handle_message()``.
Milestone M2: agent transparency. ``send()``/``edit_message()`` are mapped to
``message.start``/``message.update``/``message.stop`` (streaming), tool
@@ -22,13 +24,13 @@ reasoning prefix is split into a ``reasoning`` field. Outbox and search land
in M3; media, push, and desktop land in later milestones (see
``docs/14-milestones.md``).
Milestone M4: media. Inbound ``media.upload`` (chunked binary frames) is
reassembled in a temp file, verified (size + sha256), re-sniffed, and cached
via hermes ``cache_*_from_bytes``; the resulting refs attach to the next
Milestone M4: media. Inbound uploads (``POST /v1/media``) are streamed to a
temp file, verified (size + sha256), re-sniffed, and cached via hermes
``cache_*_from_bytes``; the resulting refs attach to the next
``message.send`` as ``MessageEvent.media_urls``. Outbound ``send_*`` calls
register the (delivery-validated) file in the media registry and emit
``media.offer``; ``media.pull`` streams the file back as chunked binary
frames, re-checking ``validate_media_delivery_path`` at pull time.
``media.offer``; ``GET /v1/media/{id}`` streams the file back, re-checking
``validate_media_delivery_path`` at pull time.
Milestone M5: push + offline. Frames with no live subscriber are parked in
the outbox (M3) AND wake the device via the push backend (``push.py``: FCM
@@ -127,7 +129,6 @@ from .pairing import ( # noqa: E402
qr_payload,
)
from .push import NtfyBackend, PushBackend, build_push_backend # noqa: E402
from .ws_server import WsServer # noqa: E402
# ---------------------------------------------------------------------------
# Slash-command catalog (the app's "/" drawer)
@@ -459,8 +460,6 @@ DEFAULT_PUSH_BACKEND = "fcm"
DEFAULT_OUTBOX_RETENTION_HOURS = 72
DEFAULT_MAX_UPLOAD_BYTES = 100 * 1024 * 1024 # 100 MB
# Max length of a client-supplied media_ref (mu_*/md_* ids are short).
MAX_MEDIA_REF_LEN = 64
# How often (seconds) the outbox-prune "storage reclaimed" notice may repeat.
_PRUNE_NOTIFY_INTERVAL_S = 3600.0
@@ -802,15 +801,12 @@ class _TurnState:
def check_requirements() -> bool:
"""PASSIVE dependency probe: ``websockets`` importable + token set.
"""PASSIVE dependency probe: token set.
Must be side-effect free (called from ``hermes setup`` / ``status`` /
dashboard readiness). Never installs.
dashboard readiness). Never installs. The HTTP transport is stdlib-only,
so there is no extra dependency to probe.
"""
try:
import websockets # noqa: F401 (core dep)
except Exception:
return False
return bool(_get_scoped_secret("ANDROID_TOKEN"))
@@ -856,9 +852,9 @@ def _env_enablement() -> dict | None:
host = os.getenv("ANDROID_WS_HOST", "").strip()
if host:
seed["host"] = host
port_raw = os.getenv("ANDROID_WS_PORT", "").strip()
if port_raw:
seed["port"] = _parse_port(port_raw)
http_port_raw = os.getenv("ANDROID_HTTP_PORT", "").strip()
if http_port_raw:
seed["http_port"] = _parse_port(http_port_raw)
push = os.getenv("ANDROID_PUSH_BACKEND", "").strip().lower()
if push:
seed["push_backend"] = push
@@ -1032,10 +1028,16 @@ def interactive_setup() -> None:
else:
print_info("Existing ANDROID_TOKEN found (not shown).")
host = prompt("WS bind host", default=get_env_value("ANDROID_WS_HOST") or DEFAULT_HOST)
host = prompt("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)))
# _parse_port falls back to DEFAULT_PORT (8790) for empty input, so the
# HTTP default must be applied explicitly (docs/19: 8791).
http_port_raw = (get_env_value("ANDROID_HTTP_PORT") or "").strip()
port = prompt(
"HTTP port",
default=str(int(http_port_raw) if http_port_raw.isdigit() else DEFAULT_HTTP_PORT),
)
save_env_value("ANDROID_HTTP_PORT", str(_parse_port(port)))
backend = prompt(
"Push backend (fcm/ntfy)",
default=get_env_value("ANDROID_PUSH_BACKEND") or DEFAULT_PUSH_BACKEND,
@@ -1063,19 +1065,20 @@ def interactive_setup() -> None:
class AndroidAdapter(BasePlatformAdapter):
"""WebSocket-backed adapter for the native Iris Android / Desktop app.
"""HTTP-backed adapter for the native Iris Android / Desktop app.
M1: the WS server (``ws_server.WsServer``) authenticates devices with the
pairing token, the connection registry tracks live sockets, ``send()``
The HTTP server (``http_server.HttpServer``) authenticates devices with
the pairing token, the device registry tracks live subscribers, ``send()``
emits ``message`` frames, and inbound ``message.send`` frames become
``MessageEvent``s for ``handle_message()``.
"""
# WS has no message-size limit. The stream consumer resolves its per-chat
# chunking budget via ``max_message_length_for_chat`` -> this attribute
# (defaulting to 4096 when unset), which would split long replies — and
# complete HTML artifacts — across multiple fence-reopened messages. A
# large cap disables that chunking so a reply arrives as a single message.
# The HTTP transport has no per-message size limit. The stream consumer
# resolves its per-chat chunking budget via ``max_message_length_for_chat``
# -> this attribute (defaulting to 4096 when unset), which would split
# long replies — and complete HTML artifacts — across multiple
# fence-reopened messages. A large cap disables that chunking so a reply
# arrives as a single message.
MAX_MESSAGE_LENGTH = 1_000_000
def __init__(self, config, **kwargs):
@@ -1088,12 +1091,10 @@ class AndroidAdapter(BasePlatformAdapter):
extra = getattr(config, "extra", {}) or {}
# Connection settings (env vars override config.yaml)
# Connection settings (env vars override config.yaml). The bind host
# is shared with the (legacy) WS-era env var name for compatibility.
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))
)
# docs/19: HTTP fallback leg (same bind host as the WS; optional TLS).
# docs/19: HTTP transport (the only device-facing transport; optional TLS).
self.http_port = _parse_port(
os.getenv("ANDROID_HTTP_PORT", "") or str(extra.get("http_port", DEFAULT_HTTP_PORT))
)
@@ -1127,8 +1128,6 @@ class AndroidAdapter(BasePlatformAdapter):
self.home_channel_name = DEFAULT_HOME_CHANNEL_NAME
# 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", "")
self.http_cert = _get_scoped_secret("ANDROID_HTTP_CERT") or extra.get("http_cert", "")
self.http_key = _get_scoped_secret("ANDROID_HTTP_KEY") or extra.get("http_key", "")
@@ -1141,13 +1140,12 @@ class AndroidAdapter(BasePlatformAdapter):
# Runtime state
self._devices = DeviceRegistry(get_hermes_home() / "android" / "devices.db")
self._ws_server = WsServer(self, self._devices)
# docs/19: HTTP fallback leg (inert until the app uses it; a bind
# failure disables it without affecting the WS).
# docs/19: HTTP transport (the only device-facing transport).
self._http_server = HttpServer(self, self._devices)
# docs/19 §19.7: reply sinks for in-flight HTTP requests — while a
# POST /v1/frame is being dispatched, the handler's point-to-point
# replies are captured here and returned as the HTTP response.
# Entry shape: (sink queue, abandoned event).
self._http_reply_sinks: dict[str, tuple[queue.Queue, threading.Event]] = {}
self._connected = False
# M2: per-chat turn state for outbound frame classification.
@@ -1191,7 +1189,7 @@ class AndroidAdapter(BasePlatformAdapter):
# ── Connection lifecycle ──────────────────────────────────────────────
async def connect(self, *, is_reconnect: bool = False) -> bool:
"""Bring the platform up: bind the WS server on host:port."""
"""Bring the platform up: bind the HTTP server on host:http_port."""
if not self.token:
logger.error("android: ANDROID_TOKEN must be set")
self._set_fatal_error(
@@ -1201,41 +1199,24 @@ class AndroidAdapter(BasePlatformAdapter):
)
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)
try:
await self._ws_server.start()
except Exception:
self._connected = False
return False
# docs/19: start the HTTP fallback leg next to the WS. Bind failure
# is NON-fatal (unlike the WS): the plugin keeps working WS-only.
# The HTTP server is the only device-facing transport, so a bind
# failure is fatal (the app has no other way to reach the gateway).
# start() never raises; it disables the leg and logs on failure.
await self._http_server.start()
if not self._http_server.enabled:
logger.error("android: HTTP server failed to bind %s:%s", self.host, self.http_port)
self._set_fatal_error(
"bind_failed",
f"HTTP port {self.http_port} unavailable",
retryable=False,
)
return False
# M5: announce gateway health to connected clients (none yet at
# startup; the frame + plumbing exist for future transitions).
# Reset in case this adapter instance previously went down (the
# gateway may reconnect the same adapter after a fatal error).
self._gateway_status = protocol.STATUS_ONLINE
await self._ws_server.broadcast(protocol.status(self._gateway_status))
await self._http_server.fanout(protocol.status(self._gateway_status), cursor=None)
# M3: ensure the default (home) channel exists in the directory so the
@@ -1257,29 +1238,18 @@ class AndroidAdapter(BasePlatformAdapter):
self._connected = True
self._mark_connected()
logger.info("android: connected; WS server on %s:%s", self.host, self.port)
logger.info("android: connected; HTTP server on %s:%s", self.host, self.http_port)
return True
async def disconnect(self) -> None:
"""Tear down the platform: stop the server, close device sockets."""
"""Tear down the platform: stop the server, close device streams."""
# Tell live clients the gateway is going away (restart/shutdown) so
# the app can distinguish a clean gateway teardown from a plain
# network drop: the "Gateway restarting" chat notice is shown only
# when this frame was received (docs/04 §status).
self._gateway_status = protocol.STATUS_RESTARTING
with contextlib.suppress(Exception):
await self._ws_server.broadcast(protocol.status(self._gateway_status))
await self._http_server.fanout(protocol.status(self._gateway_status), cursor=None)
with contextlib.suppress(ImportError):
from gateway.status import release_scoped_lock
lock_key = getattr(self, "_lock_key", None)
if lock_key:
release_scoped_lock("android", lock_key)
try:
await self._ws_server.stop()
except Exception:
logger.warning("android: WS server stop failed", exc_info=True)
try:
await self._http_server.stop()
except Exception:
@@ -1689,31 +1659,24 @@ class AndroidAdapter(BasePlatformAdapter):
return self._http_reply_sinks.pop(device_id, None)
async def _broadcast_both(self, frame: "protocol.Frame") -> None:
"""Bare (non-outbox) broadcast to both transports (docs/19): the
frame reaches WS devices and live SSE/long-poll subscribers."""
await self._ws_server.broadcast(frame)
"""Bare (non-outbox) broadcast to live subscribers (docs/19): the
frame reaches every live SSE/long-poll subscriber."""
await self._http_server.fanout(frame, cursor=None)
async def _reply(self, device_id: str, frame: "protocol.Frame") -> None:
"""Point-to-point reply with HTTP-leg fallback (docs/19 §19.7).
"""Point-to-point reply with broadcast fallback (docs/19 §19.7).
WS-originated requests keep point-to-point delivery. For an
in-flight HTTP request (a reply sink is registered) the frame goes
into the HTTP response. If the device has no live WS and no sink
(e.g. it dropped mid-request), the frame is broadcast so the SSE
stream delivers it (single-user model).
For an in-flight HTTP request (a reply sink is registered) the frame
goes into the HTTP response. Otherwise it is broadcast so the
device's SSE stream delivers it (single-user model).
"""
entry = self._http_reply_sinks.get(device_id)
if entry is not None:
entry[0].put(frame)
return
if await self._ws_server.send_to(device_id, frame):
return
await self._ws_server.broadcast(frame)
await self._http_server.fanout(frame, cursor=None)
await self._broadcast_both(frame)
async def _broadcast_or_log(self, chat_id: str, frame: "protocol.Frame") -> None:
delivered = await self._ws_server.broadcast(frame)
# M3/M5: always append to the outbox so a reconnecting app can catch
# up on *all* recent frames, not just the ones that were parked. This
# covers the case where the app's in-memory ChatStore is reset (e.g.
@@ -1729,7 +1692,7 @@ class AndroidAdapter(BasePlatformAdapter):
# docs/19 §19.8: a device reading SSE/long-poll IS a live subscriber
# — count it in the delivery total or every message would push AND
# stream to a device that is already receiving it.
delivered += await self._http_server.fanout(frame, cursor)
delivered = await self._http_server.fanout(frame, cursor)
if delivered == 0:
logger.info(
"android: no live devices for %s; %s frame parked in outbox (cursor=%s)",
@@ -1827,12 +1790,7 @@ class AndroidAdapter(BasePlatformAdapter):
device_id = device.get("device_id")
if not device_id:
continue
# Prefer the live connection's token (fcm.register refreshes it
# in memory) over the possibly-stale registry row.
conn = self._ws_server.connection(device_id)
token = getattr(conn, backend.token_field, None) if conn is not None else None
if not token:
token = device.get(backend.token_field)
token = device.get(backend.token_field)
if not token:
continue
try:
@@ -1894,13 +1852,11 @@ class AndroidAdapter(BasePlatformAdapter):
if isinstance(tid, str) and tid:
thread_id = tid
frame = protocol.typing(chat_id, True, thread_id=thread_id)
await self._ws_server.broadcast(frame)
await self._http_server.fanout(frame, cursor=None)
async def stop_typing(self, chat_id: str) -> None:
"""Clear the typing indicator (``typing`` frame, on=false)."""
frame = protocol.typing(chat_id, False)
await self._ws_server.broadcast(frame)
await self._http_server.fanout(frame, cursor=None)
# ── M4: outbound media (agent -> app) ─────────────────────────────────
@@ -2243,150 +2199,6 @@ class AndroidAdapter(BasePlatformAdapter):
threading.Thread(target=_work, daemon=True, name="android-thread-title").start()
# ── M4: inbound media (app -> agent) ──────────────────────────────────
#
# ``media.upload.start`` -> raw binary frames (one at a time per
# connection) -> ``media.upload.end``. The session streams to a temp
# file (bounded RAM); on end we verify size + sha256, re-sniff the kind,
# and cache via hermes ``cache_*_from_bytes``. ``media.pull`` serves an
# outbound offer as chunked binary frames, re-checking the delivery-path
# validation at pull time.
async def on_media_upload_start(self, frame: protocol.Frame, device_id: str) -> None:
payload = frame.payload
media_ref = str(payload.get("media_ref") or "").strip()
if not media_ref or len(media_ref) > MAX_MEDIA_REF_LEN:
await self._reply(
device_id,
protocol.error(
protocol.ERR_UNSUPPORTED, "media.upload.start requires media_ref", id=frame.id
),
)
return
kind = payload.get("kind")
if kind not in media_bridge.KINDS:
await self._reply(
device_id,
protocol.error(
protocol.ERR_UNSUPPORTED, f"unsupported media kind {kind!r}", id=frame.id
),
)
return
mime = str(payload.get("mime") or "application/octet-stream")[:128]
filename = str(payload.get("filename") or "upload")[:255]
size = payload.get("size")
try:
size = int(size) if size is not None else -1
except (TypeError, ValueError):
size = -1
if size <= 0:
await self._reply(
device_id,
protocol.error(
protocol.ERR_UNSUPPORTED,
"media.upload.start requires a positive size",
id=frame.id,
),
)
return
if size > self.max_upload_bytes:
await self._reply(
device_id,
protocol.error(
protocol.ERR_MEDIA_TOO_LARGE,
f"upload of {size} bytes exceeds limit ({self.max_upload_bytes})",
id=frame.id,
),
)
return
try:
self._media.create_upload(
device_id,
media_ref,
kind,
mime,
filename,
size,
frame.id,
self.max_upload_bytes,
)
except media_bridge.MediaError as e:
await self._reply(device_id, protocol.error(e.code, e.message, id=frame.id))
return
# No ack: WS ordering guarantees the server processes this before the
# first binary chunk; failures arrive as ``error`` frames.
async def on_media_chunk(self, device_id: str, chunk: bytes) -> None:
session = self._media.get_upload(device_id)
if session is None:
return # stray binary frame: ignore (forward-compat)
session.feed(chunk)
if session.failed:
await self._reply(
device_id,
protocol.error(session.error_code, session.error_message, id=session.request_id),
)
self._media.discard_upload(device_id, session.media_ref)
async def on_media_upload_end(self, frame: protocol.Frame, device_id: str) -> None:
payload = frame.payload
media_ref = str(payload.get("media_ref") or "").strip()
sha256 = str(payload.get("sha256") or "").strip().lower()
if not media_ref:
await self._reply(
device_id,
protocol.error(
protocol.ERR_UNSUPPORTED, "media.upload.end requires media_ref", id=frame.id
),
)
return
try:
entry = self._media.complete_upload(device_id, media_ref, sha256)
except media_bridge.MediaError as e:
await self._reply(device_id, protocol.error(e.code, e.message, id=frame.id))
return
await self._reply(
device_id, protocol.media_upload_ack(True, entry.media_id, id=frame.id)
)
async def on_media_pull(self, frame: protocol.Frame, device_id: str) -> None:
payload = frame.payload
media_id = str(payload.get("media_id") or "").strip()
entry = self._media.get_outbound(media_id) if media_id else None
if entry is None:
await self._reply(
device_id,
protocol.error(
protocol.ERR_NOT_FOUND, f"unknown media_id {media_id!r}", id=frame.id
),
)
return
# Delivery-path security: re-validate at pull time (the file may have
# moved / been replaced since the offer).
safe = validate_media_delivery_path(entry.path)
if safe is None:
await self._reply(
device_id,
protocol.error(protocol.ERR_NOT_FOUND, "media no longer deliverable", id=frame.id),
)
return
conn = self._ws_server.connection(device_id)
if conn is None:
return
try:
await media_bridge.stream_file(conn.ws, safe, media_bridge.DEFAULT_CHUNK_BYTES)
except Exception as e:
logger.warning("android: media.pull stream failed for %s: %s", media_id, e)
await self._reply(
device_id, protocol.error(protocol.ERR_INTERNAL, f"pull failed: {e}", id=frame.id)
)
return
await self._reply(device_id, protocol.media_pull_end(True, id=frame.id))
def on_connection_closed(self, device_id: str) -> None:
"""M4: drop in-flight upload temp files for a disconnected device."""
self._media.discard_device(device_id)
# ── M3: channel directory management (app -> agent) ───────────────────
#
# Each request is answered by broadcasting the matching ``channel.*``
@@ -2421,7 +2233,7 @@ class AndroidAdapter(BasePlatformAdapter):
return
resp = protocol.channel_created(entry)
resp.id = frame.id
await self._ws_server.broadcast(resp)
await self._http_server.fanout(resp)
# M5: banner + push mirror (parked in the outbox when offline).
await self._broadcast_or_log(
entry["chat_id"],
@@ -2467,7 +2279,7 @@ class AndroidAdapter(BasePlatformAdapter):
return
resp = protocol.channel_renamed(entry)
resp.id = frame.id
await self._ws_server.broadcast(resp)
await self._http_server.fanout(resp)
# M5: banner + push mirror (parked in the outbox when offline).
await self._broadcast_or_log(
chat_id,
@@ -2500,7 +2312,7 @@ class AndroidAdapter(BasePlatformAdapter):
# new is_default flag) so every device reconciles the default change.
resp = protocol.channel_renamed(entry)
resp.id = frame.id
await self._ws_server.broadcast(resp)
await self._http_server.fanout(resp)
async def on_channel_favorite(self, frame: protocol.Frame, device_id: str) -> None:
chat_id = frame.chat_id or frame.payload.get("chat_id")
@@ -2524,7 +2336,7 @@ class AndroidAdapter(BasePlatformAdapter):
# new favorite flag) so every device reconciles the change.
resp = protocol.channel_renamed(entry)
resp.id = frame.id
await self._ws_server.broadcast(resp)
await self._http_server.fanout(resp)
async def on_channel_icon(self, frame: protocol.Frame, device_id: str) -> None:
chat_id = frame.chat_id or frame.payload.get("chat_id")
@@ -2557,7 +2369,7 @@ class AndroidAdapter(BasePlatformAdapter):
return
resp = protocol.channel_renamed(entry)
resp.id = frame.id
await self._ws_server.broadcast(resp)
await self._http_server.fanout(resp)
async def on_channel_set_automation(self, frame: protocol.Frame, device_id: str) -> None:
chat_id = frame.chat_id or frame.payload.get("chat_id")
@@ -2585,7 +2397,7 @@ class AndroidAdapter(BasePlatformAdapter):
# new automation flag) so every device reconciles the change.
resp = protocol.channel_renamed(entry)
resp.id = frame.id
await self._ws_server.broadcast(resp)
await self._http_server.fanout(resp)
async def on_channel_delete(self, frame: protocol.Frame, device_id: str) -> None:
chat_id = frame.chat_id or frame.payload.get("chat_id")
@@ -2632,7 +2444,7 @@ class AndroidAdapter(BasePlatformAdapter):
)
resp = protocol.channel_deleted(chat_id)
resp.id = frame.id
await self._ws_server.broadcast(resp)
await self._http_server.fanout(resp)
# M5: banner + push mirror (parked in the outbox when offline).
await self._broadcast_or_log(
chat_id,
@@ -2844,8 +2656,8 @@ class AndroidAdapter(BasePlatformAdapter):
async def on_fcm_register(self, frame: protocol.Frame, device_id: str) -> None:
"""Update the device's push tokens (FCM rotation / ntfy topic).
Persists to the device registry AND refreshes the live connection so
the next push targets the current token without a stale read.
Persists to the device registry so the next push targets the current
token without a stale read.
"""
fcm_token = frame.payload.get("fcm_token")
ntfy_topic = frame.payload.get("ntfy_topic")
@@ -2858,12 +2670,6 @@ class AndroidAdapter(BasePlatformAdapter):
except Exception:
logger.warning("android: fcm.register update failed", exc_info=True)
return
conn = self._ws_server.connection(device_id)
if conn is not None:
if fcm_token is not None:
conn.fcm_token = fcm_token
if ntfy_topic is not None:
conn.ntfy_topic = ntfy_topic
logger.info("android: push tokens updated for %s", device_id)
# ── M5: approval / clarify banners ────────────────────────────────────
@@ -3107,7 +2913,7 @@ def register(ctx):
validate_config=validate_config,
is_connected=is_connected,
required_env=["ANDROID_TOKEN"],
install_hint="No extra packages needed (websockets + httpx are core deps)",
install_hint="No extra packages needed (httpx is a core dep)",
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