feat(chat): save delivered files with native picker (#803)
This commit is contained in:
@@ -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.Check
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.ContentCopy
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
|
import androidx.compose.material.icons.filled.Download
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -93,6 +95,9 @@ fun ChatBubble(
|
|||||||
isCurrentMatch: Boolean = false,
|
isCurrentMatch: Boolean = false,
|
||||||
onRespondApproval: (String) -> Unit = {},
|
onRespondApproval: (String) -> Unit = {},
|
||||||
onOpenAttachment: (Attachment) -> Unit = {},
|
onOpenAttachment: (Attachment) -> Unit = {},
|
||||||
|
onSaveAttachment: (Attachment) -> Unit = {},
|
||||||
|
savingAttachmentPath: String? = null,
|
||||||
|
canSaveAttachment: Boolean = true,
|
||||||
onImageClick: (ImageViewerModel) -> Unit = {},
|
onImageClick: (ImageViewerModel) -> Unit = {},
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
@@ -115,6 +120,9 @@ fun ChatBubble(
|
|||||||
searchQuery = searchQuery,
|
searchQuery = searchQuery,
|
||||||
isCurrentMatch = isCurrentMatch,
|
isCurrentMatch = isCurrentMatch,
|
||||||
onOpenAttachment = onOpenAttachment,
|
onOpenAttachment = onOpenAttachment,
|
||||||
|
onSaveAttachment = onSaveAttachment,
|
||||||
|
savingAttachmentPath = savingAttachmentPath,
|
||||||
|
canSaveAttachment = canSaveAttachment,
|
||||||
onImageClick = onImageClick,
|
onImageClick = onImageClick,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
@@ -128,6 +136,9 @@ fun ChatBubble(
|
|||||||
searchQuery = searchQuery,
|
searchQuery = searchQuery,
|
||||||
isCurrentMatch = isCurrentMatch,
|
isCurrentMatch = isCurrentMatch,
|
||||||
onOpenAttachment = onOpenAttachment,
|
onOpenAttachment = onOpenAttachment,
|
||||||
|
onSaveAttachment = onSaveAttachment,
|
||||||
|
savingAttachmentPath = savingAttachmentPath,
|
||||||
|
canSaveAttachment = canSaveAttachment,
|
||||||
onImageClick = onImageClick,
|
onImageClick = onImageClick,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
@@ -155,6 +166,9 @@ private fun UserBubble(
|
|||||||
searchQuery: String = "",
|
searchQuery: String = "",
|
||||||
isCurrentMatch: Boolean = false,
|
isCurrentMatch: Boolean = false,
|
||||||
onOpenAttachment: (Attachment) -> Unit = {},
|
onOpenAttachment: (Attachment) -> Unit = {},
|
||||||
|
onSaveAttachment: (Attachment) -> Unit = {},
|
||||||
|
savingAttachmentPath: String? = null,
|
||||||
|
canSaveAttachment: Boolean = true,
|
||||||
onImageClick: (ImageViewerModel) -> Unit = {},
|
onImageClick: (ImageViewerModel) -> Unit = {},
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
@@ -256,6 +270,9 @@ private fun UserBubble(
|
|||||||
attachment = attachment,
|
attachment = attachment,
|
||||||
textColor = userBubbleTextColor,
|
textColor = userBubbleTextColor,
|
||||||
onOpen = { onOpenAttachment(it) },
|
onOpen = { onOpenAttachment(it) },
|
||||||
|
onSave = { onSaveAttachment(it) },
|
||||||
|
savingPath = savingAttachmentPath,
|
||||||
|
canSave = canSaveAttachment,
|
||||||
onImageClick = onImageClick,
|
onImageClick = onImageClick,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
@@ -320,6 +337,9 @@ private fun AssistantBubble(
|
|||||||
searchQuery: String = "",
|
searchQuery: String = "",
|
||||||
isCurrentMatch: Boolean = false,
|
isCurrentMatch: Boolean = false,
|
||||||
onOpenAttachment: (Attachment) -> Unit = {},
|
onOpenAttachment: (Attachment) -> Unit = {},
|
||||||
|
onSaveAttachment: (Attachment) -> Unit = {},
|
||||||
|
savingAttachmentPath: String? = null,
|
||||||
|
canSaveAttachment: Boolean = true,
|
||||||
onImageClick: (ImageViewerModel) -> Unit = {},
|
onImageClick: (ImageViewerModel) -> Unit = {},
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
@@ -399,6 +419,9 @@ private fun AssistantBubble(
|
|||||||
attachment = attachment,
|
attachment = attachment,
|
||||||
textColor = textColor,
|
textColor = textColor,
|
||||||
onOpen = { onOpenAttachment(it) },
|
onOpen = { onOpenAttachment(it) },
|
||||||
|
onSave = { onSaveAttachment(it) },
|
||||||
|
savingPath = savingAttachmentPath,
|
||||||
|
canSave = canSaveAttachment,
|
||||||
onImageClick = onImageClick,
|
onImageClick = onImageClick,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
@@ -642,9 +665,14 @@ private fun buildHighlightedString(
|
|||||||
private fun InlineAttachment(
|
private fun InlineAttachment(
|
||||||
attachment: Attachment,
|
attachment: Attachment,
|
||||||
textColor: Color,
|
textColor: Color,
|
||||||
onOpen: (Attachment) -> Unit = {},
|
onOpen: (Attachment) -> Unit,
|
||||||
onImageClick: (ImageViewerModel) -> 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) }
|
val clickable = Modifier.clickable { onOpen(attachment) }
|
||||||
if (attachment.isVideo) {
|
if (attachment.isVideo) {
|
||||||
var showVideoDialog by remember { mutableStateOf(false) }
|
var showVideoDialog by remember { mutableStateOf(false) }
|
||||||
@@ -675,30 +703,35 @@ private fun InlineAttachment(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Non-image file — show a card with file icon and name. Tapping fetches
|
// Non-image file — native save picker on the trailing action.
|
||||||
// the bytes (gateway-sourced) or opens the local URI.
|
|
||||||
Surface(
|
Surface(
|
||||||
shape = RoundedCornerShape(8.dp),
|
shape = RoundedCornerShape(12.dp),
|
||||||
color = textColor.copy(alpha = 0.1f),
|
color = textColor.copy(alpha = 0.08f),
|
||||||
border = BorderStroke(1.dp, textColor.copy(alpha = 0.2f)),
|
border = BorderStroke(1.dp, textColor.copy(alpha = 0.16f)),
|
||||||
modifier = clickable,
|
modifier = clickable,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(8.dp),
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Surface(
|
||||||
imageVector = Icons.AutoMirrored.Filled.InsertDriveFile,
|
shape = RoundedCornerShape(10.dp),
|
||||||
contentDescription = null,
|
color = MaterialTheme.colorScheme.primaryContainer,
|
||||||
modifier = Modifier.size(24.dp),
|
) {
|
||||||
tint = textColor.copy(alpha = 0.8f),
|
Icon(
|
||||||
)
|
imageVector = Icons.AutoMirrored.Filled.InsertDriveFile,
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
contentDescription = null,
|
||||||
Column {
|
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(
|
||||||
text = attachment.name,
|
text = attachment.name,
|
||||||
color = textColor,
|
color = textColor,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
@@ -708,6 +741,25 @@ private fun InlineAttachment(
|
|||||||
style = MaterialTheme.typography.labelSmall,
|
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 androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.m57.hermescontrol.NavigationController
|
import com.m57.hermescontrol.NavigationController
|
||||||
import com.m57.hermescontrol.R
|
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.ConnectionStatus
|
||||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||||
@@ -107,6 +109,16 @@ import java.util.Locale
|
|||||||
|
|
||||||
private const val SESSION_SYNC_INTERVAL_MS = 30_000L
|
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.
|
* Chat screen — the primary conversation surface of Hermes Control.
|
||||||
*
|
*
|
||||||
@@ -135,6 +147,32 @@ fun ChatScreen(
|
|||||||
val scrollController = rememberChatScrollController(listState, scrollScope)
|
val scrollController = rememberChatScrollController(listState, scrollScope)
|
||||||
var isOlderPagingArmed by remember(state.currentSessionId) { mutableStateOf(false) }
|
var isOlderPagingArmed by remember(state.currentSessionId) { mutableStateOf(false) }
|
||||||
var showContextSheet by remember { 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.
|
// Periodic session sync while connected.
|
||||||
LaunchedEffect(lifecycleOwner, state.currentSessionId, state.connectionStatus) {
|
LaunchedEffect(lifecycleOwner, state.currentSessionId, state.connectionStatus) {
|
||||||
@@ -401,10 +439,14 @@ fun ChatScreen(
|
|||||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||||
snackbarHost = {
|
snackbarHost = {
|
||||||
SnackbarHost(snackbarHostState) { data ->
|
SnackbarHost(snackbarHostState) { data ->
|
||||||
|
val statusColors = LocalHermesStatusColors.current
|
||||||
|
val isDownloadComplete = data.visuals.message.startsWith("Saved ")
|
||||||
Snackbar(
|
Snackbar(
|
||||||
snackbarData = data,
|
snackbarData = data,
|
||||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
containerColor =
|
||||||
contentColor = MaterialTheme.colorScheme.onErrorContainer,
|
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,
|
clarifyRequest = state.clarifyRequest,
|
||||||
onRespondClarify = viewModel::respondToClarify,
|
onRespondClarify = viewModel::respondToClarify,
|
||||||
onDismissClarify = viewModel::dismissClarify,
|
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 },
|
onImageClick = { viewingImage = it },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -224,8 +224,9 @@ data class ChatUiState(
|
|||||||
val errorMessage: String? = null,
|
val errorMessage: String? = null,
|
||||||
// Background job completion toast (issue #527) — non-blocking snackbar
|
// Background job completion toast (issue #527) — non-blocking snackbar
|
||||||
val backgroundCompleteMessage: String? = null,
|
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 openError: String? = null,
|
||||||
|
val savingAttachmentPath: String? = null,
|
||||||
val clarifyRequest: ClarifyUi? = null,
|
val clarifyRequest: ClarifyUi? = null,
|
||||||
// Sudo / secret prompts — surfaced as dialogs (issue #524)
|
// Sudo / secret prompts — surfaced as dialogs (issue #524)
|
||||||
val sudoPrompt: SudoPromptUi? = null,
|
val sudoPrompt: SudoPromptUi? = null,
|
||||||
@@ -2646,18 +2647,7 @@ class ChatViewModel(
|
|||||||
.onFailure { /* fall through to cache-copy below */ }
|
.onFailure { /* fall through to cache-copy below */ }
|
||||||
}
|
}
|
||||||
// GATEWAY, or LOCAL direct-open failed → fetch/copy then open.
|
// GATEWAY, or LOCAL direct-open failed → fetch/copy then open.
|
||||||
val path =
|
val path = gatewayPathFor(attachment)
|
||||||
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
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
when (val result = GatewayFileClient.fetch(path)) {
|
when (val result = GatewayFileClient.fetch(path)) {
|
||||||
is GatewayFileResult.Success -> {
|
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. */
|
/** Open bytes written to a cache file via FileProvider + ACTION_VIEW. */
|
||||||
private fun openBytes(
|
private fun openBytes(
|
||||||
ctx: android.content.Context,
|
ctx: android.content.Context,
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ object ImageBytesResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun extensionForMime(mime: String): String =
|
fun extensionForMime(mime: String): String =
|
||||||
when (mime.lowercase()) {
|
when (mime.substringBefore(';').trim().lowercase()) {
|
||||||
"image/jpeg", "image/jpg" -> "jpg"
|
"image/jpeg", "image/jpg" -> "jpg"
|
||||||
"image/png" -> "png"
|
"image/png" -> "png"
|
||||||
"image/gif" -> "gif"
|
"image/gif" -> "gif"
|
||||||
|
|||||||
@@ -66,20 +66,19 @@ fun mediaMimeForPath(path: String): String {
|
|||||||
return MEDIA_BY_EXT[ext]?.second ?: "application/octet-stream"
|
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.
|
/** Trailing filename from a path or URL.
|
||||||
*
|
*
|
||||||
* Handles both a bare gateway path (`/tmp/report.pdf`) and a full
|
* 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
|
* `/api/files/download?path=<enc>` URL, where the real filename lives in the
|
||||||
* percent-encoded `path=` query parameter. */
|
* percent-encoded `path=` query parameter. */
|
||||||
fun mediaNameFromPath(path: String): String {
|
fun mediaNameFromPath(path: String): String {
|
||||||
val queryPathMatch = Regex("""[?&]path=([^&]+)""", RegexOption.IGNORE_CASE).find(path)
|
gatewayPathFromUrl(path)?.split('/', '\\')?.lastOrNull { it.isNotBlank() }?.let { return it }
|
||||||
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 }
|
|
||||||
}
|
|
||||||
val cleanPath = path.split('?', limit = 2)[0]
|
val cleanPath = path.split('?', limit = 2)[0]
|
||||||
return runCatching {
|
return runCatching {
|
||||||
java.net
|
java.net
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ fun ChatMessageList(
|
|||||||
clarifyRequest: ClarifyUi? = null,
|
clarifyRequest: ClarifyUi? = null,
|
||||||
onRespondClarify: ((String) -> Unit)? = null,
|
onRespondClarify: ((String) -> Unit)? = null,
|
||||||
onDismissClarify: (() -> Unit)? = null,
|
onDismissClarify: (() -> Unit)? = null,
|
||||||
|
onSaveAttachment: (com.m57.hermescontrol.data.model.Attachment) -> Unit = {},
|
||||||
|
savingAttachmentPath: String? = null,
|
||||||
onImageClick: (ImageViewerModel) -> Unit = {},
|
onImageClick: (ImageViewerModel) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
if (messages.isEmpty() && !isLoading) {
|
if (messages.isEmpty() && !isLoading) {
|
||||||
@@ -115,6 +117,9 @@ fun ChatMessageList(
|
|||||||
isCurrentMatch = isCurrentMatch,
|
isCurrentMatch = isCurrentMatch,
|
||||||
onRespondApproval = viewModel::respondToApproval,
|
onRespondApproval = viewModel::respondToApproval,
|
||||||
onOpenAttachment = viewModel::openAttachment,
|
onOpenAttachment = viewModel::openAttachment,
|
||||||
|
onSaveAttachment = onSaveAttachment,
|
||||||
|
savingAttachmentPath = savingAttachmentPath,
|
||||||
|
canSaveAttachment = savingAttachmentPath == null,
|
||||||
onImageClick = onImageClick,
|
onImageClick = onImageClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -141,6 +146,9 @@ fun ChatMessageList(
|
|||||||
searchQuery = "",
|
searchQuery = "",
|
||||||
isCurrentMatch = false,
|
isCurrentMatch = false,
|
||||||
onOpenAttachment = viewModel::openAttachment,
|
onOpenAttachment = viewModel::openAttachment,
|
||||||
|
onSaveAttachment = onSaveAttachment,
|
||||||
|
savingAttachmentPath = savingAttachmentPath,
|
||||||
|
canSaveAttachment = savingAttachmentPath == null,
|
||||||
onImageClick = onImageClick,
|
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.buildJsonObject
|
||||||
import kotlinx.serialization.json.put
|
import kotlinx.serialization.json.put
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertArrayEquals
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertNotNull
|
import org.junit.Assert.assertNotNull
|
||||||
@@ -2882,6 +2883,129 @@ class ChatViewModelTest {
|
|||||||
cacheDir.deleteRecursively()
|
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
|
@Test
|
||||||
fun `openAttachment GATEWAY not-found surfaces openError`() =
|
fun `openAttachment GATEWAY not-found surfaces openError`() =
|
||||||
runTest {
|
runTest {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class ImageBytesResolverTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `extensionForMime maps known types`() {
|
fun `extensionForMime maps known types`() {
|
||||||
assertEquals("png", ImageBytesResolver.extensionForMime("image/png"))
|
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/jpeg"))
|
||||||
assertEquals("jpg", ImageBytesResolver.extensionForMime("image/JPG"))
|
assertEquals("jpg", ImageBytesResolver.extensionForMime("image/JPG"))
|
||||||
assertEquals("gif", ImageBytesResolver.extensionForMime("image/gif"))
|
assertEquals("gif", ImageBytesResolver.extensionForMime("image/gif"))
|
||||||
|
|||||||
Reference in New Issue
Block a user