diff --git a/Mixin.xcodeproj/project.pbxproj b/Mixin.xcodeproj/project.pbxproj index 14b76c8486..c4e4147884 100644 --- a/Mixin.xcodeproj/project.pbxproj +++ b/Mixin.xcodeproj/project.pbxproj @@ -663,6 +663,7 @@ 7CE3A25C2771A8AB006BE765 /* DeleteAccountVerifyCodeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE3A25B2771A8AB006BE765 /* DeleteAccountVerifyCodeViewController.swift */; }; 7CE5E7A8269BDA29000B7904 /* HomeAppsPinTipsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE5E7A6269BDA29000B7904 /* HomeAppsPinTipsViewController.swift */; }; 7CE5E7A9269BDA29000B7904 /* HomeAppsPinTipsView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 7CE5E7A7269BDA29000B7904 /* HomeAppsPinTipsView.xib */; }; + 7CE7ADD72A24983800A6259F /* DeviceTransferMessageProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE7ADD62A24983800A6259F /* DeviceTransferMessageProcessor.swift */; }; 7CEB735429DB24F3006FB5B2 /* RestoreChatViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CEB735229DB24F3006FB5B2 /* RestoreChatViewController.swift */; }; 7CEB735529DB24F3006FB5B2 /* RestoreChatView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 7CEB735329DB24F3006FB5B2 /* RestoreChatView.xib */; }; 7CEB735829DB272F006FB5B2 /* RestoreChatTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CEB735629DB272F006FB5B2 /* RestoreChatTableViewCell.swift */; }; @@ -697,7 +698,6 @@ 940BAE222629741C00FFF753 /* AuthorizationsContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 940BAE212629741C00FFF753 /* AuthorizationsContentViewController.swift */; }; 94149B432A17B4D5003E9E1A /* NetworkSpeedConditioner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94149B422A17B4D5003E9E1A /* NetworkSpeedConditioner.swift */; }; 94149B482A190889003E9E1A /* DeviceTransferRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94149B472A190889003E9E1A /* DeviceTransferRecord.swift */; }; - 94149B582A1A6A6D003E9E1A /* DeviceTransferClosedReason.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94149B572A1A6A6D003E9E1A /* DeviceTransferClosedReason.swift */; }; 9416548325CD2D9A007E76D0 /* OggOpusRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9416548225CD2D9A007E76D0 /* OggOpusRecorder.swift */; }; 9416548B25CD7190007E76D0 /* AudioMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9416548A25CD7190007E76D0 /* AudioMetadata.swift */; }; 941655C425CD7DA7007E76D0 /* AudioSessionClientPriority.swift in Sources */ = {isa = PBXBuildFile; fileRef = 941655C325CD7DA7007E76D0 /* AudioSessionClientPriority.swift */; }; @@ -719,6 +719,7 @@ 94341BFB2862F302009C9147 /* libopusenc.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 94341BF72862F302009C9147 /* libopusenc.xcframework */; }; 94341C002863530B009C9147 /* libogg.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 94341BFF2862F454009C9147 /* libogg.xcframework */; }; 9438252725EE697300709B7D /* CacheableAssetFileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9438252625EE697300709B7D /* CacheableAssetFileManager.swift */; }; + 9439116A2A39EA4300CF6DC7 /* DeviceTransferProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 943911692A39EA4300CF6DC7 /* DeviceTransferProgress.swift */; }; 94396F2629EB11E300A57833 /* DeviceTransferProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94396F2529EB11E300A57833 /* DeviceTransferProtocol.swift */; }; 94396F2829EB475500A57833 /* NWParameters+DeviceTransfer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94396F2729EB475500A57833 /* NWParameters+DeviceTransfer.swift */; }; 94396F2A29EBE52400A57833 /* DeviceTransferClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94396F2929EBE52400A57833 /* DeviceTransferClient.swift */; }; @@ -1736,6 +1737,7 @@ 7CE3A25B2771A8AB006BE765 /* DeleteAccountVerifyCodeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteAccountVerifyCodeViewController.swift; sourceTree = ""; }; 7CE5E7A6269BDA29000B7904 /* HomeAppsPinTipsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeAppsPinTipsViewController.swift; sourceTree = ""; }; 7CE5E7A7269BDA29000B7904 /* HomeAppsPinTipsView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = HomeAppsPinTipsView.xib; sourceTree = ""; }; + 7CE7ADD62A24983800A6259F /* DeviceTransferMessageProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceTransferMessageProcessor.swift; sourceTree = ""; }; 7CEB735229DB24F3006FB5B2 /* RestoreChatViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoreChatViewController.swift; sourceTree = ""; }; 7CEB735329DB24F3006FB5B2 /* RestoreChatView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = RestoreChatView.xib; sourceTree = ""; }; 7CEB735629DB272F006FB5B2 /* RestoreChatTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoreChatTableViewCell.swift; sourceTree = ""; }; @@ -1773,7 +1775,6 @@ 940BAE212629741C00FFF753 /* AuthorizationsContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorizationsContentViewController.swift; sourceTree = ""; }; 94149B422A17B4D5003E9E1A /* NetworkSpeedConditioner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkSpeedConditioner.swift; sourceTree = ""; }; 94149B472A190889003E9E1A /* DeviceTransferRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceTransferRecord.swift; sourceTree = ""; }; - 94149B572A1A6A6D003E9E1A /* DeviceTransferClosedReason.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceTransferClosedReason.swift; sourceTree = ""; }; 9416548225CD2D9A007E76D0 /* OggOpusRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OggOpusRecorder.swift; sourceTree = ""; }; 9416548A25CD7190007E76D0 /* AudioMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioMetadata.swift; sourceTree = ""; }; 941655C325CD7DA7007E76D0 /* AudioSessionClientPriority.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionClientPriority.swift; sourceTree = ""; }; @@ -1796,6 +1797,7 @@ 94341BF72862F302009C9147 /* libopusenc.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = libopusenc.xcframework; sourceTree = ""; }; 94341BFF2862F454009C9147 /* libogg.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = libogg.xcframework; sourceTree = ""; }; 9438252625EE697300709B7D /* CacheableAssetFileManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheableAssetFileManager.swift; sourceTree = ""; }; + 943911692A39EA4300CF6DC7 /* DeviceTransferProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceTransferProgress.swift; sourceTree = ""; }; 94396F2529EB11E300A57833 /* DeviceTransferProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceTransferProtocol.swift; sourceTree = ""; }; 94396F2729EB475500A57833 /* NWParameters+DeviceTransfer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NWParameters+DeviceTransfer.swift"; sourceTree = ""; }; 94396F2929EBE52400A57833 /* DeviceTransferClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceTransferClient.swift; sourceTree = ""; }; @@ -2762,10 +2764,10 @@ 7CEB735A29DBB6F3006FB5B2 /* TransferMessage */, 7C140F5829E19C9D00F05506 /* Metadata */, 94C199722A1CA9430098EDB3 /* DeviceTransferError.swift */, - 94149B572A1A6A6D003E9E1A /* DeviceTransferClosedReason.swift */, 94E1D44D29E9393C00511267 /* DeviceTransferServer.swift */, 940B304A2A160BAB00B45D26 /* DeviceTransferServerDataSource.swift */, 94396F2929EBE52400A57833 /* DeviceTransferClient.swift */, + 7CE7ADD62A24983800A6259F /* DeviceTransferMessageProcessor.swift */, 94396F2F29ED005200A57833 /* DeviceTransferFileStream.swift */, 94396F2529EB11E300A57833 /* DeviceTransferProtocol.swift */, 94396F2729EB475500A57833 /* NWParameters+DeviceTransfer.swift */, @@ -2869,6 +2871,7 @@ 7C07A17C29D8FDBC00D4835C /* NetworkInterface.swift */, 940B30482A16050D00B45D26 /* NetworkSpeedInspector.swift */, 94149B422A17B4D5003E9E1A /* NetworkSpeedConditioner.swift */, + 943911692A39EA4300CF6DC7 /* DeviceTransferProgress.swift */, ); path = Utility; sourceTree = ""; @@ -4700,7 +4703,6 @@ 7BD38B6020BEA03200D06E5C /* WaveformView.swift in Sources */, DFB19006233220290021CAF3 /* PINLogCell.swift in Sources */, 94DF7D5928DC0F17006E415B /* Acknowledgement.swift in Sources */, - 94149B582A1A6A6D003E9E1A /* DeviceTransferClosedReason.swift in Sources */, 7C427BC428373F8000FFDE12 /* Wallpaper.swift in Sources */, E090B90F23B0B27F0012C7E9 /* ConversationDAO+Search.swift in Sources */, 7BF42E3122DC85E9005066E6 /* GalleryImageItemViewController.swift in Sources */, @@ -4771,6 +4773,7 @@ 7C7635B826A13461006101DB /* HomeAppsConstants.swift in Sources */, DFB190002330F6A40021CAF3 /* BiographyViewController.swift in Sources */, 7B7F7E391FD43F2500A1C91F /* DetailInfoMessageCell.swift in Sources */, + 7CE7ADD72A24983800A6259F /* DeviceTransferMessageProcessor.swift in Sources */, 7B8BFC8A1FDD77F9004E19DB /* UnknownMessageViewModel.swift in Sources */, 7CEB736C29DBD1C0006FB5B2 /* DeviceTransferMessage.swift in Sources */, 7B35AF7C228AA6CD00E8101D /* MessagesWithinConversationSearchResult.swift in Sources */, @@ -5064,6 +5067,7 @@ 9424C64B259246B600FFDAE0 /* main.swift in Sources */, 945278982626BCD600023A6C /* HighlightableButton.swift in Sources */, 7BEB5D9F22B79F5500B8B10E /* EmergencyContactLoginVerificationCodeViewController.swift in Sources */, + 9439116A2A39EA4300CF6DC7 /* DeviceTransferProgress.swift in Sources */, 7CEB736429DBCBE5006FB5B2 /* DeviceTransferSnapshot.swift in Sources */, 7B59535122672D3500D59DB4 /* TopResultCell.swift in Sources */, 7CEB736629DBCD6A006FB5B2 /* DeviceTransferSticker.swift in Sources */, diff --git a/Mixin/Resources/en.lproj/Localizable.strings b/Mixin/Resources/en.lproj/Localizable.strings index 2b435608ab..ca7ed15394 100644 --- a/Mixin/Resources/en.lproj/Localizable.strings +++ b/Mixin/Resources/en.lproj/Localizable.strings @@ -429,6 +429,7 @@ "i_am_good" = "I’m good."; "image" = "image"; "immediately" = "Immediately"; +"importing_chat_progress" = "Importing chat (%@%%)"; "in_connecting" = "Connecting..."; "include_files" = "Include Files"; "include_videos" = "Include Videos"; diff --git a/Mixin/Resources/es.lproj/Localizable.strings b/Mixin/Resources/es.lproj/Localizable.strings index b69dddb4e6..7ecf78b531 100644 --- a/Mixin/Resources/es.lproj/Localizable.strings +++ b/Mixin/Resources/es.lproj/Localizable.strings @@ -429,6 +429,7 @@ "i_am_good" = "Estoy bien."; "image" = "imagen"; "immediately" = "Inmediatamente"; +"importing_chat_progress" = "Importing chat (%@%%)"; "in_connecting" = "Conectando..."; "include_files" = "Incluir archivos"; "include_videos" = "Incluir vídeos"; diff --git a/Mixin/Resources/ja.lproj/Localizable.strings b/Mixin/Resources/ja.lproj/Localizable.strings index cd48d98549..af729c8c16 100644 --- a/Mixin/Resources/ja.lproj/Localizable.strings +++ b/Mixin/Resources/ja.lproj/Localizable.strings @@ -429,6 +429,7 @@ "i_am_good" = "いい気分"; "image" = "画像"; "immediately" = "すぐに"; +"importing_chat_progress" = "Importing chat (%@%%)"; "in_connecting" = "接続中..."; "include_files" = "ファイルが含まれています"; "include_videos" = "動画が含まれています"; diff --git a/Mixin/Resources/ru.lproj/Localizable.strings b/Mixin/Resources/ru.lproj/Localizable.strings index 8e718abc42..27d08c1fdf 100644 --- a/Mixin/Resources/ru.lproj/Localizable.strings +++ b/Mixin/Resources/ru.lproj/Localizable.strings @@ -429,6 +429,7 @@ "i_am_good" = "Я в порядке."; "image" = "изображение"; "immediately" = "Немедленно"; +"importing_chat_progress" = "Importing chat (%@%%)"; "in_connecting" = "Подключение..."; "include_files" = "Включить файлы"; "include_videos" = "Включить видео"; diff --git a/Mixin/Resources/zh-Hans.lproj/Localizable.strings b/Mixin/Resources/zh-Hans.lproj/Localizable.strings index bb95de0fd3..9ccd94e816 100644 --- a/Mixin/Resources/zh-Hans.lproj/Localizable.strings +++ b/Mixin/Resources/zh-Hans.lproj/Localizable.strings @@ -429,6 +429,7 @@ "i_am_good" = "我很好。"; "image" = "图片"; "immediately" = "立刻"; +"importing_chat_progress" = "导入聊天记录(%@%%)"; "in_connecting" = "正在连接..."; "include_files" = "包括文件"; "include_videos" = "包括视频"; diff --git a/Mixin/Resources/zh-Hant.lproj/Localizable.strings b/Mixin/Resources/zh-Hant.lproj/Localizable.strings index f7f9d2463b..277ff785c3 100644 --- a/Mixin/Resources/zh-Hant.lproj/Localizable.strings +++ b/Mixin/Resources/zh-Hant.lproj/Localizable.strings @@ -429,6 +429,7 @@ "i_am_good" = "我很好。"; "image" = "圖片"; "immediately" = "立刻"; +"importing_chat_progress" = "匯入聊天記錄(%@%%)"; "in_connecting" = "正在連線..."; "include_files" = "包括檔案"; "include_videos" = "包括影片"; diff --git a/Mixin/Service/DeviceTransfer/DeviceTransferClient.swift b/Mixin/Service/DeviceTransfer/DeviceTransferClient.swift index 83224385bf..bf608d75f5 100644 --- a/Mixin/Service/DeviceTransfer/DeviceTransferClient.swift +++ b/Mixin/Service/DeviceTransfer/DeviceTransferClient.swift @@ -1,13 +1,16 @@ import Foundation import Network +import Combine import MixinServices final class DeviceTransferClient { enum State { case idle - case transfer(progress: Double, speed: String) - case closed(DeviceTransferClosedReason) + case transfer(progress: Float, speed: String) // `progress` is between 0.0 and 1.0 + case failed(DeviceTransferError) + case importing(progress: Float) // `progress` is between 0.0 and 1.0 + case finished } @Published private(set) var state: State = .idle @@ -18,22 +21,35 @@ final class DeviceTransferClient { private let key: DeviceTransferKey private let remotePlatform: DeviceTransferPlatform private let connection: NWConnection + private let cacheContainerURL: URL + private let messageProcessor: DeviceTransferMessageProcessor private let queue = Queue(label: "one.mixin.messenger.DeviceTransferClient") private let speedInspector = NetworkSpeedInspector() private weak var statisticsTimer: Timer? private var fileStream: DeviceTransferFileStream? + private var messageProcessingObservers: Set = [] - // Access counts on main queue - private var processedCount = 0 - private var totalCount: Int? + // Access on main queue + private var progress = DeviceTransferProgress() private var opaquePointer: UnsafeMutableRawPointer { Unmanaged.passUnretained(self).toOpaque() } - init(hostname: String, port: UInt16, code: UInt16, key: DeviceTransferKey, remotePlatform: DeviceTransferPlatform) { + init(hostname: String, port: UInt16, code: UInt16, key: DeviceTransferKey, remotePlatform: DeviceTransferPlatform) throws { + // https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/FileSystemOverview/FileSystemOverview.html#//apple_ref/doc/uid/TP40010672-CH2-SW2 + // In iOS 5.0 and later, the system may delete the Caches directory on rare occasions when the system is very low on disk space. + // This will never occur while an app is running. + let manager = FileManager.default + let cacheContainerURL = try manager.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + .appendingPathComponent("DeviceTransfer") + if manager.fileExists(atPath: cacheContainerURL.path) { + try? manager.removeItem(at: cacheContainerURL) + } + try manager.createDirectory(at: cacheContainerURL, withIntermediateDirectories: true) + self.hostname = hostname self.port = port self.code = code @@ -45,6 +61,11 @@ final class DeviceTransferClient { let endpoint = NWEndpoint.hostPort(host: host, port: port) return NWConnection(to: endpoint, using: .deviceTransfer) }() + self.cacheContainerURL = cacheContainerURL + self.messageProcessor = .init(key: key.aes, + remotePlatform: remotePlatform, + cacheContainerURL: cacheContainerURL, + inputQueue: queue) Logger.general.info(category: "DeviceTransferClient", message: "\(opaquePointer) init") } @@ -54,6 +75,14 @@ final class DeviceTransferClient { func start() { Logger.general.info(category: "DeviceTransferClient", message: "Will start connecting to [\(hostname)]:\(port)") + messageProcessor.$processingError + .receive(on: queue.dispatchQueue) + .sink { error in + if let error { + self.fail(error: .importing(error)) + } + } + .store(in: &messageProcessingObservers) connection.stateUpdateHandler = { [weak self, unowned connection] state in switch state { case .setup: @@ -82,7 +111,7 @@ final class DeviceTransferClient { case .failed(let error): Logger.general.warn(category: "DeviceTransferClient", message: "Failed: \(error)") if let self { - self.stop(reason: .exception(.failed(error))) + self.fail(error: .connectionFailed(error)) } case .cancelled: Logger.general.info(category: "DeviceTransferClient", message: "Connection cancelled") @@ -93,22 +122,22 @@ final class DeviceTransferClient { connection.start(queue: queue.dispatchQueue) } - private func stop(reason: DeviceTransferClosedReason) { + private func fail(error: DeviceTransferError) { assert(queue.isCurrent) - Logger.general.info(category: "DeviceTransferClient", message: "Stop: \(reason) Processed: \(processedCount) Total: \(totalCount)") + Logger.general.info(category: "DeviceTransferClient", message: "Failed: \(error), progress: \(progress)") DispatchQueue.main.sync { self.statisticsTimer?.invalidate() } connection.cancel() - switch reason { - case .finished: - state = .closed(.finished) - case .exception(let error): - state = .closed(.exception(error)) - } + messageProcessingObservers.forEach { $0.cancel() } + messageProcessingObservers.removeAll() + messageProcessor.cancel() + try? FileManager.default.removeItem(at: cacheContainerURL) + Logger.general.info(category: "DeviceTransferClient", message: "Cache container removed") + state = .failed(error) } - private func startUpdatingProgressAndSpeed() { + private func startUpdatingStatistics() { assert(Queue.main.isCurrent) statisticsTimer?.invalidate() statisticsTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] timer in @@ -118,12 +147,7 @@ final class DeviceTransferClient { return } let speed = self.speedInspector.drain() - let progress: Double - if let totalCount = self.totalCount { - progress = Double(self.processedCount) * 100 / Double(totalCount) - } else { - progress = 0 - } + let progress = self.progress.fractionCompleted self.queue.async { guard case .transfer = self.state else { DispatchQueue.main.sync(execute: timer.invalidate) @@ -154,7 +178,9 @@ extension DeviceTransferClient { let message = contentContext?.protocolMetadata(definition: DeviceTransferProtocol.definition) as? NWProtocolFramer.Message else { if isComplete { - self.stop(reason: .exception(.remoteComplete)) + if case .transfer = self.state { + self.fail(error: .remoteComplete) + } Logger.general.warn(category: "DeviceTransferClient", message: "Remote closed") } return @@ -191,7 +217,7 @@ extension DeviceTransferClient { let localHMAC = HMACSHA256.mac(for: encryptedData, using: key.hmac) let remoteHMAC = content[firstHMACIndex...] guard localHMAC == remoteHMAC else { - stop(reason: .exception(.mismatchedHMAC(local: localHMAC, remote: remoteHMAC))) + fail(error: .mismatchedHMAC(local: localHMAC, remote: remoteHMAC)) return } @@ -209,12 +235,11 @@ extension DeviceTransferClient { Logger.general.info(category: "DeviceTransferClient", message: "Total count: \(count)") self.state = .transfer(progress: 0, speed: "") DispatchQueue.main.async { - self.totalCount = count - self.startUpdatingProgressAndSpeed() + self.progress.totalUnitCount = count + self.startUpdatingStatistics() } case .finish: Logger.general.info(category: "DeviceTransferClient", message: "Received finish command") - ConversationDAO.shared.updateLastMessageIdAndCreatedAt() do { let command = DeviceTransferCommand(action: .finish) let content = try DeviceTransferProtocol.output(command: command, key: key) @@ -223,7 +248,29 @@ extension DeviceTransferClient { } catch { Logger.general.error(category: "DeviceTransferClient", message: "Failed to finish command: \(error)") } - self.stop(reason: .finished) + messageProcessor.$progress + .receive(on: queue.dispatchQueue) + .sink { progress in + self.state = .importing(progress: progress) + } + .store(in: &messageProcessingObservers) + messageProcessor.$isFinished + .receive(on: queue.dispatchQueue) + .sink { isFinished in + guard isFinished else { + return + } + Logger.general.info(category: "DeviceTransferClient", message: "Import finished") + ConversationDAO.shared.updateLastMessageIdAndCreatedAt() + try? FileManager.default.removeItem(at: self.cacheContainerURL) + Logger.general.info(category: "DeviceTransferClient", message: "Cache container removed") + DispatchQueue.main.sync { + self.statisticsTimer?.invalidate() + } + self.state = .finished + } + .store(in: &messageProcessingObservers) + messageProcessor.finishProcessing() default: break } @@ -233,7 +280,7 @@ extension DeviceTransferClient { assert(queue.isCurrent) DispatchQueue.main.sync { speedInspector.add(byteCount: content.count) - processedCount += 1 + progress.completedUnitCount += 1 } let firstHMACIndex = content.endIndex.advanced(by: -DeviceTransferProtocol.hmacDataCount) @@ -241,75 +288,15 @@ extension DeviceTransferClient { let localHMAC = HMACSHA256.mac(for: encryptedData, using: key.hmac) let remoteHMAC = content[firstHMACIndex...] guard localHMAC == remoteHMAC else { - stop(reason: .exception(.mismatchedHMAC(local: localHMAC, remote: remoteHMAC))) + fail(error: .mismatchedHMAC(local: localHMAC, remote: remoteHMAC)) return } - - let decryptedData: Data do { - decryptedData = try AESCryptor.decrypt(encryptedData, with: key.aes) + try messageProcessor.process(encryptedMessage: encryptedData) } catch { - Logger.general.error(category: "DeviceTransferClient", message: "Unable to decrypt: \(error)") + Logger.general.error(category: "DeviceTransferClient", message: "Handle message: \(error)") return } - - do { - struct TypeWrapper: Decodable { - let type: DeviceTransferRecordType - } - - let decoder = JSONDecoder.default - let wrapper = try decoder.decode(TypeWrapper.self, from: decryptedData) - switch wrapper.type { - case .conversation: - let conversation = try decoder.decode(DeviceTransferTypedRecord.self, from: decryptedData).data - ConversationDAO.shared.save(conversation: conversation.toConversation(from: remotePlatform)) - case .participant: - let participant = try decoder.decode(DeviceTransferTypedRecord.self, from: decryptedData).data - ParticipantDAO.shared.save(participant: participant.toParticipant()) - case .user: - let user = try decoder.decode(DeviceTransferTypedRecord.self, from: decryptedData).data - UserDAO.shared.save(user: user.toUser()) - case .app: - let app = try decoder.decode(DeviceTransferTypedRecord.self, from: decryptedData).data - AppDAO.shared.save(app: app.toApp()) - case .asset: - let asset = try decoder.decode(DeviceTransferTypedRecord.self, from: decryptedData).data - AssetDAO.shared.save(asset: asset.toAsset()) - case .snapshot: - let snapshot = try decoder.decode(DeviceTransferTypedRecord.self, from: decryptedData).data - SnapshotDAO.shared.save(snapshot: snapshot.toSnapshot()) - case .sticker: - let sticker = try decoder.decode(DeviceTransferTypedRecord.self, from: decryptedData).data - StickerDAO.shared.save(sticker: sticker.toSticker()) - case .pinMessage: - let pinMessage = try decoder.decode(DeviceTransferTypedRecord.self, from: decryptedData).data - PinMessageDAO.shared.save(pinMessage: pinMessage.toPinMessage()) - case .transcriptMessage: - let transcriptMessage = try decoder.decode(DeviceTransferTypedRecord.self, from: decryptedData).data - TranscriptMessageDAO.shared.save(transcriptMessage: transcriptMessage.toTranscriptMessage()) - case .message: - let message = try decoder.decode(DeviceTransferTypedRecord.self, from: decryptedData).data - if MessageCategory.isLegal(category: message.category) { - MessageDAO.shared.save(message: message.toMessage()) - } else { - Logger.general.warn(category: "DeviceTransferClient", message: "Message is illegal: \(message)") - } - case .messageMention: - let messageMention = try decoder.decode(DeviceTransferTypedRecord.self, from: decryptedData).data - if let mention = messageMention.toMessageMention() { - MessageMentionDAO.shared.save(messageMention: mention) - } else { - Logger.general.warn(category: "DeviceTransferClient", message: "Message Mention does not exist: \(messageMention)") - } - case .expiredMessage: - let expiredMessage = try decoder.decode(DeviceTransferTypedRecord.self, from: decryptedData).data - ExpiredMessageDAO.shared.save(expiredMessage: expiredMessage.toExpiredMessage()) - } - } catch { - let content = String(data: decryptedData, encoding: .utf8) ?? "Data(\(decryptedData.count))" - Logger.general.error(category: "DeviceTransferClient", message: "Error: \(error) Content: \(content)") - } } private func receiveFile(context: DeviceTransferProtocol.FileContext, content: Data) { @@ -324,12 +311,18 @@ extension DeviceTransferClient { isReceivingNewFile = false } else { assertionFailure("Should be closed by the end of previous call") - currentStream.close() - stream = DeviceTransferFileStream(context: context, key: key) + do { + try currentStream.close() + } catch let error as DeviceTransferError { + fail(error: error) + } catch { + fail(error: .receiveFile(error)) + } + stream = .init(context: context, key: key, containerURL: cacheContainerURL) isReceivingNewFile = true } } else { - stream = DeviceTransferFileStream(context: context, key: key) + stream = .init(context: context, key: key, containerURL: cacheContainerURL) isReceivingNewFile = true } if isReceivingNewFile { @@ -339,7 +332,7 @@ extension DeviceTransferClient { DispatchQueue.main.sync { speedInspector.add(byteCount: content.count) if isReceivingNewFile { - processedCount += 1 + progress.completedUnitCount += 1 } } @@ -347,11 +340,18 @@ extension DeviceTransferClient { try stream.write(data: content) } catch { Logger.general.error(category: "DeviceTransferClient", message: "Failed to write: \(error)") - stop(reason: .exception(.receiveFile(error))) + fail(error: .receiveFile(error)) } if context.remainingLength == 0 { - stream.close() + do { + try stream.close() + } catch let error as DeviceTransferError { + fail(error: error) + } catch { + fail(error: .receiveFile(error)) + } self.fileStream = nil + messageProcessor.reportFileReceived() } } diff --git a/Mixin/Service/DeviceTransfer/DeviceTransferClosedReason.swift b/Mixin/Service/DeviceTransfer/DeviceTransferClosedReason.swift deleted file mode 100644 index 21540c9f4a..0000000000 --- a/Mixin/Service/DeviceTransfer/DeviceTransferClosedReason.swift +++ /dev/null @@ -1,6 +0,0 @@ -import Foundation - -enum DeviceTransferClosedReason { - case finished - case exception(DeviceTransferError) -} diff --git a/Mixin/Service/DeviceTransfer/DeviceTransferError.swift b/Mixin/Service/DeviceTransfer/DeviceTransferError.swift index 8f127f85f0..5af46cc258 100644 --- a/Mixin/Service/DeviceTransfer/DeviceTransferError.swift +++ b/Mixin/Service/DeviceTransfer/DeviceTransferError.swift @@ -5,6 +5,7 @@ enum DeviceTransferError: Error { case mismatchedConnection case encrypt(Error) case mismatchedHMAC(local: Data, remote: Data) - case failed(Error) + case connectionFailed(Error) case receiveFile(Error) + case importing(DeviceTransferMessageProcessor.ProcessingError) } diff --git a/Mixin/Service/DeviceTransfer/DeviceTransferFileStream.swift b/Mixin/Service/DeviceTransfer/DeviceTransferFileStream.swift index dc4c742a73..7498baa033 100644 --- a/Mixin/Service/DeviceTransfer/DeviceTransferFileStream.swift +++ b/Mixin/Service/DeviceTransfer/DeviceTransferFileStream.swift @@ -9,8 +9,8 @@ class DeviceTransferFileStream: InstanceInitializable { self.id = id } - convenience init(context: DeviceTransferProtocol.FileContext, key: DeviceTransferKey) { - if let impl = DeviceTransferFileStreamImpl(context, key: key) { + convenience init(context: DeviceTransferProtocol.FileContext, key: DeviceTransferKey, containerURL: URL) { + if let impl = DeviceTransferFileStreamImpl(context, key: key, containerURL: containerURL) { self.init(instance: impl as! Self) } else { self.init(id: context.fileHeader.id) @@ -21,7 +21,7 @@ class DeviceTransferFileStream: InstanceInitializable { } - func close() { + func close() throws { } @@ -29,9 +29,7 @@ class DeviceTransferFileStream: InstanceInitializable { fileprivate final class DeviceTransferFileStreamImpl: DeviceTransferFileStream { - private let tempURL: URL private let handle: FileHandle - private let destinationURLs: [URL] private let fileManager: FileManager = .default private var decryptor: AESCryptor @@ -39,7 +37,7 @@ fileprivate final class DeviceTransferFileStreamImpl: DeviceTransferFileStream { private var localHMAC: HMACSHA256 private var remoteHMAC = Data(capacity: DeviceTransferProtocol.hmacDataCount) - init?(_ context: DeviceTransferProtocol.FileContext, key: DeviceTransferKey) { + init?(_ context: DeviceTransferProtocol.FileContext, key: DeviceTransferKey, containerURL: URL) { let decryptor: AESCryptor do { decryptor = try AESCryptor(operation: .decrypt, iv: context.fileHeader.iv, key: key.aes) @@ -51,36 +49,14 @@ fileprivate final class DeviceTransferFileStreamImpl: DeviceTransferFileStream { let id = context.fileHeader.id.uuidString.lowercased() let idData = context.fileHeader.id.data - var destinationURLs: [URL] - if let message = MessageDAO.shared.getMessage(messageId: id), let mediaURL = message.mediaUrl { - guard let category = AttachmentContainer.Category(messageCategory: message.category) else { - Logger.general.error(category: "DeviceTransferFileStream", message: "Invalid category: \(message.category)") - return nil - } - let url = AttachmentContainer.url(for: category, filename: mediaURL) - destinationURLs = [url] - if let transcriptMessage = TranscriptMessageDAO.shared.transcriptMessage(messageId: id), let mediaURL = transcriptMessage.mediaUrl { - let url = AttachmentContainer.url(transcriptId: transcriptMessage.transcriptId, filename: mediaURL) - destinationURLs.append(url) - } - } else if let transcriptMessage = TranscriptMessageDAO.shared.transcriptMessage(messageId: id), let mediaURL = transcriptMessage.mediaUrl { - let url = AttachmentContainer.url(transcriptId: transcriptMessage.transcriptId, filename: mediaURL) - destinationURLs = [url] - } else { - Logger.general.warn(category: "DeviceTransferFileStream", message: "No message found for: \(id)") - return nil - } - do { - let tempURL = fileManager.temporaryDirectory.appendingPathComponent("devicetransfer.tmp") - if fileManager.fileExists(atPath: tempURL.path) { - try fileManager.removeItem(at: tempURL) + let fileURL = containerURL.appendingPathComponent(id) + if fileManager.fileExists(atPath: fileURL.path) { + try fileManager.removeItem(at: fileURL) } - fileManager.createFile(atPath: tempURL.path, contents: nil) + fileManager.createFile(atPath: fileURL.path, contents: nil) - self.tempURL = tempURL - self.handle = try FileHandle(forWritingTo: tempURL) - self.destinationURLs = destinationURLs + self.handle = try FileHandle(forWritingTo: fileURL) self.decryptor = decryptor self.remainingDataCount = Int(context.header.length) - idData.count - DeviceTransferProtocol.ivDataCount self.localHMAC = HMACSHA256(key: key.hmac) @@ -122,11 +98,7 @@ fileprivate final class DeviceTransferFileStreamImpl: DeviceTransferFileStream { } } - override func close() { - defer { - try? fileManager.removeItem(at: tempURL) - } - + override func close() throws { do { let finalData = try decryptor.finalize() handle.write(finalData) @@ -135,33 +107,16 @@ fileprivate final class DeviceTransferFileStreamImpl: DeviceTransferFileStream { Logger.general.error(category: "DeviceTransferFileStream", message: "\(id) Close: \(error)") } - guard remoteHMAC.count == DeviceTransferProtocol.hmacDataCount else { - Logger.general.error(category: "DeviceTransferFileStream", message: "\(id) Invalid HMAC: \(remoteHMAC.count)") - return - } let localHMAC = localHMAC.finalize() guard localHMAC == remoteHMAC else { let local = localHMAC.base64EncodedString() let remote = remoteHMAC.base64EncodedString() Logger.general.error(category: "DeviceTransferFileStream", message: "\(id) Local HMAC: \(local), Remote HMAC: \(remote)") - return - } - - for destinationURL in destinationURLs { - let path = destinationURL.path - if fileManager.fileExists(atPath: path) { - if fileManager.fileSize(path) == 0 { - try? fileManager.removeItem(atPath: path) - } else { - continue - } - } - do { - try fileManager.copyItem(at: tempURL, to: destinationURL) - } catch { - Logger.general.error(category: "DeviceTransferFileStream", message: "\(id) Not copied: \(error)") - } + throw DeviceTransferError.mismatchedHMAC(local: localHMAC, remote: remoteHMAC) } + #if DEBUG + Logger.general.debug(category: "DeviceTransferFileStream", message: "\(id) Closed") + #endif } } diff --git a/Mixin/Service/DeviceTransfer/DeviceTransferMessageProcessor.swift b/Mixin/Service/DeviceTransfer/DeviceTransferMessageProcessor.swift new file mode 100644 index 0000000000..f2212cefc5 --- /dev/null +++ b/Mixin/Service/DeviceTransfer/DeviceTransferMessageProcessor.swift @@ -0,0 +1,406 @@ +import Foundation +import MixinServices + +fileprivate let fileManager = FileManager.default + +final class DeviceTransferMessageProcessor { + + // This class is not thread safe to achieve best performance within a niche usage + // To avoid data racing, always call `process(message:)`, `finishProcessing` in strict order + + enum ProcessingError: Error { + case createInputStream + case readInputStream(Error?) + case mismatchedLengthRead(required: Int, read: Int) + case enumerateFiles + } + + private class Cache { + + typealias MessageLength = UInt32 + + static let maxCount = 10 * Int(bytesPerMegaByte) + static let messageLengthLayoutSize = 4 + + let index: UInt + let url: URL + let handle: FileHandle + + var wroteCount: Int = 0 + + var isOversized: Bool { + wroteCount >= Self.maxCount + } + + init(index: UInt, containerURL: URL) throws { + let url = containerURL.appendingPathComponent(String(index) + ".cache") + try Data().write(to: url) + self.index = index + self.url = url + self.handle = try FileHandle(forWritingTo: url) + } + + } + + @Published private(set) var progress: Float = 0 + @Published private(set) var processingError: ProcessingError? + @Published private(set) var isFinished = false + + private let key: Data + private let remotePlatform: DeviceTransferPlatform + private let cacheContainerURL: URL + private let inputQueue: Queue + private let processingQueue = Queue(label: "one.mixin.messenger.DeviceTransferMessageProcessor") + private let messageSavingBatchCount = 100 + private let progressReportingInterval = 10 // Update progress every 10 items are processed + + // https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man3/pthread_rwlock_wrlock.3.html#//apple_ref/doc/man/3/pthread_rwlock_wrlock + // To prevent writer starvation, writers are favored over readers. + private var cancellationLock = pthread_rwlock_t() + private var _cancelled = false + private var isCancelled: Bool { + get { + pthread_rwlock_rdlock(&self.cancellationLock) + let cancelled = _cancelled + pthread_rwlock_unlock(&self.cancellationLock) + return cancelled + } + set { + pthread_rwlock_wrlock(&cancellationLock) + _cancelled = newValue + pthread_rwlock_unlock(&cancellationLock) + } + } + + private var processingProgress = DeviceTransferProgress() + private var writingCache: Cache? + private var cacheReadingBuffer = Data(count: Int(bytesPerKiloByte)) + + // Messages are saved to database in batch. See `messageSavingBatchCount` + private var pendingMessages: [Message] = [] + + init(key: Data, remotePlatform: DeviceTransferPlatform, cacheContainerURL: URL, inputQueue: Queue) { + self.key = key + self.remotePlatform = remotePlatform + self.cacheContainerURL = cacheContainerURL + self.inputQueue = inputQueue + pthread_rwlock_init(&cancellationLock, nil) + } + + func process(encryptedMessage: Data) throws { + assert(inputQueue.isCurrent) + + let cache: Cache + if let writingCache { + cache = writingCache + } else { + Logger.general.info(category: "DeviceTransferMessageProcessor", message: "Create cache 0") + cache = try Cache(index: 0, containerURL: cacheContainerURL) + writingCache = cache + } + + let length = Cache.MessageLength(encryptedMessage.count).data(endianness: .little) + cache.handle.write(length) + cache.wroteCount += length.count + cache.handle.write(encryptedMessage) + cache.wroteCount += encryptedMessage.count + processingQueue.async { + self.processingProgress.totalUnitCount += 1 + } + + if cache.isOversized { + cache.handle.closeFile() + processingQueue.async { + self.process(cache: cache) + try? fileManager.removeItem(at: cache.url) + } + let nextIndex = cache.index + 1 + Logger.general.info(category: "DeviceTransferMessageProcessor", message: "Create cache \(nextIndex)") + writingCache = try Cache(index: nextIndex, containerURL: cacheContainerURL) + } + } + + func reportFileReceived() { + processingQueue.async { + self.processingProgress.totalUnitCount += 1 + } + } + + func finishProcessing() { + assert(inputQueue.isCurrent) + guard let lastCache = writingCache else { + Logger.general.error(category: "DeviceTransferMessageProcessor", message: "No writing cache on finished") + return + } + writingCache = nil + lastCache.handle.closeFile() + processingQueue.async { + self.process(cache: lastCache) + try? fileManager.removeItem(at: lastCache.url) + self.savePendingMessages() + self.processFiles() + Logger.general.info(category: "DeviceTransferMessageProcessor", message: "Processing finished with progress: \(self.processingProgress)") + if !self.isCancelled && self.processingError == nil { + self.isFinished = true + } + } + } + + func cancel() { + Logger.general.info(category: "DeviceTransferMessageProcessor", message: "Cancelled") + isCancelled = true + } + +} + +// MARK: - Cache Processing +extension DeviceTransferMessageProcessor { + + private func reportProgress() { + assert(processingQueue.isCurrent) + self.progress = processingProgress.fractionCompleted + } + + private func savePendingMessages() { + guard !pendingMessages.isEmpty else { + return + } + MessageDAO.shared.save(messages: pendingMessages) + pendingMessages.removeAll(keepingCapacity: false) + Logger.general.info(category: "DeviceTransferMessageProcessor", message: "All pending messages are saved") + } + + private func process(cache: Cache) { + assert(processingQueue.isCurrent) + guard !isCancelled else { + Logger.general.info(category: "DeviceTransferMessageProcessor", message: "Not processing cache \(cache.index) by cancellation") + return + } + + guard let stream = InputStream(url: cache.url) else { + processingError = .createInputStream + return + } + stream.open() + defer { + stream.close() + } + + Logger.general.info(category: "DeviceTransferMessageProcessor", message: "Begin processing cache \(cache.index)") + var completedCountOnLastProgressReporting = processingProgress.completedUnitCount + while stream.hasBytesAvailable { + guard !isCancelled else { + Logger.general.info(category: "DeviceTransferMessageProcessor", message: "Not processing cache \(cache.index) by cancellation") + return + } + + let requiredLength: Int + switch read(from: stream, to: &cacheReadingBuffer, length: Cache.messageLengthLayoutSize) { + case .endOfStream: + if isCancelled { + Logger.general.info(category: "DeviceTransferMessageProcessor", message: "End processing cache \(cache.index) with cancellation") + } else { + reportProgress() + Logger.general.info(category: "DeviceTransferMessageProcessor", message: "End processing cache \(cache.index)") + } + return + case .operationFailed(let error): + processingError = .readInputStream(error) + return + case .success: + requiredLength = Int(Cache.MessageLength(data: cacheReadingBuffer, endianess: .little)) + } + + if cacheReadingBuffer.count < requiredLength { + cacheReadingBuffer.count = requiredLength + } + switch read(from: stream, to: &cacheReadingBuffer, length: requiredLength) { + case .endOfStream: + assertionFailure("Impossible") + Logger.general.error(category: "DeviceTransferMessageProcessor", message: "EOS after length is read") + return + case .operationFailed(let error): + processingError = .readInputStream(error) + return + case .success(let readLength): + guard requiredLength == readLength else { + Logger.general.error(category: "DeviceTransferMessageProcessor", message: "Error reading: \(readLength), required: \(requiredLength)") + processingError = .mismatchedLengthRead(required: requiredLength, read: readLength) + return + } + let encryptedData = cacheReadingBuffer[.. ReadStreamResult { + var totalBytesRead = 0 + while totalBytesRead < length { + let bytesRead = buffer.withUnsafeMutableBytes { buffer in + let pointer = buffer.baseAddress!.advanced(by: totalBytesRead) + return stream.read(pointer, maxLength: length - totalBytesRead) + } + switch bytesRead { + case 0: + return .endOfStream + case -1: + return .operationFailed(stream.streamError) + default: + totalBytesRead += bytesRead + } + } + return .success(totalBytesRead) + } + +} + +// MARK: - Data Processing +extension DeviceTransferMessageProcessor { + + private func process(jsonData: Data) { + struct TypeWrapper: Decodable { + let type: DeviceTransferRecordType + } + struct DataWrapper: Decodable { + let data: Record + } + let decoder = JSONDecoder.default + do { + let type = try decoder.decode(TypeWrapper.self, from: jsonData).type + switch type { + case .conversation: + let conversation = try decoder.decode(DataWrapper.self, from: jsonData).data + ConversationDAO.shared.save(conversation: conversation.toConversation(from: remotePlatform)) + case .participant: + let participant = try decoder.decode(DataWrapper.self, from: jsonData).data + ParticipantDAO.shared.save(participant: participant.toParticipant()) + case .user: + let user = try decoder.decode(DataWrapper.self, from: jsonData).data + UserDAO.shared.save(user: user.toUser()) + case .app: + let app = try decoder.decode(DataWrapper.self, from: jsonData).data + AppDAO.shared.save(app: app.toApp()) + case .asset: + let asset = try decoder.decode(DataWrapper.self, from: jsonData).data + AssetDAO.shared.save(asset: asset.toAsset()) + case .snapshot: + let snapshot = try decoder.decode(DataWrapper.self, from: jsonData).data + SnapshotDAO.shared.save(snapshot: snapshot.toSnapshot()) + case .sticker: + let sticker = try decoder.decode(DataWrapper.self, from: jsonData).data + StickerDAO.shared.save(sticker: sticker.toSticker()) + case .pinMessage: + let pinMessage = try decoder.decode(DataWrapper.self, from: jsonData).data + PinMessageDAO.shared.save(pinMessage: pinMessage.toPinMessage()) + case .transcriptMessage: + let transcriptMessage = try decoder.decode(DataWrapper.self, from: jsonData).data + TranscriptMessageDAO.shared.save(transcriptMessage: transcriptMessage.toTranscriptMessage()) + case .message: + let message = try decoder.decode(DataWrapper.self, from: jsonData).data + if MessageCategory.isLegal(category: message.category) { + pendingMessages.append(message.toMessage()) + if pendingMessages.count == messageSavingBatchCount { + MessageDAO.shared.save(messages: pendingMessages) + pendingMessages.removeAll(keepingCapacity: true) + } + } else { + Logger.general.warn(category: "DeviceTransferMessageProcessor", message: "Message is illegal: \(message)") + } + case .messageMention: + savePendingMessages() + let messageMention = try decoder.decode(DataWrapper.self, from: jsonData).data + if let mention = messageMention.toMessageMention() { + MessageMentionDAO.shared.save(messageMention: mention) + } else { + Logger.general.warn(category: "DeviceTransferMessageProcessor", message: "Message Mention does not exist: \(messageMention)") + } + case .expiredMessage: + savePendingMessages() + let expiredMessage = try decoder.decode(DataWrapper.self, from: jsonData).data + ExpiredMessageDAO.shared.save(expiredMessage: expiredMessage.toExpiredMessage()) + } + } catch { + let content = String(data: jsonData, encoding: .utf8) ?? "Data(\(jsonData.count))" + Logger.general.error(category: "DeviceTransferMessageProcessor", message: "Error: \(error) Content: \(content)") + } + } + + private func processFiles() { + assert(processingQueue.isCurrent) + guard let fileEnumerator = fileManager.enumerator(at: cacheContainerURL, includingPropertiesForKeys: nil) else { + processingError = .enumerateFiles + Logger.general.error(category: "DeviceTransferMessageProcessor", message: "Can't create file enumerator") + return + } + Logger.general.info(category: "DeviceTransferMessageProcessor", message: "Start processing files") + var processedCountOnLastProgressReporting = processingProgress.completedUnitCount + + for case let fileURL as URL in fileEnumerator { + guard !isCancelled else { + Logger.general.info(category: "DeviceTransferMessageProcessor", message: "Stop processing files by cancellation") + return + } + let id = fileURL.lastPathComponent + + var destinationURLs: [URL] = [] + if let message = MessageDAO.shared.getMessage(messageId: id), + let mediaURL = message.mediaUrl, + let category = AttachmentContainer.Category(messageCategory: message.category) + { + let url = AttachmentContainer.url(for: category, filename: mediaURL) + destinationURLs = [url] + } else { + destinationURLs = [] + } + + if let transcriptMessage = TranscriptMessageDAO.shared.transcriptMessage(messageId: id), + let mediaURL = transcriptMessage.mediaUrl + { + let url = AttachmentContainer.url(transcriptId: transcriptMessage.transcriptId, filename: mediaURL) + destinationURLs.append(url) + } + + for destinationURL in destinationURLs { + do { + if fileManager.fileExists(atPath: destinationURL.path) { + try fileManager.removeItem(at: destinationURL) + } + try fileManager.copyItem(at: fileURL, to: destinationURL) + } catch { + Logger.general.error(category: "DeviceTransferMessageProcessor", message: "\(id) copy failed: \(error)") + } + } + + processingProgress.completedUnitCount += 1 + if processingProgress.completedUnitCount - processedCountOnLastProgressReporting == progressReportingInterval { + processedCountOnLastProgressReporting = processingProgress.completedUnitCount + reportProgress() + } + + try? fileManager.removeItem(at: fileURL) + } + } + +} diff --git a/Mixin/Service/DeviceTransfer/DeviceTransferServer.swift b/Mixin/Service/DeviceTransfer/DeviceTransferServer.swift index 8415f60e5e..6697c729e5 100644 --- a/Mixin/Service/DeviceTransfer/DeviceTransferServer.swift +++ b/Mixin/Service/DeviceTransfer/DeviceTransferServer.swift @@ -12,8 +12,13 @@ final class DeviceTransferServer { enum State { case idle case listening(hostname: String, port: UInt16) - case transfer(progress: Double, speed: String) - case closed(DeviceTransferClosedReason) + case transfer(progress: Float, speed: String) // `progress` is between 0.0 and 1.0 + case closed(ClosedReason) + } + + enum ClosedReason { + case finished + case exception(DeviceTransferError) } let code: UInt16 = .random(in: 0...999) @@ -85,7 +90,7 @@ extension DeviceTransferServer { } } catch { Logger.general.warn(category: "DeviceTransferServer", message: "Listener ready without a hostname") - self?.state = .closed(.exception(.failed(error))) + self?.state = .closed(.exception(.connectionFailed(error))) } case let .failed(error), let .waiting(error): Logger.general.warn(category: "DeviceTransferServer", message: "Not listening: \(error)") @@ -159,7 +164,7 @@ extension DeviceTransferServer { lastConnectionRejectedReason = nil } - private func stop(reason: DeviceTransferClosedReason) { + private func stop(reason: ClosedReason) { assert(queue.isCurrent) Logger.general.info(category: "DeviceTransferServer", message: "Stop with reason: \(reason)") listener?.cancel() @@ -186,7 +191,7 @@ extension DeviceTransferServer { } startListening { error in Logger.general.error(category: "DeviceTransferServer", message: "Failed to start listening after connection rejected") - self.state = .closed(.exception(.failed(error))) + self.state = .closed(.exception(.connectionFailed(error))) } } @@ -214,7 +219,7 @@ extension DeviceTransferServer { } case .failed(let error): Logger.general.warn(category: "DeviceTransferServer", message: "Failed: \(error)") - self?.stop(reason: .exception(.failed(error))) + self?.stop(reason: .exception(.connectionFailed(error))) case .cancelled: Logger.general.info(category: "DeviceTransferServer", message: "Connection cancelled") @unknown default: diff --git a/Mixin/Service/DeviceTransfer/DeviceTransferServerDataSource.swift b/Mixin/Service/DeviceTransfer/DeviceTransferServerDataSource.swift index da676c6142..a7a8af26fe 100644 --- a/Mixin/Service/DeviceTransfer/DeviceTransferServerDataSource.swift +++ b/Mixin/Service/DeviceTransfer/DeviceTransferServerDataSource.swift @@ -25,7 +25,7 @@ final class DeviceTransferServerDataSource { // MARK: - Data Count extension DeviceTransferServerDataSource { - func totalCount() -> Int { + func totalCount() -> Int64 { assert(!Queue.main.isCurrent) let messagesCount = MessageDAO.shared.messagesCount() let attachmentsCount = attachmentsCount() @@ -46,9 +46,9 @@ extension DeviceTransferServerDataSource { return total } - private func attachmentsCount() -> Int { + private func attachmentsCount() -> Int64 { let folders = AttachmentContainer.Category.allCases.map(\.pathComponent) + ["Transcript"] - let count = folders.reduce(0) { previousCount, folder in + let count: Int64 = folders.reduce(0) { previousCount, folder in let folderURL = AttachmentContainer.url.appendingPathComponent(folder) let count = validFileCount(in: folderURL) return previousCount + count @@ -56,8 +56,8 @@ extension DeviceTransferServerDataSource { return count } - private func validFileCount(in url: URL) -> Int { - var count = 0 + private func validFileCount(in url: URL) -> Int64 { + var count: Int64 = 0 guard let fileEnumerator = FileManager.default.enumerator(at: url, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) else { return 0 } diff --git a/Mixin/Service/DeviceTransfer/Utility/DeviceTransferProgress.swift b/Mixin/Service/DeviceTransfer/Utility/DeviceTransferProgress.swift new file mode 100644 index 0000000000..ceaeaa22d8 --- /dev/null +++ b/Mixin/Service/DeviceTransfer/Utility/DeviceTransferProgress.swift @@ -0,0 +1,36 @@ +import Foundation + +struct DeviceTransferProgress { + + // NSProgress rounds `fractionCompleted` as binary, which may not be as + // expected from the view of decimal progress + // e.g. when `totalUnitCount` is `.max / 100`, and `completedUnitCount` + // is `(.max / 100) - 1`, the `fractionCompleted` will be 1.0, which may + // be considered as finished, or leads to misunderstanding + + var totalUnitCount: Int64 + var completedUnitCount: Int64 + + var fractionCompleted: Float { + if totalUnitCount == 0 { + return 0 + } else { + // Currently provides 4 digits for precision, that is 0.01% ~ 100.0% + return Float(completedUnitCount * 10000 / totalUnitCount) / 10000 + } + } + + init(totalUnitCount: Int64 = 0, completedUnitCount: Int64 = 0) { + self.totalUnitCount = 0 + self.completedUnitCount = 0 + } + +} + +extension DeviceTransferProgress: CustomStringConvertible { + + var description: String { + "" + } + +} diff --git a/Mixin/UserInterface/Controllers/DeviceTransfer/DeviceTransferProgressViewController.swift b/Mixin/UserInterface/Controllers/DeviceTransfer/DeviceTransferProgressViewController.swift index 28b1785220..2a82f0aaaa 100644 --- a/Mixin/UserInterface/Controllers/DeviceTransfer/DeviceTransferProgressViewController.swift +++ b/Mixin/UserInterface/Controllers/DeviceTransfer/DeviceTransferProgressViewController.swift @@ -164,7 +164,18 @@ extension DeviceTransferProgressViewController { case let .transfer(progress, speed): updateTitleLabel(with: progress, speed: speed) case let .closed(reason): - handleConnectionClosing(reason: reason) + switch reason { + case .finished: + let hint = R.string.localizable.transfer_completed() + titleLabel.text = hint + progressView.progress = 1 + transferSucceeded(hint: hint) + speedLabel.isHidden = true + stateObserver?.cancel() + Logger.general.info(category: "DeviceTransferProgress", message: "Transfer succeeded") + case .exception(let error): + handleConnectionClosing(error: error) + } } } @@ -174,13 +185,17 @@ extension DeviceTransferProgressViewController { Logger.general.warn(category: "DeviceTransferProgress", message: "Invalid state: \(state)") case let .transfer(progress, speed): updateTitleLabel(with: progress, speed: speed) - case let .closed(reason): - handleConnectionClosing(reason: reason) + case let .failed(error): + handleConnectionClosing(error: error) + case let .importing(progress): + updateTitleLabel(with: progress) + case .finished: + importFinished() } } - private func updateTitleLabel(with transferProgress: Double, speed: String) { - let progress = String(format: "%.2f", transferProgress) + private func updateTitleLabel(with transferProgress: Float, speed: String) { + let progress = String(format: "%.2f", transferProgress * 100) switch connection { case .server: titleLabel.text = R.string.localizable.transferring_chat_progress(progress) @@ -189,36 +204,33 @@ extension DeviceTransferProgressViewController { case .cloud: break } - progressView.progress = Float(transferProgress / 100) + progressView.progress = transferProgress speedLabel.text = speed } - private func handleConnectionClosing(reason: DeviceTransferClosedReason) { - switch reason { - case .finished: - let hint: String - switch connection { - case .server: - hint = R.string.localizable.transfer_completed() - case .client: - hint = R.string.localizable.restore_completed() - case .cloud: - return - } - titleLabel.text = hint - progressView.progress = 1 - transferSucceeded(hint: hint) - speedLabel.isHidden = true - stateObserver?.cancel() - Logger.general.info(category: "DeviceTransferProgress", message: "Transfer succeeded") - case .exception(let error): - let hint = R.string.localizable.transfer_failed() - titleLabel.text = hint - transferFailed(hint: hint) - speedLabel.isHidden = true - stateObserver?.cancel() - Logger.general.error(category: "DeviceTransferProgress", message: "Transfer failed: \(error)") - } + private func updateTitleLabel(with importProgress: Float) { + tipLabel.text = R.string.localizable.keep_running_foreground() + titleLabel.text = R.string.localizable.importing_chat_progress(String(format: "%.2f", importProgress * 100)) + progressView.progress = importProgress + speedLabel.isHidden = true + } + + private func handleConnectionClosing(error: DeviceTransferError) { + let hint = R.string.localizable.transfer_failed() + titleLabel.text = hint + transferFailed(hint: hint) + speedLabel.isHidden = true + stateObserver?.cancel() + Logger.general.error(category: "DeviceTransferProgress", message: "Transfer failed: \(error)") + } + + private func importFinished() { + let hint = R.string.localizable.restore_completed() + titleLabel.text = hint + progressView.progress = 1 + transferSucceeded(hint: hint) + stateObserver?.cancel() + Logger.general.info(category: "DeviceTransferProgress", message: "Transfer succeeded") } } diff --git a/Mixin/UserInterface/Controllers/DeviceTransfer/RestoreFromDesktopViewController.swift b/Mixin/UserInterface/Controllers/DeviceTransfer/RestoreFromDesktopViewController.swift index 84c85ded6d..45c23b48e5 100644 --- a/Mixin/UserInterface/Controllers/DeviceTransfer/RestoreFromDesktopViewController.swift +++ b/Mixin/UserInterface/Controllers/DeviceTransfer/RestoreFromDesktopViewController.swift @@ -119,17 +119,24 @@ extension RestoreFromDesktopViewController { dataSource.replaceSection(at: 0, with: section, animation: .automatic) tableView.isUserInteractionEnabled = true case let .push(context): - let client = DeviceTransferClient(hostname: context.hostname, - port: context.port, - code: context.code, - key: context.key, - remotePlatform: command.platform) - stateObserver = client.$state - .receive(on: DispatchQueue.main) - .sink { [weak self] state in - self?.stateDidChange(client: client, state: state) + do { + let client = try DeviceTransferClient(hostname: context.hostname, + port: context.port, + code: context.code, + key: context.key, + remotePlatform: command.platform) + stateObserver = client.$state + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + self?.stateDidChange(client: client, state: state) + } + client.start() + } catch { + Logger.general.error(category: "RestoreFromDesktop", message: "Unable to init client: \(error)") + alert(R.string.localizable.connection_establishment_failed(), message: nil) { _ in + self.navigationController?.popViewController(animated: true) } - client.start() + } default: Logger.general.info(category: "RestoreFromDesktop", message: "Invalid command") alert(R.string.localizable.connection_establishment_failed(), message: nil) { _ in @@ -140,7 +147,7 @@ extension RestoreFromDesktopViewController { private func stateDidChange(client: DeviceTransferClient, state: DeviceTransferClient.State) { switch state { - case .idle: + case .idle, .importing, .finished: break case .transfer: stateObserver?.cancel() @@ -148,14 +155,12 @@ extension RestoreFromDesktopViewController { tableView.isUserInteractionEnabled = true let progress = DeviceTransferProgressViewController(connection: .client(client, .desktop)) navigationController?.pushViewController(progress, animated: true) - case let .closed(reason): + case let .failed(error): dataSource.replaceSection(at: 0, with: section, animation: .automatic) tableView.isUserInteractionEnabled = true stateObserver?.cancel() - if case let .exception(error) = reason { - alert(R.string.localizable.connection_establishment_failed(), message: error.localizedDescription) { _ in - self.navigationController?.popViewController(animated: true) - } + alert(R.string.localizable.connection_establishment_failed(), message: error.localizedDescription) { _ in + self.navigationController?.popViewController(animated: true) } } } diff --git a/Mixin/UserInterface/Windows/UrlWindow.swift b/Mixin/UserInterface/Windows/UrlWindow.swift index 2697ea8258..2de79dad94 100644 --- a/Mixin/UserInterface/Windows/UrlWindow.swift +++ b/Mixin/UserInterface/Windows/UrlWindow.swift @@ -888,17 +888,22 @@ class UrlWindow { UIApplication.currentActivity()?.alert(R.string.localizable.unable_synced_between_different_account()) return true } - let client = DeviceTransferClient(hostname: context.hostname, - port: context.port, - code: context.code, - key: context.key, - remotePlatform: command.platform) - client.start() - let progress = DeviceTransferProgressViewController(connection: .client(client, .phone)) - if let navigationController = AppDelegate.current.mainWindow.rootViewController as? UINavigationController { - navigationController.pushViewController(progress, animated: true) - } else { - AppDelegate.current.mainWindow.rootViewController?.present(progress, animated: true) + do { + let client = try DeviceTransferClient(hostname: context.hostname, + port: context.port, + code: context.code, + key: context.key, + remotePlatform: command.platform) + client.start() + let progress = DeviceTransferProgressViewController(connection: .client(client, .phone)) + if let navigationController = AppDelegate.current.mainWindow.rootViewController as? UINavigationController { + navigationController.pushViewController(progress, animated: true) + } else { + AppDelegate.current.mainWindow.rootViewController?.present(progress, animated: true) + } + } catch { + Logger.general.error(category: "UrlWindow", message: "Unable to init client: \(error)") + UIApplication.currentActivity()?.alert(R.string.localizable.connection_establishment_failed()) } return true } diff --git a/MixinServices/MixinServices/Crypto/AESCryptor.swift b/MixinServices/MixinServices/Crypto/AESCryptor.swift index 1c33a97dcb..1016e2be83 100644 --- a/MixinServices/MixinServices/Crypto/AESCryptor.swift +++ b/MixinServices/MixinServices/Crypto/AESCryptor.swift @@ -126,10 +126,11 @@ extension AESCryptor { guard ivPlusCipher.count > ivSize else { throw Error.badInput } - return try crypt(input: ivPlusCipher[ivSize...], + let firstCipherIndex = ivPlusCipher.startIndex.advanced(by: ivSize) + return try crypt(input: ivPlusCipher[firstCipherIndex...], operation: CCOperation(kCCDecrypt), key: key, - iv: ivPlusCipher[0.. Data { diff --git a/MixinServices/MixinServices/Database/User/DAO/AppDAO.swift b/MixinServices/MixinServices/Database/User/DAO/AppDAO.swift index 986108d201..1e19bc678c 100644 --- a/MixinServices/MixinServices/Database/User/DAO/AppDAO.swift +++ b/MixinServices/MixinServices/Database/User/DAO/AppDAO.swift @@ -44,8 +44,8 @@ public final class AppDAO: UserDatabaseDAO { return db.select(with: sql, arguments: [limit]) } - public func appsCount() -> Int { - let count: Int? = db.select(with: "SELECT COUNT(*) FROM apps") + public func appsCount() -> Int64 { + let count: Int64? = db.select(with: "SELECT COUNT(*) FROM apps") return count ?? 0 } diff --git a/MixinServices/MixinServices/Database/User/DAO/AssetDAO.swift b/MixinServices/MixinServices/Database/User/DAO/AssetDAO.swift index 87de4889f4..36d5e23c71 100644 --- a/MixinServices/MixinServices/Database/User/DAO/AssetDAO.swift +++ b/MixinServices/MixinServices/Database/User/DAO/AssetDAO.swift @@ -113,8 +113,8 @@ public final class AssetDAO: UserDatabaseDAO { return db.select(with: sql, arguments: [limit]) } - public func assetsCount() -> Int { - let count: Int? = db.select(with: "SELECT COUNT(*) FROM assets") + public func assetsCount() -> Int64 { + let count: Int64? = db.select(with: "SELECT COUNT(*) FROM assets") return count ?? 0 } diff --git a/MixinServices/MixinServices/Database/User/DAO/ConversationDAO.swift b/MixinServices/MixinServices/Database/User/DAO/ConversationDAO.swift index 45026fb0e4..295b2fccf6 100644 --- a/MixinServices/MixinServices/Database/User/DAO/ConversationDAO.swift +++ b/MixinServices/MixinServices/Database/User/DAO/ConversationDAO.swift @@ -668,8 +668,8 @@ public final class ConversationDAO: UserDatabaseDAO { return db.select(with: sql, arguments: [limit]) } - public func conversationsCount() -> Int { - let count: Int? = db.select(with: "SELECT COUNT(*) FROM conversations") + public func conversationsCount() -> Int64 { + let count: Int64? = db.select(with: "SELECT COUNT(*) FROM conversations") return count ?? 0 } diff --git a/MixinServices/MixinServices/Database/User/DAO/ExpiredMessageDAO.swift b/MixinServices/MixinServices/Database/User/DAO/ExpiredMessageDAO.swift index 49db63fa0a..eeb2d396de 100644 --- a/MixinServices/MixinServices/Database/User/DAO/ExpiredMessageDAO.swift +++ b/MixinServices/MixinServices/Database/User/DAO/ExpiredMessageDAO.swift @@ -152,8 +152,8 @@ public final class ExpiredMessageDAO: UserDatabaseDAO { return db.select(with: sql, arguments: [limit]) } - public func expiredMessagesCount() -> Int { - let count: Int? = db.select(with: "SELECT COUNT(*) FROM expired_messages") + public func expiredMessagesCount() -> Int64 { + let count: Int64? = db.select(with: "SELECT COUNT(*) FROM expired_messages") return count ?? 0 } diff --git a/MixinServices/MixinServices/Database/User/DAO/MessageDAO.swift b/MixinServices/MixinServices/Database/User/DAO/MessageDAO.swift index 9611c9d115..20c08f1fa6 100644 --- a/MixinServices/MixinServices/Database/User/DAO/MessageDAO.swift +++ b/MixinServices/MixinServices/Database/User/DAO/MessageDAO.swift @@ -989,8 +989,8 @@ extension MessageDAO { return db.select(with: sql, arguments: [limit]) } - public func messagesCount() -> Int { - let count: Int? = db.select(with: "SELECT COUNT(*) FROM messages") + public func messagesCount() -> Int64 { + let count: Int64? = db.select(with: "SELECT COUNT(*) FROM messages") return count ?? 0 } @@ -1000,26 +1000,28 @@ extension MessageDAO { order: [Message.column(of: .createdAt).desc]) } - public func save(message: Message) { + public func save(messages: [Message]) { db.write { db in - let exists = try message.exists(db) - guard !exists else { - return - } - try message.save(db) - let shouldInsertIntoFTSTable = AppGroupUserDefaults.Database.isFTSInitialized - && message.status != MessageStatus.FAILED.rawValue - && MessageCategory.ftsAvailableCategoryStrings.contains(message.category) - if shouldInsertIntoFTSTable { - let children: [TranscriptMessage]? - if message.category.hasSuffix("_TRANSCRIPT") { - children = try TranscriptMessage - .filter(TranscriptMessage.column(of: .transcriptId) == message.messageId) - .fetchAll(db) - } else { - children = nil + for message in messages { + let exists = try message.exists(db) + guard !exists else { + continue + } + try message.save(db) + let shouldInsertIntoFTSTable = AppGroupUserDefaults.Database.isFTSInitialized + && message.status != MessageStatus.FAILED.rawValue + && MessageCategory.ftsAvailableCategoryStrings.contains(message.category) + if shouldInsertIntoFTSTable { + let children: [TranscriptMessage]? + if message.category.hasSuffix("_TRANSCRIPT") { + children = try TranscriptMessage + .filter(TranscriptMessage.column(of: .transcriptId) == message.messageId) + .fetchAll(db) + } else { + children = nil + } + try insertFTSContent(db, message: message, children: children) } - try insertFTSContent(db, message: message, children: children) } } } diff --git a/MixinServices/MixinServices/Database/User/DAO/MessageMentionDAO.swift b/MixinServices/MixinServices/Database/User/DAO/MessageMentionDAO.swift index 3b7540ab5f..c54815f56b 100644 --- a/MixinServices/MixinServices/Database/User/DAO/MessageMentionDAO.swift +++ b/MixinServices/MixinServices/Database/User/DAO/MessageMentionDAO.swift @@ -24,8 +24,8 @@ public final class MessageMentionDAO: UserDatabaseDAO { return db.select(with: sql, arguments: [limit]) } - public func messageMentionsCount() -> Int { - let count: Int? = db.select(with: "SELECT COUNT(*) FROM message_mentions") + public func messageMentionsCount() -> Int64 { + let count: Int64? = db.select(with: "SELECT COUNT(*) FROM message_mentions") return count ?? 0 } diff --git a/MixinServices/MixinServices/Database/User/DAO/ParticipantDAO.swift b/MixinServices/MixinServices/Database/User/DAO/ParticipantDAO.swift index 835a16c125..13ddb6b2a0 100644 --- a/MixinServices/MixinServices/Database/User/DAO/ParticipantDAO.swift +++ b/MixinServices/MixinServices/Database/User/DAO/ParticipantDAO.swift @@ -184,8 +184,8 @@ public final class ParticipantDAO: UserDatabaseDAO { return db.select(with: sql, arguments: [limit]) } - public func participantsCount() -> Int { - let count: Int? = db.select(with: "SELECT COUNT(*) FROM participants") + public func participantsCount() -> Int64 { + let count: Int64? = db.select(with: "SELECT COUNT(*) FROM participants") return count ?? 0 } diff --git a/MixinServices/MixinServices/Database/User/DAO/PinMessageDAO.swift b/MixinServices/MixinServices/Database/User/DAO/PinMessageDAO.swift index 9809fdd072..f8990652ca 100644 --- a/MixinServices/MixinServices/Database/User/DAO/PinMessageDAO.swift +++ b/MixinServices/MixinServices/Database/User/DAO/PinMessageDAO.swift @@ -122,8 +122,8 @@ public final class PinMessageDAO: UserDatabaseDAO { return db.select(with: sql, arguments: [limit]) } - public func pinMessagesCount() -> Int { - let count: Int? = db.select(with: "SELECT COUNT(*) FROM pin_messages") + public func pinMessagesCount() -> Int64 { + let count: Int64? = db.select(with: "SELECT COUNT(*) FROM pin_messages") return count ?? 0 } diff --git a/MixinServices/MixinServices/Database/User/DAO/SnapshotDAO.swift b/MixinServices/MixinServices/Database/User/DAO/SnapshotDAO.swift index 3b03d67012..50cbb983c0 100644 --- a/MixinServices/MixinServices/Database/User/DAO/SnapshotDAO.swift +++ b/MixinServices/MixinServices/Database/User/DAO/SnapshotDAO.swift @@ -125,8 +125,8 @@ public final class SnapshotDAO: UserDatabaseDAO { return db.select(with: sql, arguments: [limit]) } - public func snapshotsCount() -> Int { - let count: Int? = db.select(with: "SELECT COUNT(*) FROM snapshots") + public func snapshotsCount() -> Int64 { + let count: Int64? = db.select(with: "SELECT COUNT(*) FROM snapshots") return count ?? 0 } diff --git a/MixinServices/MixinServices/Database/User/DAO/StickerDAO.swift b/MixinServices/MixinServices/Database/User/DAO/StickerDAO.swift index e17f27c9dc..34a72250e7 100644 --- a/MixinServices/MixinServices/Database/User/DAO/StickerDAO.swift +++ b/MixinServices/MixinServices/Database/User/DAO/StickerDAO.swift @@ -172,8 +172,8 @@ public final class StickerDAO: UserDatabaseDAO { return db.select(with: sql, arguments: [limit]) } - public func stickersCount() -> Int { - let count: Int? = db.select(with: "SELECT COUNT(*) FROM stickers") + public func stickersCount() -> Int64 { + let count: Int64? = db.select(with: "SELECT COUNT(*) FROM stickers") return count ?? 0 } diff --git a/MixinServices/MixinServices/Database/User/DAO/TranscriptMessageDAO.swift b/MixinServices/MixinServices/Database/User/DAO/TranscriptMessageDAO.swift index 8eef4a5c9a..e64330fcd9 100644 --- a/MixinServices/MixinServices/Database/User/DAO/TranscriptMessageDAO.swift +++ b/MixinServices/MixinServices/Database/User/DAO/TranscriptMessageDAO.swift @@ -99,8 +99,8 @@ public final class TranscriptMessageDAO: UserDatabaseDAO { return db.select(with: sql, arguments: [limit]) } - public func transcriptMessagesCount() -> Int { - let count: Int? = db.select(with: "SELECT COUNT(*) FROM transcript_messages") + public func transcriptMessagesCount() -> Int64 { + let count: Int64? = db.select(with: "SELECT COUNT(*) FROM transcript_messages") return count ?? 0 } diff --git a/MixinServices/MixinServices/Database/User/DAO/UserDAO.swift b/MixinServices/MixinServices/Database/User/DAO/UserDAO.swift index 28fc0a20aa..f45485d069 100644 --- a/MixinServices/MixinServices/Database/User/DAO/UserDAO.swift +++ b/MixinServices/MixinServices/Database/User/DAO/UserDAO.swift @@ -230,8 +230,8 @@ public final class UserDAO: UserDatabaseDAO { return db.select(with: sql, arguments: [limit]) } - public func usersCount() -> Int { - let count: Int? = db.select(with: "SELECT COUNT(*) FROM users") + public func usersCount() -> Int64 { + let count: Int64? = db.select(with: "SELECT COUNT(*) FROM users") return count ?? 0 } diff --git a/MixinServices/MixinServices/Services/WebSocket/Model/DeviceTransferCommand.swift b/MixinServices/MixinServices/Services/WebSocket/Model/DeviceTransferCommand.swift index c8940fb076..7533756a7f 100644 --- a/MixinServices/MixinServices/Services/WebSocket/Model/DeviceTransferCommand.swift +++ b/MixinServices/MixinServices/Services/WebSocket/Model/DeviceTransferCommand.swift @@ -25,9 +25,9 @@ public struct DeviceTransferCommand { public enum Action { case pull case push(PushContext) - case start(Int) + case start(Int64) case connect(code: UInt16, userID: String) - case progress(Double) + case progress(Float) // `progress` is between 0.0 and 1.0, encoded to / decoded from between 0.0 and 100.0 case cancel case finish } @@ -115,15 +115,15 @@ extension DeviceTransferCommand: Codable { userID: userID) return .push(context) case ActionName.start: - let count = try container.decode(Int.self, forKey: .total) + let count = try container.decode(Int64.self, forKey: .total) return .start(count) case ActionName.connect: let code = try container.decode(UInt16.self, forKey: .code) let userID = try container.decode(String.self, forKey: .userID) return .connect(code: code, userID: userID) case ActionName.progress: - let progress = try container.decode(Double.self, forKey: .progress) - return .progress(progress) + let progress = try container.decode(Float.self, forKey: .progress) + return .progress(progress / 100) case ActionName.cancel: return .cancel case ActionName.finish: @@ -160,7 +160,7 @@ extension DeviceTransferCommand: Codable { try container.encode(userID, forKey: .userID) case let .progress(progress): try container.encode(ActionName.progress, forKey: .action) - try container.encode(progress, forKey: .progress) + try container.encode(progress * 100, forKey: .progress) case .cancel: try container.encode(ActionName.cancel, forKey: .action) case .finish: