From 5ff86514dee7b7118ad6aa06fa2dcdfca8301b58 Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Mon, 8 Jun 2026 11:32:24 +0000 Subject: [PATCH] android, desktop, ios: clear migration errors for oversized database and low storage --- .../Views/Migration/MigrateFromDevice.swift | 55 ++++++++++++++++++- apps/ios/SimpleXChat/FileUtils.swift | 4 ++ .../simplex/common/views/helpers/Utils.kt | 4 ++ .../views/migration/MigrateFromDevice.kt | 39 ++++++++++++- .../commonMain/resources/MR/base/strings.xml | 4 ++ 5 files changed, 103 insertions(+), 3 deletions(-) diff --git a/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift b/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift index 2ff376701c..4b38d46c12 100644 --- a/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift +++ b/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift @@ -37,6 +37,7 @@ private enum MigrateFromDeviceViewAlert: Identifiable { case archiveExportedWithErrors(archivePath: URL, archiveErrors: [ArchiveError]) case error(title: LocalizedStringKey, error: String = "") + case notEnoughStorage(required: Int64, available: Int64) var id: String { switch self { @@ -52,6 +53,7 @@ private enum MigrateFromDeviceViewAlert: Identifiable { case let .archiveExportedWithErrors(path, _): return "archiveExportedWithErrors \(path)" case let .error(title, _): return "error \(title)" + case .notEnoughStorage: return "notEnoughStorage" } } } @@ -185,6 +187,16 @@ struct MigrateFromDevice: View { ) case let .error(title, error): return Alert(title: Text(title), message: Text(error)) + case let .notEnoughStorage(required, available): + return 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: "migration alert"), + ByteCountFormatter.string(fromByteCount: required, countStyle: .binary), + ByteCountFormatter.string(fromByteCount: available, countStyle: .binary))), + primaryButton: .default(Text("Continue")) { exportArchive() }, + secondaryButton: .cancel { migrationState = .uploadConfirmation } + ) } } .interactiveDismissDisabled(backDisabled) @@ -262,7 +274,7 @@ struct MigrateFromDevice: View { progressView() } .onAppear { - exportArchive() + exportArchiveCheckingStorage() } } @@ -463,6 +475,19 @@ struct MigrateFromDevice: View { } } + private func exportArchiveCheckingStorage() { + Task { + let dataBytes = await Task.detached { estimatedExportBytes() }.value + // migration transiently writes an uncompressed temp copy and a padded encrypted upload copy, so require ~2x + let required = dataBytes * 2 + if let available = availableImportantBytes(at: getDocumentsDirectory()), available < required { + await MainActor.run { alert = .notEnoughStorage(required: required, available: available) } + } else { + await MainActor.run { exportArchive() } + } + } + } + private func exportArchive() { Task { do { @@ -488,6 +513,20 @@ 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) } @@ -782,3 +821,17 @@ struct MigrateFromDevice_Previews: PreviewProvider { MigrateFromDevice(showProgressOnSettings: Binding.constant(false)) } } + +private func estimatedExportBytes() -> Int64 { + let files = Int64(directoryFileCountAndSize(getAppFilesDirectory())?.1 ?? 0) + let wallpapers = Int64(directoryFileCountAndSize(getWallpaperDirectory())?.1 ?? 0) + let dbPrefix = getAppDatabasePath().path + let chatDb = Int64(fileSize(URL(fileURLWithPath: dbPrefix + "_chat.db")) ?? 0) + let agentDb = Int64(fileSize(URL(fileURLWithPath: dbPrefix + "_agent.db")) ?? 0) + return files + wallpapers + chatDb + agentDb +} + +private func availableImportantBytes(at url: URL) -> Int64? { + (try? url.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]))? + .volumeAvailableCapacityForImportantUsage +} diff --git a/apps/ios/SimpleXChat/FileUtils.swift b/apps/ios/SimpleXChat/FileUtils.swift index 3d0dd663c1..603b171459 100644 --- a/apps/ios/SimpleXChat/FileUtils.swift +++ b/apps/ios/SimpleXChat/FileUtils.swift @@ -29,6 +29,10 @@ public let MAX_VIDEO_SIZE_AUTO_RCV: Int64 = 1_047_552 // 1023KB // Spec: spec/services/files.md#MAX_FILE_SIZE_XFTP public let MAX_FILE_SIZE_XFTP: Int64 = 1_073_741_824 // 1GB +// Hard limit for standalone XFTP uploads (device migration archive); +// mirrors maxFileSizeHard (gb 5) in simplexmq Simplex/FileTransfer/Description.hs +public let MAX_FILE_SIZE_XFTP_HARD: Int64 = 5_368_709_120 // 5GB + public let MAX_FILE_SIZE_LOCAL: Int64 = Int64.max public let MAX_FILE_SIZE_SMP: Int64 = 8000000 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index 23c622bc34..705c25c659 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -126,6 +126,10 @@ const val MAX_FILE_SIZE_SMP: Long = 8000000 const val MAX_FILE_SIZE_XFTP: Long = 1_073_741_824 // 1GB +// Hard limit for standalone XFTP uploads (e.g. device migration archive); +// mirrors maxFileSizeHard (gb 5) in simplexmq Simplex/FileTransfer/Description.hs +const val MAX_FILE_SIZE_XFTP_HARD: Long = 5_368_709_120 // 5GB + const val MAX_FILE_SIZE_LOCAL: Long = Long.MAX_VALUE expect fun getAppFileUri(fileName: String): URI 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 39c4cb0b7f..54622f8070 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 @@ -274,7 +274,7 @@ private fun MutableState.ArchivingView() { ProgressView() } LaunchedEffect(Unit) { - exportArchive() + exportArchiveCheckingStorage() } } @@ -480,6 +480,33 @@ private suspend fun MutableState.verifyDatabasePassphrase(db } } +private fun MutableState.exportArchiveCheckingStorage() { + withBGApi { + databaseExportDir.mkdirs() + // migration transiently writes an uncompressed temp copy and a padded encrypted upload copy, + // so require ~2x the data on the volume + 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) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.migrate_from_device_not_enough_space_title), + text = String.format(generalGetString(MR.strings.migrate_from_device_not_enough_space_desc), formatBytes(requiredBytes), formatBytes(availableBytes)), + confirmText = generalGetString(MR.strings.chat_database_exported_continue), + onConfirm = { exportArchive() }, + onDismiss = { state = MigrationFromState.UploadConfirmation }, + onDismissRequest = { state = MigrationFromState.UploadConfirmation }, + ) + } else { + exportArchive() + } + } +} + private fun MutableState.exportArchive() { withLongRunningApi { try { @@ -506,7 +533,15 @@ private fun MutableState.exportArchive() { private fun MutableState.uploadArchive(archivePath: String) { val totalBytes = File(archivePath).length() if (totalBytes > 0L) { - state = MigrationFromState.DatabaseInit(totalBytes, archivePath) + 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) + } } else { AlertManager.shared.showAlertMsg(generalGetString(MR.strings.migrate_from_device_exported_file_doesnt_exist)) state = MigrationFromState.UploadConfirmation diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 5bca9402a8..fd6aab2b2e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -2831,6 +2831,10 @@ Migrate to another device Error saving settings Exported file doesn\'t exist + Database is too large + The exported archive (%1$s) is larger than the maximum size supported for migration (%2$s). + You may not have enough storage + Exporting the database may need about %1$s of free space, but only %2$s is available. Continue anyway? Error exporting chat database Preparing upload Error uploading the archive