feat(system): multipart backup import via SAF picker — replace path-based import (issue #786) (#839)

* feat(system): multipart backup import via SAF picker — replace path-based import (issue #786)

* fix(system): backup trigger sends required JSON body — 422 fix (BackupRequest contract)

* fix(system): backup download waits for async archive + saves to Downloads

* fix(system): curator min_idle_hours Double parse + single-flight backup download

* fix(system): stream backup download to Downloads — @Streaming + chunked MediaStore write (OOM fix)

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-07 16:44:41 +03:00
1 parent 9d0baa78fd
commit 7998248507
8 files changed
+358 -43

No files matched your search

@@ -12,3 +12,13 @@ data class ActionResponse(
val uploaded_bytes: Long? = null,
val update_command: String? = null,
)
/**
* Body for POST /api/ops/backup — the backend requires a JSON body
* (BackupRequest; `output` optional). Sending no body at all gets a 422.
* Empty object (default) matches the desktop's `{"output": ...}` contract.
*/
@Serializable
data class BackupTriggerRequest(
val output: String? = null,
)
@@ -7,7 +7,9 @@ data class CuratorResponse(
val paused: Boolean? = null,
val interval_hours: Int? = null,
val last_run_at: String? = null,
val min_idle_hours: Int? = null,
// Backend sends a float (e.g. 2.0) — Int here broke JSON parsing (logcat
// "Unexpected symbol '.' in numeric literal at path: $.min_idle_hours").
val min_idle_hours: Double? = null,
val stale_after_days: Int? = null,
val archive_after_days: Int? = null,
)
@@ -8,6 +8,7 @@ import com.m57.hermescontrol.data.model.AddMcpServerRequest
import com.m57.hermescontrol.data.model.AgentPluginInstallBody
import com.m57.hermescontrol.data.model.AnalyticsResponse
import com.m57.hermescontrol.data.model.AuxiliaryModelsResponse
import com.m57.hermescontrol.data.model.BackupTriggerRequest
import com.m57.hermescontrol.data.model.BulkDeleteRequest
import com.m57.hermescontrol.data.model.BulkDeleteResponse
import com.m57.hermescontrol.data.model.CheckpointsResponse
@@ -144,6 +145,7 @@ import retrofit2.http.PUT
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
import retrofit2.http.Streaming
interface HermesApiService {
@GET("api/skills/content")
@@ -750,8 +752,11 @@ interface HermesApiService {
@Body request: EnvVarDeleteRequest,
): Response<Unit>
// Backend requires a JSON body (BackupRequest) — bodyless POST 422s.
@POST("api/ops/backup")
suspend fun triggerBackup(): Response<ActionResponse>
suspend fun triggerBackup(
@Body body: BackupTriggerRequest,
): Response<ActionResponse>
@POST("api/ops/doctor")
suspend fun runDoctor(): Response<DoctorResponse>
@@ -869,15 +874,23 @@ interface HermesApiService {
): Response<ActionResponse>
// ── Admin: Backup download ────────────────────────────────────────
// @Streaming: backups are 300+ MB — Retrofit's default buffering of
// ResponseBody OOMs the app (observed crash with a 327MB archive).
@Streaming
@GET("api/ops/backup/download")
suspend fun downloadBackup(
@Query("archive") archive: String,
): Response<ResponseBody>
// ── Admin: Backup import ──────────────────────────────────────────
@POST("api/ops/import")
suspend fun runImport(
@Body body: Map<String, Any>,
// Multipart upload (issue #786): the path-based POST /api/ops/import
// is useless on a phone (server-side path typed into the app); the
// desktop's upload contract is the real one — file + force.
@Multipart
@POST("api/ops/import-upload")
suspend fun importUpload(
@Part("force") force: okhttp3.RequestBody,
@Part file: okhttp3.MultipartBody.Part,
): Response<ActionResponse>
// ── Admin: Debug share ────────────────────────────────────────────
@@ -7,7 +7,9 @@ import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import androidx.core.content.FileProvider
import java.io.ByteArrayInputStream
import java.io.File
import java.io.InputStream
/**
* Persist / dispatch chat images on the *device* (Downloads / Gallery / share
@@ -30,6 +32,19 @@ object MediaImageStore {
bytes: ByteArray,
displayName: String,
mimeType: String,
): Uri? = saveToDownloads(context, ByteArrayInputStream(bytes), bytes.size.toLong(), displayName, mimeType)
/**
* Streaming variant of [saveToDownloads] — for large payloads (e.g. backup
* archives, 300+ MB) that must NOT be held in memory as a [ByteArray].
* Copies [input] to the Download collection in chunks. Same return contract.
*/
fun saveToDownloads(
context: Context,
input: InputStream,
length: Long?,
displayName: String,
mimeType: String,
): Uri? {
val safe = sanitizeName(displayName)
val (collection, relativePath) =
@@ -42,6 +57,9 @@ object MediaImageStore {
ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, safe)
put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
if (length != null) {
put(MediaStore.MediaColumns.SIZE, length)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
put(MediaStore.MediaColumns.IS_PENDING, 1)
@@ -50,7 +68,9 @@ object MediaImageStore {
val resolver = context.contentResolver
val uri = resolver.insert(collection, values) ?: return null
return try {
resolver.openOutputStream(uri)?.use { it.write(bytes) } ?: return null.also {
resolver.openOutputStream(uri)?.use { out ->
input.use { it.copyTo(out, DEFAULT_BUFFER_SIZE) }
} ?: return null.also {
runCatching { resolver.delete(uri, null, null) }
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@@ -1,5 +1,8 @@
package com.m57.hermescontrol.ui.system
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
@@ -22,6 +25,7 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.CloudDownload
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.DeleteForever
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Devices
import androidx.compose.material.icons.filled.HealthAndSafety
import androidx.compose.material.icons.filled.Link
@@ -35,6 +39,7 @@ import androidx.compose.material.icons.filled.Speed
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.Update
import androidx.compose.material.icons.filled.UploadFile
import androidx.compose.material.icons.filled.VerifiedUser
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
@@ -55,11 +60,13 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
@@ -73,6 +80,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.theme.LocalSpacing
import com.m57.hermescontrol.ui.chat.MediaImageStore
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
@@ -86,6 +94,9 @@ import com.m57.hermescontrol.ui.common.StatusBadgeType
import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.system.components.CredentialEntryRow
import com.m57.hermescontrol.ui.system.components.HookCard
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
// ── Helpers ─────────────────────────────────────────────────────────────
@@ -126,6 +137,38 @@ fun SystemScreen(
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
// ── Backup import (issue #786) ────────────────────────────────────────
// SAF file picker → stage uri/name/mime → confirm dialog → read bytes on
// IO → multipart upload via SystemViewModel.importArchive.
val context = LocalContext.current
val importScope = rememberCoroutineScope()
var pendingImport by remember { mutableStateOf<PendingImport?>(null) }
val importPickerLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
) { uri: Uri? ->
if (uri == null) return@rememberLauncherForActivityResult
importScope.launch {
val meta =
withContext(Dispatchers.IO) {
runCatching {
val name =
uri.lastPathSegment?.substringAfterLast("/")?.takeIf { it.isNotBlank() }
?: "backup.zip"
val mime = context.contentResolver.getType(uri) ?: "application/octet-stream"
PendingImport(uri, name, mime)
}
}
meta.fold(
onSuccess = {
pendingImport = it
viewModel.setImportFile(it.name)
},
onFailure = { viewModel.showToast("Could not read selected file: ${it.message}") },
)
}
}
// ── Confirmation dialogs ──────────────────────────────────────────────
if (state.updateConfirmOpen) {
@@ -205,7 +248,30 @@ fun SystemScreen(
confirmButton = {
TextButton(onClick = {
importConfirm = false
viewModel.runImport(state.importPath)
val pending = pendingImport
pendingImport = null
if (pending != null) {
importScope.launch {
val read =
withContext(Dispatchers.IO) {
runCatching {
context.contentResolver.openInputStream(pending.uri)?.use { it.readBytes() }
}
}
read.fold(
onSuccess = { bytes ->
if (bytes != null) {
viewModel.importArchive(pending.name, bytes, pending.mime)
} else {
viewModel.showToast("Could not read selected file")
}
},
onFailure = {
viewModel.showToast("Could not read selected file: ${it.message}")
},
)
}
}
}) {
Text(stringResource(R.string.system_confirm_restore))
}
@@ -271,7 +337,46 @@ fun SystemScreen(
credentialsSection(state, spacing, viewModel, credToRemove, { credToRemove = it })
// ── 7. Operations ──────────────────────────────────────
operationsSection(state, spacing, viewModel, importConfirm, { importConfirm = it })
operationsSection(
state,
spacing,
viewModel,
importConfirm,
{ importConfirm = it },
{
importPickerLauncher.launch(
arrayOf(
"application/zip",
"application/x-zip-compressed",
"application/octet-stream",
"*/*",
),
)
},
{
viewModel.downloadBackup { body, fileName ->
importScope.launch {
val uri =
withContext(Dispatchers.IO) {
runCatching {
MediaImageStore.saveToDownloads(
context,
body.byteStream(),
body.contentLength().takeIf { it >= 0 },
fileName,
"application/zip",
)
}.getOrNull()
}
if (uri != null) {
viewModel.showToast("Backup saved to Downloads")
} else {
viewModel.showToast("Could not save backup to Downloads")
}
}
}
},
)
// ── 8. Checkpoints ─────────────────────────────────────
checkpointsSection(state, spacing, pruneConfirm, { pruneConfirm = it })
@@ -958,6 +1063,8 @@ private fun LazyListScope.operationsSection(
viewModel: SystemViewModel,
importConfirm: Boolean,
onImportConfirm: (Boolean) -> Unit,
onImportPick: () -> Unit,
downloadBackup: () -> Unit,
) {
item {
SectionHeader(title = stringResource(R.string.system_sec_operations))
@@ -1075,19 +1182,34 @@ private fun LazyListScope.operationsSection(
)
}
OutlinedButton(
onClick = { viewModel.downloadBackup() },
enabled = state.backupArchive != null,
onClick = downloadBackup,
enabled = state.backupArchive != null && !state.isDownloading,
modifier = Modifier.weight(1f),
contentPadding = actionButtonPadding,
) {
ActionButtonContent(
icon = Icons.Filled.CloudDownload,
if (state.isDownloading) {
androidx.compose.material3.CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.size(spacing.xs))
} else {
Icon(
Icons.Filled.CloudDownload,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.size(spacing.xs))
}
Text(
text = stringResource(R.string.system_op_backup_download),
maxLines = 1,
softWrap = false,
)
}
}
// Restore from uploaded zip
// Restore from picked archive (issue #786: SAF picker → upload)
Spacer(modifier = Modifier.height(spacing.sm))
Text(
text = stringResource(R.string.system_sec_restore),
@@ -1096,20 +1218,48 @@ private fun LazyListScope.operationsSection(
)
Spacer(modifier = Modifier.height(spacing.sm))
OutlinedTextField(
value = state.importPath,
onValueChange = viewModel::updateImportPath,
label = { Text(stringResource(R.string.system_op_restore_path_label)) },
placeholder = { Text(stringResource(R.string.system_op_restore_path)) },
singleLine = true,
OutlinedButton(
onClick = onImportPick,
enabled = !state.isImporting,
modifier = Modifier.fillMaxWidth(),
textStyle = MaterialTheme.typography.bodySmall,
)
contentPadding = actionButtonPadding,
) {
Icon(Icons.Filled.UploadFile, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.size(spacing.xs))
Text(
text = stringResource(R.string.system_op_restore_pick),
maxLines = 1,
softWrap = false,
)
}
state.importFileName?.let { name ->
Spacer(modifier = Modifier.height(spacing.sm))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(spacing.xs),
) {
Icon(
imageVector = Icons.Filled.Description,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = name,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Spacer(modifier = Modifier.height(spacing.sm))
Button(
onClick = { onImportConfirm(true) },
enabled = state.importPath.isNotBlank(),
enabled = state.importFileName != null && !state.isImporting,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
@@ -1117,8 +1267,17 @@ private fun LazyListScope.operationsSection(
),
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.RestartAlt, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.size(spacing.xs))
if (state.isImporting) {
androidx.compose.material3.CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onError,
)
Spacer(modifier = Modifier.size(spacing.xs))
} else {
Icon(Icons.Filled.RestartAlt, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.size(spacing.xs))
}
Text(stringResource(R.string.system_op_restore_upload), maxLines = 1, softWrap = false)
}
@@ -1551,3 +1710,10 @@ private fun ActionButtonContent(
)
}
}
/** Staged SAF-picked backup archive for the restore flow (issue #786). */
private data class PendingImport(
val uri: Uri,
val name: String,
val mime: String,
)
@@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.data.model.ActionResponse
import com.m57.hermescontrol.data.model.ActionStatusResponse
import com.m57.hermescontrol.data.model.BackupTriggerRequest
import com.m57.hermescontrol.data.model.CheckpointsResponse
import com.m57.hermescontrol.data.model.CredentialPoolProvider
import com.m57.hermescontrol.data.model.CuratorResponse
@@ -17,6 +18,7 @@ import com.m57.hermescontrol.data.model.StatusResponse
import com.m57.hermescontrol.data.model.SystemStatsResponse
import com.m57.hermescontrol.data.model.UpdateCheckResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkError
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
@@ -32,6 +34,9 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
data class SystemUiState(
val isLoading: Boolean = false,
@@ -64,8 +69,11 @@ data class SystemUiState(
val hookTimeout: String = "",
val hookApprove: Boolean = true,
val creatingHook: Boolean = false,
// Import
val importPath: String = "",
// Import (issue #786): SAF-picked backup archive staged by the screen
val importFileName: String? = null,
val isImporting: Boolean = false,
// True while the async backup-archive retry loop is running
val isDownloading: Boolean = false,
// Debug share
val shareRedact: Boolean = true,
val sharing: Boolean = false,
@@ -79,12 +87,18 @@ class SystemViewModel :
ToastHost {
companion object {
private const val TAG = "SystemViewModel"
// The backup action is async — retry a 404 download until the zip
// exists (up to ~3 minutes, 328MB backups took ~70s in testing).
private const val DOWNLOAD_RETRY_DELAY_MS = 5_000L
private const val MAX_DOWNLOAD_ATTEMPTS = 36
}
private val _uiState = MutableStateFlow(SystemUiState())
val uiState: StateFlow<SystemUiState> = _uiState.asStateFlow()
private var actionPollingJob: Job? = null
private var downloadJob: Job? = null
// ── Full parallel data load ────────────────────────────────────────
@@ -417,7 +431,12 @@ class SystemViewModel :
fun triggerBackup() {
viewModelScope.launch {
val result = withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.triggerBackup() } }
val result =
withContext(Dispatchers.IO) {
safeApiCall {
ApiClient.hermesApi.triggerBackup(BackupTriggerRequest())
}
}
when (result) {
is NetworkResult.Success -> {
_uiState.update {
@@ -436,38 +455,123 @@ class SystemViewModel :
}
}
fun downloadBackup() {
/**
* Download the triggered backup archive. The backup action is ASYNC — the
* trigger returns the path instantly, but the zip (can be hundreds of MB)
* takes a while to write. The backend 404s ("Backup not found") until the
* file exists, so a 404 is retried with a delay instead of failing the tap.
* The @Streaming body is handed to [onBody] — the screen streams it to
* Downloads via MediaImageStore (never buffered in memory).
*/
fun downloadBackup(onBody: (body: okhttp3.ResponseBody, fileName: String) -> Unit) {
val archive =
_uiState.value.backupArchive ?: run {
_uiState.update { it.copy(toastMessage = "No backup archive available") }
return
}
// Single-flight: a second tap while a retry loop is running must not
// spawn a parallel loop (logcat showed interleaved duplicate requests).
downloadJob?.cancel()
downloadJob =
viewModelScope.launch {
_uiState.update { it.copy(isDownloading = true) }
try {
var attempt = 0
while (attempt < MAX_DOWNLOAD_ATTEMPTS) {
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.downloadBackup(archive) }
}
when (result) {
is NetworkResult.Success -> {
// @Streaming body — hand it off as-is; the screen
// streams it to Downloads (never bytes() — OOM).
onBody(result.data, archive.substringAfterLast('/'))
return@launch
}
is NetworkResult.Failure -> {
val code = (result.error as? NetworkError.Http)?.code
if (code == 404) {
// Live countdown so the wait is visible —
// 328MB backups took ~60s in testing.
val elapsed = attempt * DOWNLOAD_RETRY_DELAY_MS / 1000
_uiState.update {
it.copy(toastMessage = "Waiting for backup… ${elapsed}s")
}
attempt++
delay(DOWNLOAD_RETRY_DELAY_MS)
} else {
_uiState.update {
it.copy(toastMessage = "Failed to download backup: ${result.error.message}")
}
return@launch
}
}
}
}
_uiState.update { it.copy(toastMessage = "Backup is still being created — try again in a moment") }
} finally {
_uiState.update { it.copy(isDownloading = false) }
}
}
}
fun setImportFile(name: String) {
_uiState.update { it.copy(importFileName = name) }
}
fun clearImportFile() {
_uiState.update { it.copy(importFileName = null) }
}
fun importArchive(
fileName: String,
bytes: ByteArray,
mimeType: String,
) {
// Mirrors FilesViewModel.uploadFile: multipart file + force (issue #786)
val forceBody = "false".toRequestBody("text/plain".toMediaTypeOrNull())
val part =
MultipartBody.Part.createFormData(
"file",
fileName,
bytes.toRequestBody(mimeType.toMediaTypeOrNull()),
)
_uiState.update { it.copy(isImporting = true) }
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.downloadBackup(archive) }
safeApiCall {
ApiClient.hermesApi.importUpload(forceBody, part)
}
}
when (result) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Backup downloaded") }
result.data.name?.let { pollActionStatus(it) }
_uiState.update {
it.copy(
isImporting = false,
importFileName = null,
toastMessage = "Import started",
)
}
}
is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to download backup: ${result.error.message}") }
_uiState.update {
it.copy(
isImporting = false,
toastMessage = "Import failed: ${result.error.message}",
)
}
}
}
}
}
fun updateImportPath(v: String) {
_uiState.update { it.copy(importPath = v) }
}
fun runImport(path: String) {
runOperation(
apiCall = { safeApiCall { ApiClient.hermesApi.runImport(mapOf("path" to path)) } },
label = "Import",
)
fun showToast(message: String) {
_uiState.update { it.copy(toastMessage = message) }
}
// ── Debug share actions ────────────────────────────────────────────