feat(chat): save delivered files with native picker (#803)
This commit is contained in:
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,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user