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,
)
}