From 6aee02c4da09ac065897cc47b17b40f5124af78f Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:46:36 +0300 Subject: [PATCH] feat: add Files tab backed by the managed-files endpoint (#727) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../com/m57/hermescontrol/NavigationKeys.kt | 2 + .../com/m57/hermescontrol/ScreenRegistry.kt | 8 + .../hermescontrol/data/model/ManagedFile.kt | 96 ++++ .../data/remote/HermesApiService.kt | 45 ++ .../m57/hermescontrol/ui/files/FilesScreen.kt | 523 ++++++++++++++++++ .../hermescontrol/ui/files/FilesViewModel.kt | 333 +++++++++++ app/src/main/res/values-ko/strings.xml | 1 + app/src/main/res/values/strings.xml | 16 + 8 files changed, 1024 insertions(+) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/ManagedFile.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/files/FilesScreen.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/files/FilesViewModel.kt diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt index 7472757f..50b0789a 100644 --- a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt +++ b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt @@ -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 diff --git a/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt b/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt index 5628bfa9..6b592c16 100644 --- a/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt +++ b/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt @@ -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, diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/ManagedFile.kt b/app/src/main/java/com/m57/hermescontrol/data/model/ManagedFile.kt new file mode 100644 index 00000000..a2d26651 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/ManagedFile.kt @@ -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 = 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, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index c2a32327..ad4b9f7d 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -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 + + // ── 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 + + @GET("api/files/read") + suspend fun readManagedFile( + @Query("path") path: String, + ): Response + + @POST("api/files/upload") + suspend fun uploadManagedFile( + @Body body: ManagedFileUpload, + ): Response + + @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 + + @POST("api/files/mkdir") + suspend fun createManagedDirectory( + @Body body: ManagedDirectoryCreate, + ): Response + + @HTTP(method = "DELETE", path = "api/files", hasBody = true) + suspend fun deleteManagedFile( + @Body body: ManagedFileDelete, + ): Response } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/files/FilesScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/files/FilesScreen.kt new file mode 100644 index 00000000..22d98316 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/files/FilesScreen.kt @@ -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>, + 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}") + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/files/FilesViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/files/FilesViewModel.kt new file mode 100644 index 00000000..6b58ca84 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/files/FilesViewModel.kt @@ -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> = emptyList(), + val entries: List = emptyList(), + val isLoading: Boolean = false, + val errorMessage: String? = null, + // ── Transient action state ── + val busyPaths: Set = 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 NetworkResult.map(transform: (T) -> R): NetworkResult = + 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 = _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) -> 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, + 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> { + if (path.isBlank()) return emptyList() + val parts = path.split("/").filter { it.isNotBlank() } + val crumbs = mutableListOf>() + var acc = "" + for (part in parts) { + acc += "/$part" + crumbs.add(acc to part) + } + return crumbs + } +} diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 7af9a50b..9da67ed6 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -80,6 +80,7 @@ 제공자 분석 결제 + 파일 헤르메스 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ff496884..9d72e0f3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -91,6 +91,7 @@ Providers Analytics Billing + Files Hermes @@ -760,6 +761,21 @@ Open provider documentation All + + Managed files root + No files here + This folder is empty. Create a folder or upload a file to get started. + Go up + Upload file + New folder + Create + Delete + New Folder + Folder name + my-folder + Delete? + Are you sure you want to delete \"%1$s\"? This cannot be undone. + No channels configured Add messaging platforms in Hermes to see them here.