feat: add Files tab backed by the managed-files endpoint (#727)

* feat: add Files tab backed by the managed-files endpoint

Add a Hermes Mobile tab that browses the gateway's managed-files root via the
existing /api/files* backend routes: list/navigate folders, breadcrumb path,
create folder, upload (streaming multipart), delete, and open files via the
authenticated GatewayFileClient download URL.

- data/model/ManagedFile: entry + list/read/upload/mkdir/delete models
- HermesApiService: list/read/upload(+stream)/mkdir/delete endpoints
- FilesViewModel: state, navigation, per-row busy, transient-state cleanup
- FilesScreen: folder list, breadcrumb, FAB new folder, upload picker,
  delete confirm, open via system viewer
- NavigationKeys + ScreenRegistry + strings: register in Inspect drawer section

* fix(files): open files in-app via FileProvider instead of browser URL

The previous file-open path handed a ?token=-authenticated URL to the system
browser, but /api/files/download only accepts the ephemeral in-process session
token as ?token=, not the mobile's Bearer token — so the request failed and the
browser rendered the JSON error body instead of the file.

Fix: download the file in-app through the authenticated client (Bearer/
session-cookie, same path as the rest of the app), decode the base64 data_url,
write it to cacheDir, and open it via FileProvider.getUriForFile + ACTION_VIEW
(mirrors the existing ChatScreen/SystemViewModel open-from-cache pattern).
Works on a remote phone since the file is materialized locally first.

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-26 22:46:36 +03:00
1 parent 439ae9fcba
commit 6aee02c4da
8 files changed
+1024

No files matched your search

@@ -0,0 +1,523 @@
@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
package com.m57.hermescontrol.ui.files
import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.CreateNewFolder
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Upload
import androidx.compose.material.icons.outlined.AttachFile
import androidx.compose.material.icons.outlined.Description
import androidx.compose.material.icons.outlined.Image
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.ManagedFileEntry
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.common.SkeletonListState
import com.m57.hermescontrol.ui.common.ToastEffect
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private val filesContentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp)
private val filesItemSpacing = Arrangement.spacedBy(8.dp)
@Composable
fun FilesScreen(
modifier: Modifier = Modifier,
onOpenDrawer: (() -> Unit)? = null,
viewModel: FilesViewModel = viewModel { FilesViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
val uploadLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent(),
onResult = { uri ->
if (uri == null) return@rememberLauncherForActivityResult
val fileName =
uri.lastPathSegment?.substringAfterLast("/")?.takeIf { it.isNotBlank() }
?: "upload.bin"
val mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream"
runCatching {
context.contentResolver.openInputStream(uri)?.use { stream ->
viewModel.uploadFile(fileName, stream.readBytes(), mimeType)
}
}.onFailure {
viewModel.showToast("Could not read selected file: ${it.message}")
}
},
)
DisposableEffect(Unit) {
onDispose { viewModel.clearTransientState() }
}
val hasEntries = state.entries.isNotEmpty()
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
// ── Create-directory dialog ────────────────────────────────────────
if (state.isCreatingDir) {
CreateDirDialog(
name = state.newDirName,
onNameChange = viewModel::setNewDirName,
onConfirm = viewModel::createDir,
onDismiss = viewModel::dismissCreateDir,
)
}
// ── Delete confirmation dialog ─────────────────────────────────────
state.deleteTarget?.let { entry ->
AlertDialog(
onDismissRequest = viewModel::dismissDelete,
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.Delete,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.files_dialog_delete_title))
}
},
text = {
Text(
stringResource(
R.string.files_dialog_delete_message,
entry.name,
),
)
},
confirmButton = {
Button(
onClick = viewModel::confirmDelete,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
),
) {
Text(stringResource(R.string.files_action_delete))
}
},
dismissButton = {
TextButton(onClick = viewModel::dismissDelete) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
HermesScaffold(
title = { Text(stringResource(R.string.screen_files)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = viewModel::refresh,
actions = {
if (state.parentPath != null) {
IconButton(onClick = viewModel::navigateUp) {
Icon(
imageVector = Icons.Filled.ArrowUpward,
contentDescription = stringResource(R.string.files_action_up),
)
}
}
IconButton(onClick = { uploadLauncher.launch("*/*") }) {
Icon(
imageVector = Icons.Filled.Upload,
contentDescription = stringResource(R.string.files_action_upload),
)
}
},
) { paddingValues ->
Box(modifier = Modifier.fillMaxSize()) {
when {
state.isLoading && !hasEntries -> {
SkeletonListState(modifier = Modifier.padding(paddingValues))
}
state.errorMessage != null && !hasEntries -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = viewModel::refresh,
modifier = Modifier.padding(paddingValues),
)
}
!hasEntries -> {
EmptyState(
title = stringResource(R.string.files_empty_title),
subtitle = stringResource(R.string.files_empty_desc),
onAction = viewModel::refresh,
actionLabel = stringResource(R.string.content_desc_refresh),
modifier = Modifier.padding(paddingValues),
)
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = filesContentPadding,
verticalArrangement = filesItemSpacing,
) {
// ── Breadcrumb bar ────────────────────────────────
item(key = "breadcrumb") {
BreadcrumbBar(
crumbs = state.crumbs,
onCrumbClick = viewModel::navigateToCrumb,
)
}
// ── Up row (when not at root) ─────────────────────
if (state.parentPath != null) {
item(key = "up") {
FileRow(
icon = Icons.Filled.ArrowUpward,
name = "..",
subtitle = stringResource(R.string.files_action_up),
isBusy = false,
onClick = viewModel::navigateUp,
)
}
}
// ── Entries ───────────────────────────────────────
items(
items = state.entries,
key = { it.path },
) { entry ->
FileRow(
entry = entry,
isBusy = entry.path in state.busyPaths || state.openingPath == entry.path,
onClick = {
if (entry.isDirectory) {
viewModel.navigateTo(entry)
} else {
openManagedFile(entry, viewModel, context)
}
},
onDelete = { viewModel.requestDelete(entry) },
)
}
}
}
}
FloatingActionButton(
onClick = viewModel::openCreateDir,
modifier =
Modifier
.align(Alignment.BottomEnd)
.padding(16.dp),
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
) {
Icon(
imageVector = Icons.Filled.CreateNewFolder,
contentDescription = stringResource(R.string.files_action_new_folder),
)
}
}
}
}
// ── Breadcrumb bar ───────────────────────────────────────────────────────────
@Composable
private fun BreadcrumbBar(
crumbs: List<Pair<String, String>>,
onCrumbClick: (String) -> Unit,
) {
if (crumbs.isEmpty()) {
Text(
text = stringResource(R.string.files_root_label),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 4.dp),
)
return
}
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(bottom = 4.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
crumbs.forEachIndexed { index, (path, name) ->
if (index > 0) {
Text(
text = "/",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text = name,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.clickable { onCrumbClick(path) }
.padding(horizontal = 2.dp),
)
}
}
}
// ── File row ─────────────────────────────────────────────────────────────────
@Composable
private fun FileRow(
entry: ManagedFileEntry? = null,
icon: androidx.compose.ui.graphics.vector.ImageVector =
entry?.let { fileIcon(it) } ?: Icons.Filled.Folder,
name: String = entry?.name ?: "",
subtitle: String? = entry?.let { fileSubtitle(it) },
isBusy: Boolean,
onClick: () -> Unit,
onDelete: (() -> Unit)? = null,
) {
Card(
modifier =
Modifier
.fillMaxWidth()
.clickable(enabled = !isBusy, onClick = onClick),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
),
shape = RoundedCornerShape(12.dp),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(28.dp),
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = name,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (subtitle != null) {
Text(
text = subtitle,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
if (isBusy) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
)
} else if (onDelete != null && entry != null && !entry.isDirectory) {
IconButton(onClick = onDelete) {
Icon(
imageVector = Icons.Filled.Delete,
contentDescription = stringResource(R.string.files_action_delete),
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(20.dp),
)
}
}
}
}
}
// ── Create directory dialog ──────────────────────────────────────────────────
@Composable
private fun CreateDirDialog(
name: String,
onNameChange: (String) -> Unit,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.files_dialog_new_folder_title)) },
text = {
OutlinedTextField(
value = name,
onValueChange = onNameChange,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text(stringResource(R.string.files_new_folder_label)) },
placeholder = { Text(stringResource(R.string.files_new_folder_placeholder)) },
)
},
confirmButton = {
Button(
onClick = onConfirm,
enabled = name.isNotBlank(),
) {
Text(stringResource(R.string.files_action_create))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
// ── Helpers ──────────────────────────────────────────────────────────────────
private fun fileIcon(entry: ManagedFileEntry): androidx.compose.ui.graphics.vector.ImageVector =
when {
entry.isDirectory -> Icons.Filled.Folder
entry.mimeType?.startsWith("image/") == true -> Icons.Outlined.Image
entry.mimeType?.startsWith("text/") == true -> Icons.Outlined.Description
else -> Icons.Outlined.AttachFile
}
private fun fileSubtitle(entry: ManagedFileEntry): String {
val size =
entry.size?.let { bytes ->
when {
bytes >= 1024 * 1024 -> "%.1f MB".format(bytes / (1024.0 * 1024.0))
bytes >= 1024 -> "%.1f KB".format(bytes / 1024.0)
else -> "$bytes B"
}
} ?: "—"
val mtime =
entry.mtime?.let {
try {
val sdf =
SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault())
sdf.format(Date(it.toLong() * 1000))
} catch (_: Exception) {
null
}
} ?: ""
return if (mtime.isBlank()) size else "$size · $mtime"
}
/**
* Download a managed file **in-app** through the authenticated client and open
* it on the device via a `FileProvider`-shared URI + `ACTION_VIEW`.
*
* This replaces the earlier approach of handing a `?token=`-authenticated URL
* to the system browser: `/api/files/download` only accepts the *ephemeral
* in-process* session token as `?token=`, not the mobile's Bearer token, so a
* browser-opened link returned a JSON error body instead of the file. Fetching
* the bytes through [FilesViewModel.downloadFile] reuses the normal
* Bearer/session-cookie auth (same path as the rest of the app) and works on a
* remote phone because the file is materialized locally before viewing.
*/
private fun openManagedFile(
entry: ManagedFileEntry,
viewModel: FilesViewModel,
context: android.content.Context,
) {
viewModel.downloadFile(entry) { result ->
when (result) {
is com.m57.hermescontrol.data.remote.NetworkResult.Success -> {
val data = result.data
val bytes = data.bytes
if (bytes == null) {
viewModel.showToast("Could not decode file")
return@downloadFile
}
runCatching {
val safeName =
entry.name
.replace(Regex("[^A-Za-z0-9._-]"), "_")
.takeIf { it.isNotBlank() } ?: "file"
val file = File(context.cacheDir, "hermes_files_$safeName")
file.writeBytes(bytes)
val uri =
FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file,
)
val intent =
Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, data.mimeType)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(intent)
}.onFailure {
viewModel.showToast("Could not open file: ${it.message}")
}
}
is com.m57.hermescontrol.data.remote.NetworkResult.Failure -> {
viewModel.showToast("Failed to open file: ${result.error.message}")
}
}
}
}
@@ -0,0 +1,333 @@
package com.m57.hermescontrol.ui.files
import android.util.Base64
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.ManagedDirectoryCreate
import com.m57.hermescontrol.data.model.ManagedFileActionResponse
import com.m57.hermescontrol.data.model.ManagedFileDelete
import com.m57.hermescontrol.data.model.ManagedFileEntry
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
data class FilesUiState(
val currentPath: String = "",
val parentPath: String? = null,
/** Breadcrumb parts: pair of (absolute path, display name). */
val crumbs: List<Pair<String, String>> = emptyList(),
val entries: List<ManagedFileEntry> = emptyList(),
val isLoading: Boolean = false,
val errorMessage: String? = null,
// ── Transient action state ──
val busyPaths: Set<String> = emptySet(),
val isCreatingDir: Boolean = false,
val newDirName: String = "",
val isUploading: Boolean = false,
val deleteTarget: ManagedFileEntry? = null,
val openingPath: String? = null,
val toastMessage: String? = null,
)
/** Decoded file ready to be written to disk and opened on-device. */
data class DownloadedFile(
val name: String,
val mimeType: String,
val bytes: ByteArray?,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is DownloadedFile) return false
return name == other.name &&
mimeType == other.mimeType &&
(bytes?.contentEquals(other.bytes) == true)
}
override fun hashCode(): Int {
var r = name.hashCode()
r = 31 * r + mimeType.hashCode()
r = 31 * r + (bytes?.contentHashCode() ?: 0)
return r
}
}
/** Map a successful [NetworkResult] to a new value; pass failures through. */
inline fun <T, R> NetworkResult<T>.map(transform: (T) -> R): NetworkResult<R> =
when (this) {
is NetworkResult.Success -> NetworkResult.Success(transform(this.data))
is NetworkResult.Failure -> this
}
class FilesViewModel :
ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(FilesUiState())
val uiState: StateFlow<FilesUiState> = _uiState.asStateFlow()
fun load(path: String? = null) {
safeLaunchLoad(
apiCall = {
safeApiCall { ApiClient.hermesApi.listManagedFiles(path) }
},
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
_uiState.update { state ->
state.copy(
isLoading = false,
currentPath = data.path,
parentPath = data.parent,
crumbs = buildCrumbs(data.path),
entries = data.entries,
)
}
},
onError = { errorMsg ->
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to list files: $errorMsg",
)
}
},
)
}
fun refresh() = load(_uiState.value.currentPath.ifBlank { null })
fun navigateTo(entry: ManagedFileEntry) {
if (!entry.isDirectory) return
load(entry.path)
}
fun navigateUp() {
_uiState.value.parentPath?.let { load(it) }
}
fun navigateToCrumb(path: String) = load(path)
// ── Create directory ──────────────────────────────────────────────────
fun openCreateDir() {
_uiState.update { it.copy(isCreatingDir = true, newDirName = "") }
}
fun dismissCreateDir() {
_uiState.update { it.copy(isCreatingDir = false, newDirName = "") }
}
fun setNewDirName(name: String) {
_uiState.update { it.copy(newDirName = name) }
}
fun createDir() {
val name = _uiState.value.newDirName.trim()
val base = _uiState.value.currentPath
if (name.isBlank()) {
_uiState.update { it.copy(toastMessage = "Folder name is required") }
return
}
if (name.contains("/")) {
_uiState.update { it.copy(toastMessage = "Folder name cannot contain /") }
return
}
val target = if (base.isBlank()) name else "$base/$name"
_uiState.update { it.copy(isCreatingDir = false, newDirName = "", isLoading = true) }
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall {
ApiClient.hermesApi.createManagedDirectory(ManagedDirectoryCreate(target))
}
}
handleActionResult(result, "Folder created", "Failed to create folder")
}
}
// ── Upload (streaming multipart) ──────────────────────────────────────
fun uploadFile(
fileName: String,
bytes: ByteArray,
mimeType: String,
) {
val base = _uiState.value.currentPath
val target = if (base.isBlank()) fileName else "$base/$fileName"
val pathBody = target.toRequestBody("text/plain".toMediaTypeOrNull())
val overwriteBody = "true".toRequestBody("text/plain".toMediaTypeOrNull())
val part =
MultipartBody.Part.createFormData(
"file",
fileName,
bytes.toRequestBody(mimeType.toMediaTypeOrNull()),
)
_uiState.update { it.copy(isUploading = true) }
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall {
ApiClient.hermesApi.uploadManagedFileStream(pathBody, overwriteBody, part)
}
}
handleActionResult(result, "File uploaded", "Failed to upload file")
}
}
// ── Delete ────────────────────────────────────────────────────────────
fun requestDelete(entry: ManagedFileEntry) {
_uiState.update { it.copy(deleteTarget = entry) }
}
fun dismissDelete() {
_uiState.update { it.copy(deleteTarget = null) }
}
fun confirmDelete() {
val target = _uiState.value.deleteTarget ?: return
_uiState.update {
it.copy(
deleteTarget = null,
busyPaths = it.busyPaths + target.path,
)
}
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall {
ApiClient.hermesApi.deleteManagedFile(
ManagedFileDelete(target.path, recursive = target.isDirectory),
)
}
}
val cleared =
_uiState.value.busyPaths - target.path
when (result) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
busyPaths = cleared,
toastMessage = "Deleted ${target.name}",
)
}
refresh()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
busyPaths = cleared,
toastMessage = "Failed to delete: ${result.error.message}",
)
}
}
}
}
}
// ── Read / open (in-app download + base64 decode) ───────────────────────
// The backend returns file bytes as a base64 `data_url` (GET /api/files/read).
// We decode in-app and hand the raw bytes to the Screen, which writes them
// to cacheDir and opens via FileProvider — reusing the normal Bearer/session
// auth instead of the browser-only `?token=` escape hatch.
fun downloadFile(
entry: ManagedFileEntry,
onResult: (NetworkResult<DownloadedFile>) -> Unit,
) {
_uiState.update { it.copy(openingPath = entry.path) }
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.readManagedFile(entry.path) }
.map { read ->
val bytes = decodeDataUrl(read.dataUrl)
DownloadedFile(
name = entry.name,
mimeType = read.mimeType.ifBlank { "application/octet-stream" },
bytes = bytes,
)
}
}
_uiState.update { it.copy(openingPath = null) }
onResult(result)
}
}
private fun decodeDataUrl(dataUrl: String): ByteArray? {
val comma = dataUrl.indexOf(',')
if (comma < 0) return null
val payload = dataUrl.substring(comma + 1)
return runCatching { Base64.decode(payload, Base64.DEFAULT) }.getOrNull()
}
private fun handleActionResult(
result: NetworkResult<ManagedFileActionResponse>,
successMsg: String,
errorMsg: String,
) {
when (result) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isLoading = false,
isUploading = false,
toastMessage = successMsg,
)
}
refresh()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
isUploading = false,
toastMessage = "$errorMsg: ${result.error.message}",
)
}
}
}
}
fun showToast(message: String) {
_uiState.update { it.copy(toastMessage = message) }
}
override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) }
}
/** Reset transient state on screen re-entry (issue #619 pattern). */
fun clearTransientState() {
_uiState.update {
it.copy(
errorMessage = null,
toastMessage = null,
isCreatingDir = false,
newDirName = "",
isUploading = false,
deleteTarget = null,
)
}
}
private fun buildCrumbs(path: String): List<Pair<String, String>> {
if (path.isBlank()) return emptyList()
val parts = path.split("/").filter { it.isNotBlank() }
val crumbs = mutableListOf<Pair<String, String>>()
var acc = ""
for (part in parts) {
acc += "/$part"
crumbs.add(acc to part)
}
return crumbs
}
}