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).
This commit is contained in:
Narasimha-sc committed 2026-10-18 08:10:09 +00:00
1 parent d4ed948deb
commit cf5acd0ce3
4 files changed
+111 -84

No files matched your search

@@ -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(
@@ -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 {
@@ -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<Boolean>()
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<String?>,
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
@@ -273,7 +273,7 @@ private fun MutableState<MigrationFromState>.ArchivingView() {
ProgressView()
}
LaunchedEffect(Unit) {
exportArchiveCheckingStorage()
exportArchive()
}
}
@@ -479,19 +479,24 @@ private suspend fun MutableState<MigrationFromState>.verifyDatabasePassphrase(db
}
}
private fun MutableState<MigrationFromState>.exportArchiveCheckingStorage() {
withBGApi {
if (confirmExportStorage()) exportArchive()
else state = MigrationFromState.UploadConfirmation
}
}
private fun MutableState<MigrationFromState>.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<MigrationFromState>.exportArchive() {
private fun MutableState<MigrationFromState>.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