From cf5acd0ce344f52668cad11bd78050f9f7f02226 Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Sun, 18 Oct 2026 02:27:34 +0000 Subject: [PATCH] android, desktop, ios: check export storage per folder, delete rejected migration archive Android/desktop: migration copies the data to the export folder while it writes the archive to the migration folder, and the upload then writes an encrypted copy of the archive there too; on desktop the two folders can be on different volumes (temp folder vs data folder), so require twice the data size free in the archive folder and the data size in the export folder. Run the check inside the export's long-running task, so the wait for the user's answer is not timed as a slow API call. Use the generic Continue label. All platforms: check the archive size right after archiving, before the "exported with errors" alert (on Android/desktop that alert's Continue closed the "too large" alert at once), and delete an archive that is too large to migrate instead of leaving it on disk; show the export progress before the storage check, so the "Export database" button is disabled while the files are measured. iOS: show the warning through the views' own alerts, so nothing waits on it when the screen is closed (e.g. by SimpleX Lock while the user frees space). --- .../Shared/Views/Database/DatabaseView.swift | 23 +++- .../Views/Migration/MigrateFromDevice.swift | 111 ++++++++++-------- .../common/views/database/DatabaseView.kt | 28 +++-- .../views/migration/MigrateFromDevice.kt | 33 +++--- 4 files changed, 111 insertions(+), 84 deletions(-) diff --git a/apps/ios/Shared/Views/Database/DatabaseView.swift b/apps/ios/Shared/Views/Database/DatabaseView.swift index d64ce104c3..845603b513 100644 --- a/apps/ios/Shared/Views/Database/DatabaseView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseView.swift @@ -17,6 +17,7 @@ enum DatabaseAlert: Identifiable { case archiveImported case archiveImportedWithErrors(archiveErrors: [ArchiveError]) case archiveExportedWithErrors(archivePath: URL, archiveErrors: [ArchiveError]) + case lowStorage(required: Int64, available: Int64) case deleteChat case chatDeleted case deleteLegacyDatabase @@ -32,6 +33,7 @@ enum DatabaseAlert: Identifiable { case .archiveImported: return "archiveImported" case .archiveImportedWithErrors: return "archiveImportedWithErrors" case .archiveExportedWithErrors: return "archiveExportedWithErrors" + case .lowStorage: return "lowStorage" case .deleteChat: return "deleteChat" case .chatDeleted: return "chatDeleted" case .deleteLegacyDatabase: return "deleteLegacyDatabase" @@ -306,6 +308,10 @@ struct DatabaseView: View { showShareSheet(items: [archivePath]) } ) + case let .lowStorage(required, available): + return lowStorageAlert(required, available, onContinue: { + Task { await exportArchiveNow() } + }, onCancel: {}) case .deleteChat: return Alert( title: Text("Delete chat profile?"), @@ -466,7 +472,21 @@ struct DatabaseView: View { } private func exportArchive() async -> Bool { - if !(await confirmExportStorage()) { return false } + await MainActor.run { + progressIndicator = true + } + if let s = await exportStorageShortfall() { + await MainActor.run { + alert = .lowStorage(required: s.required, available: s.available) + progressIndicator = false + } + } else { + await exportArchiveNow() + } + return false + } + + private func exportArchiveNow() async { await MainActor.run { progressIndicator = true } @@ -487,7 +507,6 @@ struct DatabaseView: View { progressIndicator = false } } - return false } static func importArchive( diff --git a/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift b/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift index 483c00ad3a..d63a6d0d00 100644 --- a/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift +++ b/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift @@ -35,6 +35,7 @@ private enum MigrateFromDeviceViewAlert: Identifiable { case databaseError(_ title: LocalizedStringKey = "Database error", message: String) case unknownError(_ title: LocalizedStringKey = "Unknown error", message: String) case archiveExportedWithErrors(archivePath: URL, archiveErrors: [ArchiveError]) + case lowStorage(required: Int64, available: Int64) case error(title: LocalizedStringKey, error: String = "") @@ -50,6 +51,7 @@ private enum MigrateFromDeviceViewAlert: Identifiable { case let .databaseError(title, message): return "\(title) \(message)" case let .unknownError(title, message): return "\(title) \(message)" case let .archiveExportedWithErrors(path, _): return "archiveExportedWithErrors \(path)" + case .lowStorage: return "lowStorage" case let .error(title, _): return "error \(title)" } @@ -183,6 +185,12 @@ struct MigrateFromDevice: View { Task { await uploadArchive(path: archivePath) } } ) + case let .lowStorage(required, available): + return lowStorageAlert(required, available, onContinue: { + Task { await archiveAndUpload() } + }, onCancel: { + migrationState = .uploadConfirmation + }) case let .error(title, error): return Alert(title: Text(title), message: Text(error)) } @@ -262,7 +270,7 @@ struct MigrateFromDevice: View { progressView() } .onAppear { - exportArchiveCheckingStorage() + exportArchive() } } @@ -463,31 +471,46 @@ struct MigrateFromDevice: View { } } - private func exportArchiveCheckingStorage() { + private func exportArchive() { Task { - if await confirmExportStorage() { await MainActor.run { exportArchive() } } - else { await MainActor.run { migrationState = .uploadConfirmation } } + if let s = await exportStorageShortfall() { + await MainActor.run { alert = .lowStorage(required: s.required, available: s.available) } + } else { + await archiveAndUpload() + } } } - private func exportArchive() { - Task { - do { - try? FileManager.default.createDirectory(at: getMigrationTempFilesDirectory(), withIntermediateDirectories: true) - let (archivePath, errs) = try await exportChatArchive(getMigrationTempFilesDirectory()) - if errs.isEmpty { - await uploadArchive(path: archivePath) - } else { - await MainActor.run { - alert = .archiveExportedWithErrors(archivePath: archivePath, archiveErrors: errs) - migrationState = .uploadConfirmation - } - } - } catch let error { + private func archiveAndUpload() async { + do { + try? FileManager.default.createDirectory(at: getMigrationTempFilesDirectory(), withIntermediateDirectories: true) + let (archivePath, errs) = try await exportChatArchive(getMigrationTempFilesDirectory()) + let archiveBytes = (try? FileManager.default.attributesOfItem(atPath: archivePath.path)[.size] as? Int64) ?? 0 + if archiveBytes > MAX_FILE_SIZE_XFTP_HARD { + try? FileManager.default.removeItem(at: archivePath) await MainActor.run { - alert = .error(title: "Error exporting chat database", error: responseError(error)) + alert = .error( + title: "Database is too large", + error: String.localizedStringWithFormat( + NSLocalizedString("The exported archive (%@) is larger than the maximum size supported for migration (%@).", comment: "migration alert"), + ByteCountFormatter.string(fromByteCount: archiveBytes, countStyle: .binary), + ByteCountFormatter.string(fromByteCount: MAX_FILE_SIZE_XFTP_HARD, countStyle: .binary) + ) + ) migrationState = .uploadConfirmation } + } else if errs.isEmpty { + await uploadArchive(path: archivePath) + } else { + await MainActor.run { + alert = .archiveExportedWithErrors(archivePath: archivePath, archiveErrors: errs) + migrationState = .uploadConfirmation + } + } + } catch let error { + await MainActor.run { + alert = .error(title: "Error exporting chat database", error: responseError(error)) + migrationState = .uploadConfirmation } } } @@ -495,20 +518,6 @@ struct MigrateFromDevice: View { private func uploadArchive(path archivePath: URL) async { if let attrs = try? FileManager.default.attributesOfItem(atPath: archivePath.path), let totalBytes = attrs[.size] as? Int64 { - if totalBytes > MAX_FILE_SIZE_XFTP_HARD { - await MainActor.run { - alert = .error( - title: "Database is too large", - error: String.localizedStringWithFormat( - NSLocalizedString("The exported archive (%@) is larger than the maximum size supported for migration (%@).", comment: "migration alert"), - ByteCountFormatter.string(fromByteCount: totalBytes, countStyle: .binary), - ByteCountFormatter.string(fromByteCount: MAX_FILE_SIZE_XFTP_HARD, countStyle: .binary) - ) - ) - migrationState = .uploadConfirmation - } - return - } await MainActor.run { migrationState = .uploadProgress(uploadedBytes: 0, totalBytes: totalBytes, fileId: 0, archivePath: archivePath, ctrl: nil) } @@ -806,27 +815,25 @@ struct MigrateFromDevice_Previews: PreviewProvider { } } -// Advisory check before exporting the database — export transiently uses ~2x the data on disk. -func confirmExportStorage() async -> Bool { +// export transiently holds a copy of the data and the archive of it +func exportStorageShortfall() async -> (required: Int64, available: Int64)? { let dataBytes = await Task.detached { estimatedExportBytes() }.value let required = dataBytes * 2 - guard let available = availableImportantBytes(at: getDocumentsDirectory()), available < required else { return true } - return await withCheckedContinuation { cont in - DispatchQueue.main.async { - showAlert( - NSLocalizedString("You may not have enough storage", comment: "alert title"), - message: String.localizedStringWithFormat( - NSLocalizedString("Exporting the database may need about %@ of free space, but only %@ is available. Continue anyway?", comment: "alert message"), - ByteCountFormatter.string(fromByteCount: required, countStyle: .binary), - ByteCountFormatter.string(fromByteCount: available, countStyle: .binary) - ), - actions: { - [ UIAlertAction(title: NSLocalizedString("Continue", comment: ""), style: .default) { _ in cont.resume(returning: true) }, - UIAlertAction(title: NSLocalizedString("Cancel", comment: ""), style: .cancel) { _ in cont.resume(returning: false) } ] - } - ) - } - } + guard let available = availableImportantBytes(at: getDocumentsDirectory()), available < required else { return nil } + return (required, available) +} + +func lowStorageAlert(_ required: Int64, _ available: Int64, onContinue: @escaping () -> Void, onCancel: @escaping () -> Void) -> Alert { + Alert( + title: Text("You may not have enough storage"), + message: Text(String.localizedStringWithFormat( + NSLocalizedString("Exporting the database may need about %@ of free space, but only %@ is available. Continue anyway?", comment: "alert message"), + ByteCountFormatter.string(fromByteCount: required, countStyle: .binary), + ByteCountFormatter.string(fromByteCount: available, countStyle: .binary) + )), + primaryButton: .default(Text("Continue"), action: onContinue), + secondaryButton: .cancel(onCancel) + ) } private func estimatedExportBytes() -> Int64 { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt index b93f193f81..8c2ea21259 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt @@ -603,22 +603,23 @@ fun deleteDatabaseBackups() { File(dataDir, "$agentDatabaseFileName.bak").delete() } -// Advisory check before exporting the database — export transiently uses ~2x the data on disk. -suspend fun confirmExportStorage(): Boolean { +// export holds a copy of the data in databaseExportDir while it writes the archive to archiveDir, +// where a migration upload then also writes an encrypted copy of the archive +suspend fun confirmExportStorage(archiveDir: File = databaseExportDir): Boolean { databaseExportDir.mkdirs() - val requiredBytes = 2L * ( - directoryFileCountAndSize(appFilesDir.absolutePath).second + - directoryFileCountAndSize(wallpapersDir.absolutePath).second + - File(dataDir, chatDatabaseFileName).length() + - File(dataDir, agentDatabaseFileName).length() - ) - val availableBytes = databaseExportDir.usableSpace - if (availableBytes >= requiredBytes) return true + archiveDir.mkdirs() + val dataBytes = directoryFileCountAndSize(appFilesDir.absolutePath).second + + directoryFileCountAndSize(wallpapersDir.absolutePath).second + + File(dataDir, chatDatabaseFileName).length() + + File(dataDir, agentDatabaseFileName).length() + val (shortDir, requiredBytes) = listOf(archiveDir to 2L * dataBytes, databaseExportDir to dataBytes) + .firstOrNull { (dir, needed) -> dir.usableSpace < needed } ?: return true + val availableBytes = shortDir.usableSpace val proceed = CompletableDeferred() AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.not_enough_storage_title), text = String.format(generalGetString(MR.strings.not_enough_storage_desc), formatBytes(requiredBytes), formatBytes(availableBytes)), - confirmText = generalGetString(MR.strings.chat_database_exported_continue), + confirmText = generalGetString(MR.strings.continue_to_next_step), onConfirm = { proceed.complete(true) }, onDismiss = { proceed.complete(false) }, onDismissRequest = { proceed.complete(false) }, @@ -632,8 +633,11 @@ private suspend fun exportArchive( chatArchiveFile: MutableState, saveArchiveLauncher: FileChooserLauncher ): Boolean { - if (!confirmExportStorage()) return false progressIndicator.value = true + if (!confirmExportStorage()) { + progressIndicator.value = false + return false + } try { val (archiveFile, archiveErrors) = exportChatArchive(m, null, chatArchiveFile) chatArchiveFile.value = archiveFile diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt index f4bb2f8969..bfdbb8e64e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt @@ -273,7 +273,7 @@ private fun MutableState.ArchivingView() { ProgressView() } LaunchedEffect(Unit) { - exportArchiveCheckingStorage() + exportArchive() } } @@ -479,19 +479,24 @@ private suspend fun MutableState.verifyDatabasePassphrase(db } } -private fun MutableState.exportArchiveCheckingStorage() { - withBGApi { - if (confirmExportStorage()) exportArchive() - else state = MigrationFromState.UploadConfirmation - } -} - private fun MutableState.exportArchive() { withLongRunningApi { + if (!confirmExportStorage(getMigrationTempFilesDirectory())) { + state = MigrationFromState.UploadConfirmation + return@withLongRunningApi + } try { getMigrationTempFilesDirectory().mkdir() val (archivePath, archiveErrors) = exportChatArchive(chatModel, getMigrationTempFilesDirectory(), mutableStateOf("")) - if (archiveErrors.isEmpty()) { + val archiveBytes = File(archivePath).length() + if (archiveBytes > MAX_FILE_SIZE_XFTP_HARD) { + File(archivePath).delete() + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.migrate_from_device_database_too_large_title), + String.format(generalGetString(MR.strings.migrate_from_device_database_too_large_desc), formatBytes(archiveBytes), formatBytes(MAX_FILE_SIZE_XFTP_HARD)) + ) + state = MigrationFromState.UploadConfirmation + } else if (archiveErrors.isEmpty()) { uploadArchive(archivePath) } else { showArchiveExportedWithErrorsAlert(generalGetString(MR.strings.chat_database_exported_migrate), archiveErrors) { @@ -512,15 +517,7 @@ private fun MutableState.exportArchive() { private fun MutableState.uploadArchive(archivePath: String) { val totalBytes = File(archivePath).length() if (totalBytes > 0L) { - if (totalBytes > MAX_FILE_SIZE_XFTP_HARD) { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.migrate_from_device_database_too_large_title), - String.format(generalGetString(MR.strings.migrate_from_device_database_too_large_desc), formatBytes(totalBytes), formatBytes(MAX_FILE_SIZE_XFTP_HARD)) - ) - state = MigrationFromState.UploadConfirmation - } else { - state = MigrationFromState.DatabaseInit(totalBytes, archivePath) - } + state = MigrationFromState.DatabaseInit(totalBytes, archivePath) } else { AlertManager.shared.showAlertMsg(generalGetString(MR.strings.migrate_from_device_exported_file_doesnt_exist)) state = MigrationFromState.UploadConfirmation