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