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,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>
}