diff --git a/app/src/main/java/com/m57/hermescontrol/HermesControlApp.kt b/app/src/main/java/com/m57/hermescontrol/HermesControlApp.kt index 0bd00987..c451fd3d 100644 --- a/app/src/main/java/com/m57/hermescontrol/HermesControlApp.kt +++ b/app/src/main/java/com/m57/hermescontrol/HermesControlApp.kt @@ -1,11 +1,16 @@ package com.m57.hermescontrol import android.app.Application +import coil.ImageLoader +import coil.ImageLoaderFactory import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.remote.NetworkMonitor +import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.ui.analytics.AnalyticsPreloader -class HermesControlApp : Application() { +class HermesControlApp : + Application(), + ImageLoaderFactory { override fun onCreate() { super.onCreate() AuthManager.init(this) @@ -18,4 +23,11 @@ class HermesControlApp : Application() { // cold backend). Fire-and-forget; never blocks UI startup. AnalyticsPreloader.preload(this) } + + override fun newImageLoader(): ImageLoader = + ImageLoader + .Builder(this) + .okHttpClient(OkHttpProvider.base) + .crossfade(true) + .build() } diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/Attachment.kt b/app/src/main/java/com/m57/hermescontrol/data/model/Attachment.kt index 1cc12774..001feb73 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/Attachment.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/Attachment.kt @@ -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, +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/GatewayFileClient.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/GatewayFileClient.kt index 6ebb27a4..4129370b 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/GatewayFileClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/GatewayFileClient.kt @@ -41,11 +41,15 @@ object GatewayFileClient { path: String, ): String? { val trimmedBase = baseUrl.trimEnd('/') - if (trimmedBase.isBlank() || token.isBlank()) return null + if (trimmedBase.isBlank()) return null val norm = normalizePath(path) ?: return null val encPath = URLEncoder.encode(norm, StandardCharsets.UTF_8.name()).replace("+", "%20") - val encToken = URLEncoder.encode(token, StandardCharsets.UTF_8.name()).replace("+", "%20") - return "$trimmedBase$DOWNLOAD_PATH?path=$encPath&token=$encToken" + return if (token.isNotBlank()) { + val encToken = URLEncoder.encode(token, StandardCharsets.UTF_8.name()).replace("+", "%20") + "$trimmedBase$DOWNLOAD_PATH?path=$encPath&token=$encToken" + } else { + "$trimmedBase$DOWNLOAD_PATH?path=$encPath" + } } /** diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt index 0608c401..b160dd78 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt @@ -114,6 +114,7 @@ fun ChatBubble( searchQuery: String = "", isCurrentMatch: Boolean = false, onRespondApproval: (String) -> Unit = {}, + onOpenAttachment: (Attachment) -> Unit = {}, modifier: Modifier = Modifier, ) { val screenWidth = LocalConfiguration.current.screenWidthDp.dp @@ -129,17 +130,25 @@ fun ChatBubble( ) { when (message.role) { MessageRole.USER -> { - UserBubble(message, maxBubbleWidth, searchQuery, isCurrentMatch, modifier) + UserBubble( + message = message, + maxWidth = maxBubbleWidth, + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + onOpenAttachment = onOpenAttachment, + modifier = modifier, + ) } MessageRole.ASSISTANT -> { AssistantBubble( - message, - maxBubbleWidth, - isDarkTheme, - searchQuery, - isCurrentMatch, - modifier, + message = message, + maxWidth = maxBubbleWidth, + isDarkTheme = isDarkTheme, + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + onOpenAttachment = onOpenAttachment, + modifier = modifier, ) } @@ -164,6 +173,7 @@ private fun UserBubble( maxWidth: androidx.compose.ui.unit.Dp, searchQuery: String = "", isCurrentMatch: Boolean = false, + onOpenAttachment: (Attachment) -> Unit = {}, modifier: Modifier = Modifier, ) { val clipboard = LocalClipboard.current @@ -263,6 +273,7 @@ private fun UserBubble( InlineAttachment( attachment = attachment, textColor = userBubbleTextColor, + onOpen = { onOpenAttachment(it) }, ) Spacer(modifier = Modifier.height(4.dp)) } @@ -325,6 +336,7 @@ private fun AssistantBubble( isDarkTheme: Boolean, searchQuery: String = "", isCurrentMatch: Boolean = false, + onOpenAttachment: (Attachment) -> Unit = {}, modifier: Modifier = Modifier, ) { val bubbleColor = if (isDarkTheme) AssistantBubble else AssistantBubbleLight @@ -414,6 +426,7 @@ private fun AssistantBubble( InlineAttachment( attachment = attachment, textColor = textColor, + onOpen = { onOpenAttachment(it) }, ) Spacer(modifier = Modifier.height(4.dp)) } @@ -2293,24 +2306,30 @@ private fun buildHighlightedString( private fun InlineAttachment( attachment: Attachment, textColor: Color, + onOpen: (Attachment) -> Unit = {}, ) { + val clickable = Modifier.clickable { onOpen(attachment) } if (attachment.isImage) { - // Image attachment — show as a rounded thumbnail + // Image attachment — show as a rounded thumbnail (Coil loads the + // gateway download URL directly when gateway-sourced). AsyncImage( model = attachment.uri, contentDescription = attachment.name, modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)), + .clip(RoundedCornerShape(12.dp)) + .then(clickable), contentScale = ContentScale.FillWidth, ) } else { - // Non-image file — show a card with file icon and name + // Non-image file — show a card with file icon and name. Tapping fetches + // the bytes (gateway-sourced) or opens the local URI. Surface( shape = RoundedCornerShape(8.dp), color = textColor.copy(alpha = 0.1f), border = BorderStroke(1.dp, textColor.copy(alpha = 0.2f)), + modifier = clickable, ) { Row( modifier = Modifier.padding(8.dp), diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 19cef023..29027dba 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -334,6 +334,7 @@ fun ChatScreen( messages = state.messages, errorMessage = state.errorMessage, backgroundCompleteMessage = state.backgroundCompleteMessage, + openError = state.openError, isSearchActive = state.isSearchActive, currentSearchMatchIndex = state.currentSearchMatchIndex, searchMatchIndices = state.searchMatchIndices, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 54adbc75..2d47ff1b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -10,10 +10,14 @@ import androidx.lifecycle.viewModelScope 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.model.ModelProvider import com.m57.hermescontrol.data.model.PinnedModel import com.m57.hermescontrol.data.model.SessionMessage 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.remote.NetworkResult import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.remote.safeApiCall @@ -66,6 +70,8 @@ data class ChatUiState( val errorMessage: String? = null, // Background job completion toast (issue #527) — non-blocking snackbar val backgroundCompleteMessage: String? = null, + // Attachment open failure — surfaced as a non-blocking snackbar (issue #724) + val openError: String? = null, val clarifyRequest: ClarifyUi? = null, // Sudo / secret prompts — surfaced as dialogs (issue #524) val sudoPrompt: SudoPromptUi? = null, @@ -415,6 +421,16 @@ class ChatViewModel( // than waiting up to 5s for the next session-sync poll. viewModelScope.launch { fetchContextUsage() } } + + is ReducerEffect.AttachHostMedia -> { + // Issue #724: turn host-path MEDIA: directives into real + // attachments (images inline, every other file tappable) + // via the gateway /api/files/download endpoint. Works on a + // remote phone too. + viewModelScope.launch(Dispatchers.IO) { + attachHostMedia(effect.sessionId, effect.messageId) + } + } } } @@ -1052,10 +1068,11 @@ class ChatViewModel( // #576). For those we fall back to slash.exec, which runs the // full COMMAND_REGISTRY through the worker. val result = - wsClient.request( - WsMethods.COMMAND_DISPATCH, - mapOf("name" to name, "arg" to arg, "session_id" to sessionId), - ).await() + wsClient + .request( + WsMethods.COMMAND_DISPATCH, + mapOf("name" to name, "arg" to arg, "session_id" to sessionId), + ).await() handleDispatchResult(result) } catch (e: HermesWsClient.HermesRpcException) { val msg = e.message.orEmpty() @@ -1068,13 +1085,14 @@ class ChatViewModel( // which routes the full CLI command set through the worker. try { val result = - wsClient.request( - WsMethods.SLASH_EXEC, - mapOf( - "command" to "/$name${if (arg.isNotEmpty()) " $arg" else ""}", - "session_id" to sessionId, - ), - ).await() + wsClient + .request( + WsMethods.SLASH_EXEC, + mapOf( + "command" to "/$name${if (arg.isNotEmpty()) " $arg" else ""}", + "session_id" to sessionId, + ), + ).await() val output = (result as? Map<*, *>)?.get("output") as? String if (!output.isNullOrBlank()) addAssistantMessage(output) } catch (e2: HermesWsClient.HermesRpcException) { @@ -1698,6 +1716,9 @@ class ChatViewModel( .filter { it.reasoningText.isNotBlank() } .associateBy { it.content } + val baseUrl = AuthManager.getBaseUrl() + val token = AuthManager.getToken().orEmpty() + return messages.mapIndexed { index, msg -> val role = when (msg.role?.lowercase()) { @@ -1714,25 +1735,249 @@ class ChatViewModel( ?.toLong() ?: System.currentTimeMillis() - val content = msg.contentText + val rawContent = msg.contentText val reasoning = if (msg.reasoningText.isNotBlank()) { msg.reasoningText } else { - existingReasoningMap[content]?.reasoningText.orEmpty() + existingReasoningMap[rawContent]?.reasoningText.orEmpty() } + var finalContent = rawContent + var attachments: List? = null + if (role == MessageRole.ASSISTANT && rawContent.contains("MEDIA:")) { + val items = HostMediaExtractor.extract(rawContent) + if (items.isNotEmpty()) { + finalContent = HostMediaExtractor.strip(rawContent) + attachments = + items + .mapNotNull { item -> + val url = + GatewayFileClient.buildDownloadUrl( + baseUrl, + token, + item.path, + ) ?: return@mapNotNull null + Attachment( + uri = url, + name = mediaNameFromPath(item.path), + mimeType = mediaMimeForPath(item.path), + size = 0, + gatewayUrl = url, + source = AttachmentSource.GATEWAY, + ) + }.takeIf { it.isNotEmpty() } + } + } + ChatMessage( id = "rest-$sessionId-$globalIndex", role = role, - content = content, + content = finalContent, reasoningText = reasoning, + attachments = attachments, timestamp = timestamp, isStreaming = false, ) } } + // ── Issue #724: attach host-path MEDIA: files as real attachments ──── + // + // The gateway's WebSocket stream delivers the raw `MEDIA:` directive + // the desktop app turns into an authenticated `/api/files/download?...` + // URL. We parse every directive, build the download URL via + // [GatewayFileClient], classify it (image / audio / video / file) using + // [mediaKindForPath], and attach it to the message. Images render inline + // (Coil loads the URL); every other type becomes a tappable, fetchable + // attachment. The directive text is stripped from the message body. Works + // on a remote phone (real HTTP). Mobile-only; backend untouched. Pure + // parsing lives in [HostMediaExtractor]. + + /** + * ViewModel-side handler for [ReducerEffect.AttachHostMedia]: find the local + * message by id, convert any `MEDIA:` directives into [Attachment]s + * (via the gateway download URL) and strip them from the text. Role, + * reasoning, timestamp and existing attachments are preserved; new gateway + * attachments are appended. Idempotent — skips if gateway attachments for + * the same paths already exist. + */ + private fun attachHostMedia( + sessionId: String, + messageId: String, + ) { + val current = _uiState.value.messages.find { it.id == messageId } ?: return + val content = current.content + val items = HostMediaExtractor.extract(content) + if (items.isEmpty()) return + + val baseUrl = AuthManager.getBaseUrl() + val token = AuthManager.getToken() + if (baseUrl.isBlank() || token.isNullOrBlank()) return + + val existingUrls = + current.attachments + .orEmpty() + .mapNotNull { it.gatewayUrl } + .toSet() + val newAttachments = + items.mapNotNull { item -> + val url = GatewayFileClient.buildDownloadUrl(baseUrl, token, item.path) ?: return@mapNotNull null + if (url in existingUrls) return@mapNotNull null + Attachment( + uri = url, + name = mediaNameFromPath(item.path), + mimeType = mediaMimeForPath(item.path), + size = 0, + gatewayUrl = url, + source = AttachmentSource.GATEWAY, + ) + } + if (newAttachments.isEmpty()) return + + val stripped = HostMediaExtractor.strip(content) + _uiState.update { state -> + state.copy( + messages = + state.messages.map { msg -> + if (msg.id == messageId) { + msg.copy( + content = stripped, + attachments = + (msg.attachments.orEmpty() + newAttachments) + .distinctBy { it.gatewayUrl ?: it.uri }, + ) + } else { + msg + } + }, + ) + } + } + + /** + * Open an attachment when its chip/thumbnail is tapped. + * + * - LOCAL (user-picked) files: open the original `content://` URI + * directly via [android.content.Intent.ACTION_VIEW] — the resolver + * already grants read access for the picked document. If that fails + * (e.g. the permission lapsed), we copy to cache and retry via + * FileProvider so the tap is never a silent no-op. + * - GATEWAY (agent `MEDIA:`) files: fetch the bytes via + * [GatewayFileClient], write them to a cache file, and open with + * [android.content.Intent.ACTION_VIEW] through FileProvider — so a + * remote phone can view agent-delivered files in-place. + * + * Failures surface through [ChatUiState.openError] (non-blocking + * snackbar); the tap is never swallowed. + */ + fun openAttachment(attachment: Attachment) { + val ctx = getApplication().applicationContext + if (attachment.source == AttachmentSource.LOCAL) { + // Best-effort direct open of the picked content URI. + runCatching { openWithView(ctx, android.net.Uri.parse(attachment.uri), attachment.mimeType) } + .onSuccess { return } + .onFailure { /* fall through to cache-copy below */ } + } + // GATEWAY, or LOCAL direct-open failed → fetch/copy then open. + val path = + attachment.gatewayUrl?.let { url -> + runCatching { + java.net + .URL(url) + .query + .split('&') + .firstOrNull { it.startsWith("path=") } + ?.removePrefix("path=") + ?.let { java.net.URLDecoder.decode(it, "UTF-8") } + }.getOrNull() + } ?: attachment.name + viewModelScope.launch(Dispatchers.IO) { + when (val result = GatewayFileClient.fetch(path)) { + is GatewayFileResult.Success -> { + openBytes(ctx, result.file) + } + + is GatewayFileResult.NotFound -> { + showOpenError("File not found on gateway: ${attachment.name}") + } + + is GatewayFileResult.Forbidden -> { + showOpenError("Access denied: ${attachment.name}") + } + + is GatewayFileResult.TooLarge -> { + showOpenError("File too large to open: ${attachment.name}") + } + + is GatewayFileResult.Unauthorized -> { + showOpenError("Session expired — reconnect to open: ${attachment.name}") + } + + is GatewayFileResult.Failure -> { + showOpenError("Could not open ${attachment.name}: ${result.throwable.message}") + } + } + } + } + + /** Open bytes written to a cache file via FileProvider + ACTION_VIEW. */ + private fun openBytes( + ctx: android.content.Context, + file: GatewayFile, + ) { + runCatching { + val dir = java.io.File(ctx.cacheDir, "gateway_files").also { it.mkdirs() } + val safeName = file.name.replace(Regex("[/\\\\]"), "_").ifBlank { "file" } + val out = java.io.File(dir, safeName) + out.writeBytes(file.bytes) + val uri = + androidx.core.content.FileProvider.getUriForFile( + ctx, + "${ctx.packageName}.fileprovider", + out, + ) + openWithView(ctx, uri, file.mimeType) + }.onFailure { showOpenError("Could not open ${file.name}: ${it.message}") } + } + + /** Fire an ACTION_VIEW intent; throws if no activity can handle the type. */ + private fun openWithView( + ctx: android.content.Context, + uri: android.net.Uri, + mimeType: String, + ) { + val viewIntent = + android.content.Intent(android.content.Intent.ACTION_VIEW).apply { + setDataAndType(uri, mimeType.ifBlank { "*/*" }) + addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) + } + try { + ctx.startActivity(viewIntent) + } catch (e: Throwable) { + val fallbackIntent = + android.content.Intent(android.content.Intent.ACTION_VIEW).apply { + setDataAndType(uri, "*/*") + addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) + } + val chooser = + android.content.Intent.createChooser(fallbackIntent, "Open file").apply { + addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) + } + ctx.startActivity(chooser) + } + } + + private fun showOpenError(message: String) { + _uiState.update { it.copy(openError = message) } + } + + fun clearOpenError() { + _uiState.update { it.copy(openError = null) } + } + private fun serverMessageIndex( id: String, sessionId: String, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt index eaf1a0b2..55d7fac6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt @@ -255,6 +255,13 @@ object ChatWsEventReducer { // Refresh the context meter immediately on completion instead of // waiting for the next 5s session-sync poll. effects.add(ReducerEffect.RefreshContextUsage) + // Mobile-only media fix (issue #724): the WS stream delivers the raw + // `MEDIA:` directive. Turn it into real attachments (images + // inline, every other file tappable) via the gateway /api/files/download + // endpoint — works on a remote phone too. + if (msg.role == MessageRole.ASSISTANT && sid != null) { + effects.add(ReducerEffect.AttachHostMedia(sid, msg.id)) + } return ReducerResult( state = state.copy( @@ -288,6 +295,12 @@ object ChatWsEventReducer { if (sid != null) { effects.add(ReducerEffect.PersistMessage(msg, sid)) } + // Mobile-only media fix (issue #724): attach any `MEDIA:` file + // carried in the finalized assistant message as a real attachment. See + // the ReducerEffect.AttachHostMedia KDoc for the rationale. + if (msg.role == MessageRole.ASSISTANT && sid != null) { + effects.add(ReducerEffect.AttachHostMedia(sid, msg.id)) + } return ReducerResult( state = state.copy( @@ -669,4 +682,22 @@ sealed class ReducerEffect { data object RefreshSessions : ReducerEffect() data object RefreshContextUsage : ReducerEffect() + + /** + * Ask the ViewModel to turn any `MEDIA:` directives in the + * just-finalized assistant message into real [Attachment]s (images inline, + * every other file type tappable) and strip the directives from the text. + * + * Why: the gateway's WebSocket stream delivers the raw `MEDIA:` + * directive (the same one the desktop app resolves via `mediaExternalUrl` + * to the authenticated `/api/files/download` endpoint, which streams the + * host file over HTTP). Attaching them lets the renderer show images inline + * and offer every other file as a fetchable attachment — even on a remote + * phone (real HTTP, unlike a host-local file read). Mirrors desktop's + * `mediaExternalUrl`; mobile-only, backend untouched. See issue #724. + */ + data class AttachHostMedia( + val sessionId: String, + val messageId: String, + ) : ReducerEffect() } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/HostMediaExtractor.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/HostMediaExtractor.kt new file mode 100644 index 00000000..d969dc58 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/HostMediaExtractor.kt @@ -0,0 +1,77 @@ +package com.m57.hermescontrol.ui.chat + +/** + * Issue #724: parse agent-delivered `MEDIA:` directives out of message + * text so they can be attached (and rendered) as real files. + * + * The gateway's WebSocket stream delivers the raw `MEDIA:` directive the + * desktop app resolves via `mediaExternalUrl` — an authenticated + * `/api/files/download?path=&token=` URL that streams the host file's + * bytes over HTTP. Mobile turns each directive into an [Attachment] (see + * [com.m57.hermescontrol.ui.chat.ChatViewModel.attachHostMedia]) so the + * renderer can show images inline and offer every other file type as a + * tappable, fetchable attachment — including on a *remote* phone. + * + * Matches ANY absolute (or `~/`) host path regardless of extension, so images, + * audio, video, CSV, PDF and arbitrary files are all supported. The pure parse + * logic lives here so it can be unit-tested without an Android context. + */ +internal object HostMediaExtractor { + /** A single `MEDIA:` directive found in message text. */ + data class Item( + val match: String, // the full matched directive (for stripping) + val path: String, // normalized absolute path + ) + + /** Matches `MEDIA:` (any extension), supporting quotes/backticks around the path. */ + private val MEDIA_TAG_RE = + Regex( + """[`"']?MEDIA:\s*(?:"([^"]+)"|'([^']+)'|`([^`]+)`|((?:~|/|[A-Za-z]:)[^\s`"')]*))[`"']?""", + RegexOption.IGNORE_CASE, + ) + + /** Extract every `MEDIA:` directive from [text] as a normalized item. */ + fun extract(text: String): List { + if (!text.contains("MEDIA:")) return emptyList() + return MEDIA_TAG_RE + .findAll(text) + .mapNotNull { m -> + val raw = m.groupValues.drop(1).firstOrNull { it.isNotBlank() } ?: return@mapNotNull null + val path = normalizePath(raw) ?: return@mapNotNull null + Item(match = m.value, path = path) + }.toList() + } + + /** Remove all `MEDIA:` directives from [text] (leaves other content). */ + fun strip(text: String): String { + if (!text.contains("MEDIA:")) return text + return MEDIA_TAG_RE + .replace(text) { "" } + .replace(Regex("""[ \t]+"""), " ") + .replace(Regex("""\n{3,}"""), "\n\n") + .trim() + } + + /** Expand ~/ and verify the path is absolute. Null if not an absolute path. */ + internal fun normalizePath(raw: String): String? { + val trimmed = + raw + .trim() + .removeSurrounding("`") + .removeSurrounding("\"") + .removeSurrounding("'") + val expanded = + if (trimmed.startsWith("~")) { + val home = System.getenv("HOME") ?: return null + home + trimmed.removePrefix("~") + } else { + trimmed + } + if (!expanded.startsWith("/") && + !Regex("^[A-Za-z]:[/\\\\]").containsMatchIn(expanded) + ) { + return null + } + return expanded + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt index c48762d7..1e598505 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt @@ -43,6 +43,8 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.remote.GatewayFileClient import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.SearchHighlightColors import com.m57.hermescontrol.theme.searchHighlightColors @@ -268,7 +270,7 @@ fun MarkdownText( // AsyncImage is a Compose runtime composable — load it lazily // behind remember so the parser stays pure and testable (the // parseBlocks/parseInline unit tests never touch Compose). - val model: Any = block.uri + val model: Any = remember(block.uri) { resolveImageUrl(block.uri) } androidx.compose.foundation.layout.Box( modifier = Modifier @@ -1000,3 +1002,26 @@ internal enum class TableAlign { CENTER, RIGHT, } + +private fun resolveImageUrl(uri: String): String { + val trimmed = uri.trim() + if (trimmed.isBlank()) return uri + if (trimmed.startsWith("http://") || trimmed.startsWith("https://") || trimmed.startsWith("data:image/")) { + return trimmed + } + val baseUrl = runCatching { AuthManager.getBaseUrl() }.getOrDefault("") + val token = runCatching { AuthManager.getToken() }.getOrNull().orEmpty() + val downloadUrl = GatewayFileClient.buildDownloadUrl(baseUrl, token, trimmed) + if (downloadUrl != null) return downloadUrl + + if (baseUrl.isNotBlank() && (trimmed.startsWith("/api/") || trimmed.startsWith("api/"))) { + val cleanPath = if (trimmed.startsWith("/")) trimmed else "/$trimmed" + val sep = if (cleanPath.contains("?")) "&" else "?" + return if (token.isNotBlank() && !cleanPath.contains("token=")) { + "${baseUrl.trimEnd('/')}$cleanPath${sep}token=$token" + } else { + "${baseUrl.trimEnd('/')}$cleanPath" + } + } + return uri +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt index 7ac05e48..66eaeca3 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt @@ -33,6 +33,7 @@ fun ChatLifecycleEffects( messages: List, errorMessage: String?, backgroundCompleteMessage: String?, + openError: String?, isSearchActive: Boolean, currentSearchMatchIndex: Int, searchMatchIndices: List, @@ -127,6 +128,14 @@ fun ChatLifecycleEffects( } } + // Show attachment-open failures as a non-blocking snackbar (issue #724) + LaunchedEffect(openError) { + openError?.let { message -> + snackbarHostState.showSnackbar(message) + viewModel.clearOpenError() + } + } + // Sudo / secret prompt dialogs (issue #524) sudoPrompt?.let { prompt -> SudoPromptDialog( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMessageList.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMessageList.kt index 20a37878..9a84d2fe 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMessageList.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMessageList.kt @@ -108,6 +108,7 @@ fun ChatMessageList( searchQuery = if (isSearchActive) searchQuery else "", isCurrentMatch = isCurrentMatch, onRespondApproval = viewModel::respondToApproval, + onOpenAttachment = viewModel::openAttachment, ) } } @@ -127,6 +128,7 @@ fun ChatMessageList( isDarkTheme = isDark, searchQuery = "", isCurrentMatch = false, + onOpenAttachment = viewModel::openAttachment, ) } } diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/GatewayFileClientTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/GatewayFileClientTest.kt index 25b701d3..27942aed 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/remote/GatewayFileClientTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/GatewayFileClientTest.kt @@ -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 diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index d6a5fe89..938065cf 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -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() + // 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().setDataAndType(any(), any()) } answers { self as Intent } + every { anyConstructed().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")) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/HostMediaExtractorTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/HostMediaExtractorTest.kt new file mode 100644 index 00000000..3eca86d1 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/HostMediaExtractorTest.kt @@ -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) + } +}