* 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,
|
||||
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 ``, 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
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user