feat(chat): full-screen image viewer (issue #723) (#729)

* feat(chat): full-screen image viewer (issue #723)

Tap any chat image (markdown ![alt](url), inline attachment thumbnail,
or bubble image) to open a full-screen viewer:
- pinch-zoom + pan via native detectTransformGestures
- downswipe-to-dismiss at min zoom
- Save -> device Downloads (MediaStore), Share -> Android share sheet
  (both fully device-local; never sent back to the Hermes server)

Plumbing: onImageClick(ImageViewerModel) flows MarkdownText ->
ChatBubble/InlineAttachment -> ChatMessageList -> ChatScreen (state +
ImageViewerDialog). ImageBytesResolver reuses the existing authed
OkHttpProvider path for gateway http(s) sources; data:/content://
handled locally. No new dependencies.

Closes #723

* fix(chat): resolve strings at composable scope to satisfy LocalContextGetResourceValueCall lint

Lint forbids reading resource *values* from LocalContext.current inside
coroutine lambdas. Hoist all viewer strings via stringResource() at
composable scope and reuse them in onSave/onShare.

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-27 15:21:19 +03:00
1 parent a1155e8015
commit f45a68b0db
11 files changed
+651 -4

No files matched your search

@@ -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 {
@@ -211,6 +211,7 @@ fun ChatScreen(
var isListening by rememberSaveable { mutableStateOf(false) }
var lastAnimatedMessageId by rememberSaveable { mutableStateOf<String?>(null) }
var showReloginDialog by rememberSaveable { mutableStateOf(false) }
var viewingImage by rememberSaveable { mutableStateOf<ImageViewerModel?>(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 },
)
}
}
}
@@ -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"
}
}
@@ -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,
)
}
}
}
}
}
}
}
}
@@ -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/*",
)
@@ -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,
@@ -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"
}
}
@@ -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,
)
}
}