refactor(chat): one media viewer and export pipeline (#1328) (#1355)

This commit is contained in:
m57 authored and GitHub committed 2026-09-28 13:21:59 +03:00
1 parent 15bc0e21c7
commit b199c0347a
13 files changed
+450 -726

No files matched your search

@@ -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 {
@@ -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,
)
}
@@ -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"
}
}
@@ -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 `![alt](url)`, inline
* attachment). Provides pinch-zoom + pan, a downswipe-to-dismiss gesture, and a
* top bar with **Save** (writes to device Downloads via [MediaImageStore]) and
* **Share** (Android share sheet) — both fully device-local, never touching the
* Hermes server.
*
* Save/Share resolve the image bytes through [ImageBytesResolver], which handles
* the same model kinds Coil already renders (`data:`, `content://`, `http(s)`).
*/
@Composable
fun ImageViewerDialog(
image: ImageViewerModel,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var scale by remember { mutableStateOf(1f) }
var offsetX by remember { mutableStateOf(0f) }
var offsetY by remember { mutableStateOf(0f) }
var isBusy by remember { mutableStateOf(false) }
// Resolve string resources at composable scope (Lint forbids reading
// resource *values* from LocalContext.current inside coroutine lambdas).
val savedMsg = stringResource(R.string.image_viewer_saved)
val saveFailedMsg = stringResource(R.string.image_viewer_save_failed)
val loadFailedFmt = stringResource(R.string.image_viewer_load_failed)
val shareTitle = stringResource(R.string.image_viewer_share_title)
val shareFailedMsg = stringResource(R.string.image_viewer_share_failed)
val onSave: () -> Unit = {
if (!isBusy) {
isBusy = true
scope.launch(Dispatchers.IO) {
val resolved = ImageBytesResolver.resolve(context, image.model, image.mimeType)
val result =
when (resolved) {
is ImageBytesResolver.Result.Bytes -> {
val name = image.name.ifBlank { "hermes-image.${resolved.extension}" }
val uri =
MediaImageStore.saveToDownloads(
context,
resolved.bytes,
name,
resolved.mimeType,
)
if (uri != null) {
savedMsg
} else {
saveFailedMsg
}
}
is ImageBytesResolver.Result.Error -> {
String.format(loadFailedFmt, resolved.message)
}
}
withContext(Dispatchers.Main) {
isBusy = false
Toast.makeText(context, result, Toast.LENGTH_SHORT).show()
}
}
}
}
val onShare: () -> Unit = {
if (!isBusy) {
isBusy = true
scope.launch(Dispatchers.IO) {
val resolved = ImageBytesResolver.resolve(context, image.model, image.mimeType)
val intent =
when (resolved) {
is ImageBytesResolver.Result.Bytes -> {
val name = image.name.ifBlank { "hermes-image.${resolved.extension}" }
MediaImageStore.buildShareIntent(
context,
resolved.bytes,
name,
resolved.mimeType,
)
}
is ImageBytesResolver.Result.Error -> {
null
}
}
withContext(Dispatchers.Main) {
isBusy = false
if (intent != null) {
context.startActivity(
android.content.Intent
.createChooser(intent, shareTitle),
)
} else {
Toast
.makeText(
context,
shareFailedMsg,
Toast.LENGTH_SHORT,
).show()
}
}
}
}
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.scrim,
) {
Box(modifier = Modifier.fillMaxSize()) {
// Zoomable / pannable image.
AsyncImage(
model = image.model,
contentDescription =
image.name.ifBlank { stringResource(R.string.image_viewer_content_desc) },
modifier =
Modifier
.fillMaxSize()
.graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = offsetX,
translationY = offsetY,
).pointerInput(Unit) {
detectTransformGestures(
onGesture = { _, pan, zoom, _ ->
val newScale = (scale * zoom).coerceIn(1f, 5f)
// While zoomed in, allow free panning; at min scale,
// only vertical drags are permitted (for dismiss).
scale = newScale
offsetX = if (newScale > 1f) offsetX + pan.x else 0f
offsetY += pan.y
// Downswipe-to-dismiss when at min zoom.
if (newScale <= 1f && offsetY > 120f) {
onDismiss()
}
},
)
},
contentScale = ContentScale.Fit,
)
// Top action bar — Save / Share / Close.
Row(
modifier =
Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onDismiss) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.image_viewer_close),
tint = MaterialTheme.colorScheme.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,
)
}
}
}
}
}
}
}
}
@@ -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
@@ -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,
)
}
}
}
}
}
}
}
}
@@ -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)
}
@@ -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,
-5
View File
@@ -1261,7 +1261,6 @@
<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="plugins_memory_manage">إدارة المزود…</string>
<string name="memory_provider_empty_title">لا توجد إعدادات للمزود</string>
<string name="memory_provider_empty_desc">لا يحتوي هذا المزود على خيارات قابلة للتهيئة.</string>
@@ -1277,10 +1276,6 @@
<string name="memory_provider_secret_label">مفتاح API (اتركه فارغاً للإبقاء عليه)</string>
<string name="memory_provider_bool_on">مفعل</string>
<string name="memory_provider_bool_off">معطل</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>
<string name="analytics_most_used_components">المكونات الأكثر استخداماً</string>
<string name="analytics_no_model_data">لا تتوفر بيانات للنماذج</string>
<string name="analytics_no_skill_data">لا تتوفر بيانات للمهارات</string>
-5
View File
@@ -1082,11 +1082,6 @@
<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>
<string name="timeline_marker_skill_invocation">스킬 호출됨</string>
<string name="timeline_marker_internal_notification">시스템 알림</string>
<string name="timeline_marker_max_iterations">도구 호출 한도 도달</string>
-5
View File
@@ -1223,7 +1223,6 @@
<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="plugins_memory_manage">管理提供商…</string>
<string name="memory_provider_empty_title">没有提供商配置</string>
<string name="memory_provider_empty_desc">此提供商没有可配置的界面。</string>
@@ -1239,10 +1238,6 @@
<string name="memory_provider_secret_label">API 密钥(留空保持不变)</string>
<string name="memory_provider_bool_on">开</string>
<string name="memory_provider_bool_off">关</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>
<plurals name="skills_hub_scan_findings_count">
<item quantity="one">%1$d 处发现</item>
<item quantity="other">%1$d 处发现</item>
-5
View File
@@ -1469,7 +1469,6 @@
<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="plugins_memory_manage">Manage provider…</string>
<string name="memory_provider_empty_title">No provider config</string>
<string name="memory_provider_empty_desc">This provider has no configurable surface.</string>
@@ -1485,10 +1484,6 @@
<string name="memory_provider_secret_label">API key (leave blank to keep)</string>
<string name="memory_provider_bool_on">On</string>
<string name="memory_provider_bool_off">Off</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>
<!-- hardcoded-UI string migration (en) -->
<string name="analytics_most_used_components">Most Used Components</string>
<string name="analytics_no_model_data">No model data available</string>
@@ -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<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("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)
}
}