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

@@ -53,6 +53,8 @@ import kotlinx.serialization.Serializable
@Serializable data object BillingScreen : NavKey
@Serializable data object FilesScreen : NavKey
// ── Settings drill-down sub-pages ──────────────────────────────────────
@Serializable data object SettingsConnection : NavKey
@@ -14,6 +14,7 @@ import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.EmojiEvents
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.HistoryEdu
import androidx.compose.material.icons.filled.Info
@@ -33,6 +34,7 @@ import com.m57.hermescontrol.ui.channels.ChannelsScreen as ChannelsScreenContent
import com.m57.hermescontrol.ui.chat.ChatScreen as ChatScreenContent
import com.m57.hermescontrol.ui.config.ConfigScreen as ConfigScreenContent
import com.m57.hermescontrol.ui.cron.CronJobsScreen as CronJobsScreenContent
import com.m57.hermescontrol.ui.files.FilesScreen as FilesScreenContent
import com.m57.hermescontrol.ui.gateway.GatewayScreen as GatewayScreenContent
import com.m57.hermescontrol.ui.kanban.KanbanScreen as KanbanScreenContent
import com.m57.hermescontrol.ui.keys.KeysScreen as KeysScreenContent
@@ -196,6 +198,12 @@ object ScreenRegistry {
Icons.Filled.AccountBalanceWallet,
DrawerSection.INSPECT,
) { sessionId, openDrawer -> BillingScreenContent(onOpenDrawer = openDrawer) },
ScreenDefinition(
FilesScreen,
R.string.screen_files,
Icons.Filled.Folder,
DrawerSection.INSPECT,
) { sessionId, openDrawer -> FilesScreenContent(onOpenDrawer = openDrawer) },
ScreenDefinition(
AchievementsScreen,
R.string.screen_achievements,
@@ -0,0 +1,96 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* A single entry returned by `GET /api/files` — a file or directory living
* under the gateway's managed-files root.
*
* Mirrors the backend `_managed_file_entry()` dict exactly
* (hermes_cli/web_server.py). `mtime` is an epoch float on the wire; the
* mobile model declares it `Double?` so a fractional value does not throw a
* kotlinx decoding error (see dashboard-tab-to-android pitfalls: numeric
* type drift).
*/
@Serializable
data class ManagedFileEntry(
val name: String = "",
val path: String = "",
@SerialName("is_directory")
val isDirectory: Boolean = false,
val size: Long? = null,
val mtime: Double? = null,
@SerialName("mime_type")
val mimeType: String? = null,
)
/**
* Response of `GET /api/files` (directory listing with navigation metadata).
*
* `parent` is `null` at the managed root (or when a locked root hides the
* parent). `entries` is already server-sorted directories-first, then
* case-insensitive name.
*/
@Serializable
data class ManagedFilesListResponse(
val path: String = "",
val parent: String? = null,
val entries: List<ManagedFileEntry> = emptyList(),
val root: String? = null,
@SerialName("locked_root")
val lockedRoot: String? = null,
@SerialName("can_change_path")
val canChangePath: Boolean = false,
)
/** Response of `GET /api/files/read` — base64 data URL of a file's bytes. */
@Serializable
data class ManagedFileRead(
val name: String = "",
val path: String = "",
val size: Long = 0,
@SerialName("mime_type")
val mimeType: String = "application/octet-stream",
@SerialName("data_url")
val dataUrl: String = "",
val root: String? = null,
@SerialName("locked_root")
val lockedRoot: String? = null,
@SerialName("can_change_path")
val canChangePath: Boolean = false,
)
/** Generic `{"ok": true}`-style acknowledgement used by upload/mkdir/delete. */
@Serializable
data class ManagedFileActionResponse(
val ok: Boolean = false,
val path: String? = null,
val entry: ManagedFileEntry? = null,
val root: String? = null,
@SerialName("locked_root")
val lockedRoot: String? = null,
@SerialName("can_change_path")
val canChangePath: Boolean = false,
)
/** Request body for `POST /api/files/upload` (base64 data URL form). */
@Serializable
data class ManagedFileUpload(
val path: String,
val data_url: String,
val overwrite: Boolean = false,
)
/** Request body for `POST /api/files/mkdir`. */
@Serializable
data class ManagedDirectoryCreate(
val path: String,
)
/** Request body for `DELETE /api/files`. */
@Serializable
data class ManagedFileDelete(
val path: String,
val recursive: Boolean = false,
)
@@ -35,6 +35,12 @@ import com.m57.hermescontrol.data.model.KanbanBoardResponse
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
import com.m57.hermescontrol.data.model.KanbanTask
import com.m57.hermescontrol.data.model.LogResponse
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.ManagedFileRead
import com.m57.hermescontrol.data.model.ManagedFileUpload
import com.m57.hermescontrol.data.model.ManagedFilesListResponse
import com.m57.hermescontrol.data.model.McpCatalogInstallRequest
import com.m57.hermescontrol.data.model.McpCatalogResponse
import com.m57.hermescontrol.data.model.McpServer
@@ -106,9 +112,11 @@ import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.HTTP
import retrofit2.http.Multipart
import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
@@ -757,4 +765,41 @@ interface HermesApiService {
@Path("name") name: String,
@Query("lines") lines: Int = 200,
): Response<ActionStatusResponse>
// ── Managed Files ──────────────────────────────────────────────────
// Backed by hermes_cli/web_server.py /api/files* endpoints. The mobile app
// talks to the dashboard (9119) and authenticates with the standard
// Bearer/session-cookie middleware, so no ?token= query param is needed.
@GET("api/files")
suspend fun listManagedFiles(
@Query("path") path: String? = null,
): Response<ManagedFilesListResponse>
@GET("api/files/read")
suspend fun readManagedFile(
@Query("path") path: String,
): Response<ManagedFileRead>
@POST("api/files/upload")
suspend fun uploadManagedFile(
@Body body: ManagedFileUpload,
): Response<ManagedFileActionResponse>
@Multipart
@POST("api/files/upload-stream")
suspend fun uploadManagedFileStream(
@Part("path") path: okhttp3.RequestBody,
@Part("overwrite") overwrite: okhttp3.RequestBody,
@Part file: okhttp3.MultipartBody.Part,
): Response<ManagedFileActionResponse>
@POST("api/files/mkdir")
suspend fun createManagedDirectory(
@Body body: ManagedDirectoryCreate,
): Response<ManagedFileActionResponse>
@HTTP(method = "DELETE", path = "api/files", hasBody = true)
suspend fun deleteManagedFile(
@Body body: ManagedFileDelete,
): Response<ManagedFileActionResponse>
}
@@ -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
}
}