From 19c053b3fa7eb7ed19732166ecc7c1388aa35da2 Mon Sep 17 00:00:00 2001 From: AlexDev_ <56083016+alexdev03@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:47:55 +0200 Subject: [PATCH] feat(chat): save delivered files with native picker (#803) --- .../m57/hermescontrol/ui/chat/ChatBubble.kt | 86 +++++++++--- .../m57/hermescontrol/ui/chat/ChatScreen.kt | 72 +++++++++- .../hermescontrol/ui/chat/ChatViewModel.kt | 68 ++++++++-- .../ui/chat/ImageBytesResolver.kt | 2 +- .../m57/hermescontrol/ui/chat/MediaTypes.kt | 15 +-- .../ui/chat/components/ChatMessageList.kt | 8 ++ .../ui/chat/AttachmentSavePolicyTest.kt | 28 ++++ .../ui/chat/ChatViewModelTest.kt | 124 ++++++++++++++++++ .../ui/chat/ImageBytesResolverTest.kt | 1 + 9 files changed, 363 insertions(+), 41 deletions(-) create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/AttachmentSavePolicyTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt index c17be07b..d9664c17 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt @@ -33,9 +33,11 @@ import androidx.compose.material.icons.automirrored.filled.InsertDriveFile import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Download import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -93,6 +95,9 @@ fun ChatBubble( isCurrentMatch: Boolean = false, onRespondApproval: (String) -> Unit = {}, onOpenAttachment: (Attachment) -> Unit = {}, + onSaveAttachment: (Attachment) -> Unit = {}, + savingAttachmentPath: String? = null, + canSaveAttachment: Boolean = true, onImageClick: (ImageViewerModel) -> Unit = {}, modifier: Modifier = Modifier, ) { @@ -115,6 +120,9 @@ fun ChatBubble( searchQuery = searchQuery, isCurrentMatch = isCurrentMatch, onOpenAttachment = onOpenAttachment, + onSaveAttachment = onSaveAttachment, + savingAttachmentPath = savingAttachmentPath, + canSaveAttachment = canSaveAttachment, onImageClick = onImageClick, modifier = modifier, ) @@ -128,6 +136,9 @@ fun ChatBubble( searchQuery = searchQuery, isCurrentMatch = isCurrentMatch, onOpenAttachment = onOpenAttachment, + onSaveAttachment = onSaveAttachment, + savingAttachmentPath = savingAttachmentPath, + canSaveAttachment = canSaveAttachment, onImageClick = onImageClick, modifier = modifier, ) @@ -155,6 +166,9 @@ private fun UserBubble( searchQuery: String = "", isCurrentMatch: Boolean = false, onOpenAttachment: (Attachment) -> Unit = {}, + onSaveAttachment: (Attachment) -> Unit = {}, + savingAttachmentPath: String? = null, + canSaveAttachment: Boolean = true, onImageClick: (ImageViewerModel) -> Unit = {}, modifier: Modifier = Modifier, ) { @@ -256,6 +270,9 @@ private fun UserBubble( attachment = attachment, textColor = userBubbleTextColor, onOpen = { onOpenAttachment(it) }, + onSave = { onSaveAttachment(it) }, + savingPath = savingAttachmentPath, + canSave = canSaveAttachment, onImageClick = onImageClick, ) Spacer(modifier = Modifier.height(4.dp)) @@ -320,6 +337,9 @@ private fun AssistantBubble( searchQuery: String = "", isCurrentMatch: Boolean = false, onOpenAttachment: (Attachment) -> Unit = {}, + onSaveAttachment: (Attachment) -> Unit = {}, + savingAttachmentPath: String? = null, + canSaveAttachment: Boolean = true, onImageClick: (ImageViewerModel) -> Unit = {}, modifier: Modifier = Modifier, ) { @@ -399,6 +419,9 @@ private fun AssistantBubble( attachment = attachment, textColor = textColor, onOpen = { onOpenAttachment(it) }, + onSave = { onSaveAttachment(it) }, + savingPath = savingAttachmentPath, + canSave = canSaveAttachment, onImageClick = onImageClick, ) Spacer(modifier = Modifier.height(4.dp)) @@ -642,9 +665,14 @@ private fun buildHighlightedString( private fun InlineAttachment( attachment: Attachment, textColor: Color, - onOpen: (Attachment) -> Unit = {}, - onImageClick: (ImageViewerModel) -> Unit = {}, + onOpen: (Attachment) -> Unit, + onSave: (Attachment) -> Unit, + savingPath: String?, + canSave: Boolean, + onImageClick: (ImageViewerModel) -> Unit, ) { + val attachmentPath = attachment.gatewayUrl?.let(::gatewayPathFromUrl) ?: attachment.name + val isSaving = savingPath != null && savingPath == attachmentPath val clickable = Modifier.clickable { onOpen(attachment) } if (attachment.isVideo) { var showVideoDialog by remember { mutableStateOf(false) } @@ -675,30 +703,35 @@ private fun InlineAttachment( }, ) } else { - // Non-image file — show a card with file icon and name. Tapping fetches - // the bytes (gateway-sourced) or opens the local URI. + // Non-image file — native save picker on the trailing action. Surface( - shape = RoundedCornerShape(8.dp), - color = textColor.copy(alpha = 0.1f), - border = BorderStroke(1.dp, textColor.copy(alpha = 0.2f)), + shape = RoundedCornerShape(12.dp), + color = textColor.copy(alpha = 0.08f), + border = BorderStroke(1.dp, textColor.copy(alpha = 0.16f)), modifier = clickable, ) { Row( - modifier = Modifier.padding(8.dp), + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.InsertDriveFile, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = textColor.copy(alpha = 0.8f), - ) - Spacer(modifier = Modifier.width(8.dp)) - Column { + Surface( + shape = RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.primaryContainer, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.InsertDriveFile, + contentDescription = null, + modifier = Modifier.padding(9.dp).size(20.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + Spacer(modifier = Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { Text( text = attachment.name, color = textColor, - style = MaterialTheme.typography.bodySmall, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -708,6 +741,25 @@ private fun InlineAttachment( style = MaterialTheme.typography.labelSmall, ) } + if (attachment.gatewayUrl != null) { + IconButton( + onClick = { onSave(attachment) }, + enabled = canSave, + ) { + if (isSaving) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + ) + } else { + Icon( + imageVector = Icons.Default.Download, + contentDescription = "Save ${attachment.name}", + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } } } } 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 9e3b054c..3bab2dc6 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 @@ -73,6 +73,8 @@ import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.NavigationController import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.Attachment +import com.m57.hermescontrol.data.model.AttachmentSource import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.theme.LocalHermesStatusColors @@ -107,6 +109,16 @@ import java.util.Locale private const val SESSION_SYNC_INTERVAL_MS = 30_000L +internal fun acceptedSaveDestination( + resultCode: Int, + destination: Uri?, +): Uri? = destination.takeIf { resultCode == Activity.RESULT_OK } + +internal fun canStartAttachmentSave( + pendingSavePath: String?, + savingAttachmentPath: String?, +): Boolean = pendingSavePath == null && savingAttachmentPath == null + /** * Chat screen — the primary conversation surface of Hermes Control. * @@ -135,6 +147,32 @@ fun ChatScreen( val scrollController = rememberChatScrollController(listState, scrollScope) var isOlderPagingArmed by remember(state.currentSessionId) { mutableStateOf(false) } var showContextSheet by remember { mutableStateOf(false) } + var pendingSavePath by rememberSaveable { mutableStateOf(null) } + var pendingSaveName by rememberSaveable { mutableStateOf(null) } + var pendingSaveMimeType by rememberSaveable { mutableStateOf(null) } + val saveAttachmentLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + try { + val path = pendingSavePath + val destination = acceptedSaveDestination(result.resultCode, result.data?.data) + if (destination != null && path != null) { + viewModel.saveAttachment( + Attachment( + uri = "gateway:$path", + name = pendingSaveName ?: "download", + mimeType = pendingSaveMimeType ?: "application/octet-stream", + size = 0, + source = AttachmentSource.GATEWAY, + ), + destination, + ) + } + } finally { + pendingSavePath = null + pendingSaveName = null + pendingSaveMimeType = null + } + } // Periodic session sync while connected. LaunchedEffect(lifecycleOwner, state.currentSessionId, state.connectionStatus) { @@ -401,10 +439,14 @@ fun ChatScreen( navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, snackbarHost = { SnackbarHost(snackbarHostState) { data -> + val statusColors = LocalHermesStatusColors.current + val isDownloadComplete = data.visuals.message.startsWith("Saved ") Snackbar( snackbarData = data, - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer, + containerColor = + if (isDownloadComplete) statusColors.success else MaterialTheme.colorScheme.errorContainer, + contentColor = + if (isDownloadComplete) statusColors.onSuccess else MaterialTheme.colorScheme.onErrorContainer, ) } }, @@ -511,6 +553,32 @@ fun ChatScreen( clarifyRequest = state.clarifyRequest, onRespondClarify = viewModel::respondToClarify, onDismissClarify = viewModel::dismissClarify, + onSaveAttachment = { attachment -> + if (!canStartAttachmentSave(pendingSavePath, state.savingAttachmentPath)) { + return@ChatMessageList + } + pendingSavePath = viewModel.gatewayPathFor(attachment) + pendingSaveName = attachment.name + pendingSaveMimeType = attachment.mimeType + saveAttachmentLauncher.launch( + Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = + attachment.mimeType + .substringBefore(';') + .trim() + .takeIf { it.isNotBlank() } ?: "application/octet-stream" + putExtra( + Intent.EXTRA_TITLE, + attachment.name + .substringAfterLast('/') + .substringAfterLast('\\') + .ifBlank { "download" }, + ) + }, + ) + }, + savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath, onImageClick = { viewingImage = it }, ) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index a03114c9..c7938c90 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -224,8 +224,9 @@ data class ChatUiState( val errorMessage: String? = null, // Background job completion toast (issue #527) — non-blocking snackbar val backgroundCompleteMessage: String? = null, - // Attachment open failure — surfaced as a non-blocking snackbar (issue #724) + // Attachment feedback — surfaced as a non-blocking snackbar (issue #724) val openError: String? = null, + val savingAttachmentPath: String? = null, val clarifyRequest: ClarifyUi? = null, // Sudo / secret prompts — surfaced as dialogs (issue #524) val sudoPrompt: SudoPromptUi? = null, @@ -2646,18 +2647,7 @@ class ChatViewModel( .onFailure { /* fall through to cache-copy below */ } } // GATEWAY, or LOCAL direct-open failed → fetch/copy then open. - val path = - attachment.gatewayUrl?.let { url -> - runCatching { - java.net - .URL(url) - .query - .split('&') - .firstOrNull { it.startsWith("path=") } - ?.removePrefix("path=") - ?.let { java.net.URLDecoder.decode(it, "UTF-8") } - }.getOrNull() - } ?: attachment.name + val path = gatewayPathFor(attachment) viewModelScope.launch(Dispatchers.IO) { when (val result = GatewayFileClient.fetch(path)) { is GatewayFileResult.Success -> { @@ -2687,6 +2677,58 @@ class ChatViewModel( } } + /** Save an agent-delivered file through Android's system document picker. */ + fun saveAttachment( + attachment: Attachment, + destination: android.net.Uri, + ) { + if (_uiState.value.savingAttachmentPath != null) return + val path = gatewayPathFor(attachment) + _uiState.update { it.copy(savingAttachmentPath = path) } + viewModelScope.launch(Dispatchers.IO) { + try { + when (val result = GatewayFileClient.fetch(path)) { + is GatewayFileResult.Success -> { + val resolver = getApplication().contentResolver + runCatching { + resolver + .openOutputStream(destination, "wt") + ?.use { it.write(result.file.bytes) } + ?: error("destination is unavailable") + }.onSuccess { + showOpenError("Saved ${result.file.name}") + }.onFailure { + showOpenError("Could not save ${attachment.name}: ${it.message}") + } + } + + is GatewayFileResult.NotFound -> showOpenError("File not found on gateway: ${attachment.name}") + is GatewayFileResult.Forbidden -> showOpenError("Access denied: ${attachment.name}") + is GatewayFileResult.TooLarge -> showOpenError("File too large to save: ${attachment.name}") + is GatewayFileResult.Unauthorized -> + showOpenError( + "Session expired — reconnect to save: ${attachment.name}", + ) + is GatewayFileResult.Failure -> + showOpenError("Could not save ${attachment.name}: ${result.throwable.message}") + } + } finally { + _uiState.update { + if (it.savingAttachmentPath == path) { + it.copy(savingAttachmentPath = null) + } else { + it + } + } + } + } + } + + fun gatewayPathFor(attachment: Attachment): String = + attachment.gatewayUrl?.let(::gatewayPathFromUrl) + ?: attachment.uri.removePrefix("gateway:").takeIf { it != attachment.uri } + ?: attachment.name + /** Open bytes written to a cache file via FileProvider + ACTION_VIEW. */ private fun openBytes( ctx: android.content.Context, 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 index 8d5886b2..431c6898 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ImageBytesResolver.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ImageBytesResolver.kt @@ -115,7 +115,7 @@ object ImageBytesResolver { } fun extensionForMime(mime: String): String = - when (mime.lowercase()) { + when (mime.substringBefore(';').trim().lowercase()) { "image/jpeg", "image/jpg" -> "jpg" "image/png" -> "png" "image/gif" -> "gif" diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaTypes.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaTypes.kt index 29c2a6c9..bfa16d3b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaTypes.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaTypes.kt @@ -66,20 +66,19 @@ fun mediaMimeForPath(path: String): String { return MEDIA_BY_EXT[ext]?.second ?: "application/octet-stream" } +/** Decoded gateway file path carried in a `/api/files/download` URL. */ +fun gatewayPathFromUrl(url: String): String? = + Regex("""[?&]path=([^&]+)""", RegexOption.IGNORE_CASE).find(url)?.groupValues?.get(1)?.let { + runCatching { URLDecoder.decode(it, StandardCharsets.UTF_8.name()) }.getOrDefault(it) + } + /** Trailing filename from a path or URL. * * Handles both a bare gateway path (`/tmp/report.pdf`) and a full * `/api/files/download?path=` URL, where the real filename lives in the * percent-encoded `path=` query parameter. */ fun mediaNameFromPath(path: String): String { - val queryPathMatch = Regex("""[?&]path=([^&]+)""", RegexOption.IGNORE_CASE).find(path) - if (queryPathMatch != null) { - val targetPath = - runCatching { - URLDecoder.decode(queryPathMatch.groupValues[1], StandardCharsets.UTF_8.name()) - }.getOrDefault(queryPathMatch.groupValues[1]) - targetPath.split('/', '\\').lastOrNull { it.isNotBlank() }?.let { return it } - } + gatewayPathFromUrl(path)?.split('/', '\\')?.lastOrNull { it.isNotBlank() }?.let { return it } val cleanPath = path.split('?', limit = 2)[0] return runCatching { java.net diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMessageList.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMessageList.kt index 75110710..d2baaf53 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMessageList.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMessageList.kt @@ -53,6 +53,8 @@ fun ChatMessageList( clarifyRequest: ClarifyUi? = null, onRespondClarify: ((String) -> Unit)? = null, onDismissClarify: (() -> Unit)? = null, + onSaveAttachment: (com.m57.hermescontrol.data.model.Attachment) -> Unit = {}, + savingAttachmentPath: String? = null, onImageClick: (ImageViewerModel) -> Unit = {}, ) { if (messages.isEmpty() && !isLoading) { @@ -115,6 +117,9 @@ fun ChatMessageList( isCurrentMatch = isCurrentMatch, onRespondApproval = viewModel::respondToApproval, onOpenAttachment = viewModel::openAttachment, + onSaveAttachment = onSaveAttachment, + savingAttachmentPath = savingAttachmentPath, + canSaveAttachment = savingAttachmentPath == null, onImageClick = onImageClick, ) } @@ -141,6 +146,9 @@ fun ChatMessageList( searchQuery = "", isCurrentMatch = false, onOpenAttachment = viewModel::openAttachment, + onSaveAttachment = onSaveAttachment, + savingAttachmentPath = savingAttachmentPath, + canSaveAttachment = savingAttachmentPath == null, onImageClick = onImageClick, ) } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/AttachmentSavePolicyTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/AttachmentSavePolicyTest.kt new file mode 100644 index 00000000..b1c83c35 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/AttachmentSavePolicyTest.kt @@ -0,0 +1,28 @@ +package com.m57.hermescontrol.ui.chat + +import android.app.Activity +import android.net.Uri +import io.mockk.mockk +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class AttachmentSavePolicyTest { + @Test + fun `save result accepts a destination only for RESULT_OK`() { + val destination = mockk() + + assertSame(destination, acceptedSaveDestination(Activity.RESULT_OK, destination)) + assertNull(acceptedSaveDestination(Activity.RESULT_CANCELED, destination)) + assertNull(acceptedSaveDestination(Activity.RESULT_OK, null)) + } + + @Test + fun `picker cannot start while picker or save is pending`() { + assertTrue(canStartAttachmentSave(null, null)) + assertFalse(canStartAttachmentSave("/tmp/picker.pdf", null)) + assertFalse(canStartAttachmentSave(null, "/tmp/saving.pdf")) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index 3dcb8fe5..fd6cda0f 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -49,6 +49,7 @@ import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put import org.junit.After +import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull @@ -2882,6 +2883,129 @@ class ChatViewModelTest { cacheDir.deleteRecursively() } + @Test + fun `saveAttachment writes gateway file to selected document`() = + runTest { + mockkObject(GatewayFileClient) + val bytes = "downloaded".toByteArray() + coEvery { GatewayFileClient.fetch("/tmp/note.txt") } returns + GatewayFileResult.Success(GatewayFile("note.txt", "text/plain", bytes)) + val resolver = mockk() + val output = java.io.ByteArrayOutputStream() + val destination = mockk() + every { app.contentResolver } returns resolver + every { resolver.openOutputStream(destination, "wt") } returns output + + val vm = createViewModel() + val attachment = + Attachment( + uri = "unused", + name = "note.txt", + mimeType = "text/plain", + gatewayUrl = "https://gw/api/files/download?path=%2Ftmp%2Fnote.txt&token=t", + source = AttachmentSource.GATEWAY, + ) + + vm.saveAttachment(attachment, destination) + advanceUntilIdle() + + assertArrayEquals(bytes, output.toByteArray()) + assertNull(vm.uiState.value.savingAttachmentPath) + assertEquals("Saved note.txt", vm.uiState.value.openError) + } + + @Test + fun `saveAttachment never deletes the selected document when download fails`() = + runTest { + mockkObject(GatewayFileClient) + val resolver = mockk(relaxed = true) + val destination = mockk() + every { app.contentResolver } returns resolver + coEvery { + GatewayFileClient.fetch("/tmp/missing.pdf") + } returns GatewayFileResult.NotFound + + val (viewModel, _) = createViewModelWithSession() + viewModel.saveAttachment( + attachment = + Attachment( + uri = "gateway:/tmp/missing.pdf", + name = "missing.pdf", + mimeType = "application/pdf", + size = 0, + source = AttachmentSource.GATEWAY, + gatewayUrl = "https://host/files/download?path=%2Ftmp%2Fmissing.pdf", + ), + destination = destination, + ) + advanceUntilIdle() + + verify(exactly = 0) { resolver.delete(any(), any(), any()) } + assertNull(viewModel.uiState.value.savingAttachmentPath) + } + + @Test + fun `saveAttachment never deletes the selected document when writing fails`() = + runTest { + mockkObject(GatewayFileClient) + val resolver = mockk(relaxed = true) + val destination = mockk() + every { app.contentResolver } returns resolver + every { resolver.openOutputStream(destination, "wt") } throws IllegalStateException("write failed") + coEvery { GatewayFileClient.fetch("/tmp/report.pdf") } returns + GatewayFileResult.Success(GatewayFile("report.pdf", "application/pdf", byteArrayOf(1))) + + val viewModel = createViewModel() + viewModel.saveAttachment( + Attachment( + uri = "gateway:/tmp/report.pdf", + name = "report.pdf", + mimeType = "application/pdf", + source = AttachmentSource.GATEWAY, + ), + destination, + ) + advanceUntilIdle() + + verify(exactly = 0) { resolver.delete(any(), any(), any()) } + assertNull(viewModel.uiState.value.savingAttachmentPath) + assertTrue(viewModel.uiState.value.openError.orEmpty().startsWith("Could not save")) + } + + @Test + fun `saveAttachment ignores overlapping saves`() = + runTest { + mockkObject(GatewayFileClient) + val firstResult = CompletableDeferred() + val fetchedPaths = mutableListOf() + coEvery { GatewayFileClient.fetch(capture(fetchedPaths)) } coAnswers { firstResult.await() } + val resolver = mockk(relaxed = true) + every { app.contentResolver } returns resolver + val viewModel = createViewModel() + + viewModel.saveAttachment( + Attachment("gateway:/tmp/first.pdf", "first.pdf", "application/pdf", source = AttachmentSource.GATEWAY), + mockk(), + ) + runCurrent() + viewModel.saveAttachment( + Attachment( + "gateway:/tmp/second.pdf", + "second.pdf", + "application/pdf", + source = AttachmentSource.GATEWAY, + ), + mockk(), + ) + + assertEquals("/tmp/first.pdf", viewModel.uiState.value.savingAttachmentPath) + assertEquals(listOf("/tmp/first.pdf"), fetchedPaths) + + firstResult.complete(GatewayFileResult.NotFound) + advanceUntilIdle() + assertNull(viewModel.uiState.value.savingAttachmentPath) + } + @Test fun `openAttachment GATEWAY not-found surfaces openError`() = runTest { 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 index 58a8a276..4b458a8e 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ImageBytesResolverTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ImageBytesResolverTest.kt @@ -27,6 +27,7 @@ class ImageBytesResolverTest { @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"))