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

@@ -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,