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 b160dd78..b79c799f 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 @@ -115,6 +115,7 @@ fun ChatBubble( isCurrentMatch: Boolean = false, onRespondApproval: (String) -> Unit = {}, onOpenAttachment: (Attachment) -> Unit = {}, + onImageClick: (ImageViewerModel) -> Unit = {}, modifier: Modifier = Modifier, ) { val screenWidth = LocalConfiguration.current.screenWidthDp.dp @@ -136,6 +137,7 @@ fun ChatBubble( searchQuery = searchQuery, isCurrentMatch = isCurrentMatch, onOpenAttachment = onOpenAttachment, + onImageClick = onImageClick, modifier = modifier, ) } @@ -148,6 +150,7 @@ fun ChatBubble( searchQuery = searchQuery, isCurrentMatch = isCurrentMatch, onOpenAttachment = onOpenAttachment, + onImageClick = onImageClick, modifier = modifier, ) } @@ -174,6 +177,7 @@ private fun UserBubble( searchQuery: String = "", isCurrentMatch: Boolean = false, onOpenAttachment: (Attachment) -> Unit = {}, + onImageClick: (ImageViewerModel) -> Unit = {}, modifier: Modifier = Modifier, ) { val clipboard = LocalClipboard.current @@ -274,6 +278,7 @@ private fun UserBubble( attachment = attachment, textColor = userBubbleTextColor, onOpen = { onOpenAttachment(it) }, + onImageClick = onImageClick, ) Spacer(modifier = Modifier.height(4.dp)) } @@ -337,6 +342,7 @@ private fun AssistantBubble( searchQuery: String = "", isCurrentMatch: Boolean = false, onOpenAttachment: (Attachment) -> Unit = {}, + onImageClick: (ImageViewerModel) -> Unit = {}, modifier: Modifier = Modifier, ) { val bubbleColor = if (isDarkTheme) AssistantBubble else AssistantBubbleLight @@ -416,6 +422,7 @@ private fun AssistantBubble( isStreaming = message.isStreaming, searchQuery = searchQuery, isCurrentMatch = isCurrentMatch, + onImageClick = onImageClick, ) } // Render inline attachments (mirrors UserBubble so agent-delivered @@ -427,6 +434,7 @@ private fun AssistantBubble( attachment = attachment, textColor = textColor, onOpen = { onOpenAttachment(it) }, + onImageClick = onImageClick, ) Spacer(modifier = Modifier.height(4.dp)) } @@ -2307,11 +2315,11 @@ private fun InlineAttachment( attachment: Attachment, textColor: Color, onOpen: (Attachment) -> Unit = {}, + onImageClick: (ImageViewerModel) -> Unit = {}, ) { val clickable = Modifier.clickable { onOpen(attachment) } if (attachment.isImage) { - // Image attachment — show as a rounded thumbnail (Coil loads the - // gateway download URL directly when gateway-sourced). + // Image attachment — show as a rounded thumbnail; tap opens the viewer. AsyncImage( model = attachment.uri, contentDescription = attachment.name, @@ -2319,7 +2327,15 @@ private fun InlineAttachment( Modifier .fillMaxWidth() .clip(RoundedCornerShape(12.dp)) - .then(clickable), + .clickable { + onImageClick( + ImageViewerModel( + model = attachment.uri, + name = attachment.name, + mimeType = attachment.mimeType, + ), + ) + }, contentScale = ContentScale.FillWidth, ) } else { 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 29027dba..86d4de40 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 @@ -211,6 +211,7 @@ fun ChatScreen( var isListening by rememberSaveable { mutableStateOf(false) } var lastAnimatedMessageId by rememberSaveable { mutableStateOf(null) } var showReloginDialog by rememberSaveable { mutableStateOf(false) } + var viewingImage by rememberSaveable { mutableStateOf(null) } val snackbarHostState = remember { SnackbarHostState() } val isDark = isSystemInDarkTheme() val context = LocalContext.current @@ -496,6 +497,7 @@ fun ChatScreen( clarifyRequest = state.clarifyRequest, onRespondClarify = viewModel::respondToClarify, onDismissClarify = viewModel::dismissClarify, + onImageClick = { viewingImage = it }, ) // Loading overlay @@ -638,5 +640,12 @@ fun ChatScreen( onDismiss = { showContextSheet = false }, ) } + + viewingImage?.let { image -> + ImageViewerDialog( + image = image, + onDismiss = { viewingImage = null }, + ) + } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ImageBytesResolver.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ImageBytesResolver.kt new file mode 100644 index 00000000..f16b0bd4 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ImageBytesResolver.kt @@ -0,0 +1,125 @@ +package com.m57.hermescontrol.ui.chat + +import android.content.Context +import android.net.Uri +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.Request +import java.util.Base64 + +/** + * Resolve a chat image [ImageViewerModel.model] into raw bytes so the viewer can + * Save/Share it *locally on the device* — never back to the Hermes server + * (issue #723). + * + * Every model kind handled here is exactly what Coil already renders in the + * bubble, so a model that displays is also resolvable here: + * - `data:image/...;base64,...` — agent-delivered inline media, + * - `content://...` — a locally-picked user image, + * - `http(s)://...` — gateway-served URL (carries the `?token=` auth param the + * same way the app fetches it; no backend change needed). + * + * All I/O runs on [Dispatchers.IO]. + */ +object ImageBytesResolver { + /** Outcome of [resolve]. */ + sealed interface Result { + /** Resolved bytes plus the concrete MIME type and file extension. */ + data class Bytes( + val bytes: ByteArray, + val mimeType: String, + val extension: String, + ) : Result + + /** Could not load the image; [message] is safe to surface to the user. */ + data class Error(val message: String) : Result + } + + suspend fun resolve( + context: Context, + model: String, + fallbackMime: String, + ): Result = + withContext(Dispatchers.IO) { + runCatching { + when { + model.startsWith("data:") -> decodeDataUrl(model, fallbackMime) + model.startsWith("content://") -> readContentUri(context, model, fallbackMime) + model.startsWith("https://") || model.startsWith("http://") -> fetchHttp(model, fallbackMime) + else -> Result.Error("Unsupported image source") + } + }.getOrElse { e -> Result.Error(e.message ?: "Failed to load image") } + } + + private fun decodeDataUrl( + model: String, + fallbackMime: String, + ): Result { + val comma = model.indexOf(',') + if (comma < 0) return Result.Error("Malformed data URL") + val meta = model.substring(0, comma) + val data = model.substring(comma + 1).replace(Regex("\\s+"), "") + val mime = + meta + .substringAfter("data:", "image/*") + .substringBefore(";") + .ifBlank { fallbackMime } + if (!meta.contains(";base64", ignoreCase = true)) { + return Result.Error("Only base64 data URLs are supported") + } + val bytes = + runCatching { Base64.getDecoder().decode(data) } + .getOrElse { return Result.Error("Could not decode image data") } + return Result.Bytes(bytes, mime, extensionForMime(mime)) + } + + private fun readContentUri( + context: Context, + model: String, + fallbackMime: String, + ): Result { + val uri = Uri.parse(model) + val bytes = + runCatching { context.contentResolver.openInputStream(uri)?.use { it.readBytes() } } + .getOrNull() + ?: return Result.Error("Could not open local image") + val mime = context.contentResolver.getType(uri) ?: fallbackMime + return Result.Bytes(bytes, mime, extensionForMime(mime)) + } + + private fun fetchHttp( + model: String, + fallbackMime: String, + ): Result { + val request = + Request + .Builder() + .url(model) + .build() + return runCatching { + OkHttpProvider.base.newCall(request).execute().use { resp -> + if (!resp.isSuccessful) return Result.Error("Download failed (HTTP ${resp.code})") + val bytes = resp.body.bytes() + val mime = + resp.header("Content-Type") + ?.substringBefore(";") + ?.ifBlank { fallbackMime } + ?: fallbackMime + Result.Bytes(bytes, mime, extensionForMime(mime)) + } + }.getOrElse { e -> Result.Error(e.message ?: "Could not download image") } + } + + fun extensionForMime(mime: String): String = + when (mime.lowercase()) { + "image/jpeg", "image/jpg" -> "jpg" + "image/png" -> "png" + "image/gif" -> "gif" + "image/webp" -> "webp" + "image/bmp" -> "bmp" + "image/heic", "image/heif" -> "heic" + "image/svg+xml" -> "svg" + else -> "img" + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ImageViewerDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ImageViewerDialog.kt new file mode 100644 index 00000000..44ba3f52 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ImageViewerDialog.kt @@ -0,0 +1,235 @@ +package com.m57.hermescontrol.ui.chat + +import android.widget.Toast +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import coil.compose.AsyncImage +import com.m57.hermescontrol.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Full-screen image viewer (issue #723). + * + * Opens from any chat image (bubble thumbnail, markdown `![alt](url)`, inline + * attachment). Provides pinch-zoom + pan, a downswipe-to-dismiss gesture, and a + * top bar with **Save** (writes to device Downloads via [MediaImageStore]) and + * **Share** (Android share sheet) — both fully device-local, never touching the + * Hermes server. + * + * Save/Share resolve the image bytes through [ImageBytesResolver], which handles + * the same model kinds Coil already renders (`data:`, `content://`, `http(s)`). + */ +@Composable +fun ImageViewerDialog( + image: ImageViewerModel, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var scale by remember { mutableStateOf(1f) } + var offsetX by remember { mutableStateOf(0f) } + var offsetY by remember { mutableStateOf(0f) } + var isBusy by remember { mutableStateOf(false) } + + // Resolve string resources at composable scope (Lint forbids reading + // resource *values* from LocalContext.current inside coroutine lambdas). + val savedMsg = stringResource(R.string.image_viewer_saved) + val saveFailedMsg = stringResource(R.string.image_viewer_save_failed) + val loadFailedFmt = stringResource(R.string.image_viewer_load_failed) + val shareTitle = stringResource(R.string.image_viewer_share_title) + val shareFailedMsg = stringResource(R.string.image_viewer_share_failed) + + val onSave: () -> Unit = { + if (!isBusy) { + isBusy = true + scope.launch(Dispatchers.IO) { + val resolved = ImageBytesResolver.resolve(context, image.model, image.mimeType) + val result = + when (resolved) { + is ImageBytesResolver.Result.Bytes -> { + val name = image.name.ifBlank { "hermes-image.${resolved.extension}" } + val uri = + MediaImageStore.saveToDownloads( + context, + resolved.bytes, + name, + resolved.mimeType, + ) + if (uri != null) { + savedMsg + } else { + saveFailedMsg + } + } + + is ImageBytesResolver.Result.Error -> + String.format(loadFailedFmt, resolved.message) + } + withContext(Dispatchers.Main) { + isBusy = false + Toast.makeText(context, result, Toast.LENGTH_SHORT).show() + } + } + } + } + + val onShare: () -> Unit = { + if (!isBusy) { + isBusy = true + scope.launch(Dispatchers.IO) { + val resolved = ImageBytesResolver.resolve(context, image.model, image.mimeType) + val intent = + when (resolved) { + is ImageBytesResolver.Result.Bytes -> { + val name = image.name.ifBlank { "hermes-image.${resolved.extension}" } + MediaImageStore.buildShareIntent( + context, + resolved.bytes, + name, + resolved.mimeType, + ) + } + + is ImageBytesResolver.Result.Error -> null + } + withContext(Dispatchers.Main) { + isBusy = false + if (intent != null) { + context.startActivity( + android.content.Intent + .createChooser(intent, shareTitle), + ) + } else { + Toast + .makeText( + context, + shareFailedMsg, + Toast.LENGTH_SHORT, + ).show() + } + } + } + } + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.scrim, + ) { + Box(modifier = Modifier.fillMaxSize()) { + // Zoomable / pannable image. + AsyncImage( + model = image.model, + contentDescription = + image.name.ifBlank { stringResource(R.string.image_viewer_content_desc) }, + modifier = + Modifier + .fillMaxSize() + .graphicsLayer( + scaleX = scale, + scaleY = scale, + translationX = offsetX, + translationY = offsetY, + ).pointerInput(Unit) { + detectTransformGestures( + onGesture = { _, pan, zoom, _ -> + val newScale = (scale * zoom).coerceIn(1f, 5f) + // While zoomed in, allow free panning; at min scale, + // only vertical drags are permitted (for dismiss). + scale = newScale + offsetX = if (newScale > 1f) offsetX + pan.x else 0f + offsetY += pan.y + // Downswipe-to-dismiss when at min zoom. + if (newScale <= 1f && offsetY > 120f) { + onDismiss() + } + }, + ) + }, + contentScale = ContentScale.Fit, + ) + + // Top action bar — Save / Share / Close. + Row( + modifier = + Modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.image_viewer_close), + tint = MaterialTheme.colorScheme.onPrimary, + ) + } + Row { + if (isBusy) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp).padding(horizontal = 8.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + IconButton(onClick = onSave, enabled = !isBusy) { + Icon( + imageVector = Icons.Filled.Download, + contentDescription = stringResource(R.string.image_viewer_save), + tint = MaterialTheme.colorScheme.onPrimary, + ) + } + IconButton(onClick = onShare, enabled = !isBusy) { + Icon( + imageVector = Icons.Filled.Share, + contentDescription = stringResource(R.string.image_viewer_share), + tint = MaterialTheme.colorScheme.onPrimary, + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ImageViewerModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ImageViewerModel.kt new file mode 100644 index 00000000..a15e2c4a --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ImageViewerModel.kt @@ -0,0 +1,21 @@ +package com.m57.hermescontrol.ui.chat + +/** + * A single image available for the full-screen viewer (issue #723). + * + * @param model The Coil model already rendered in the bubble — one of: + * - `data:image/...;base64,...` — agent-delivered inline media, + * - `content://...` — a locally-picked user image, + * - `http(s)://...` — gateway-served URL (carries the `?token=` auth param + * the same way the desktop/client already fetches it; no backend change). + * @param name Preferred file name for Save/Share. When blank the viewer falls + * back to a timestamped name derived from the resolved image type. + * @param mimeType Best-known MIME type (e.g. an attachment's `mimeType`). For + * markdown `![alt](url)` images this is unknown up-front and is refined from + * the resolved bytes at save/share time. + */ +data class ImageViewerModel( + val model: String, + val name: String = "", + val mimeType: String = "image/*", +) 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 1e598505..3f17f584 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 @@ -1,6 +1,7 @@ package com.m57.hermescontrol.ui.chat import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -74,6 +75,7 @@ fun MarkdownText( searchQuery: String = "", isCurrentMatch: Boolean = false, modifier: Modifier = Modifier, + onImageClick: (ImageViewerModel) -> Unit = {}, ) { val statusColors = LocalHermesStatusColors.current val highlights = searchHighlightColors(statusColors) @@ -276,7 +278,17 @@ fun MarkdownText( Modifier .fillMaxWidth() .clip(RoundedCornerShape(12.dp)) - .padding(vertical = 4.dp), + .clickable( + onClick = { + onImageClick( + ImageViewerModel( + model = block.uri, + name = block.alt, + mimeType = "image/*", + ), + ) + }, + ).padding(vertical = 4.dp), ) { coil.compose.AsyncImage( model = model, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaImageStore.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaImageStore.kt new file mode 100644 index 00000000..87cc08dc --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaImageStore.kt @@ -0,0 +1,113 @@ +package com.m57.hermescontrol.ui.chat + +import android.content.ContentValues +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import androidx.core.content.FileProvider +import java.io.File + +/** + * Persist / dispatch chat images on the *device* (Downloads / Gallery / share + * sheet) — never back to the Hermes server (issue #723). Every operation works + * only on bytes the viewer already resolved locally, so save/share are fully + * device-local and independent of the backend. + */ +object MediaImageStore { + /** + * Write [bytes] into the device's Download collection via [MediaStore] so the + * file appears in system Downloads / Gallery apps. Returns the public [Uri] + * on success, or `null` if the write failed. + * + * On API 29+ the file lands under `Download/Hermes` using + * `RELATIVE_PATH`; on older APIs it falls back to the top-level + * `Images` collection. + */ + fun saveToDownloads( + context: Context, + bytes: ByteArray, + displayName: String, + mimeType: String, + ): Uri? { + val safe = sanitizeName(displayName) + val (collection, relativePath) = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + MediaStore.Downloads.EXTERNAL_CONTENT_URI to "Download/Hermes" + } else { + MediaStore.Images.Media.EXTERNAL_CONTENT_URI to null + } + val values = + ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, safe) + put(MediaStore.MediaColumns.MIME_TYPE, mimeType) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath) + put(MediaStore.MediaColumns.IS_PENDING, 1) + } + } + val resolver = context.contentResolver + val uri = resolver.insert(collection, values) ?: return null + return try { + resolver.openOutputStream(uri)?.use { it.write(bytes) } ?: return null.also { + runCatching { resolver.delete(uri, null, null) } + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + values.clear() + values.put(MediaStore.MediaColumns.IS_PENDING, 0) + resolver.update(uri, values, null, null) + } + uri + } catch (e: Exception) { + runCatching { resolver.delete(uri, null, null) } + null + } + } + + /** + * Build a share [Intent] for [bytes] via a [FileProvider] cache file so the + * image can be sent to another app (Messages, WhatsApp, …). Returns `null` + * if the temp file could not be written. + */ + fun buildShareIntent( + context: Context, + bytes: ByteArray, + displayName: String, + mimeType: String, + ): Intent? { + val safe = sanitizeName(displayName) + val dir = File(context.cacheDir, "shared_images").also { it.mkdirs() } + val file = File(dir, safe) + return try { + file.writeBytes(bytes) + val uri = + FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + file, + ) + Intent(Intent.ACTION_SEND).apply { + type = mimeType + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + } catch (e: Exception) { + null + } + } + + /** + * Replace unsafe characters in a display name and cap its length so it is a + * valid [MediaStore] / file name. Preserves the original extension when one + * is present. + */ + private fun sanitizeName(name: String): String { + val hasExt = name.contains('.') + val base = if (hasExt) name.substringBeforeLast('.') else name + val ext = if (hasExt) name.substringAfterLast('.') else "" + val cleanedBase = base.replace(Regex("[^A-Za-z0-9_\\-]"), "_").take(60).ifBlank { "hermes-image" } + val cleanedExt = ext.replace(Regex("[^A-Za-z0-9]"), "").take(10).ifBlank { "png" } + return "$cleanedBase.$cleanedExt" + } +} 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 9a84d2fe..884cde58 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 @@ -20,6 +20,7 @@ import com.m57.hermescontrol.ui.chat.ChatBubble import com.m57.hermescontrol.ui.chat.ChatMessage import com.m57.hermescontrol.ui.chat.ChatViewModel import com.m57.hermescontrol.ui.chat.ClarifyUi +import com.m57.hermescontrol.ui.chat.ImageViewerModel import com.m57.hermescontrol.ui.chat.MessageRole import com.m57.hermescontrol.ui.chat.SubagentIndicator import com.m57.hermescontrol.ui.common.EmptyState @@ -50,6 +51,7 @@ fun ChatMessageList( clarifyRequest: ClarifyUi? = null, onRespondClarify: ((String) -> Unit)? = null, onDismissClarify: (() -> Unit)? = null, + onImageClick: (ImageViewerModel) -> Unit = {}, ) { if (messages.isEmpty() && !isLoading) { Box( @@ -109,6 +111,7 @@ fun ChatMessageList( isCurrentMatch = isCurrentMatch, onRespondApproval = viewModel::respondToApproval, onOpenAttachment = viewModel::openAttachment, + onImageClick = onImageClick, ) } } @@ -129,6 +132,7 @@ fun ChatMessageList( searchQuery = "", isCurrentMatch = false, onOpenAttachment = viewModel::openAttachment, + onImageClick = onImageClick, ) } } diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 9da67ed6..093107d9 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -687,4 +687,15 @@ %1$d회 호출 • 전체의 %2$d%% 종료 프로세스 종료 + + + 채팅 이미지 + 닫기 + 기기에 저장 + 공유 + 이미지 공유 + 다운로드에 이미지 저장됨 + 이미지를 저장할 수 없음 + 이미지를 불러올 수 없음: %1$s + 이미지를 공유할 수 없음 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9d72e0f3..71738f16 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -863,4 +863,15 @@ %1$d calls \u2022 %2$d%% of total Kill Kill process + + + Chat image + Close + Save to device + Share + Share image + Image saved to Downloads + Could not save image + Could not load image: %1$s + Could not share image diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ImageBytesResolverTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ImageBytesResolverTest.kt new file mode 100644 index 00000000..58a8a276 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ImageBytesResolverTest.kt @@ -0,0 +1,90 @@ +package com.m57.hermescontrol.ui.chat + +import android.content.Context +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.Base64 + +@OptIn(ExperimentalCoroutinesApi::class) +class ImageBytesResolverTest { + private val context = mockk(relaxed = true) + + @Before + fun setUp() { + // resolve() runs on Dispatchers.IO; bind the main dispatcher for tests. + Dispatchers.setMain(StandardTestDispatcher()) + } + + @Test + fun `extensionForMime maps known types`() { + assertEquals("png", ImageBytesResolver.extensionForMime("image/png")) + assertEquals("jpg", ImageBytesResolver.extensionForMime("image/jpeg")) + assertEquals("jpg", ImageBytesResolver.extensionForMime("image/JPG")) + assertEquals("gif", ImageBytesResolver.extensionForMime("image/gif")) + assertEquals("webp", ImageBytesResolver.extensionForMime("image/webp")) + assertEquals("heic", ImageBytesResolver.extensionForMime("image/heic")) + assertEquals("svg", ImageBytesResolver.extensionForMime("image/svg+xml")) + assertEquals("img", ImageBytesResolver.extensionForMime("image/unknown")) + } + + @Test + fun `data URL base64 decodes to bytes with mime and extension`() = + runTest { + val raw = "hello-image".toByteArray() + val b64 = Base64.getEncoder().encodeToString(raw) + val model = "data:image/png;base64,$b64" + + val result = ImageBytesResolver.resolve(context, model, "image/*") + + assertTrue(result is ImageBytesResolver.Result.Bytes) + result as ImageBytesResolver.Result.Bytes + assertArrayEquals(raw, result.bytes) + assertEquals("image/png", result.mimeType) + assertEquals("png", result.extension) + } + + @Test + fun `data URL falls back to fallbackMime when meta omitted`() = + runTest { + val raw = byteArrayOf(1, 2, 3, 4) + val b64 = Base64.getEncoder().encodeToString(raw) + val model = "data:;base64,$b64" + + val result = ImageBytesResolver.resolve(context, model, "image/webp") + + assertTrue(result is ImageBytesResolver.Result.Bytes) + result as ImageBytesResolver.Result.Bytes + assertEquals("image/webp", result.mimeType) + assertEquals("webp", result.extension) + } + + @Test + fun `malformed data URL (no comma) returns Error`() = + runTest { + val result = ImageBytesResolver.resolve(context, "data:image/png;base64XXXX", "image/*") + assertTrue(result is ImageBytesResolver.Result.Error) + } + + @Test + fun `non-base64 data URL returns Error`() = + runTest { + val result = ImageBytesResolver.resolve(context, "data:image/png,notbase64", "image/*") + assertTrue(result is ImageBytesResolver.Result.Error) + } + + @Test + fun `unsupported model source returns Error`() = + runTest { + val result = ImageBytesResolver.resolve(context, "file:///sdcard/x.png", "image/*") + assertTrue(result is ImageBytesResolver.Result.Error) + } +}