feat(chat): save delivered files with native picker (#803)

This commit is contained in:
AlexDev_ authored and GitHub committed 2026-08-04 20:47:55 +03:00
1 parent 509a018434
commit 19c053b3fa
9 files changed
+363 -41

No files matched your search

@@ -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,
)
}
}
}
}
}
}
@@ -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<String?>(null) }
var pendingSaveName by rememberSaveable { mutableStateOf<String?>(null) }
var pendingSaveMimeType by rememberSaveable { mutableStateOf<String?>(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 },
)
@@ -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<Application>().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,
@@ -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"
@@ -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=<enc>` 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
@@ -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,
)
}
@@ -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<Uri>()
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"))
}
}
@@ -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<android.content.ContentResolver>()
val output = java.io.ByteArrayOutputStream()
val destination = mockk<android.net.Uri>()
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<android.content.ContentResolver>(relaxed = true)
val destination = mockk<android.net.Uri>()
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<android.content.ContentResolver>(relaxed = true)
val destination = mockk<android.net.Uri>()
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<GatewayFileResult>()
val fetchedPaths = mutableListOf<String>()
coEvery { GatewayFileClient.fetch(capture(fetchedPaths)) } coAnswers { firstResult.await() }
val resolver = mockk<android.content.ContentResolver>(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 {
@@ -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"))