* feat(chat): full-screen image viewer (issue #723) Tap any chat image (markdown , 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:
1 parent
a1155e8015
commit
f45a68b0db
11 files changed
+651
-4
No files matched your search
@@ -115,6 +115,7 @@ fun ChatBubble(
|
|||||||
isCurrentMatch: Boolean = false,
|
isCurrentMatch: Boolean = false,
|
||||||
onRespondApproval: (String) -> Unit = {},
|
onRespondApproval: (String) -> Unit = {},
|
||||||
onOpenAttachment: (Attachment) -> Unit = {},
|
onOpenAttachment: (Attachment) -> Unit = {},
|
||||||
|
onImageClick: (ImageViewerModel) -> Unit = {},
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||||
@@ -136,6 +137,7 @@ fun ChatBubble(
|
|||||||
searchQuery = searchQuery,
|
searchQuery = searchQuery,
|
||||||
isCurrentMatch = isCurrentMatch,
|
isCurrentMatch = isCurrentMatch,
|
||||||
onOpenAttachment = onOpenAttachment,
|
onOpenAttachment = onOpenAttachment,
|
||||||
|
onImageClick = onImageClick,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -148,6 +150,7 @@ fun ChatBubble(
|
|||||||
searchQuery = searchQuery,
|
searchQuery = searchQuery,
|
||||||
isCurrentMatch = isCurrentMatch,
|
isCurrentMatch = isCurrentMatch,
|
||||||
onOpenAttachment = onOpenAttachment,
|
onOpenAttachment = onOpenAttachment,
|
||||||
|
onImageClick = onImageClick,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -174,6 +177,7 @@ private fun UserBubble(
|
|||||||
searchQuery: String = "",
|
searchQuery: String = "",
|
||||||
isCurrentMatch: Boolean = false,
|
isCurrentMatch: Boolean = false,
|
||||||
onOpenAttachment: (Attachment) -> Unit = {},
|
onOpenAttachment: (Attachment) -> Unit = {},
|
||||||
|
onImageClick: (ImageViewerModel) -> Unit = {},
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val clipboard = LocalClipboard.current
|
val clipboard = LocalClipboard.current
|
||||||
@@ -274,6 +278,7 @@ private fun UserBubble(
|
|||||||
attachment = attachment,
|
attachment = attachment,
|
||||||
textColor = userBubbleTextColor,
|
textColor = userBubbleTextColor,
|
||||||
onOpen = { onOpenAttachment(it) },
|
onOpen = { onOpenAttachment(it) },
|
||||||
|
onImageClick = onImageClick,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
}
|
}
|
||||||
@@ -337,6 +342,7 @@ private fun AssistantBubble(
|
|||||||
searchQuery: String = "",
|
searchQuery: String = "",
|
||||||
isCurrentMatch: Boolean = false,
|
isCurrentMatch: Boolean = false,
|
||||||
onOpenAttachment: (Attachment) -> Unit = {},
|
onOpenAttachment: (Attachment) -> Unit = {},
|
||||||
|
onImageClick: (ImageViewerModel) -> Unit = {},
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val bubbleColor = if (isDarkTheme) AssistantBubble else AssistantBubbleLight
|
val bubbleColor = if (isDarkTheme) AssistantBubble else AssistantBubbleLight
|
||||||
@@ -416,6 +422,7 @@ private fun AssistantBubble(
|
|||||||
isStreaming = message.isStreaming,
|
isStreaming = message.isStreaming,
|
||||||
searchQuery = searchQuery,
|
searchQuery = searchQuery,
|
||||||
isCurrentMatch = isCurrentMatch,
|
isCurrentMatch = isCurrentMatch,
|
||||||
|
onImageClick = onImageClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Render inline attachments (mirrors UserBubble so agent-delivered
|
// Render inline attachments (mirrors UserBubble so agent-delivered
|
||||||
@@ -427,6 +434,7 @@ private fun AssistantBubble(
|
|||||||
attachment = attachment,
|
attachment = attachment,
|
||||||
textColor = textColor,
|
textColor = textColor,
|
||||||
onOpen = { onOpenAttachment(it) },
|
onOpen = { onOpenAttachment(it) },
|
||||||
|
onImageClick = onImageClick,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
}
|
}
|
||||||
@@ -2307,11 +2315,11 @@ private fun InlineAttachment(
|
|||||||
attachment: Attachment,
|
attachment: Attachment,
|
||||||
textColor: Color,
|
textColor: Color,
|
||||||
onOpen: (Attachment) -> Unit = {},
|
onOpen: (Attachment) -> Unit = {},
|
||||||
|
onImageClick: (ImageViewerModel) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val clickable = Modifier.clickable { onOpen(attachment) }
|
val clickable = Modifier.clickable { onOpen(attachment) }
|
||||||
if (attachment.isImage) {
|
if (attachment.isImage) {
|
||||||
// Image attachment — show as a rounded thumbnail (Coil loads the
|
// Image attachment — show as a rounded thumbnail; tap opens the viewer.
|
||||||
// gateway download URL directly when gateway-sourced).
|
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = attachment.uri,
|
model = attachment.uri,
|
||||||
contentDescription = attachment.name,
|
contentDescription = attachment.name,
|
||||||
@@ -2319,7 +2327,15 @@ private fun InlineAttachment(
|
|||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.clip(RoundedCornerShape(12.dp))
|
||||||
.then(clickable),
|
.clickable {
|
||||||
|
onImageClick(
|
||||||
|
ImageViewerModel(
|
||||||
|
model = attachment.uri,
|
||||||
|
name = attachment.name,
|
||||||
|
mimeType = attachment.mimeType,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
contentScale = ContentScale.FillWidth,
|
contentScale = ContentScale.FillWidth,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -211,6 +211,7 @@ fun ChatScreen(
|
|||||||
var isListening by rememberSaveable { mutableStateOf(false) }
|
var isListening by rememberSaveable { mutableStateOf(false) }
|
||||||
var lastAnimatedMessageId by rememberSaveable { mutableStateOf<String?>(null) }
|
var lastAnimatedMessageId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
var showReloginDialog by rememberSaveable { mutableStateOf(false) }
|
var showReloginDialog by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var viewingImage by rememberSaveable { mutableStateOf<ImageViewerModel?>(null) }
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val isDark = isSystemInDarkTheme()
|
val isDark = isSystemInDarkTheme()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -496,6 +497,7 @@ fun ChatScreen(
|
|||||||
clarifyRequest = state.clarifyRequest,
|
clarifyRequest = state.clarifyRequest,
|
||||||
onRespondClarify = viewModel::respondToClarify,
|
onRespondClarify = viewModel::respondToClarify,
|
||||||
onDismissClarify = viewModel::dismissClarify,
|
onDismissClarify = viewModel::dismissClarify,
|
||||||
|
onImageClick = { viewingImage = it },
|
||||||
)
|
)
|
||||||
|
|
||||||
// Loading overlay
|
// Loading overlay
|
||||||
@@ -638,5 +640,12 @@ fun ChatScreen(
|
|||||||
onDismiss = { showContextSheet = false },
|
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 ``, 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 `` 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
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -74,6 +75,7 @@ fun MarkdownText(
|
|||||||
searchQuery: String = "",
|
searchQuery: String = "",
|
||||||
isCurrentMatch: Boolean = false,
|
isCurrentMatch: Boolean = false,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
onImageClick: (ImageViewerModel) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val statusColors = LocalHermesStatusColors.current
|
val statusColors = LocalHermesStatusColors.current
|
||||||
val highlights = searchHighlightColors(statusColors)
|
val highlights = searchHighlightColors(statusColors)
|
||||||
@@ -276,7 +278,17 @@ fun MarkdownText(
|
|||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.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(
|
coil.compose.AsyncImage(
|
||||||
model = model,
|
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.ChatMessage
|
||||||
import com.m57.hermescontrol.ui.chat.ChatViewModel
|
import com.m57.hermescontrol.ui.chat.ChatViewModel
|
||||||
import com.m57.hermescontrol.ui.chat.ClarifyUi
|
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.MessageRole
|
||||||
import com.m57.hermescontrol.ui.chat.SubagentIndicator
|
import com.m57.hermescontrol.ui.chat.SubagentIndicator
|
||||||
import com.m57.hermescontrol.ui.common.EmptyState
|
import com.m57.hermescontrol.ui.common.EmptyState
|
||||||
@@ -50,6 +51,7 @@ fun ChatMessageList(
|
|||||||
clarifyRequest: ClarifyUi? = null,
|
clarifyRequest: ClarifyUi? = null,
|
||||||
onRespondClarify: ((String) -> Unit)? = null,
|
onRespondClarify: ((String) -> Unit)? = null,
|
||||||
onDismissClarify: (() -> Unit)? = null,
|
onDismissClarify: (() -> Unit)? = null,
|
||||||
|
onImageClick: (ImageViewerModel) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
if (messages.isEmpty() && !isLoading) {
|
if (messages.isEmpty() && !isLoading) {
|
||||||
Box(
|
Box(
|
||||||
@@ -109,6 +111,7 @@ fun ChatMessageList(
|
|||||||
isCurrentMatch = isCurrentMatch,
|
isCurrentMatch = isCurrentMatch,
|
||||||
onRespondApproval = viewModel::respondToApproval,
|
onRespondApproval = viewModel::respondToApproval,
|
||||||
onOpenAttachment = viewModel::openAttachment,
|
onOpenAttachment = viewModel::openAttachment,
|
||||||
|
onImageClick = onImageClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,6 +132,7 @@ fun ChatMessageList(
|
|||||||
searchQuery = "",
|
searchQuery = "",
|
||||||
isCurrentMatch = false,
|
isCurrentMatch = false,
|
||||||
onOpenAttachment = viewModel::openAttachment,
|
onOpenAttachment = viewModel::openAttachment,
|
||||||
|
onImageClick = onImageClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -687,4 +687,15 @@
|
|||||||
<string name="analytics_tool_uses">%1$d회 호출 • 전체의 %2$d%%</string>
|
<string name="analytics_tool_uses">%1$d회 호출 • 전체의 %2$d%%</string>
|
||||||
<string name="processes_kill_confirm">종료</string>
|
<string name="processes_kill_confirm">종료</string>
|
||||||
<string name="processes_kill_cd">프로세스 종료</string>
|
<string name="processes_kill_cd">프로세스 종료</string>
|
||||||
|
|
||||||
|
<!-- Image viewer (issue #723) -->
|
||||||
|
<string name="image_viewer_content_desc">채팅 이미지</string>
|
||||||
|
<string name="image_viewer_close">닫기</string>
|
||||||
|
<string name="image_viewer_save">기기에 저장</string>
|
||||||
|
<string name="image_viewer_share">공유</string>
|
||||||
|
<string name="image_viewer_share_title">이미지 공유</string>
|
||||||
|
<string name="image_viewer_saved">다운로드에 이미지 저장됨</string>
|
||||||
|
<string name="image_viewer_save_failed">이미지를 저장할 수 없음</string>
|
||||||
|
<string name="image_viewer_load_failed">이미지를 불러올 수 없음: %1$s</string>
|
||||||
|
<string name="image_viewer_share_failed">이미지를 공유할 수 없음</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -863,4 +863,15 @@
|
|||||||
<string name="analytics_tool_uses">%1$d calls \u2022 %2$d%% of total</string>
|
<string name="analytics_tool_uses">%1$d calls \u2022 %2$d%% of total</string>
|
||||||
<string name="processes_kill_confirm">Kill</string>
|
<string name="processes_kill_confirm">Kill</string>
|
||||||
<string name="processes_kill_cd">Kill process</string>
|
<string name="processes_kill_cd">Kill process</string>
|
||||||
|
|
||||||
|
<!-- Image viewer (issue #723) -->
|
||||||
|
<string name="image_viewer_content_desc">Chat image</string>
|
||||||
|
<string name="image_viewer_close">Close</string>
|
||||||
|
<string name="image_viewer_save">Save to device</string>
|
||||||
|
<string name="image_viewer_share">Share</string>
|
||||||
|
<string name="image_viewer_share_title">Share image</string>
|
||||||
|
<string name="image_viewer_saved">Image saved to Downloads</string>
|
||||||
|
<string name="image_viewer_save_failed">Could not save image</string>
|
||||||
|
<string name="image_viewer_load_failed">Could not load image: %1$s</string>
|
||||||
|
<string name="image_viewer_share_failed">Could not share image</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -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<Context>(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user