diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MediaViewerDeviceTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MediaViewerDeviceTest.kt index 49617fb4..f7eea804 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MediaViewerDeviceTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MediaViewerDeviceTest.kt @@ -210,6 +210,59 @@ class MediaViewerDeviceTest { assertEquals(expectedSize, actualSize) } + @Test + fun imageUsesImageSurfaceAndStreamsSaveToDownloads() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val context = instrumentation.targetContext + val name = "viewer-image-${System.nanoTime()}.png" + val file = File(context.cacheDir, name) + val bitmap = android.graphics.Bitmap.createBitmap(8, 8, android.graphics.Bitmap.Config.ARGB_8888) + file.outputStream().use { bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it) } + bitmap.recycle() + compose.setContent { + var visible by remember { mutableStateOf(true) } + HermesControlTheme { + if (visible) { + MediaViewerDialog(file.toURI().toString(), { visible = false }, name, "image/png") + } + } + } + awaitTag("media_image") + // Images never get the player surface. + assertTrue(compose.onAllNodesWithTag("media_play_pause_button").fetchSemanticsNodes().isEmpty()) + compose.onNodeWithTag("media_save_button").assertIsDisplayed().performClick() + + val resolver = context.contentResolver + var savedUri: Uri? = null + compose.waitUntil(15_000) { + resolver + .query( + MediaStore.Downloads.EXTERNAL_CONTENT_URI, + arrayOf(MediaStore.MediaColumns._ID), + "${MediaStore.MediaColumns.DISPLAY_NAME} = ?", + arrayOf(name), + null, + )?.use { c -> + if (c.moveToFirst()) { + val id = c.getLong(c.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)) + savedUri = ContentUris.withAppendedId(MediaStore.Downloads.EXTERNAL_CONTENT_URI, id) + true + } else { + false + } + } ?: false + } + try { + assertArrayEquals(file.readBytes(), resolver.openInputStream(savedUri!!)?.use { it.readBytes() }) + } finally { + savedUri?.let { resolver.delete(it, null, null) } + } + compose.onNodeWithTag("media_close_button").performClick() + compose.waitUntil(15_000) { + compose.onAllNodesWithTag("media_viewer_dialog").fetchSemanticsNodes().isEmpty() + } + } + @Test fun invalidSourceShowsRetryAndCanClose() { compose.setContent { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 1288de73..919ec93e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -106,6 +106,7 @@ import com.m57.hermescontrol.ui.chat.components.ChatTimelineSheet import com.m57.hermescontrol.ui.chat.components.ConnectionSetupSheet import com.m57.hermescontrol.ui.chat.components.ContextDetailSheet import com.m57.hermescontrol.ui.chat.components.ContextUsageChip +import com.m57.hermescontrol.ui.chat.components.MediaViewerDialog import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay import com.m57.hermescontrol.ui.chat.components.ReloginDialog import com.m57.hermescontrol.ui.chat.components.ReplyErrorCard @@ -1066,9 +1067,11 @@ fun ChatScreen( } viewingImage?.let { image -> - ImageViewerDialog( - image = image, - onDismiss = { viewingImage = null }, + MediaViewerDialog( + mediaUri = image.model, + onDismissRequest = { viewingImage = null }, + title = image.name.ifBlank { null }, + mimeType = image.mimeType, ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ImageBytesResolver.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ImageBytesResolver.kt deleted file mode 100644 index 84894f01..00000000 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ImageBytesResolver.kt +++ /dev/null @@ -1,125 +0,0 @@ -package com.m57.hermescontrol.ui.chat - -import android.content.Context -import android.net.Uri -import com.m57.hermescontrol.data.remote.OkHttpProvider -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import okhttp3.Request -import java.util.Base64 - -/** - * Resolve a chat image [ImageViewerModel.model] into raw bytes so the viewer can - * Save/Share it *locally on the device* — never back to the Hermes server - * (issue #723). - * - * Every model kind handled here is exactly what Coil already renders in the - * bubble, so a model that displays is also resolvable here: - * - `data:image/...;base64,...` — agent-delivered inline media, - * - `content://...` — a locally-picked user image, - * - `http(s)://...` — gateway-served URL (carries the `?token=` auth param the - * same way the app fetches it; no backend change needed). - * - * All I/O runs on [Dispatchers.IO]. - */ -object ImageBytesResolver { - /** Outcome of [resolve]. */ - sealed interface Result { - /** Resolved bytes plus the concrete MIME type and file extension. */ - data class Bytes( - val bytes: ByteArray, - val mimeType: String, - val extension: String, - ) : Result - - /** Could not load the image; [message] is safe to surface to the user. */ - data class Error( - val message: String, - ) : Result - } - - suspend fun resolve( - context: Context, - model: String, - fallbackMime: String, - ): Result = - withContext(Dispatchers.IO) { - runCatching { - when { - model.startsWith("data:") -> decodeDataUrl(model, fallbackMime) - model.startsWith("content://") -> readContentUri(context, model, fallbackMime) - model.startsWith("https://") || model.startsWith("http://") -> fetchHttp(model, fallbackMime) - else -> Result.Error("Unsupported image source") - } - }.getOrElse { e -> Result.Error(e.message ?: "Failed to load image") } - } - - private fun decodeDataUrl( - model: String, - fallbackMime: String, - ): Result { - val comma = model.indexOf(',') - if (comma < 0) return Result.Error("Malformed data URL") - val meta = model.substring(0, comma) - val data = model.substring(comma + 1).replace(Regex("\\s+"), "") - val mime = - meta - .substringAfter("data:", "image/*") - .substringBefore(";") - .ifBlank { fallbackMime } - if (!meta.contains(";base64", ignoreCase = true)) { - return Result.Error("Only base64 data URLs are supported") - } - val bytes = - runCatching { Base64.getDecoder().decode(data) } - .getOrElse { return Result.Error("Could not decode image data") } - return Result.Bytes(bytes, mime, extensionForMime(mime)) - } - - private fun readContentUri( - context: Context, - model: String, - fallbackMime: String, - ): Result { - val uri = Uri.parse(model) - val bytes = - runCatching { context.contentResolver.openInputStream(uri)?.use { it.readBytes() } } - .getOrNull() - ?: return Result.Error("Could not open local image") - val mime = context.contentResolver.getType(uri) ?: fallbackMime - return Result.Bytes(bytes, mime, extensionForMime(mime)) - } - - private fun fetchHttp( - model: String, - fallbackMime: String, - ): Result { - val request = - Request - .Builder() - .url(model) - .build() - return runCatching { - OkHttpProvider.base.newCall(request).execute().use { resp -> - if (!resp.isSuccessful) return Result.Error("Download failed (HTTP ${resp.code})") - val bytes = resp.body.bytes() - val mime = - resp - .header("Content-Type") - ?.substringBefore(";") - ?.ifBlank { fallbackMime } - ?: fallbackMime - Result.Bytes(bytes, mime, extensionForMime(mime)) - } - }.getOrElse { e -> Result.Error(e.message ?: "Could not download image") } - } - - /** Image-only view of the shared MIME table: anything that isn't `image/` saves as `.img`. */ - fun extensionForMime(mime: String): String = - if (mime.trim().startsWith("image/", ignoreCase = true)) { - com.m57.hermescontrol.ui.chat - .extensionForMime(mime) - } else { - "img" - } -} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ImageViewerDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ImageViewerDialog.kt deleted file mode 100644 index f202609d..00000000 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ImageViewerDialog.kt +++ /dev/null @@ -1,238 +0,0 @@ -package com.m57.hermescontrol.ui.chat - -import android.widget.Toast -import androidx.compose.foundation.gestures.detectTransformGestures -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Download -import androidx.compose.material.icons.filled.Share -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import coil3.compose.AsyncImage -import com.m57.hermescontrol.R -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -/** - * Full-screen image viewer (issue #723). - * - * Opens from any chat image (bubble thumbnail, markdown `![alt](url)`, inline - * attachment). Provides pinch-zoom + pan, a downswipe-to-dismiss gesture, and a - * top bar with **Save** (writes to device Downloads via [MediaImageStore]) and - * **Share** (Android share sheet) — both fully device-local, never touching the - * Hermes server. - * - * Save/Share resolve the image bytes through [ImageBytesResolver], which handles - * the same model kinds Coil already renders (`data:`, `content://`, `http(s)`). - */ -@Composable -fun ImageViewerDialog( - image: ImageViewerModel, - onDismiss: () -> Unit, -) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - var scale by remember { mutableStateOf(1f) } - var offsetX by remember { mutableStateOf(0f) } - var offsetY by remember { mutableStateOf(0f) } - var isBusy by remember { mutableStateOf(false) } - - // Resolve string resources at composable scope (Lint forbids reading - // resource *values* from LocalContext.current inside coroutine lambdas). - val savedMsg = stringResource(R.string.image_viewer_saved) - val saveFailedMsg = stringResource(R.string.image_viewer_save_failed) - val loadFailedFmt = stringResource(R.string.image_viewer_load_failed) - val shareTitle = stringResource(R.string.image_viewer_share_title) - val shareFailedMsg = stringResource(R.string.image_viewer_share_failed) - - val onSave: () -> Unit = { - if (!isBusy) { - isBusy = true - scope.launch(Dispatchers.IO) { - val resolved = ImageBytesResolver.resolve(context, image.model, image.mimeType) - val result = - when (resolved) { - is ImageBytesResolver.Result.Bytes -> { - val name = image.name.ifBlank { "hermes-image.${resolved.extension}" } - val uri = - MediaImageStore.saveToDownloads( - context, - resolved.bytes, - name, - resolved.mimeType, - ) - if (uri != null) { - savedMsg - } else { - saveFailedMsg - } - } - - is ImageBytesResolver.Result.Error -> { - String.format(loadFailedFmt, resolved.message) - } - } - withContext(Dispatchers.Main) { - isBusy = false - Toast.makeText(context, result, Toast.LENGTH_SHORT).show() - } - } - } - } - - val onShare: () -> Unit = { - if (!isBusy) { - isBusy = true - scope.launch(Dispatchers.IO) { - val resolved = ImageBytesResolver.resolve(context, image.model, image.mimeType) - val intent = - when (resolved) { - is ImageBytesResolver.Result.Bytes -> { - val name = image.name.ifBlank { "hermes-image.${resolved.extension}" } - MediaImageStore.buildShareIntent( - context, - resolved.bytes, - name, - resolved.mimeType, - ) - } - - is ImageBytesResolver.Result.Error -> { - null - } - } - withContext(Dispatchers.Main) { - isBusy = false - if (intent != null) { - context.startActivity( - android.content.Intent - .createChooser(intent, shareTitle), - ) - } else { - Toast - .makeText( - context, - shareFailedMsg, - Toast.LENGTH_SHORT, - ).show() - } - } - } - } - } - - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.scrim, - ) { - Box(modifier = Modifier.fillMaxSize()) { - // Zoomable / pannable image. - AsyncImage( - model = image.model, - contentDescription = - image.name.ifBlank { stringResource(R.string.image_viewer_content_desc) }, - modifier = - Modifier - .fillMaxSize() - .graphicsLayer( - scaleX = scale, - scaleY = scale, - translationX = offsetX, - translationY = offsetY, - ).pointerInput(Unit) { - detectTransformGestures( - onGesture = { _, pan, zoom, _ -> - val newScale = (scale * zoom).coerceIn(1f, 5f) - // While zoomed in, allow free panning; at min scale, - // only vertical drags are permitted (for dismiss). - scale = newScale - offsetX = if (newScale > 1f) offsetX + pan.x else 0f - offsetY += pan.y - // Downswipe-to-dismiss when at min zoom. - if (newScale <= 1f && offsetY > 120f) { - onDismiss() - } - }, - ) - }, - contentScale = ContentScale.Fit, - ) - - // Top action bar — Save / Share / Close. - Row( - modifier = - Modifier - .fillMaxWidth() - .statusBarsPadding() - .padding(8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton(onClick = onDismiss) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.image_viewer_close), - tint = MaterialTheme.colorScheme.onSurface, - ) - } - Row { - if (isBusy) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp).padding(horizontal = 8.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onSurface, - ) - } else { - IconButton(onClick = onSave, enabled = !isBusy) { - Icon( - imageVector = Icons.Filled.Download, - contentDescription = stringResource(R.string.image_viewer_save), - tint = MaterialTheme.colorScheme.onSurface, - ) - } - IconButton(onClick = onShare, enabled = !isBusy) { - Icon( - imageVector = Icons.Filled.Share, - contentDescription = stringResource(R.string.image_viewer_share), - tint = MaterialTheme.colorScheme.onSurface, - ) - } - } - } - } - } - } - } -} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaImageStore.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaImageStore.kt index 2cd42077..776fe1d0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaImageStore.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaImageStore.kt @@ -2,42 +2,21 @@ package com.m57.hermescontrol.ui.chat import android.content.ContentValues import android.content.Context -import android.content.Intent import android.net.Uri import android.os.Build import android.provider.MediaStore -import androidx.core.content.FileProvider -import java.io.ByteArrayInputStream -import java.io.File import java.io.InputStream /** - * Persist / dispatch chat images on the *device* (Downloads / Gallery / share - * sheet) — never back to the Hermes server (issue #723). Every operation works - * only on bytes the viewer already resolved locally, so save/share are fully - * device-local and independent of the backend. + * Streams non-chat downloads (backup archives) into device Downloads. Chat + * media save/share goes through [MediaExportHelper] (issue #1328). */ object MediaImageStore { /** - * Write [bytes] into the device's Download collection via [MediaStore] so the - * file appears in system Downloads / Gallery apps. Returns the public [Uri] - * on success, or `null` if the write failed. - * - * On API 29+ the file lands under `Download/Hermes` using - * `RELATIVE_PATH`; on older APIs it falls back to the top-level - * `Images` collection. - */ - fun saveToDownloads( - context: Context, - bytes: ByteArray, - displayName: String, - mimeType: String, - ): Uri? = saveToDownloads(context, ByteArrayInputStream(bytes), bytes.size.toLong(), displayName, mimeType) - - /** - * Streaming variant of [saveToDownloads] — for large payloads (e.g. backup - * archives, 300+ MB) that must NOT be held in memory as a [ByteArray]. - * Copies [input] to the Download collection in chunks. Same return contract. + * Stream [input] into the device's Download collection via [MediaStore] — + * for large payloads (e.g. backup archives, 300+ MB) that must NOT be held + * in memory. Returns the public [Uri] on success, or `null` on failure. + * On API 29+ the file lands under `Download/Hermes`. */ fun saveToDownloads( context: Context, @@ -85,38 +64,6 @@ object MediaImageStore { } } - /** - * Build a share [Intent] for [bytes] via a [FileProvider] cache file so the - * image can be sent to another app (Messages, WhatsApp, …). Returns `null` - * if the temp file could not be written. - */ - fun buildShareIntent( - context: Context, - bytes: ByteArray, - displayName: String, - mimeType: String, - ): Intent? { - val safe = sanitizeName(displayName) - val dir = File(context.cacheDir, "shared_images").also { it.mkdirs() } - val file = File(dir, safe) - return try { - file.writeBytes(bytes) - val uri = - FileProvider.getUriForFile( - context, - "${context.packageName}.fileprovider", - file, - ) - Intent(Intent.ACTION_SEND).apply { - type = mimeType - putExtra(Intent.EXTRA_STREAM, uri) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - } catch (e: Exception) { - null - } - } - /** * Replace unsafe characters in a display name and cap its length so it is a * valid [MediaStore] / file name. Preserves the original extension when one diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ImageViewerContent.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ImageViewerContent.kt new file mode 100644 index 00000000..9ffbe313 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ImageViewerContent.kt @@ -0,0 +1,155 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import coil3.compose.AsyncImage +import com.m57.hermescontrol.R +import com.m57.hermescontrol.ui.chat.mediaNameFromPath + +/** + * Image surface of [MediaViewerDialog]: pinch-zoom + pan, downswipe-to-dismiss, + * and the shared streaming Save/Share actions. No ExoPlayer is created here. + */ +@Composable +internal fun ImageViewerContent( + mediaUri: String, + onDismissRequest: () -> Unit, + title: String?, + mimeType: String?, +) { + var scale by remember { mutableFloatStateOf(1f) } + var offsetX by remember { mutableFloatStateOf(0f) } + var offsetY by remember { mutableFloatStateOf(0f) } + val displayName = + remember(mediaUri, title) { + title?.takeIf { it.isNotBlank() } ?: mediaNameFromPath(mediaUri) + } + val export = rememberMediaExportActions(mediaUri, mimeType, displayName) + + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface( + modifier = Modifier.fillMaxSize().testTag("media_viewer_dialog"), + color = MaterialTheme.colorScheme.scrim, + ) { + Box(modifier = Modifier.fillMaxSize()) { + AsyncImage( + model = mediaUri, + contentDescription = + title?.takeIf { it.isNotBlank() } + ?: stringResource(R.string.image_viewer_content_desc), + modifier = + Modifier + .fillMaxSize() + .testTag("media_image") + .graphicsLayer( + scaleX = scale, + scaleY = scale, + translationX = offsetX, + translationY = offsetY, + ).pointerInput(Unit) { + detectTransformGestures( + onGesture = { _, pan, zoom, _ -> + val newScale = (scale * zoom).coerceIn(1f, 5f) + // While zoomed in, allow free panning; at min scale, + // only vertical drags are permitted (for dismiss). + scale = newScale + offsetX = if (newScale > 1f) offsetX + pan.x else 0f + offsetY += pan.y + // Downswipe-to-dismiss when at min zoom. + if (newScale <= 1f && offsetY > 120f) { + onDismissRequest() + } + }, + ) + }, + contentScale = ContentScale.Fit, + ) + + Row( + modifier = + Modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = onDismissRequest, + modifier = Modifier.testTag("media_close_button"), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.image_viewer_close), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + Row { + if (export.isBusy) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp).padding(horizontal = 8.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onSurface, + ) + } else { + IconButton( + onClick = export.onSave, + modifier = Modifier.testTag("media_save_button"), + ) { + Icon( + imageVector = Icons.Filled.Download, + contentDescription = stringResource(R.string.image_viewer_save), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + IconButton( + onClick = export.onShare, + modifier = Modifier.testTag("media_share_button"), + ) { + Icon( + imageVector = Icons.Filled.Share, + contentDescription = stringResource(R.string.image_viewer_share), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MediaExportActions.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MediaExportActions.kt new file mode 100644 index 00000000..654fa690 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MediaExportActions.kt @@ -0,0 +1,217 @@ +package com.m57.hermescontrol.ui.chat.components + +import android.content.Intent +import android.os.Build +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.m57.hermescontrol.ExternalActivityLifecycleGuard +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.ws.HermesWsClient +import com.m57.hermescontrol.notification.NotificationHelper +import com.m57.hermescontrol.ui.chat.MediaExportHelper +import com.m57.hermescontrol.ui.chat.MediaSaveStrategy +import com.m57.hermescontrol.ui.chat.acceptedSaveDestination +import com.m57.hermescontrol.ui.chat.mediaSaveStrategy +import com.m57.hermescontrol.ui.chat.normalizeFrameworkMime +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** Save/Share state for the media viewer; [isBusy] is true while either is in flight. */ +internal class MediaExportActions( + val isBusy: Boolean, + val onSave: () -> Unit, + val onShare: () -> Unit, +) + +/** + * The single Save/Share pipeline for every media kind (issue #1328). Streams + * through [MediaExportHelper]: MediaStore Downloads on API 29+, a SAF + * create-document picker on API 26-28, and a FileProvider share cache. + */ +@Composable +internal fun rememberMediaExportActions( + mediaUri: String, + mimeType: String?, + displayName: String, +): MediaExportActions { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var isBusy by remember { mutableStateOf(false) } + val saveFailed = stringResource(R.string.media_player_save_failed) + val shareFailed = stringResource(R.string.media_player_share_failed) + val shareTitle = stringResource(R.string.media_player_share_title) + + val launchExternalActivity: (() -> Unit) -> Unit = { launch -> + ExternalActivityLifecycleGuard.launchExternalActivity( + acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease, + releaseConnectionLease = HermesWsClient::releaseExternalActivityConnectionLease, + prepareForBackground = { NotificationHelper.start(context) }, + cleanupAfterLaunchFailure = { NotificationHelper.stop(context) }, + launch = launch, + ) + } + + val saveDocumentLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + ) { result -> + try { + ExternalActivityLifecycleGuard.externalActivityReturned() + val destination = acceptedSaveDestination(result.resultCode, result.data?.data) + if (destination != null) { + scope.launch { + try { + val msg = + MediaExportHelper.saveMediaToUri( + context = context, + sourceUri = mediaUri, + targetUri = destination, + fallbackMime = + mimeType?.takeUnless { it == "application/octet-stream" } + ?: com.m57.hermescontrol.ui.chat + .mediaMimeForPath(displayName), + ) + withContext(Dispatchers.Main) { + Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() + } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + withContext(Dispatchers.Main) { + Toast.makeText(context, saveFailed, Toast.LENGTH_SHORT).show() + } + } finally { + isBusy = false + } + } + } else { + isBusy = false + } + } catch (e: Throwable) { + isBusy = false + if (e is CancellationException) throw e + } + } + + val onSave: () -> Unit = { + if (!isBusy) { + isBusy = true + when (mediaSaveStrategy()) { + MediaSaveStrategy.MEDIA_STORE -> { + scope.launch { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val msg = + MediaExportHelper.saveMediaToDownloads( + context = context, + uri = mediaUri, + fallbackMime = + mimeType?.takeUnless { it == "application/octet-stream" } + ?: com.m57.hermescontrol.ui.chat + .mediaMimeForPath(displayName), + displayName = displayName, + ) + withContext(Dispatchers.Main) { + Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() + } + } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + withContext(Dispatchers.Main) { + Toast.makeText(context, saveFailed, Toast.LENGTH_SHORT).show() + } + } finally { + isBusy = false + } + } + } + + MediaSaveStrategy.CREATE_DOCUMENT -> { + val suggestedName = + MediaExportHelper.resolveDisplayName( + displayName = displayName, + mimeType = mimeType, + uri = mediaUri, + ) + val rawMime = + mimeType?.takeUnless { it == "application/octet-stream" } + ?: com.m57.hermescontrol.ui.chat + .mediaMimeForPath(displayName) + val normalizedMime = normalizeFrameworkMime(rawMime) + val intent = + Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = normalizedMime + putExtra(Intent.EXTRA_TITLE, suggestedName) + } + try { + launchExternalActivity { + saveDocumentLauncher.launch(intent) + } + } catch (_: Exception) { + isBusy = false + scope.launch(Dispatchers.Main) { + Toast.makeText(context, saveFailed, Toast.LENGTH_SHORT).show() + } + } + } + } + } + } + + val onShare: () -> Unit = { + if (!isBusy) { + isBusy = true + scope.launch { + try { + val intent = + MediaExportHelper.shareMedia( + context = context, + uri = mediaUri, + fallbackMime = + mimeType?.takeUnless { it == "application/octet-stream" } + ?: com.m57.hermescontrol.ui.chat + .mediaMimeForPath(displayName), + displayName = displayName, + ) + withContext(Dispatchers.Main) { + if (intent != null) { + val chooser = Intent.createChooser(intent, shareTitle) + try { + launchExternalActivity { + context.startActivity(chooser) + } + } catch (_: Exception) { + Toast.makeText(context, shareFailed, Toast.LENGTH_SHORT).show() + } + } else { + Toast.makeText(context, shareFailed, Toast.LENGTH_SHORT).show() + } + } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + withContext(Dispatchers.Main) { + Toast.makeText(context, shareFailed, Toast.LENGTH_SHORT).show() + } + } finally { + isBusy = false + } + } + } + } + + return MediaExportActions(isBusy = isBusy, onSave = onSave, onShare = onShare) +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MediaViewerDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MediaViewerDialog.kt index b8666b08..41110577 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MediaViewerDialog.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MediaViewerDialog.kt @@ -1,11 +1,6 @@ package com.m57.hermescontrol.ui.chat.components -import android.content.Intent import android.net.Uri -import android.os.Build -import android.widget.Toast -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.OptIn import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn @@ -51,7 +46,6 @@ import androidx.compose.runtime.key import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -76,28 +70,18 @@ import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerView -import com.m57.hermescontrol.ExternalActivityLifecycleGuard import com.m57.hermescontrol.R import com.m57.hermescontrol.data.remote.MediaDataSourceProvider -import com.m57.hermescontrol.data.ws.HermesWsClient -import com.m57.hermescontrol.notification.NotificationHelper import com.m57.hermescontrol.theme.LocalHermesStatusColors -import com.m57.hermescontrol.ui.chat.MediaExportHelper import com.m57.hermescontrol.ui.chat.MediaKind -import com.m57.hermescontrol.ui.chat.MediaSaveStrategy -import com.m57.hermescontrol.ui.chat.acceptedSaveDestination import com.m57.hermescontrol.ui.chat.classifyMedia import com.m57.hermescontrol.ui.chat.mediaNameFromPath -import com.m57.hermescontrol.ui.chat.mediaSaveStrategy -import com.m57.hermescontrol.ui.chat.normalizeFrameworkMime -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext /** - * Unified Media Viewer Dialog for both Audio and Video, backed by AndroidX Media3 ExoPlayer. + * The one full-screen media viewer (issue #1328): images get the zoom/pan + * surface, audio and video the ExoPlayer surface. Both share the same streaming + * Save/Share pipeline ([rememberMediaExportActions]). */ @OptIn(UnstableApi::class) @Composable @@ -108,7 +92,12 @@ fun MediaViewerDialog( mimeType: String? = null, ) { key(mediaUri, mimeType) { - MediaViewerContent(mediaUri, onDismissRequest, title, mimeType) + val kind = remember { classifyMedia(mimeType = mimeType, name = title, uri = mediaUri) } + if (kind == MediaKind.IMAGE) { + ImageViewerContent(mediaUri, onDismissRequest, title, mimeType) + } else { + MediaViewerContent(mediaUri, onDismissRequest, title, mimeType) + } } } @@ -122,7 +111,6 @@ private fun MediaViewerContent( ) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current - val scope = rememberCoroutineScope() val mediaKind = remember(mediaUri, mimeType, title) { @@ -145,12 +133,8 @@ private fun MediaViewerContent( var dragPositionMs by remember { mutableLongStateOf(0L) } var showControls by remember { mutableStateOf(true) } - var isBusy by remember { mutableStateOf(false) } val statusColors = LocalHermesStatusColors.current - val saveFailed = stringResource(R.string.media_player_save_failed) - val shareFailed = stringResource(R.string.media_player_share_failed) - val shareTitle = stringResource(R.string.media_player_share_title) val player = remember(context, mediaUri) { @@ -260,166 +244,10 @@ private fun MediaViewerContent( } } - val launchExternalActivity: (() -> Unit) -> Unit = { launch -> - ExternalActivityLifecycleGuard.launchExternalActivity( - acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease, - releaseConnectionLease = HermesWsClient::releaseExternalActivityConnectionLease, - prepareForBackground = { NotificationHelper.start(context) }, - cleanupAfterLaunchFailure = { NotificationHelper.stop(context) }, - launch = launch, - ) - } - - val saveDocumentLauncher = - rememberLauncherForActivityResult( - contract = ActivityResultContracts.StartActivityForResult(), - ) { result -> - try { - ExternalActivityLifecycleGuard.externalActivityReturned() - val destination = acceptedSaveDestination(result.resultCode, result.data?.data) - if (destination != null) { - scope.launch { - try { - val msg = - MediaExportHelper.saveMediaToUri( - context = context, - sourceUri = mediaUri, - targetUri = destination, - fallbackMime = - mimeType?.takeUnless { it == "application/octet-stream" } - ?: com.m57.hermescontrol.ui.chat - .mediaMimeForPath(displayName), - ) - withContext(Dispatchers.Main) { - Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() - } - } catch (e: CancellationException) { - throw e - } catch (_: Exception) { - withContext(Dispatchers.Main) { - Toast.makeText(context, saveFailed, Toast.LENGTH_SHORT).show() - } - } finally { - isBusy = false - } - } - } else { - isBusy = false - } - } catch (e: Throwable) { - isBusy = false - if (e is CancellationException) throw e - } - } - - val onSave: () -> Unit = { - if (!isBusy) { - isBusy = true - when (mediaSaveStrategy()) { - MediaSaveStrategy.MEDIA_STORE -> { - scope.launch { - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val msg = - MediaExportHelper.saveMediaToDownloads( - context = context, - uri = mediaUri, - fallbackMime = - mimeType?.takeUnless { it == "application/octet-stream" } - ?: com.m57.hermescontrol.ui.chat - .mediaMimeForPath(displayName), - displayName = displayName, - ) - withContext(Dispatchers.Main) { - Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() - } - } - } catch (e: CancellationException) { - throw e - } catch (_: Exception) { - withContext(Dispatchers.Main) { - Toast.makeText(context, saveFailed, Toast.LENGTH_SHORT).show() - } - } finally { - isBusy = false - } - } - } - - MediaSaveStrategy.CREATE_DOCUMENT -> { - val suggestedName = - MediaExportHelper.resolveDisplayName( - displayName = displayName, - mimeType = mimeType, - uri = mediaUri, - ) - val rawMime = - mimeType?.takeUnless { it == "application/octet-stream" } - ?: com.m57.hermescontrol.ui.chat - .mediaMimeForPath(displayName) - val normalizedMime = normalizeFrameworkMime(rawMime) - val intent = - Intent(Intent.ACTION_CREATE_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = normalizedMime - putExtra(Intent.EXTRA_TITLE, suggestedName) - } - try { - launchExternalActivity { - saveDocumentLauncher.launch(intent) - } - } catch (_: Exception) { - isBusy = false - scope.launch(Dispatchers.Main) { - Toast.makeText(context, saveFailed, Toast.LENGTH_SHORT).show() - } - } - } - } - } - } - - val onShare: () -> Unit = { - if (!isBusy) { - isBusy = true - scope.launch { - try { - val intent = - MediaExportHelper.shareMedia( - context = context, - uri = mediaUri, - fallbackMime = - mimeType?.takeUnless { it == "application/octet-stream" } - ?: com.m57.hermescontrol.ui.chat - .mediaMimeForPath(displayName), - displayName = displayName, - ) - withContext(Dispatchers.Main) { - if (intent != null) { - val chooser = Intent.createChooser(intent, shareTitle) - try { - launchExternalActivity { - context.startActivity(chooser) - } - } catch (_: Exception) { - Toast.makeText(context, shareFailed, Toast.LENGTH_SHORT).show() - } - } else { - Toast.makeText(context, shareFailed, Toast.LENGTH_SHORT).show() - } - } - } catch (e: CancellationException) { - throw e - } catch (_: Exception) { - withContext(Dispatchers.Main) { - Toast.makeText(context, shareFailed, Toast.LENGTH_SHORT).show() - } - } finally { - isBusy = false - } - } - } - } + val exportActions = rememberMediaExportActions(mediaUri, mimeType, displayName) + val isBusy = exportActions.isBusy + val onSave = exportActions.onSave + val onShare = exportActions.onShare Dialog( onDismissRequest = onDismissRequest, diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 15e5b90e..6ff8169f 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1261,7 +1261,6 @@ إغلاق حفظ على الجهاز مشاركة -مشاركة الصورة إدارة المزود… لا توجد إعدادات للمزود لا يحتوي هذا المزود على خيارات قابلة للتهيئة. @@ -1277,10 +1276,6 @@ مفتاح API (اتركه فارغاً للإبقاء عليه) مفعل معطل -تم حفظ الصورة في التنزيلات -تعذر حفظ الصورة -تعذر تحميل الصورة: %1$s -تعذر مشاركة الصورة المكونات الأكثر استخداماً لا تتوفر بيانات للنماذج لا تتوفر بيانات للمهارات diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index c659080f..395700da 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1082,11 +1082,6 @@ 닫기 기기에 저장 공유 - 이미지 공유 - 다운로드에 이미지 저장됨 - 이미지를 저장할 수 없음 - 이미지를 불러올 수 없음: %1$s - 이미지를 공유할 수 없음 스킬 호출됨 시스템 알림 도구 호출 한도 도달 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index eac2130b..34212136 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1223,7 +1223,6 @@ 关闭 保存到设备 分享 - 分享图片 管理提供商… 没有提供商配置 此提供商没有可配置的界面。 @@ -1239,10 +1238,6 @@ API 密钥(留空保持不变) 开 关 - 图片已保存到下载目录 - 无法保存图片 - 无法加载图片:%1$s - 无法分享图片 %1$d 处发现 %1$d 处发现 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c05a133d..793ce2ba 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1469,7 +1469,6 @@ Close Save to device Share - Share image Manage provider… No provider config This provider has no configurable surface. @@ -1485,10 +1484,6 @@ API key (leave blank to keep) On Off - Image saved to Downloads - Could not save image - Could not load image: %1$s - Could not share image Most Used Components No model data available diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ImageBytesResolverTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ImageBytesResolverTest.kt deleted file mode 100644 index b01aff74..00000000 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ImageBytesResolverTest.kt +++ /dev/null @@ -1,96 +0,0 @@ -package com.m57.hermescontrol.ui.chat - -import android.content.Context -import io.mockk.mockk -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.junit.Assert.assertArrayEquals -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import java.util.Base64 - -@OptIn(ExperimentalCoroutinesApi::class) -class ImageBytesResolverTest { - private val context = mockk(relaxed = true) - - @Before - fun setUp() { - // resolve() runs on Dispatchers.IO; bind the main dispatcher for tests. - Dispatchers.setMain(StandardTestDispatcher()) - } - - @Test - fun `extensionForMime maps known types`() { - assertEquals("png", ImageBytesResolver.extensionForMime("image/png")) - assertEquals("png", ImageBytesResolver.extensionForMime(" image/png; charset=binary")) - assertEquals("jpg", ImageBytesResolver.extensionForMime("image/jpeg")) - assertEquals("jpg", ImageBytesResolver.extensionForMime("image/JPG")) - assertEquals("gif", ImageBytesResolver.extensionForMime("image/gif")) - assertEquals("webp", ImageBytesResolver.extensionForMime("image/webp")) - assertEquals("heic", ImageBytesResolver.extensionForMime("image/heic")) - assertEquals("svg", ImageBytesResolver.extensionForMime("image/svg+xml")) - assertEquals("img", ImageBytesResolver.extensionForMime("image/unknown")) - assertEquals("heic", ImageBytesResolver.extensionForMime("image/heif")) - assertEquals("bmp", ImageBytesResolver.extensionForMime("image/bmp")) - // Non-image content types stay `.img`, not `.bin`/`.pdf`. - assertEquals("img", ImageBytesResolver.extensionForMime("application/pdf")) - assertEquals("img", ImageBytesResolver.extensionForMime("text/html")) - } - - @Test - fun `data URL base64 decodes to bytes with mime and extension`() = - runTest { - val raw = "hello-image".toByteArray() - val b64 = Base64.getEncoder().encodeToString(raw) - val model = "data:image/png;base64,$b64" - - val result = ImageBytesResolver.resolve(context, model, "image/*") - - assertTrue(result is ImageBytesResolver.Result.Bytes) - result as ImageBytesResolver.Result.Bytes - assertArrayEquals(raw, result.bytes) - assertEquals("image/png", result.mimeType) - assertEquals("png", result.extension) - } - - @Test - fun `data URL falls back to fallbackMime when meta omitted`() = - runTest { - val raw = byteArrayOf(1, 2, 3, 4) - val b64 = Base64.getEncoder().encodeToString(raw) - val model = "data:;base64,$b64" - - val result = ImageBytesResolver.resolve(context, model, "image/webp") - - assertTrue(result is ImageBytesResolver.Result.Bytes) - result as ImageBytesResolver.Result.Bytes - assertEquals("image/webp", result.mimeType) - assertEquals("webp", result.extension) - } - - @Test - fun `malformed data URL (no comma) returns Error`() = - runTest { - val result = ImageBytesResolver.resolve(context, "data:image/png;base64XXXX", "image/*") - assertTrue(result is ImageBytesResolver.Result.Error) - } - - @Test - fun `non-base64 data URL returns Error`() = - runTest { - val result = ImageBytesResolver.resolve(context, "data:image/png,notbase64", "image/*") - assertTrue(result is ImageBytesResolver.Result.Error) - } - - @Test - fun `unsupported model source returns Error`() = - runTest { - val result = ImageBytesResolver.resolve(context, "file:///sdcard/x.png", "image/*") - assertTrue(result is ImageBytesResolver.Result.Error) - } -}