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
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user