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