diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/ActionResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/ActionResponse.kt index ee4e5e6c..88699232 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/ActionResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/ActionResponse.kt @@ -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, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/CuratorResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/CuratorResponse.kt index 70bdf3c2..09c40865 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/CuratorResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/CuratorResponse.kt @@ -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, ) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index f8c789b8..7ebce695 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -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 + // Backend requires a JSON body (BackupRequest) — bodyless POST 422s. @POST("api/ops/backup") - suspend fun triggerBackup(): Response + suspend fun triggerBackup( + @Body body: BackupTriggerRequest, + ): Response @POST("api/ops/doctor") suspend fun runDoctor(): Response @@ -869,15 +874,23 @@ interface HermesApiService { ): Response // ── 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 // ── Admin: Backup import ────────────────────────────────────────── - @POST("api/ops/import") - suspend fun runImport( - @Body body: Map, + // 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 // ── Admin: Debug share ──────────────────────────────────────────── diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaImageStore.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaImageStore.kt index 87cc08dc..2cd42077 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaImageStore.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaImageStore.kt @@ -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) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt index e9706a7b..5caa3a53 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt @@ -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(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, +) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt index a2a87614..f60b0354 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt @@ -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 = _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 ──────────────────────────────────────────── diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 26f11683..639e510a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -425,8 +425,7 @@ Backup Restore from upload Restore upload - Restore from backups path - Restore path + Choose backup archive Restore full Hermes backup? This will overwrite your current Hermes configuration, skills, sessions, and data. This cannot be undone. diff --git a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt index 5f6b50db..7b873862 100644 --- a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt @@ -7,6 +7,7 @@ import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.model.ActionResponse import com.m57.hermescontrol.data.model.ActiveProfileResponse 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.CredentialPoolResponse import com.m57.hermescontrol.data.model.CronJob @@ -1179,7 +1180,7 @@ class E2eIntegrationTest { coEvery { mockApiService.getHooks() } returns Response.success(HookResponse()) coEvery { mockApiService.checkHermesUpdate(false) } returns Response.success(UpdateCheckResponse()) - coEvery { mockApiService.triggerBackup() } returns Response.success(ActionResponse()) + coEvery { mockApiService.triggerBackup(BackupTriggerRequest()) } returns Response.success(ActionResponse()) val viewModel = SystemViewModel() viewModel.loadAll()