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