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