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:
M57
2026-08-07 16:44:41 +03:00
committed by GitHub
co-authored by M57
parent 9d0baa78fd
commit 7998248507
8 changed files with 358 additions and 43 deletions
@@ -12,3 +12,13 @@ data class ActionResponse(
val uploaded_bytes: Long? = null, val uploaded_bytes: Long? = null,
val update_command: String? = 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 paused: Boolean? = null,
val interval_hours: Int? = null, val interval_hours: Int? = null,
val last_run_at: String? = 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 stale_after_days: Int? = null,
val archive_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.AgentPluginInstallBody
import com.m57.hermescontrol.data.model.AnalyticsResponse import com.m57.hermescontrol.data.model.AnalyticsResponse
import com.m57.hermescontrol.data.model.AuxiliaryModelsResponse 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.BulkDeleteRequest
import com.m57.hermescontrol.data.model.BulkDeleteResponse import com.m57.hermescontrol.data.model.BulkDeleteResponse
import com.m57.hermescontrol.data.model.CheckpointsResponse import com.m57.hermescontrol.data.model.CheckpointsResponse
@@ -144,6 +145,7 @@ import retrofit2.http.PUT
import retrofit2.http.Part import retrofit2.http.Part
import retrofit2.http.Path import retrofit2.http.Path
import retrofit2.http.Query import retrofit2.http.Query
import retrofit2.http.Streaming
interface HermesApiService { interface HermesApiService {
@GET("api/skills/content") @GET("api/skills/content")
@@ -750,8 +752,11 @@ interface HermesApiService {
@Body request: EnvVarDeleteRequest, @Body request: EnvVarDeleteRequest,
): Response<Unit> ): Response<Unit>
// Backend requires a JSON body (BackupRequest) — bodyless POST 422s.
@POST("api/ops/backup") @POST("api/ops/backup")
suspend fun triggerBackup(): Response<ActionResponse> suspend fun triggerBackup(
@Body body: BackupTriggerRequest,
): Response<ActionResponse>
@POST("api/ops/doctor") @POST("api/ops/doctor")
suspend fun runDoctor(): Response<DoctorResponse> suspend fun runDoctor(): Response<DoctorResponse>
@@ -869,15 +874,23 @@ interface HermesApiService {
): Response<ActionResponse> ): Response<ActionResponse>
// ── Admin: Backup download ──────────────────────────────────────── // ── 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") @GET("api/ops/backup/download")
suspend fun downloadBackup( suspend fun downloadBackup(
@Query("archive") archive: String, @Query("archive") archive: String,
): Response<ResponseBody> ): Response<ResponseBody>
// ── Admin: Backup import ────────────────────────────────────────── // ── Admin: Backup import ──────────────────────────────────────────
@POST("api/ops/import") // Multipart upload (issue #786): the path-based POST /api/ops/import
suspend fun runImport( // is useless on a phone (server-side path typed into the app); the
@Body body: Map<String, Any>, // 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> ): Response<ActionResponse>
// ── Admin: Debug share ──────────────────────────────────────────── // ── Admin: Debug share ────────────────────────────────────────────
@@ -7,7 +7,9 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.provider.MediaStore import android.provider.MediaStore
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import java.io.ByteArrayInputStream
import java.io.File import java.io.File
import java.io.InputStream
/** /**
* Persist / dispatch chat images on the *device* (Downloads / Gallery / share * Persist / dispatch chat images on the *device* (Downloads / Gallery / share
@@ -30,6 +32,19 @@ object MediaImageStore {
bytes: ByteArray, bytes: ByteArray,
displayName: String, displayName: String,
mimeType: 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? { ): Uri? {
val safe = sanitizeName(displayName) val safe = sanitizeName(displayName)
val (collection, relativePath) = val (collection, relativePath) =
@@ -42,6 +57,9 @@ object MediaImageStore {
ContentValues().apply { ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, safe) put(MediaStore.MediaColumns.DISPLAY_NAME, safe)
put(MediaStore.MediaColumns.MIME_TYPE, mimeType) put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
if (length != null) {
put(MediaStore.MediaColumns.SIZE, length)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath) put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
put(MediaStore.MediaColumns.IS_PENDING, 1) put(MediaStore.MediaColumns.IS_PENDING, 1)
@@ -50,7 +68,9 @@ object MediaImageStore {
val resolver = context.contentResolver val resolver = context.contentResolver
val uri = resolver.insert(collection, values) ?: return null val uri = resolver.insert(collection, values) ?: return null
return try { 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) } runCatching { resolver.delete(uri, null, null) }
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@@ -1,5 +1,8 @@
package com.m57.hermescontrol.ui.system 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow 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.CloudDownload
import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.DeleteForever 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.Devices
import androidx.compose.material.icons.filled.HealthAndSafety import androidx.compose.material.icons.filled.HealthAndSafety
import androidx.compose.material.icons.filled.Link 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.Stop
import androidx.compose.material.icons.filled.Storage import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.Update import androidx.compose.material.icons.filled.Update
import androidx.compose.material.icons.filled.UploadFile
import androidx.compose.material.icons.filled.VerifiedUser import androidx.compose.material.icons.filled.VerifiedUser
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
@@ -55,11 +60,13 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString 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.R
import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.theme.LocalSpacing 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.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold 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.common.ToastEffect
import com.m57.hermescontrol.ui.system.components.CredentialEntryRow import com.m57.hermescontrol.ui.system.components.CredentialEntryRow
import com.m57.hermescontrol.ui.system.components.HookCard import com.m57.hermescontrol.ui.system.components.HookCard
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
// ── Helpers ───────────────────────────────────────────────────────────── // ── Helpers ─────────────────────────────────────────────────────────────
@@ -126,6 +137,38 @@ fun SystemScreen(
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast) 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 ────────────────────────────────────────────── // ── Confirmation dialogs ──────────────────────────────────────────────
if (state.updateConfirmOpen) { if (state.updateConfirmOpen) {
@@ -205,7 +248,30 @@ fun SystemScreen(
confirmButton = { confirmButton = {
TextButton(onClick = { TextButton(onClick = {
importConfirm = false 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)) Text(stringResource(R.string.system_confirm_restore))
} }
@@ -271,7 +337,46 @@ fun SystemScreen(
credentialsSection(state, spacing, viewModel, credToRemove, { credToRemove = it }) credentialsSection(state, spacing, viewModel, credToRemove, { credToRemove = it })
// ── 7. Operations ────────────────────────────────────── // ── 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 ───────────────────────────────────── // ── 8. Checkpoints ─────────────────────────────────────
checkpointsSection(state, spacing, pruneConfirm, { pruneConfirm = it }) checkpointsSection(state, spacing, pruneConfirm, { pruneConfirm = it })
@@ -958,6 +1063,8 @@ private fun LazyListScope.operationsSection(
viewModel: SystemViewModel, viewModel: SystemViewModel,
importConfirm: Boolean, importConfirm: Boolean,
onImportConfirm: (Boolean) -> Unit, onImportConfirm: (Boolean) -> Unit,
onImportPick: () -> Unit,
downloadBackup: () -> Unit,
) { ) {
item { item {
SectionHeader(title = stringResource(R.string.system_sec_operations)) SectionHeader(title = stringResource(R.string.system_sec_operations))
@@ -1075,19 +1182,34 @@ private fun LazyListScope.operationsSection(
) )
} }
OutlinedButton( OutlinedButton(
onClick = { viewModel.downloadBackup() }, onClick = downloadBackup,
enabled = state.backupArchive != null, enabled = state.backupArchive != null && !state.isDownloading,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
contentPadding = actionButtonPadding, contentPadding = actionButtonPadding,
) { ) {
ActionButtonContent( if (state.isDownloading) {
icon = Icons.Filled.CloudDownload, 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), 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)) Spacer(modifier = Modifier.height(spacing.sm))
Text( Text(
text = stringResource(R.string.system_sec_restore), text = stringResource(R.string.system_sec_restore),
@@ -1096,20 +1218,48 @@ private fun LazyListScope.operationsSection(
) )
Spacer(modifier = Modifier.height(spacing.sm)) Spacer(modifier = Modifier.height(spacing.sm))
OutlinedTextField( OutlinedButton(
value = state.importPath, onClick = onImportPick,
onValueChange = viewModel::updateImportPath, enabled = !state.isImporting,
label = { Text(stringResource(R.string.system_op_restore_path_label)) },
placeholder = { Text(stringResource(R.string.system_op_restore_path)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(), 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)) Spacer(modifier = Modifier.height(spacing.sm))
Button( Button(
onClick = { onImportConfirm(true) }, onClick = { onImportConfirm(true) },
enabled = state.importPath.isNotBlank(), enabled = state.importFileName != null && !state.isImporting,
colors = colors =
ButtonDefaults.buttonColors( ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error, containerColor = MaterialTheme.colorScheme.error,
@@ -1117,8 +1267,17 @@ private fun LazyListScope.operationsSection(
), ),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Icon(Icons.Filled.RestartAlt, contentDescription = null, modifier = Modifier.size(18.dp)) if (state.isImporting) {
Spacer(modifier = Modifier.size(spacing.xs)) 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) 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.BuildConfig
import com.m57.hermescontrol.data.model.ActionResponse import com.m57.hermescontrol.data.model.ActionResponse
import com.m57.hermescontrol.data.model.ActionStatusResponse 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.CheckpointsResponse
import com.m57.hermescontrol.data.model.CredentialPoolProvider import com.m57.hermescontrol.data.model.CredentialPoolProvider
import com.m57.hermescontrol.data.model.CuratorResponse 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.SystemStatsResponse
import com.m57.hermescontrol.data.model.UpdateCheckResponse import com.m57.hermescontrol.data.model.UpdateCheckResponse
import com.m57.hermescontrol.data.remote.ApiClient 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.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost import com.m57.hermescontrol.ui.common.ToastHost
@@ -32,6 +34,9 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
data class SystemUiState( data class SystemUiState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
@@ -64,8 +69,11 @@ data class SystemUiState(
val hookTimeout: String = "", val hookTimeout: String = "",
val hookApprove: Boolean = true, val hookApprove: Boolean = true,
val creatingHook: Boolean = false, val creatingHook: Boolean = false,
// Import // Import (issue #786): SAF-picked backup archive staged by the screen
val importPath: String = "", val importFileName: String? = null,
val isImporting: Boolean = false,
// True while the async backup-archive retry loop is running
val isDownloading: Boolean = false,
// Debug share // Debug share
val shareRedact: Boolean = true, val shareRedact: Boolean = true,
val sharing: Boolean = false, val sharing: Boolean = false,
@@ -79,12 +87,18 @@ class SystemViewModel :
ToastHost { ToastHost {
companion object { companion object {
private const val TAG = "SystemViewModel" 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()) private val _uiState = MutableStateFlow(SystemUiState())
val uiState: StateFlow<SystemUiState> = _uiState.asStateFlow() val uiState: StateFlow<SystemUiState> = _uiState.asStateFlow()
private var actionPollingJob: Job? = null private var actionPollingJob: Job? = null
private var downloadJob: Job? = null
// ── Full parallel data load ──────────────────────────────────────── // ── Full parallel data load ────────────────────────────────────────
@@ -417,7 +431,12 @@ class SystemViewModel :
fun triggerBackup() { fun triggerBackup() {
viewModelScope.launch { viewModelScope.launch {
val result = withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.triggerBackup() } } val result =
withContext(Dispatchers.IO) {
safeApiCall {
ApiClient.hermesApi.triggerBackup(BackupTriggerRequest())
}
}
when (result) { when (result) {
is NetworkResult.Success -> { is NetworkResult.Success -> {
_uiState.update { _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 = val archive =
_uiState.value.backupArchive ?: run { _uiState.value.backupArchive ?: run {
_uiState.update { it.copy(toastMessage = "No backup archive available") } _uiState.update { it.copy(toastMessage = "No backup archive available") }
return 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 { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.downloadBackup(archive) } safeApiCall {
ApiClient.hermesApi.importUpload(forceBody, part)
}
} }
when (result) { when (result) {
is NetworkResult.Success -> { 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 -> { 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) { fun showToast(message: String) {
_uiState.update { it.copy(importPath = v) } _uiState.update { it.copy(toastMessage = message) }
}
fun runImport(path: String) {
runOperation(
apiCall = { safeApiCall { ApiClient.hermesApi.runImport(mapOf("path" to path)) } },
label = "Import",
)
} }
// ── Debug share actions ──────────────────────────────────────────── // ── Debug share actions ────────────────────────────────────────────
+1 -2
View File
@@ -425,8 +425,7 @@
<string name="system_sec_backup">Backup</string> <string name="system_sec_backup">Backup</string>
<string name="system_sec_restore">Restore from upload</string> <string name="system_sec_restore">Restore from upload</string>
<string name="system_op_restore_upload">Restore upload</string> <string name="system_op_restore_upload">Restore upload</string>
<string name="system_op_restore_path_label">Restore from backups path</string> <string name="system_op_restore_pick">Choose backup archive</string>
<string name="system_op_restore_path">Restore path</string>
<string name="system_op_import_confirm_title">Restore full Hermes backup?</string> <string name="system_op_import_confirm_title">Restore full Hermes backup?</string>
<string name="system_op_import_confirm_desc">This will overwrite your current Hermes configuration, skills, sessions, and data. This cannot be undone.</string> <string name="system_op_import_confirm_desc">This will overwrite your current Hermes configuration, skills, sessions, and data. This cannot be undone.</string>
@@ -7,6 +7,7 @@ import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.ActionResponse import com.m57.hermescontrol.data.model.ActionResponse
import com.m57.hermescontrol.data.model.ActiveProfileResponse import com.m57.hermescontrol.data.model.ActiveProfileResponse
import com.m57.hermescontrol.data.model.AuxiliaryModelsResponse import com.m57.hermescontrol.data.model.AuxiliaryModelsResponse
import com.m57.hermescontrol.data.model.BackupTriggerRequest
import com.m57.hermescontrol.data.model.CheckpointsResponse import com.m57.hermescontrol.data.model.CheckpointsResponse
import com.m57.hermescontrol.data.model.CredentialPoolResponse import com.m57.hermescontrol.data.model.CredentialPoolResponse
import com.m57.hermescontrol.data.model.CronJob import com.m57.hermescontrol.data.model.CronJob
@@ -1179,7 +1180,7 @@ class E2eIntegrationTest {
coEvery { mockApiService.getHooks() } returns Response.success(HookResponse()) coEvery { mockApiService.getHooks() } returns Response.success(HookResponse())
coEvery { mockApiService.checkHermesUpdate(false) } returns coEvery { mockApiService.checkHermesUpdate(false) } returns
Response.success(UpdateCheckResponse()) Response.success(UpdateCheckResponse())
coEvery { mockApiService.triggerBackup() } returns Response.success(ActionResponse()) coEvery { mockApiService.triggerBackup(BackupTriggerRequest()) } returns Response.success(ActionResponse())
val viewModel = SystemViewModel() val viewModel = SystemViewModel()
viewModel.loadAll() viewModel.loadAll()