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
@@ -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