From b199c0347a37dcc6893b6f210562f999a3c5a221 Mon Sep 17 00:00:00 2001
From: m57 <41645758+Hy4ri@users.noreply.github.com>
Date: Mon, 28 Sep 2026 13:21:59 +0300
Subject: [PATCH] refactor(chat): one media viewer and export pipeline (#1328)
(#1355)
---
.../ui/chat/MediaViewerDeviceTest.kt | 53 ++++
.../m57/hermescontrol/ui/chat/ChatScreen.kt | 9 +-
.../ui/chat/ImageBytesResolver.kt | 125 ---------
.../ui/chat/ImageViewerDialog.kt | 238 ------------------
.../hermescontrol/ui/chat/MediaImageStore.kt | 65 +----
.../ui/chat/components/ImageViewerContent.kt | 155 ++++++++++++
.../ui/chat/components/MediaExportActions.kt | 217 ++++++++++++++++
.../ui/chat/components/MediaViewerDialog.kt | 198 +--------------
app/src/main/res/values-ar/strings.xml | 5 -
app/src/main/res/values-ko/strings.xml | 5 -
app/src/main/res/values-zh/strings.xml | 5 -
app/src/main/res/values/strings.xml | 5 -
.../ui/chat/ImageBytesResolverTest.kt | 96 -------
13 files changed, 450 insertions(+), 726 deletions(-)
delete mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ImageBytesResolver.kt
delete mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ImageViewerDialog.kt
create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/ImageViewerContent.kt
create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/MediaExportActions.kt
delete mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ImageBytesResolverTest.kt
diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MediaViewerDeviceTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MediaViewerDeviceTest.kt
index 49617fb4..f7eea804 100644
--- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MediaViewerDeviceTest.kt
+++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MediaViewerDeviceTest.kt
@@ -210,6 +210,59 @@ class MediaViewerDeviceTest {
assertEquals(expectedSize, actualSize)
}
+ @Test
+ fun imageUsesImageSurfaceAndStreamsSaveToDownloads() {
+ val instrumentation = InstrumentationRegistry.getInstrumentation()
+ val context = instrumentation.targetContext
+ val name = "viewer-image-${System.nanoTime()}.png"
+ val file = File(context.cacheDir, name)
+ val bitmap = android.graphics.Bitmap.createBitmap(8, 8, android.graphics.Bitmap.Config.ARGB_8888)
+ file.outputStream().use { bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it) }
+ bitmap.recycle()
+ compose.setContent {
+ var visible by remember { mutableStateOf(true) }
+ HermesControlTheme {
+ if (visible) {
+ MediaViewerDialog(file.toURI().toString(), { visible = false }, name, "image/png")
+ }
+ }
+ }
+ awaitTag("media_image")
+ // Images never get the player surface.
+ assertTrue(compose.onAllNodesWithTag("media_play_pause_button").fetchSemanticsNodes().isEmpty())
+ compose.onNodeWithTag("media_save_button").assertIsDisplayed().performClick()
+
+ val resolver = context.contentResolver
+ var savedUri: Uri? = null
+ compose.waitUntil(15_000) {
+ resolver
+ .query(
+ MediaStore.Downloads.EXTERNAL_CONTENT_URI,
+ arrayOf(MediaStore.MediaColumns._ID),
+ "${MediaStore.MediaColumns.DISPLAY_NAME} = ?",
+ arrayOf(name),
+ null,
+ )?.use { c ->
+ if (c.moveToFirst()) {
+ val id = c.getLong(c.getColumnIndexOrThrow(MediaStore.MediaColumns._ID))
+ savedUri = ContentUris.withAppendedId(MediaStore.Downloads.EXTERNAL_CONTENT_URI, id)
+ true
+ } else {
+ false
+ }
+ } ?: false
+ }
+ try {
+ assertArrayEquals(file.readBytes(), resolver.openInputStream(savedUri!!)?.use { it.readBytes() })
+ } finally {
+ savedUri?.let { resolver.delete(it, null, null) }
+ }
+ compose.onNodeWithTag("media_close_button").performClick()
+ compose.waitUntil(15_000) {
+ compose.onAllNodesWithTag("media_viewer_dialog").fetchSemanticsNodes().isEmpty()
+ }
+ }
+
@Test
fun invalidSourceShowsRetryAndCanClose() {
compose.setContent {
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 1288de73..919ec93e 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
@@ -106,6 +106,7 @@ import com.m57.hermescontrol.ui.chat.components.ChatTimelineSheet
import com.m57.hermescontrol.ui.chat.components.ConnectionSetupSheet
import com.m57.hermescontrol.ui.chat.components.ContextDetailSheet
import com.m57.hermescontrol.ui.chat.components.ContextUsageChip
+import com.m57.hermescontrol.ui.chat.components.MediaViewerDialog
import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay
import com.m57.hermescontrol.ui.chat.components.ReloginDialog
import com.m57.hermescontrol.ui.chat.components.ReplyErrorCard
@@ -1066,9 +1067,11 @@ fun ChatScreen(
}
viewingImage?.let { image ->
- ImageViewerDialog(
- image = image,
- onDismiss = { viewingImage = null },
+ MediaViewerDialog(
+ mediaUri = image.model,
+ onDismissRequest = { viewingImage = null },
+ title = image.name.ifBlank { null },
+ mimeType = image.mimeType,
)
}
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
deleted file mode 100644
index 84894f01..00000000
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ImageBytesResolver.kt
+++ /dev/null
@@ -1,125 +0,0 @@
-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") }
- }
-
- /** Image-only view of the shared MIME table: anything that isn't `image/` saves as `.img`. */
- fun extensionForMime(mime: String): String =
- if (mime.trim().startsWith("image/", ignoreCase = true)) {
- com.m57.hermescontrol.ui.chat
- .extensionForMime(mime)
- } 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
deleted file mode 100644
index f202609d..00000000
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ImageViewerDialog.kt
+++ /dev/null
@@ -1,238 +0,0 @@
-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 coil3.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.onSurface,
- )
- }
- Row {
- if (isBusy) {
- CircularProgressIndicator(
- modifier = Modifier.size(24.dp).padding(horizontal = 8.dp),
- strokeWidth = 2.dp,
- color = MaterialTheme.colorScheme.onSurface,
- )
- } else {
- IconButton(onClick = onSave, enabled = !isBusy) {
- Icon(
- imageVector = Icons.Filled.Download,
- contentDescription = stringResource(R.string.image_viewer_save),
- tint = MaterialTheme.colorScheme.onSurface,
- )
- }
- IconButton(onClick = onShare, enabled = !isBusy) {
- Icon(
- imageVector = Icons.Filled.Share,
- contentDescription = stringResource(R.string.image_viewer_share),
- tint = MaterialTheme.colorScheme.onSurface,
- )
- }
- }
- }
- }
- }
- }
- }
-}
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
index 2cd42077..776fe1d0 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaImageStore.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaImageStore.kt
@@ -2,42 +2,21 @@ 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.ByteArrayInputStream
-import java.io.File
import java.io.InputStream
/**
- * 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.
+ * Streams non-chat downloads (backup archives) into device Downloads. Chat
+ * media save/share goes through [MediaExportHelper] (issue #1328).
*/
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? = saveToDownloads(context, ByteArrayInputStream(bytes), bytes.size.toLong(), displayName, mimeType)
-
- /**
- * Streaming variant of [saveToDownloads] — for large payloads (e.g. backup
- * archives, 300+ MB) that must NOT be held in memory as a [ByteArray].
- * Copies [input] to the Download collection in chunks. Same return contract.
+ * Stream [input] into the device's Download collection via [MediaStore] —
+ * for large payloads (e.g. backup archives, 300+ MB) that must NOT be held
+ * in memory. Returns the public [Uri] on success, or `null` on failure.
+ * On API 29+ the file lands under `Download/Hermes`.
*/
fun saveToDownloads(
context: Context,
@@ -85,38 +64,6 @@ object MediaImageStore {
}
}
- /**
- * 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
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ImageViewerContent.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ImageViewerContent.kt
new file mode 100644
index 00000000..9ffbe313
--- /dev/null
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ImageViewerContent.kt
@@ -0,0 +1,155 @@
+package com.m57.hermescontrol.ui.chat.components
+
+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.mutableFloatStateOf
+import androidx.compose.runtime.remember
+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.testTag
+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 coil3.compose.AsyncImage
+import com.m57.hermescontrol.R
+import com.m57.hermescontrol.ui.chat.mediaNameFromPath
+
+/**
+ * Image surface of [MediaViewerDialog]: pinch-zoom + pan, downswipe-to-dismiss,
+ * and the shared streaming Save/Share actions. No ExoPlayer is created here.
+ */
+@Composable
+internal fun ImageViewerContent(
+ mediaUri: String,
+ onDismissRequest: () -> Unit,
+ title: String?,
+ mimeType: String?,
+) {
+ var scale by remember { mutableFloatStateOf(1f) }
+ var offsetX by remember { mutableFloatStateOf(0f) }
+ var offsetY by remember { mutableFloatStateOf(0f) }
+ val displayName =
+ remember(mediaUri, title) {
+ title?.takeIf { it.isNotBlank() } ?: mediaNameFromPath(mediaUri)
+ }
+ val export = rememberMediaExportActions(mediaUri, mimeType, displayName)
+
+ Dialog(
+ onDismissRequest = onDismissRequest,
+ properties = DialogProperties(usePlatformDefaultWidth = false),
+ ) {
+ Surface(
+ modifier = Modifier.fillMaxSize().testTag("media_viewer_dialog"),
+ color = MaterialTheme.colorScheme.scrim,
+ ) {
+ Box(modifier = Modifier.fillMaxSize()) {
+ AsyncImage(
+ model = mediaUri,
+ contentDescription =
+ title?.takeIf { it.isNotBlank() }
+ ?: stringResource(R.string.image_viewer_content_desc),
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .testTag("media_image")
+ .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) {
+ onDismissRequest()
+ }
+ },
+ )
+ },
+ contentScale = ContentScale.Fit,
+ )
+
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .statusBarsPadding()
+ .padding(8.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ IconButton(
+ onClick = onDismissRequest,
+ modifier = Modifier.testTag("media_close_button"),
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(R.string.image_viewer_close),
+ tint = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ Row {
+ if (export.isBusy) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(24.dp).padding(horizontal = 8.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ } else {
+ IconButton(
+ onClick = export.onSave,
+ modifier = Modifier.testTag("media_save_button"),
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Download,
+ contentDescription = stringResource(R.string.image_viewer_save),
+ tint = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ IconButton(
+ onClick = export.onShare,
+ modifier = Modifier.testTag("media_share_button"),
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Share,
+ contentDescription = stringResource(R.string.image_viewer_share),
+ tint = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MediaExportActions.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MediaExportActions.kt
new file mode 100644
index 00000000..654fa690
--- /dev/null
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MediaExportActions.kt
@@ -0,0 +1,217 @@
+package com.m57.hermescontrol.ui.chat.components
+
+import android.content.Intent
+import android.os.Build
+import android.widget.Toast
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+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.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import com.m57.hermescontrol.ExternalActivityLifecycleGuard
+import com.m57.hermescontrol.R
+import com.m57.hermescontrol.data.ws.HermesWsClient
+import com.m57.hermescontrol.notification.NotificationHelper
+import com.m57.hermescontrol.ui.chat.MediaExportHelper
+import com.m57.hermescontrol.ui.chat.MediaSaveStrategy
+import com.m57.hermescontrol.ui.chat.acceptedSaveDestination
+import com.m57.hermescontrol.ui.chat.mediaSaveStrategy
+import com.m57.hermescontrol.ui.chat.normalizeFrameworkMime
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/** Save/Share state for the media viewer; [isBusy] is true while either is in flight. */
+internal class MediaExportActions(
+ val isBusy: Boolean,
+ val onSave: () -> Unit,
+ val onShare: () -> Unit,
+)
+
+/**
+ * The single Save/Share pipeline for every media kind (issue #1328). Streams
+ * through [MediaExportHelper]: MediaStore Downloads on API 29+, a SAF
+ * create-document picker on API 26-28, and a FileProvider share cache.
+ */
+@Composable
+internal fun rememberMediaExportActions(
+ mediaUri: String,
+ mimeType: String?,
+ displayName: String,
+): MediaExportActions {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ var isBusy by remember { mutableStateOf(false) }
+ val saveFailed = stringResource(R.string.media_player_save_failed)
+ val shareFailed = stringResource(R.string.media_player_share_failed)
+ val shareTitle = stringResource(R.string.media_player_share_title)
+
+ val launchExternalActivity: (() -> Unit) -> Unit = { launch ->
+ ExternalActivityLifecycleGuard.launchExternalActivity(
+ acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease,
+ releaseConnectionLease = HermesWsClient::releaseExternalActivityConnectionLease,
+ prepareForBackground = { NotificationHelper.start(context) },
+ cleanupAfterLaunchFailure = { NotificationHelper.stop(context) },
+ launch = launch,
+ )
+ }
+
+ val saveDocumentLauncher =
+ rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.StartActivityForResult(),
+ ) { result ->
+ try {
+ ExternalActivityLifecycleGuard.externalActivityReturned()
+ val destination = acceptedSaveDestination(result.resultCode, result.data?.data)
+ if (destination != null) {
+ scope.launch {
+ try {
+ val msg =
+ MediaExportHelper.saveMediaToUri(
+ context = context,
+ sourceUri = mediaUri,
+ targetUri = destination,
+ fallbackMime =
+ mimeType?.takeUnless { it == "application/octet-stream" }
+ ?: com.m57.hermescontrol.ui.chat
+ .mediaMimeForPath(displayName),
+ )
+ withContext(Dispatchers.Main) {
+ Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
+ }
+ } catch (e: CancellationException) {
+ throw e
+ } catch (_: Exception) {
+ withContext(Dispatchers.Main) {
+ Toast.makeText(context, saveFailed, Toast.LENGTH_SHORT).show()
+ }
+ } finally {
+ isBusy = false
+ }
+ }
+ } else {
+ isBusy = false
+ }
+ } catch (e: Throwable) {
+ isBusy = false
+ if (e is CancellationException) throw e
+ }
+ }
+
+ val onSave: () -> Unit = {
+ if (!isBusy) {
+ isBusy = true
+ when (mediaSaveStrategy()) {
+ MediaSaveStrategy.MEDIA_STORE -> {
+ scope.launch {
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ val msg =
+ MediaExportHelper.saveMediaToDownloads(
+ context = context,
+ uri = mediaUri,
+ fallbackMime =
+ mimeType?.takeUnless { it == "application/octet-stream" }
+ ?: com.m57.hermescontrol.ui.chat
+ .mediaMimeForPath(displayName),
+ displayName = displayName,
+ )
+ withContext(Dispatchers.Main) {
+ Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
+ }
+ }
+ } catch (e: CancellationException) {
+ throw e
+ } catch (_: Exception) {
+ withContext(Dispatchers.Main) {
+ Toast.makeText(context, saveFailed, Toast.LENGTH_SHORT).show()
+ }
+ } finally {
+ isBusy = false
+ }
+ }
+ }
+
+ MediaSaveStrategy.CREATE_DOCUMENT -> {
+ val suggestedName =
+ MediaExportHelper.resolveDisplayName(
+ displayName = displayName,
+ mimeType = mimeType,
+ uri = mediaUri,
+ )
+ val rawMime =
+ mimeType?.takeUnless { it == "application/octet-stream" }
+ ?: com.m57.hermescontrol.ui.chat
+ .mediaMimeForPath(displayName)
+ val normalizedMime = normalizeFrameworkMime(rawMime)
+ val intent =
+ Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
+ addCategory(Intent.CATEGORY_OPENABLE)
+ type = normalizedMime
+ putExtra(Intent.EXTRA_TITLE, suggestedName)
+ }
+ try {
+ launchExternalActivity {
+ saveDocumentLauncher.launch(intent)
+ }
+ } catch (_: Exception) {
+ isBusy = false
+ scope.launch(Dispatchers.Main) {
+ Toast.makeText(context, saveFailed, Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+ }
+ }
+ }
+
+ val onShare: () -> Unit = {
+ if (!isBusy) {
+ isBusy = true
+ scope.launch {
+ try {
+ val intent =
+ MediaExportHelper.shareMedia(
+ context = context,
+ uri = mediaUri,
+ fallbackMime =
+ mimeType?.takeUnless { it == "application/octet-stream" }
+ ?: com.m57.hermescontrol.ui.chat
+ .mediaMimeForPath(displayName),
+ displayName = displayName,
+ )
+ withContext(Dispatchers.Main) {
+ if (intent != null) {
+ val chooser = Intent.createChooser(intent, shareTitle)
+ try {
+ launchExternalActivity {
+ context.startActivity(chooser)
+ }
+ } catch (_: Exception) {
+ Toast.makeText(context, shareFailed, Toast.LENGTH_SHORT).show()
+ }
+ } else {
+ Toast.makeText(context, shareFailed, Toast.LENGTH_SHORT).show()
+ }
+ }
+ } catch (e: CancellationException) {
+ throw e
+ } catch (_: Exception) {
+ withContext(Dispatchers.Main) {
+ Toast.makeText(context, shareFailed, Toast.LENGTH_SHORT).show()
+ }
+ } finally {
+ isBusy = false
+ }
+ }
+ }
+ }
+
+ return MediaExportActions(isBusy = isBusy, onSave = onSave, onShare = onShare)
+}
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MediaViewerDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MediaViewerDialog.kt
index b8666b08..41110577 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MediaViewerDialog.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MediaViewerDialog.kt
@@ -1,11 +1,6 @@
package com.m57.hermescontrol.ui.chat.components
-import android.content.Intent
import android.net.Uri
-import android.os.Build
-import android.widget.Toast
-import androidx.activity.compose.rememberLauncherForActivityResult
-import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
@@ -51,7 +46,6 @@ import androidx.compose.runtime.key
import androidx.compose.runtime.mutableLongStateOf
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
@@ -76,28 +70,18 @@ import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
-import com.m57.hermescontrol.ExternalActivityLifecycleGuard
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.remote.MediaDataSourceProvider
-import com.m57.hermescontrol.data.ws.HermesWsClient
-import com.m57.hermescontrol.notification.NotificationHelper
import com.m57.hermescontrol.theme.LocalHermesStatusColors
-import com.m57.hermescontrol.ui.chat.MediaExportHelper
import com.m57.hermescontrol.ui.chat.MediaKind
-import com.m57.hermescontrol.ui.chat.MediaSaveStrategy
-import com.m57.hermescontrol.ui.chat.acceptedSaveDestination
import com.m57.hermescontrol.ui.chat.classifyMedia
import com.m57.hermescontrol.ui.chat.mediaNameFromPath
-import com.m57.hermescontrol.ui.chat.mediaSaveStrategy
-import com.m57.hermescontrol.ui.chat.normalizeFrameworkMime
-import kotlinx.coroutines.CancellationException
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
/**
- * Unified Media Viewer Dialog for both Audio and Video, backed by AndroidX Media3 ExoPlayer.
+ * The one full-screen media viewer (issue #1328): images get the zoom/pan
+ * surface, audio and video the ExoPlayer surface. Both share the same streaming
+ * Save/Share pipeline ([rememberMediaExportActions]).
*/
@OptIn(UnstableApi::class)
@Composable
@@ -108,7 +92,12 @@ fun MediaViewerDialog(
mimeType: String? = null,
) {
key(mediaUri, mimeType) {
- MediaViewerContent(mediaUri, onDismissRequest, title, mimeType)
+ val kind = remember { classifyMedia(mimeType = mimeType, name = title, uri = mediaUri) }
+ if (kind == MediaKind.IMAGE) {
+ ImageViewerContent(mediaUri, onDismissRequest, title, mimeType)
+ } else {
+ MediaViewerContent(mediaUri, onDismissRequest, title, mimeType)
+ }
}
}
@@ -122,7 +111,6 @@ private fun MediaViewerContent(
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
- val scope = rememberCoroutineScope()
val mediaKind =
remember(mediaUri, mimeType, title) {
@@ -145,12 +133,8 @@ private fun MediaViewerContent(
var dragPositionMs by remember { mutableLongStateOf(0L) }
var showControls by remember { mutableStateOf(true) }
- var isBusy by remember { mutableStateOf(false) }
val statusColors = LocalHermesStatusColors.current
- val saveFailed = stringResource(R.string.media_player_save_failed)
- val shareFailed = stringResource(R.string.media_player_share_failed)
- val shareTitle = stringResource(R.string.media_player_share_title)
val player =
remember(context, mediaUri) {
@@ -260,166 +244,10 @@ private fun MediaViewerContent(
}
}
- val launchExternalActivity: (() -> Unit) -> Unit = { launch ->
- ExternalActivityLifecycleGuard.launchExternalActivity(
- acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease,
- releaseConnectionLease = HermesWsClient::releaseExternalActivityConnectionLease,
- prepareForBackground = { NotificationHelper.start(context) },
- cleanupAfterLaunchFailure = { NotificationHelper.stop(context) },
- launch = launch,
- )
- }
-
- val saveDocumentLauncher =
- rememberLauncherForActivityResult(
- contract = ActivityResultContracts.StartActivityForResult(),
- ) { result ->
- try {
- ExternalActivityLifecycleGuard.externalActivityReturned()
- val destination = acceptedSaveDestination(result.resultCode, result.data?.data)
- if (destination != null) {
- scope.launch {
- try {
- val msg =
- MediaExportHelper.saveMediaToUri(
- context = context,
- sourceUri = mediaUri,
- targetUri = destination,
- fallbackMime =
- mimeType?.takeUnless { it == "application/octet-stream" }
- ?: com.m57.hermescontrol.ui.chat
- .mediaMimeForPath(displayName),
- )
- withContext(Dispatchers.Main) {
- Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
- }
- } catch (e: CancellationException) {
- throw e
- } catch (_: Exception) {
- withContext(Dispatchers.Main) {
- Toast.makeText(context, saveFailed, Toast.LENGTH_SHORT).show()
- }
- } finally {
- isBusy = false
- }
- }
- } else {
- isBusy = false
- }
- } catch (e: Throwable) {
- isBusy = false
- if (e is CancellationException) throw e
- }
- }
-
- val onSave: () -> Unit = {
- if (!isBusy) {
- isBusy = true
- when (mediaSaveStrategy()) {
- MediaSaveStrategy.MEDIA_STORE -> {
- scope.launch {
- try {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- val msg =
- MediaExportHelper.saveMediaToDownloads(
- context = context,
- uri = mediaUri,
- fallbackMime =
- mimeType?.takeUnless { it == "application/octet-stream" }
- ?: com.m57.hermescontrol.ui.chat
- .mediaMimeForPath(displayName),
- displayName = displayName,
- )
- withContext(Dispatchers.Main) {
- Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
- }
- }
- } catch (e: CancellationException) {
- throw e
- } catch (_: Exception) {
- withContext(Dispatchers.Main) {
- Toast.makeText(context, saveFailed, Toast.LENGTH_SHORT).show()
- }
- } finally {
- isBusy = false
- }
- }
- }
-
- MediaSaveStrategy.CREATE_DOCUMENT -> {
- val suggestedName =
- MediaExportHelper.resolveDisplayName(
- displayName = displayName,
- mimeType = mimeType,
- uri = mediaUri,
- )
- val rawMime =
- mimeType?.takeUnless { it == "application/octet-stream" }
- ?: com.m57.hermescontrol.ui.chat
- .mediaMimeForPath(displayName)
- val normalizedMime = normalizeFrameworkMime(rawMime)
- val intent =
- Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
- addCategory(Intent.CATEGORY_OPENABLE)
- type = normalizedMime
- putExtra(Intent.EXTRA_TITLE, suggestedName)
- }
- try {
- launchExternalActivity {
- saveDocumentLauncher.launch(intent)
- }
- } catch (_: Exception) {
- isBusy = false
- scope.launch(Dispatchers.Main) {
- Toast.makeText(context, saveFailed, Toast.LENGTH_SHORT).show()
- }
- }
- }
- }
- }
- }
-
- val onShare: () -> Unit = {
- if (!isBusy) {
- isBusy = true
- scope.launch {
- try {
- val intent =
- MediaExportHelper.shareMedia(
- context = context,
- uri = mediaUri,
- fallbackMime =
- mimeType?.takeUnless { it == "application/octet-stream" }
- ?: com.m57.hermescontrol.ui.chat
- .mediaMimeForPath(displayName),
- displayName = displayName,
- )
- withContext(Dispatchers.Main) {
- if (intent != null) {
- val chooser = Intent.createChooser(intent, shareTitle)
- try {
- launchExternalActivity {
- context.startActivity(chooser)
- }
- } catch (_: Exception) {
- Toast.makeText(context, shareFailed, Toast.LENGTH_SHORT).show()
- }
- } else {
- Toast.makeText(context, shareFailed, Toast.LENGTH_SHORT).show()
- }
- }
- } catch (e: CancellationException) {
- throw e
- } catch (_: Exception) {
- withContext(Dispatchers.Main) {
- Toast.makeText(context, shareFailed, Toast.LENGTH_SHORT).show()
- }
- } finally {
- isBusy = false
- }
- }
- }
- }
+ val exportActions = rememberMediaExportActions(mediaUri, mimeType, displayName)
+ val isBusy = exportActions.isBusy
+ val onSave = exportActions.onSave
+ val onShare = exportActions.onShare
Dialog(
onDismissRequest = onDismissRequest,
diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml
index 15e5b90e..6ff8169f 100644
--- a/app/src/main/res/values-ar/strings.xml
+++ b/app/src/main/res/values-ar/strings.xml
@@ -1261,7 +1261,6 @@
إغلاق
حفظ على الجهاز
مشاركة
-مشاركة الصورة
إدارة المزود…
لا توجد إعدادات للمزود
لا يحتوي هذا المزود على خيارات قابلة للتهيئة.
@@ -1277,10 +1276,6 @@
مفتاح API (اتركه فارغاً للإبقاء عليه)
مفعل
معطل
-تم حفظ الصورة في التنزيلات
-تعذر حفظ الصورة
-تعذر تحميل الصورة: %1$s
-تعذر مشاركة الصورة
المكونات الأكثر استخداماً
لا تتوفر بيانات للنماذج
لا تتوفر بيانات للمهارات
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
index c659080f..395700da 100644
--- a/app/src/main/res/values-ko/strings.xml
+++ b/app/src/main/res/values-ko/strings.xml
@@ -1082,11 +1082,6 @@
닫기
기기에 저장
공유
- 이미지 공유
- 다운로드에 이미지 저장됨
- 이미지를 저장할 수 없음
- 이미지를 불러올 수 없음: %1$s
- 이미지를 공유할 수 없음
스킬 호출됨
시스템 알림
도구 호출 한도 도달
diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml
index eac2130b..34212136 100644
--- a/app/src/main/res/values-zh/strings.xml
+++ b/app/src/main/res/values-zh/strings.xml
@@ -1223,7 +1223,6 @@
关闭
保存到设备
分享
- 分享图片
管理提供商…
没有提供商配置
此提供商没有可配置的界面。
@@ -1239,10 +1238,6 @@
API 密钥(留空保持不变)
开
关
- 图片已保存到下载目录
- 无法保存图片
- 无法加载图片:%1$s
- 无法分享图片
- %1$d 处发现
- %1$d 处发现
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index c05a133d..793ce2ba 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1469,7 +1469,6 @@
Close
Save to device
Share
- Share image
Manage provider…
No provider config
This provider has no configurable surface.
@@ -1485,10 +1484,6 @@
API key (leave blank to keep)
On
Off
- Image saved to Downloads
- Could not save image
- Could not load image: %1$s
- Could not share image
Most Used Components
No model data available
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
deleted file mode 100644
index b01aff74..00000000
--- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ImageBytesResolverTest.kt
+++ /dev/null
@@ -1,96 +0,0 @@
-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("png", ImageBytesResolver.extensionForMime(" image/png; charset=binary"))
- 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"))
- assertEquals("heic", ImageBytesResolver.extensionForMime("image/heif"))
- assertEquals("bmp", ImageBytesResolver.extensionForMime("image/bmp"))
- // Non-image content types stay `.img`, not `.bin`/`.pdf`.
- assertEquals("img", ImageBytesResolver.extensionForMime("application/pdf"))
- assertEquals("img", ImageBytesResolver.extensionForMime("text/html"))
- }
-
- @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)
- }
-}