HTTP transport: drop WS server, offline send queue + dead-stream watchdog
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:
1 parent
2349a95dd4
commit
e6015033b6
22 files changed
+2804
-2439
No files matched your search
+78
-272
@@ -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
|
||||
|
||||
Reference in new issue
Block a user