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

@@ -1,28 +1,40 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
/**
* Represents a file attached to a chat message.
*
* @param uri Content URI or file path to the attachment
* @param uri Content URI or file path to the attachment. For gateway-sourced
* attachments this is the authenticated `/api/files/download?...` URL, which
* Coil can load directly for images.
* @param name Display name of the file
* @param mimeType MIME type (e.g., "image/jpeg", "application/pdf")
* @param size File size in bytes
* @param localPath Optional local file system path for direct access
* @param size File size in bytes (0 when unknown, e.g. gateway-hosted files)
* @param gatewayUrl When non-null, the authenticated gateway download URL for a
* file that lives on the *gateway host* (agent-delivered `MEDIA:` directive).
* Null for locally-picked attachments.
* @param source Where the attachment originated.
*/
@Serializable
data class Attachment(
val uri: String,
val name: String,
val mimeType: String,
val size: Long,
val size: Long = 0,
val gatewayUrl: String? = null,
val source: AttachmentSource = AttachmentSource.LOCAL,
) {
val isImage: Boolean
get() = mimeType.startsWith("image/")
val isGateway: Boolean
get() = source == AttachmentSource.GATEWAY
val formattedSize: String
get() =
when {
size <= 0 -> "—"
size < 1024 -> "$size B"
size < 1024 * 1024 -> "${size / 1024} KB"
else -> "%.1f MB".format(size.toDouble() / (1024 * 1024))
@@ -31,3 +43,13 @@ data class Attachment(
val fileExtension: String
get() = name.substringAfterLast('.', "").lowercase()
}
/** Origin of an [Attachment]. */
@Serializable
enum class AttachmentSource {
/** Picked by the user on this device (content URI / local path). */
LOCAL,
/** Delivered by the agent via a gateway-hosted `MEDIA:` directive. */
GATEWAY,
}