feat(#724): full gateway-file support — attach MEDIA: files (images, audio, video, docs) (#725)

* fix(#724): inline host-path MEDIA: images on mobile (client-side)

The previous approach re-fetched the REST session-messages endpoint, but that
endpoint returns the raw MEDIA:<path> text (api_server._message_response does
not inline, and the transcript stores raw content). The desktop app instead
reads the file locally via readFileDataUrl because it is co-located with the
host. Mirror that on mobile: on assistant message.complete/done, inline any
MEDIA:<path> image into a base64 data:image/... markdown tag the renderer can
show. Mobile-only, backend untouched. Unreachable tags are stripped (graceful
degrade). Pure inlining logic extracted to MediaInline for unit testing.

* fix: correct RegexOption name (IGNORE_CASE) in MediaInline

* test: fix byte literal types in MediaInlineTest

* fix(#724): rewrite host-path MEDIA: to gateway /api/files/download URL

Pivot from the earlier base64-inline approach to the desktop-proven,
remote-phone-working fix: rewrite MEDIA:<path> directives (delivered raw over
the WS stream) into authenticated
![image](<baseUrl>/api/files/download?path=<enc>&token=<enc>) markdown tags,
mirroring desktop media.ts mediaExternalUrl. The mobile renderer (MarkdownText)
shows http(s) ![]() via Coil, so agent-sent images now render on mobile too —
including on a remote phone (real HTTP, unlike a host-local file read).
Mobile-only, backend untouched. Pure rewrite logic in MediaUrlRewriter
(replaces MediaInline) with unit tests.

* feat(#724): full gateway-file support — attach MEDIA: files (not just images)

Pivot the MEDIA: fix from image-only URL rewriting to real attachments:
- Attachment: add gatewayUrl + AttachmentSource (LOCAL/GATEWAY) so
  gateway-hosted files are first-class and render via existing InlineAttachment.
- HostMediaExtractor (replaces MediaUrlRewriter): parse ANY absolute MEDIA:
  directive (any extension) and strip them from message text.
- ChatViewModel.attachHostMedia: build the authed /api/files/download URL via
  GatewayFileClient, classify via MediaTypes, attach as Attachment (images
  inline, every other file tappable). Idempotent.
- InlineAttachment: tappable. Gateway files fetch via GatewayFileClient and
  share via a chooser Intent (FileProvider); images load the URL in Coil.
- Hence agent-delivered images, audio, video, CSV, PDF and arbitrary files all
  work on mobile — including a remote phone. Backend untouched.

Builds on #726 (GatewayFileClient). Rebased onto main after #726 merged.

* fix(#724): resolve imports, bubble callback scope, and quoted media path regex

* feat(chat): open attachment in-place on tap (#724)

Tap opens LOCAL (content://) and GATEWAY (fetch->FileProvider) files via
ACTION_VIEW; failures surface as a non-blocking snackbar via openError.

* fix: remove redundant out-of-order io.mockk.* wildcard import (ktlint CI)

ktlint import-ordering requires lexicographic order; the trailing wildcard
'import io.mockk.*' sorts before 'io.mockk.coEvery' and was redundant — every
mockk symbol is already explicitly imported, and the single mockkConstructor
call uses a fully-qualified io.mockk. prefix (needs no import).

Fixes ktlint failure on PR #725.

* fix(ci): mock android.jar Intent stub so openAttachment GATEWAY test passes

android.jar unit-test stubs Intent.setDataAndType/addFlags to throw
'... not mocked'. openWithView builds an Intent and calls them, so
openBytes' runCatching swallowed the exception and startActivity never
fired, failing the test. Mock the Intent constructor + setters and
capture the constructed instance to assert the ACTION_VIEW contract.

* fix(#724): resolve host media download URLs and deduplicate REST synced chat bubbles

* fix(#724): add FLAG_ACTIVITY_NEW_TASK for file intents and update GatewayFileClientTest

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-27 14:52:49 +03:00
1 parent 6aee02c4da
commit a1155e8015
14 files changed
+632 -35

No files matched your search

@@ -25,9 +25,15 @@ class GatewayFileClientTest {
}
@Test
fun `buildDownloadUrl rejects blank base or token`() {
fun `buildDownloadUrl rejects blank base`() {
assertNull(GatewayFileClient.buildDownloadUrl("", tok, "/tmp/x.png"))
assertNull(GatewayFileClient.buildDownloadUrl(base, "", "/tmp/x.png"))
}
@Test
fun `buildDownloadUrl permits blank token for cookie auth`() {
val url = GatewayFileClient.buildDownloadUrl(base, "", "/tmp/x.png")!!
assertEquals("$base/api/files/download?path=%2Ftmp%2Fx.png", url)
assertFalse(url.contains("token="))
}
@Test
@@ -2,18 +2,27 @@ package com.m57.hermescontrol.ui.chat
import android.app.Application
import android.content.ContentResolver
import android.content.Intent
import android.net.Uri
import android.util.Log
import androidx.core.content.FileProvider
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.HermesDatabase
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.AttachmentSource
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.GatewayFile
import com.m57.hermescontrol.data.remote.GatewayFileClient
import com.m57.hermescontrol.data.remote.GatewayFileResult
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.JsonRpcError
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository
import io.mockk.*
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
@@ -1946,4 +1955,83 @@ class ChatViewModelTest {
assertEquals(1, messages.size)
assertEquals("{\"status\":\"ok\"}", messages[0].content)
}
// ── Attachment open (issue #724) ─────────────────────────────────────
@Test
fun `openAttachment GATEWAY success fires ACTION_VIEW with FileProvider uri`() =
runTest {
val cacheDir =
java.io.File(System.getProperty("java.io.tmpdir"), "hermes_open_test_${System.nanoTime()}")
cacheDir.mkdirs()
every { app.cacheDir } returns cacheDir
mockkObject(GatewayFileClient)
val bytes = "hello".toByteArray()
coEvery {
GatewayFileClient.fetch(any())
} returns GatewayFileResult.Success(GatewayFile("note.txt", "text/plain", bytes))
val intentSlot = slot<Intent>()
// android.jar stubs Intent ctor/setters to throw "not mocked" in unit tests;
// mock the Intent constructor so openWithView can build + deliver it,
// and capture the constructed instance to assert on its setters.
mockkConstructor(Intent::class)
every { anyConstructed<Intent>().setDataAndType(any(), any()) } answers { self as Intent }
every { anyConstructed<Intent>().addFlags(any()) } answers { self as Intent }
mockkStatic(FileProvider::class)
every {
FileProvider.getUriForFile(any(), any(), any())
} returns mockk(relaxed = true)
every { app.getApplicationContext() } returns app
every { app.applicationContext } returns app
every { app.startActivity(capture(intentSlot)) } returns Unit
val vm = createViewModel()
val attachment =
Attachment(
uri = "unused",
name = "note.txt",
mimeType = "text/plain",
gatewayUrl = "https://gw/api/files/download?path=%2Ftmp%2Fnote.txt&token=t",
source = AttachmentSource.GATEWAY,
)
vm.openAttachment(attachment)
advanceUntilIdle()
// fetch was invoked (proves we entered the IO launch)
coVerify { GatewayFileClient.fetch(any()) }
// ACTION_VIEW intent delivered, with the right type + grant flag.
verify { app.startActivity(any()) }
verify { intentSlot.captured.setDataAndType(any(), eq("text/plain")) }
verify { intentSlot.captured.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) }
assertNull(vm.uiState.value.openError)
cacheDir.deleteRecursively()
}
@Test
fun `openAttachment GATEWAY not-found surfaces openError`() =
runTest {
mockkObject(GatewayFileClient)
coEvery {
GatewayFileClient.fetch(any())
} returns GatewayFileResult.NotFound
val vm = createViewModel()
val attachment =
Attachment(
uri = "unused",
name = "missing.pdf",
mimeType = "application/pdf",
gatewayUrl = "https://gw/api/files/download?path=%2Ftmp%2Fmissing.pdf&token=t",
source = AttachmentSource.GATEWAY,
)
vm.openAttachment(attachment)
advanceUntilIdle()
assertNotNull(vm.uiState.value.openError)
assertTrue(vm.uiState.value.openError!!.contains("missing.pdf"))
}
}
@@ -0,0 +1,56 @@
package com.m57.hermescontrol.ui.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class HostMediaExtractorTest {
@Test
fun `extracts single image path`() {
val items = HostMediaExtractor.extract("Here: MEDIA:/tmp/foo.png")
assertEquals(1, items.size)
assertEquals("/tmp/foo.png", items[0].path)
assertEquals("MEDIA:/tmp/foo.png", items[0].match)
}
@Test
fun `extracts multiple directives of any type`() {
val text = "MEDIA:/a/report.pdf and MEDIA:/b/clip.mp3 and MEDIA:/c/x.png"
val items = HostMediaExtractor.extract(text)
assertEquals(3, items.size)
assertTrue(items.any { it.path == "/a/report.pdf" })
assertTrue(items.any { it.path == "/b/clip.mp3" })
assertTrue(items.any { it.path == "/c/x.png" })
}
@Test
fun `returns empty when no MEDIA directive`() {
assertEquals(0, HostMediaExtractor.extract("just text").size)
}
@Test
fun `ignores relative paths`() {
assertEquals(0, HostMediaExtractor.extract("MEDIA:relative/path.png").size)
}
@Test
fun `strips directives from text`() {
val out = HostMediaExtractor.strip("See MEDIA:/tmp/a.png for details")
assertEquals("See for details", out)
assertFalse(out.contains("MEDIA:"))
}
@Test
fun `strips multiple directives`() {
val out = HostMediaExtractor.strip("MEDIA:/a/x.pdf MEDIA:/b/y.mp3 end")
assertEquals("end", out)
}
@Test
fun `handles quoted paths`() {
val items = HostMediaExtractor.extract("MEDIA:\"/tmp/a b.png\"")
assertEquals(1, items.size)
assertEquals("/tmp/a b.png", items[0].path)
}
}