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:
@@ -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 ────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user