M4: media upload/download/playback (both directions)
Gateway plugin: - media.upload (chunked binary) -> size/sha256 verify + MIME re-sniff -> cache_*_from_bytes -> media.upload.ack - media.offer / media.pull (chunked) for agent-sent media, delivery-path security re-checked at pull time - send_* overrides mint media_id and emit media.offer - message.send media_refs resolve to cached inbound media - per-send + per-chunk timeouts so a stalled peer can't starve the rest App (Kotlin CMP): - Protocol: media frame types/payloads/builders - GatewayClient: binary session, uploadMedia (chunked + streaming sha256), pullMedia serialized via Mutex so concurrent offers don't interleave - ChatStore/IrisController: MediaItem, attachments, auto-pull on offer - Platform media: SAF picker, ExoPlayer (audio mini-player + video), image loader, FileProvider document open (Android); AWT-free desktop actuals - ChatScreen: attach button + chips, media rendering, keyboard dismiss on send UI polish: - preserve image aspect ratio (no stretching), cap dominant dimension - adjustResize so only chat content squeezes for the keyboard - clear focus (hide keyboard) on send Docs: media.upload.ack in 04-wire-protocol.md + frames.schema.json + 07-media.md; M4 marked complete in 14-milestones.md. Tests: 17-test tests/gateway/test_android.py suite passes.
This commit is contained in:
1 parent
60296b33fe
commit
913ee91024
28 files changed
+2234
-53
No files matched your search
@@ -39,6 +39,11 @@ logger = logging.getLogger(__name__)
|
||||
# How long a new socket may take to present its ``hello`` before we drop it.
|
||||
HELLO_TIMEOUT_S = 10.0
|
||||
|
||||
# Max time a single outbound send may block on a peer's full write buffer
|
||||
# before we give up on that peer (so one stalled client can't starve the
|
||||
# rest of the broadcast). The peer's own ping timeout reaps it afterwards.
|
||||
SEND_TIMEOUT_S = 10.0
|
||||
|
||||
# Close codes (4000-4999 are reserved for applications).
|
||||
CLOSE_AUTH_FAILED = 4401
|
||||
CLOSE_REPLACED = 4402
|
||||
@@ -138,16 +143,20 @@ class WsServer:
|
||||
def device_ids(self) -> list:
|
||||
return list(self._connections.keys())
|
||||
|
||||
def connection(self, device_id: str) -> Optional[DeviceConnection]:
|
||||
return self._connections.get(device_id)
|
||||
|
||||
# ── Outbound ──────────────────────────────────────────────────────────
|
||||
|
||||
async def broadcast(self, frame: protocol.Frame) -> int:
|
||||
"""Send a frame to every connected device. Returns devices reached.
|
||||
Best-effort: a dead socket is skipped (deregistered on its own close)."""
|
||||
Best-effort: a dead or stalled socket is skipped (deregistered on its
|
||||
own close) so one slow peer can't starve the others."""
|
||||
data = frame.to_json()
|
||||
sent = 0
|
||||
for conn in list(self._connections.values()):
|
||||
try:
|
||||
await conn.ws.send(data)
|
||||
await asyncio.wait_for(conn.ws.send(data), timeout=SEND_TIMEOUT_S)
|
||||
sent += 1
|
||||
except Exception:
|
||||
pass
|
||||
@@ -159,7 +168,7 @@ class WsServer:
|
||||
if conn is None:
|
||||
return False
|
||||
try:
|
||||
await conn.ws.send(frame.to_json())
|
||||
await asyncio.wait_for(conn.ws.send(frame.to_json()), timeout=SEND_TIMEOUT_S)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
@@ -262,14 +271,25 @@ class WsServer:
|
||||
current = self._connections.get(device_id)
|
||||
if current is not None and current.ws is ws:
|
||||
self._connections.pop(device_id, None)
|
||||
# M4: drop in-flight upload temp files for this socket.
|
||||
try:
|
||||
self._adapter.on_connection_closed(device_id)
|
||||
except Exception:
|
||||
logger.warning("android: connection cleanup failed for %s", device_id, exc_info=True)
|
||||
logger.info("android: device disconnected: %s", device_id)
|
||||
|
||||
# ── Inbound dispatch ──────────────────────────────────────────────────
|
||||
|
||||
async def _on_frame(self, ws: ServerConnection, device_id: str, raw: Any) -> None:
|
||||
# M4: binary frames are media upload chunks (raw bytes, no JSON
|
||||
# envelope). Route them to the active upload session.
|
||||
if isinstance(raw, (bytes, bytearray, memoryview)):
|
||||
await self._adapter.on_media_chunk(device_id, bytes(raw))
|
||||
return
|
||||
|
||||
frame = protocol.Frame.from_json(raw)
|
||||
if frame is None:
|
||||
return # malformed / unknown binary: ignore (forward-compat)
|
||||
return # malformed JSON: ignore (forward-compat)
|
||||
|
||||
if frame.type == protocol.TYPE_PING:
|
||||
ts = frame.payload.get("ts")
|
||||
@@ -290,6 +310,12 @@ class WsServer:
|
||||
await self._adapter.on_search(frame, device_id)
|
||||
elif frame.type == protocol.TYPE_SYNC:
|
||||
await self._adapter.on_sync(frame, device_id)
|
||||
elif frame.type == protocol.TYPE_MEDIA_UPLOAD_START:
|
||||
await self._adapter.on_media_upload_start(frame, device_id)
|
||||
elif frame.type == protocol.TYPE_MEDIA_UPLOAD_END:
|
||||
await self._adapter.on_media_upload_end(frame, device_id)
|
||||
elif frame.type == protocol.TYPE_MEDIA_PULL:
|
||||
await self._adapter.on_media_pull(frame, device_id)
|
||||
elif frame.type == "fcm.register":
|
||||
fcm_token = frame.payload.get("fcm_token")
|
||||
ntfy_topic = frame.payload.get("ntfy_topic")
|
||||
|
||||
Reference in new issue
Block a user