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:
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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user