@@ -210,6 +210,59 @@ class MediaViewerDeviceTest {
|
|||||||
assertEquals(expectedSize, actualSize)
|
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
|
@Test
|
||||||
fun invalidSourceShowsRetryAndCanClose() {
|
fun invalidSourceShowsRetryAndCanClose() {
|
||||||
compose.setContent {
|
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.ConnectionSetupSheet
|
||||||
import com.m57.hermescontrol.ui.chat.components.ContextDetailSheet
|
import com.m57.hermescontrol.ui.chat.components.ContextDetailSheet
|
||||||
import com.m57.hermescontrol.ui.chat.components.ContextUsageChip
|
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.ReactionHeartsOverlay
|
||||||
import com.m57.hermescontrol.ui.chat.components.ReloginDialog
|
import com.m57.hermescontrol.ui.chat.components.ReloginDialog
|
||||||
import com.m57.hermescontrol.ui.chat.components.ReplyErrorCard
|
import com.m57.hermescontrol.ui.chat.components.ReplyErrorCard
|
||||||
@@ -1066,9 +1067,11 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
viewingImage?.let { image ->
|
viewingImage?.let { image ->
|
||||||
ImageViewerDialog(
|
MediaViewerDialog(
|
||||||
image = image,
|
mediaUri = image.model,
|
||||||
onDismiss = { viewingImage = null },
|
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.ContentValues
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import androidx.core.content.FileProvider
|
|
||||||
import java.io.ByteArrayInputStream
|
|
||||||
import java.io.File
|
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persist / dispatch chat images on the *device* (Downloads / Gallery / share
|
* Streams non-chat downloads (backup archives) into device Downloads. Chat
|
||||||
* sheet) — never back to the Hermes server (issue #723). Every operation works
|
* media save/share goes through [MediaExportHelper] (issue #1328).
|
||||||
* only on bytes the viewer already resolved locally, so save/share are fully
|
|
||||||
* device-local and independent of the backend.
|
|
||||||
*/
|
*/
|
||||||
object MediaImageStore {
|
object MediaImageStore {
|
||||||
/**
|
/**
|
||||||
* Write [bytes] into the device's Download collection via [MediaStore] so the
|
* Stream [input] into the device's Download collection via [MediaStore] —
|
||||||
* file appears in system Downloads / Gallery apps. Returns the public [Uri]
|
* for large payloads (e.g. backup archives, 300+ MB) that must NOT be held
|
||||||
* on success, or `null` if the write failed.
|
* in memory. Returns the public [Uri] on success, or `null` on failure.
|
||||||
*
|
* On API 29+ the file lands under `Download/Hermes`.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
fun saveToDownloads(
|
fun saveToDownloads(
|
||||||
context: Context,
|
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
|
* 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
|
* 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
|
package com.m57.hermescontrol.ui.chat.components
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
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.annotation.OptIn
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
@@ -51,7 +46,6 @@ import androidx.compose.runtime.key
|
|||||||
import androidx.compose.runtime.mutableLongStateOf
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -76,28 +70,18 @@ import androidx.media3.exoplayer.ExoPlayer
|
|||||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
import androidx.media3.ui.AspectRatioFrameLayout
|
import androidx.media3.ui.AspectRatioFrameLayout
|
||||||
import androidx.media3.ui.PlayerView
|
import androidx.media3.ui.PlayerView
|
||||||
import com.m57.hermescontrol.ExternalActivityLifecycleGuard
|
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.data.remote.MediaDataSourceProvider
|
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.theme.LocalHermesStatusColors
|
||||||
import com.m57.hermescontrol.ui.chat.MediaExportHelper
|
|
||||||
import com.m57.hermescontrol.ui.chat.MediaKind
|
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.classifyMedia
|
||||||
import com.m57.hermescontrol.ui.chat.mediaNameFromPath
|
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.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)
|
@OptIn(UnstableApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -108,7 +92,12 @@ fun MediaViewerDialog(
|
|||||||
mimeType: String? = null,
|
mimeType: String? = null,
|
||||||
) {
|
) {
|
||||||
key(mediaUri, mimeType) {
|
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 context = LocalContext.current
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
val mediaKind =
|
val mediaKind =
|
||||||
remember(mediaUri, mimeType, title) {
|
remember(mediaUri, mimeType, title) {
|
||||||
@@ -145,12 +133,8 @@ private fun MediaViewerContent(
|
|||||||
var dragPositionMs by remember { mutableLongStateOf(0L) }
|
var dragPositionMs by remember { mutableLongStateOf(0L) }
|
||||||
|
|
||||||
var showControls by remember { mutableStateOf(true) }
|
var showControls by remember { mutableStateOf(true) }
|
||||||
var isBusy by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val statusColors = LocalHermesStatusColors.current
|
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 =
|
val player =
|
||||||
remember(context, mediaUri) {
|
remember(context, mediaUri) {
|
||||||
@@ -260,166 +244,10 @@ private fun MediaViewerContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val launchExternalActivity: (() -> Unit) -> Unit = { launch ->
|
val exportActions = rememberMediaExportActions(mediaUri, mimeType, displayName)
|
||||||
ExternalActivityLifecycleGuard.launchExternalActivity(
|
val isBusy = exportActions.isBusy
|
||||||
acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease,
|
val onSave = exportActions.onSave
|
||||||
releaseConnectionLease = HermesWsClient::releaseExternalActivityConnectionLease,
|
val onShare = exportActions.onShare
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Dialog(
|
Dialog(
|
||||||
onDismissRequest = onDismissRequest,
|
onDismissRequest = onDismissRequest,
|
||||||
|
|||||||
@@ -1261,7 +1261,6 @@
|
|||||||
<string name="image_viewer_close">إغلاق</string>
|
<string name="image_viewer_close">إغلاق</string>
|
||||||
<string name="image_viewer_save">حفظ على الجهاز</string>
|
<string name="image_viewer_save">حفظ على الجهاز</string>
|
||||||
<string name="image_viewer_share">مشاركة</string>
|
<string name="image_viewer_share">مشاركة</string>
|
||||||
<string name="image_viewer_share_title">مشاركة الصورة</string>
|
|
||||||
<string name="plugins_memory_manage">إدارة المزود…</string>
|
<string name="plugins_memory_manage">إدارة المزود…</string>
|
||||||
<string name="memory_provider_empty_title">لا توجد إعدادات للمزود</string>
|
<string name="memory_provider_empty_title">لا توجد إعدادات للمزود</string>
|
||||||
<string name="memory_provider_empty_desc">لا يحتوي هذا المزود على خيارات قابلة للتهيئة.</string>
|
<string name="memory_provider_empty_desc">لا يحتوي هذا المزود على خيارات قابلة للتهيئة.</string>
|
||||||
@@ -1277,10 +1276,6 @@
|
|||||||
<string name="memory_provider_secret_label">مفتاح API (اتركه فارغاً للإبقاء عليه)</string>
|
<string name="memory_provider_secret_label">مفتاح API (اتركه فارغاً للإبقاء عليه)</string>
|
||||||
<string name="memory_provider_bool_on">مفعل</string>
|
<string name="memory_provider_bool_on">مفعل</string>
|
||||||
<string name="memory_provider_bool_off">معطل</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_most_used_components">المكونات الأكثر استخداماً</string>
|
||||||
<string name="analytics_no_model_data">لا تتوفر بيانات للنماذج</string>
|
<string name="analytics_no_model_data">لا تتوفر بيانات للنماذج</string>
|
||||||
<string name="analytics_no_skill_data">لا تتوفر بيانات للمهارات</string>
|
<string name="analytics_no_skill_data">لا تتوفر بيانات للمهارات</string>
|
||||||
|
|||||||
@@ -1082,11 +1082,6 @@
|
|||||||
<string name="image_viewer_close">닫기</string>
|
<string name="image_viewer_close">닫기</string>
|
||||||
<string name="image_viewer_save">기기에 저장</string>
|
<string name="image_viewer_save">기기에 저장</string>
|
||||||
<string name="image_viewer_share">공유</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_skill_invocation">스킬 호출됨</string>
|
||||||
<string name="timeline_marker_internal_notification">시스템 알림</string>
|
<string name="timeline_marker_internal_notification">시스템 알림</string>
|
||||||
<string name="timeline_marker_max_iterations">도구 호출 한도 도달</string>
|
<string name="timeline_marker_max_iterations">도구 호출 한도 도달</string>
|
||||||
|
|||||||
@@ -1223,7 +1223,6 @@
|
|||||||
<string name="image_viewer_close">关闭</string>
|
<string name="image_viewer_close">关闭</string>
|
||||||
<string name="image_viewer_save">保存到设备</string>
|
<string name="image_viewer_save">保存到设备</string>
|
||||||
<string name="image_viewer_share">分享</string>
|
<string name="image_viewer_share">分享</string>
|
||||||
<string name="image_viewer_share_title">分享图片</string>
|
|
||||||
<string name="plugins_memory_manage">管理提供商…</string>
|
<string name="plugins_memory_manage">管理提供商…</string>
|
||||||
<string name="memory_provider_empty_title">没有提供商配置</string>
|
<string name="memory_provider_empty_title">没有提供商配置</string>
|
||||||
<string name="memory_provider_empty_desc">此提供商没有可配置的界面。</string>
|
<string name="memory_provider_empty_desc">此提供商没有可配置的界面。</string>
|
||||||
@@ -1239,10 +1238,6 @@
|
|||||||
<string name="memory_provider_secret_label">API 密钥(留空保持不变)</string>
|
<string name="memory_provider_secret_label">API 密钥(留空保持不变)</string>
|
||||||
<string name="memory_provider_bool_on">开</string>
|
<string name="memory_provider_bool_on">开</string>
|
||||||
<string name="memory_provider_bool_off">关</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">
|
<plurals name="skills_hub_scan_findings_count">
|
||||||
<item quantity="one">%1$d 处发现</item>
|
<item quantity="one">%1$d 处发现</item>
|
||||||
<item quantity="other">%1$d 处发现</item>
|
<item quantity="other">%1$d 处发现</item>
|
||||||
|
|||||||
@@ -1469,7 +1469,6 @@
|
|||||||
<string name="image_viewer_close">Close</string>
|
<string name="image_viewer_close">Close</string>
|
||||||
<string name="image_viewer_save">Save to device</string>
|
<string name="image_viewer_save">Save to device</string>
|
||||||
<string name="image_viewer_share">Share</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="plugins_memory_manage">Manage provider…</string>
|
||||||
<string name="memory_provider_empty_title">No provider config</string>
|
<string name="memory_provider_empty_title">No provider config</string>
|
||||||
<string name="memory_provider_empty_desc">This provider has no configurable surface.</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_secret_label">API key (leave blank to keep)</string>
|
||||||
<string name="memory_provider_bool_on">On</string>
|
<string name="memory_provider_bool_on">On</string>
|
||||||
<string name="memory_provider_bool_off">Off</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) -->
|
<!-- hardcoded-UI string migration (en) -->
|
||||||
<string name="analytics_most_used_components">Most Used Components</string>
|
<string name="analytics_most_used_components">Most Used Components</string>
|
||||||
<string name="analytics_no_model_data">No model data available</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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user