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:
ARIA committed 2026-08-19 17:29:39 +02:00
1 parent 60296b33fe
commit 913ee91024
28 files changed
+2234 -53

No files matched your search

@@ -0,0 +1,16 @@
package iris.media
import java.io.File
actual class FileSource actual constructor(path: String) : AutoCloseable {
private val file = File(path)
private val input = file.inputStream()
actual fun size(): Long = file.length()
actual fun read(buf: ByteArray): Int = input.read(buf)
override fun close() {
input.close()
}
}
@@ -0,0 +1,59 @@
package iris.media
import java.io.File
import java.io.FileOutputStream
actual interface MediaWriter : AutoCloseable {
actual val path: String
actual fun write(bytes: ByteArray)
}
actual class MediaCache actual constructor(baseDir: String) {
private val maxBytes: Long = 500L * 1024 * 1024
private val mediaDir: File = File(baseDir, "media").apply { mkdirs() }
actual fun path(mediaId: String, mime: String): String? {
val f = File(mediaDir, "$mediaId${extForMime(mime)}")
return if (f.exists()) f.absolutePath else null
}
actual fun openWriter(mediaId: String, mime: String): MediaWriter =
JvmMediaWriter(File(mediaDir, "$mediaId${extForMime(mime)}"), this)
actual fun remove(mediaId: String, mime: String) {
path(mediaId, mime)?.let { File(it).delete() }
}
internal fun evict() {
val files = mediaDir.listFiles()?.filter { !it.name.endsWith(".part") } ?: return
var total = files.sumOf { it.length() }
if (total <= maxBytes) return
files.sortedBy { it.lastModified() }.forEach { f ->
if (total <= maxBytes) return
total -= f.length()
f.delete()
}
}
}
private class JvmMediaWriter(private val target: File, private val cache: MediaCache) : MediaWriter {
private val out: FileOutputStream = FileOutputStream(File(target.parentFile, "${target.name}.part"))
override val path: String get() = target.absolutePath
override fun write(bytes: ByteArray) {
out.write(bytes)
}
override fun close() {
out.close()
val part = File(target.parentFile, "${target.name}.part")
if (target.exists()) target.delete()
if (!part.renameTo(target)) {
// Rare cross-device rename failure: fall back to copy.
part.copyTo(target, overwrite = true)
part.delete()
}
cache.evict()
}
}
@@ -0,0 +1,18 @@
package iris.media
import java.security.MessageDigest
actual class Sha256 actual constructor() {
private val digest = MessageDigest.getInstance("SHA-256")
actual fun update(bytes: ByteArray, offset: Int, length: Int) {
digest.update(bytes, offset, length)
}
actual fun hex(): String {
val bytes = digest.digest()
return buildString(bytes.size * 2) {
for (b in bytes) append("%02x".format(b))
}
}
}