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
+440
-8
@@ -2,15 +2,447 @@
|
||||
|
||||
Inbound: ``media.upload`` (chunked binary frames) -> ``cache_*_from_bytes``
|
||||
-> a ``media_ref`` the adapter attaches to the ``MessageEvent``. Enforces
|
||||
size limit + sha256 + MIME re-sniff.
|
||||
size limit + sha256 + MIME re-sniff (the client's declared kind is not
|
||||
trusted). Chunks are streamed to a temp file so RAM stays bounded during
|
||||
the transfer; the file is read once at the end for the hermes cache
|
||||
helpers.
|
||||
|
||||
Outbound: ``send_*`` -> stage the file in the media cache, mint a
|
||||
``media_id``, emit ``media.offer {media_id, mime, size, filename, kind}``;
|
||||
serve bytes on ``media.pull`` as chunked binary frames. Delivery-path
|
||||
security via ``validate_media_delivery_path``.
|
||||
Outbound: ``send_*`` -> register the (already delivery-validated) file in
|
||||
the media registry, mint a ``media_id``, emit ``media.offer {media_id,
|
||||
kind, mime, size, filename}``; serve bytes on ``media.pull`` as chunked
|
||||
binary frames. Delivery-path security via ``validate_media_delivery_path``
|
||||
(re-checked at pull time, so only allowed files are servable).
|
||||
|
||||
Reuses hermes ``cache_image/audio/video/document_from_bytes`` where possible.
|
||||
All paths under ``get_hermes_home()/"android"/media``.
|
||||
Reuses hermes ``cache_image/audio/video/document_from_bytes`` + the
|
||||
``_looks_like_image`` / ``sniff_container`` magic-byte sniffers. Temp files
|
||||
live under ``get_hermes_home()/"android"/media/tmp``.
|
||||
|
||||
Milestone M4.
|
||||
"""
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
from gateway.platforms.base import (
|
||||
_looks_like_image,
|
||||
cache_audio_from_bytes,
|
||||
cache_document_from_bytes,
|
||||
cache_image_from_bytes,
|
||||
cache_video_from_bytes,
|
||||
)
|
||||
from tools.audio_container import sniff_container
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# One WS binary frame carries at most this many media bytes (docs/07 §7.5).
|
||||
DEFAULT_CHUNK_BYTES = 256 * 1024
|
||||
|
||||
KINDS = ("image", "audio", "video", "document", "voice")
|
||||
|
||||
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
# Magic-byte containers that are unambiguously audio (vs video-in-same-box).
|
||||
_AUDIO_CONTAINERS = {"m4a", "ogg", "flac", "wav", "mp3", "aac"}
|
||||
_VIDEO_CONTAINERS = {"mp4", "webm"}
|
||||
|
||||
# Extension -> MIME for outbound offers (the app picks a player/viewer from it).
|
||||
_EXT_TO_MIME: Dict[str, str] = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
".bmp": "image/bmp",
|
||||
".heic": "image/heic",
|
||||
".mp4": "video/mp4",
|
||||
".mov": "video/quicktime",
|
||||
".webm": "video/webm",
|
||||
".mkv": "video/x-matroska",
|
||||
".avi": "video/x-msvideo",
|
||||
".3gp": "video/3gpp",
|
||||
".ogg": "audio/ogg",
|
||||
".opus": "audio/ogg",
|
||||
".m4a": "audio/mp4",
|
||||
".mp3": "audio/mpeg",
|
||||
".wav": "audio/wav",
|
||||
".flac": "audio/flac",
|
||||
".aac": "audio/aac",
|
||||
".pdf": "application/pdf",
|
||||
".txt": "text/plain",
|
||||
".md": "text/markdown",
|
||||
".csv": "text/csv",
|
||||
".html": "text/html",
|
||||
".json": "application/json",
|
||||
".zip": "application/zip",
|
||||
".doc": "application/msword",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".xls": "application/vnd.ms-excel",
|
||||
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
".ppt": "application/vnd.ms-powerpoint",
|
||||
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
}
|
||||
|
||||
# MIME -> extension for inbound caching (the cache helpers take an ext).
|
||||
_MIME_TO_EXT: Dict[str, str] = {
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
"image/gif": ".gif",
|
||||
"image/bmp": ".bmp",
|
||||
"image/heic": ".heic",
|
||||
"video/mp4": ".mp4",
|
||||
"video/quicktime": ".mov",
|
||||
"video/webm": ".webm",
|
||||
"video/x-matroska": ".mkv",
|
||||
"video/x-msvideo": ".avi",
|
||||
"audio/ogg": ".ogg",
|
||||
"audio/mp4": ".m4a",
|
||||
"audio/mpeg": ".mp3",
|
||||
"audio/wav": ".wav",
|
||||
"audio/flac": ".flac",
|
||||
"audio/aac": ".aac",
|
||||
}
|
||||
|
||||
|
||||
class MediaError(Exception):
|
||||
"""Media flow failure carrying a wire ``error`` code."""
|
||||
|
||||
def __init__(self, code: str, message: str):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
|
||||
def mime_for_path(path: str) -> str:
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
return _EXT_TO_MIME.get(ext, "application/octet-stream")
|
||||
|
||||
|
||||
def ext_for_mime(mime: str, filename: str, default: str) -> str:
|
||||
"""Best-effort extension for the hermes cache helpers.
|
||||
|
||||
Prefers the MIME map, falls back to the filename's own extension, then
|
||||
*default*.
|
||||
"""
|
||||
base = (mime or "").split(";")[0].strip().lower()
|
||||
ext = _MIME_TO_EXT.get(base)
|
||||
if ext:
|
||||
return ext
|
||||
file_ext = os.path.splitext(filename or "")[1].lower()
|
||||
if file_ext and len(file_ext) <= 6:
|
||||
return file_ext
|
||||
return default
|
||||
|
||||
|
||||
def reclassify_kind(head: bytes, declared_kind: str) -> str:
|
||||
"""Re-sniff the real kind from magic bytes (don't trust the client).
|
||||
|
||||
Images are detected first (RIFF/WEBP would otherwise look like a
|
||||
container); then AV containers. Video declared by the client is kept
|
||||
when no magic matches (some codecs have no leading magic we check);
|
||||
anything else falls back to ``document``.
|
||||
"""
|
||||
if _looks_like_image(head):
|
||||
return "image"
|
||||
container = sniff_container(head)
|
||||
if container in _AUDIO_CONTAINERS:
|
||||
return "voice" if declared_kind == "voice" else "audio"
|
||||
if container in _VIDEO_CONTAINERS:
|
||||
return "video"
|
||||
return declared_kind if declared_kind == "video" else "document"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MediaEntry:
|
||||
"""One servable/cached media object (inbound ref or outbound offer)."""
|
||||
|
||||
media_id: str # mu_* (inbound ref, app-chosen) or md_* (outbound, minted)
|
||||
kind: str
|
||||
mime: str
|
||||
size: int
|
||||
filename: str
|
||||
path: str # absolute local path
|
||||
ts: float = 0.0
|
||||
|
||||
|
||||
class UploadSession:
|
||||
"""One in-flight ``media.upload`` (per connection; one at a time).
|
||||
|
||||
Chunks are appended to a temp file (bounded RAM); the sha256 is
|
||||
accumulated incrementally. The declared size is enforced as bytes
|
||||
arrive so an over-limit transfer is rejected early.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
media_ref: str,
|
||||
kind: str,
|
||||
mime: str,
|
||||
filename: str,
|
||||
declared_size: int,
|
||||
request_id: Optional[int],
|
||||
max_bytes: int,
|
||||
tmp_dir: Path,
|
||||
):
|
||||
self.media_ref = media_ref
|
||||
self.kind = kind
|
||||
self.mime = mime
|
||||
self.filename = filename
|
||||
self.declared_size = declared_size
|
||||
self.request_id = request_id
|
||||
self.max_bytes = max_bytes
|
||||
self.received = 0
|
||||
self.failed = False
|
||||
self.error_code = ""
|
||||
self.error_message = ""
|
||||
self._sha = hashlib.sha256()
|
||||
fd, self.tmp_path = tempfile.mkstemp(prefix="upl_", dir=str(tmp_dir))
|
||||
self._fh = os.fdopen(fd, "wb")
|
||||
|
||||
def feed(self, chunk: bytes) -> None:
|
||||
if self.failed:
|
||||
return
|
||||
self.received += len(chunk)
|
||||
if self.max_bytes and self.received > self.max_bytes:
|
||||
self._fail(
|
||||
"media_too_large",
|
||||
f"upload exceeds limit ({self.received} > {self.max_bytes} bytes)",
|
||||
)
|
||||
return
|
||||
try:
|
||||
self._fh.write(chunk)
|
||||
self._fh.flush()
|
||||
self._sha.update(chunk)
|
||||
except OSError as e:
|
||||
self._fail("internal", f"temp file write failed: {e}")
|
||||
|
||||
def _fail(self, code: str, message: str) -> None:
|
||||
self.failed = True
|
||||
self.error_code = code
|
||||
self.error_message = message
|
||||
logger.warning("android: upload %s failed: %s", self.media_ref, message)
|
||||
|
||||
def digest(self) -> str:
|
||||
return self._sha.hexdigest()
|
||||
|
||||
def read_bytes(self) -> bytes:
|
||||
self._fh.close()
|
||||
with open(self.tmp_path, "rb") as f:
|
||||
return f.read()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Discard the session and remove the temp file."""
|
||||
try:
|
||||
self._fh.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
os.unlink(self.tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
class MediaStore:
|
||||
"""In-memory media registry + upload session tracking.
|
||||
|
||||
Inbound refs (``mu_*``, app-chosen) are short-lived: created on
|
||||
``media.upload.end``, consumed by the next ``message.send``. Outbound
|
||||
offers (``md_*``, minted here) live until pulled (or process restart —
|
||||
the outbox replays the offer frame, but the id->path map is in-memory
|
||||
for M4).
|
||||
"""
|
||||
|
||||
def __init__(self, hermes_home: Path):
|
||||
self._tmp_dir = hermes_home / "android" / "media" / "tmp"
|
||||
self._tmp_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = threading.Lock()
|
||||
# (device_id, media_ref) -> UploadSession (one active per device)
|
||||
self._uploads: Dict[Tuple[str, str], UploadSession] = {}
|
||||
self._inbound: Dict[str, MediaEntry] = {}
|
||||
self._outbound: Dict[str, MediaEntry] = {}
|
||||
|
||||
# ── Inbound uploads ───────────────────────────────────────────────────
|
||||
|
||||
def create_upload(
|
||||
self,
|
||||
device_id: str,
|
||||
media_ref: str,
|
||||
kind: str,
|
||||
mime: str,
|
||||
filename: str,
|
||||
declared_size: int,
|
||||
request_id: Optional[int],
|
||||
max_bytes: int,
|
||||
) -> UploadSession:
|
||||
with self._lock:
|
||||
for (dev, _ref), sess in self._uploads.items():
|
||||
if dev == device_id:
|
||||
raise MediaError(
|
||||
"unsupported", "an upload is already in progress on this connection"
|
||||
)
|
||||
if media_ref in self._inbound:
|
||||
raise MediaError("unsupported", f"media_ref {media_ref} already used")
|
||||
sess = UploadSession(
|
||||
media_ref, kind, mime, filename, declared_size, request_id,
|
||||
max_bytes, self._tmp_dir,
|
||||
)
|
||||
self._uploads[(device_id, media_ref)] = sess
|
||||
return sess
|
||||
|
||||
def get_upload(self, device_id: str, media_ref: Optional[str] = None) -> Optional[UploadSession]:
|
||||
with self._lock:
|
||||
if media_ref is not None:
|
||||
return self._uploads.get((device_id, media_ref))
|
||||
for (dev, _ref), sess in self._uploads.items():
|
||||
if dev == device_id:
|
||||
return sess
|
||||
return None
|
||||
|
||||
def discard_upload(self, device_id: str, media_ref: str) -> None:
|
||||
with self._lock:
|
||||
sess = self._uploads.pop((device_id, media_ref), None)
|
||||
if sess is not None:
|
||||
sess.close()
|
||||
|
||||
def complete_upload(self, device_id: str, media_ref: str, sha256_hex: str) -> MediaEntry:
|
||||
"""Verify + cache a finished upload. Raises ``MediaError`` on failure."""
|
||||
with self._lock:
|
||||
sess = self._uploads.pop((device_id, media_ref), None)
|
||||
if sess is None:
|
||||
raise MediaError("not_found", f"no active upload for media_ref {media_ref}")
|
||||
try:
|
||||
if sess.failed:
|
||||
raise MediaError(sess.error_code, sess.error_message)
|
||||
if sess.received != sess.declared_size:
|
||||
raise MediaError(
|
||||
"internal",
|
||||
f"size mismatch (declared {sess.declared_size}, received {sess.received})",
|
||||
)
|
||||
if not _SHA256_RE.match(sha256_hex or ""):
|
||||
raise MediaError("unsupported", "sha256 must be 64 hex chars")
|
||||
if sess.digest() != sha256_hex:
|
||||
raise MediaError("internal", "sha256 mismatch")
|
||||
|
||||
data = sess.read_bytes()
|
||||
kind = reclassify_kind(data[:65536], sess.kind)
|
||||
try:
|
||||
if kind == "image":
|
||||
path = cache_image_from_bytes(
|
||||
data, ext_for_mime(sess.mime, sess.filename, ".jpg")
|
||||
)
|
||||
elif kind in ("audio", "voice"):
|
||||
path = cache_audio_from_bytes(
|
||||
data, ext_for_mime(sess.mime, sess.filename, ".ogg")
|
||||
)
|
||||
elif kind == "video":
|
||||
path = cache_video_from_bytes(
|
||||
data, ext_for_mime(sess.mime, sess.filename, ".mp4")
|
||||
)
|
||||
else:
|
||||
path = cache_document_from_bytes(data, sess.filename or "document")
|
||||
except ValueError as e:
|
||||
# hermes cap (gateway.max_inbound_media_bytes) or a
|
||||
# non-image payload masquerading as an image.
|
||||
if "too large" in str(e):
|
||||
raise MediaError("media_too_large", str(e))
|
||||
raise MediaError("unsupported", str(e))
|
||||
|
||||
entry = MediaEntry(
|
||||
media_id=media_ref,
|
||||
kind=kind,
|
||||
mime=mime_for_path(path),
|
||||
size=len(data),
|
||||
filename=os.path.basename(path),
|
||||
path=path,
|
||||
ts=time.time(),
|
||||
)
|
||||
with self._lock:
|
||||
self._inbound[media_ref] = entry
|
||||
logger.info(
|
||||
"android: upload %s cached as %s (%s, %d bytes)",
|
||||
media_ref, kind, path, len(data),
|
||||
)
|
||||
return entry
|
||||
finally:
|
||||
sess.close()
|
||||
|
||||
def get_inbound(self, media_ref: str) -> Optional[MediaEntry]:
|
||||
with self._lock:
|
||||
return self._inbound.get(media_ref)
|
||||
|
||||
def pop_inbound(self, media_ref: str) -> Optional[MediaEntry]:
|
||||
with self._lock:
|
||||
return self._inbound.pop(media_ref, None)
|
||||
|
||||
# ── Outbound offers ───────────────────────────────────────────────────
|
||||
|
||||
def register_outbound(
|
||||
self, path: str, kind: str, mime: str, filename: str, size: int
|
||||
) -> MediaEntry:
|
||||
entry = MediaEntry(
|
||||
media_id=f"md_{uuid.uuid4().hex[:12]}",
|
||||
kind=kind,
|
||||
mime=mime,
|
||||
size=size,
|
||||
filename=filename or os.path.basename(path),
|
||||
path=path,
|
||||
ts=time.time(),
|
||||
)
|
||||
with self._lock:
|
||||
self._outbound[entry.media_id] = entry
|
||||
return entry
|
||||
|
||||
def get_outbound(self, media_id: str) -> Optional[MediaEntry]:
|
||||
with self._lock:
|
||||
return self._outbound.get(media_id)
|
||||
|
||||
# ── Cleanup ───────────────────────────────────────────────────────────
|
||||
|
||||
def discard_device(self, device_id: str) -> None:
|
||||
"""Drop all in-flight uploads for a disconnected device."""
|
||||
with self._lock:
|
||||
stale = [k for k in self._uploads if k[0] == device_id]
|
||||
sessions = [self._uploads.pop(k) for k in stale]
|
||||
for sess in sessions:
|
||||
sess.close()
|
||||
|
||||
def prune_outbound(self, max_age_s: float = 24 * 3600) -> int:
|
||||
"""Drop offers older than *max_age_s* (their files stay in the hermes
|
||||
cache; only the id->path map is pruned)."""
|
||||
now = time.time()
|
||||
with self._lock:
|
||||
stale = [k for k, e in self._outbound.items() if now - e.ts > max_age_s]
|
||||
for k in stale:
|
||||
del self._outbound[k]
|
||||
return len(stale)
|
||||
|
||||
|
||||
async def stream_file(
|
||||
ws, path: str, chunk_bytes: int = DEFAULT_CHUNK_BYTES, timeout: float = 10.0
|
||||
) -> int:
|
||||
"""Stream *path* to *ws* as binary frames. Returns bytes sent.
|
||||
|
||||
Ordering is guaranteed by the WebSocket; the caller sends the terminal
|
||||
``media.pull.end`` frame afterwards. Each chunk send is bounded by
|
||||
*timeout* so a stalled puller can't wedge the handler forever (the
|
||||
caller treats the raised error as an aborted pull).
|
||||
"""
|
||||
sent = 0
|
||||
with open(path, "rb") as f:
|
||||
while True:
|
||||
chunk = f.read(chunk_bytes)
|
||||
if not chunk:
|
||||
break
|
||||
await asyncio.wait_for(ws.send(chunk), timeout=timeout)
|
||||
sent += len(chunk)
|
||||
return sent
|
||||
Reference in new issue
Block a user