From 2adce86528cdeffdd52a6100ff19e95ee5573975 Mon Sep 17 00:00:00 2001 From: senseiGai Date: Fri, 17 Apr 2026 08:28:39 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=B8=D0=BA=D1=81:=20upload=20ring=20pro?= =?UTF-8?q?gress=20=D0=B4=D0=BE=D1=85=D0=BE=D0=B4=D0=B8=D1=82=20=D0=B4?= =?UTF-8?q?=D0=BE=20100%=20+=20completion=20notification=20=D1=87=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=20userInfo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Repositories/AvatarRepository.swift | 60 +++++++++++++++++++ Rosetta/Core/Services/SessionManager.swift | 10 +++- .../Chats/ChatDetail/NativeMessageCell.swift | 31 ++++++---- .../ChatDetail/VoiceRecordingParityMath.swift | 2 +- RosettaTests/AttachmentParityTests.swift | 4 ++ .../ForegroundNotificationTests.swift | 14 +++-- RosettaTests/PushNotificationAuditTests.swift | 13 ++-- 7 files changed, 109 insertions(+), 25 deletions(-) diff --git a/Rosetta/Core/Data/Repositories/AvatarRepository.swift b/Rosetta/Core/Data/Repositories/AvatarRepository.swift index 89f41d4..2128d9a 100644 --- a/Rosetta/Core/Data/Repositories/AvatarRepository.swift +++ b/Rosetta/Core/Data/Repositories/AvatarRepository.swift @@ -72,6 +72,8 @@ final class AvatarRepository { } /// Loads avatar for the given public key. + /// NOTE: This does synchronous disk I/O + crypto decrypt on cache miss. + /// For hot paths (cell configure, layoutSubviews), use `cachedAvatar()` + `loadAvatarAsync()`. func loadAvatar(publicKey: String) -> UIImage? { if let systemAvatar = systemAccountAvatar(for: publicKey) { return systemAvatar @@ -82,6 +84,64 @@ final class AvatarRepository { // happens on disk load (below). Cache hits fire 50+/sec during scroll. return cached } + return loadFromDiskSync(normalizedKey: key, publicKey: publicKey) + } + + /// Cache-only avatar lookup — O(1), no disk I/O, no crypto. + /// Use this in hot paths (cell configure, layoutSubviews) paired with `loadAvatarAsync()`. + func cachedAvatar(publicKey: String) -> UIImage? { + if let systemAvatar = systemAccountAvatar(for: publicKey) { + return systemAvatar + } + let key = normalizedKey(publicKey) + return cache.object(forKey: key as NSString) + } + + /// Keys currently being loaded from disk — prevents duplicate background loads. + private var pendingAsyncLoads: Set = [] + + /// Async avatar load: disk I/O + crypto decrypt on background queue. + /// Completion called on MainActor with the decoded image (or nil). + /// Deduplicates: concurrent calls for the same key are coalesced. + func loadAvatarAsync(publicKey: String, completion: @escaping (UIImage?) -> Void) { + if systemAccountAvatar(for: publicKey) != nil { return } + let key = normalizedKey(publicKey) + if cache.object(forKey: key as NSString) != nil { return } // Already cached + guard !pendingAsyncLoads.contains(key) else { return } // Already loading + pendingAsyncLoads.insert(key) + + let url = avatarURL(for: key) + let password = Self.avatarPassword + DispatchQueue.global(qos: .utility).async { [weak self] in + let image = Self.decryptAvatarFromDisk(url: url, password: password) + DispatchQueue.main.async { + guard let self else { return } + self.pendingAsyncLoads.remove(key) + if let image { + self.cache.setObject(image, forKey: key as NSString, + cost: Int(image.size.width * image.size.height * 4)) + self.syncAvatarToNotificationStoreIfNeeded(image, normalizedKey: key) + } + completion(image) + } + } + } + + /// Disk load + decrypt (runs on ANY thread — no MainActor dependency). + private nonisolated static func decryptAvatarFromDisk(url: URL, password: String) -> UIImage? { + guard let fileData = try? Data(contentsOf: url) else { return nil } + // Try encrypted format + if let encryptedString = String(data: fileData, encoding: .utf8), + let decrypted = try? CryptoManager.shared.decryptWithPassword(encryptedString, password: password), + let image = UIImage(data: decrypted) { + return image + } + // Fallback: plaintext JPEG (legacy) + return UIImage(data: fileData) + } + + /// Synchronous disk load — used by non-hot-path callers (loadAvatar). + private func loadFromDiskSync(normalizedKey key: String, publicKey: String) -> UIImage? { let url = avatarURL(for: key) guard let fileData = try? Data(contentsOf: url) else { return nil } diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift index 9f2809d..70d5723 100644 --- a/Rosetta/Core/Services/SessionManager.swift +++ b/Rosetta/Core/Services/SessionManager.swift @@ -838,7 +838,8 @@ final class SessionManager { TransportManager.uploadProgress[capturedMessageId] = combined NotificationCenter.default.post( name: TransportManager.uploadProgressNotification, - object: capturedMessageId + object: capturedMessageId, + userInfo: ["progress": combined] ) } ) @@ -863,6 +864,13 @@ final class SessionManager { } } + // Signal upload completion → ring goes to 100% and hides. + // Post BEFORE clearing dict so observer gets the value via userInfo. + NotificationCenter.default.post( + name: TransportManager.uploadProgressNotification, + object: messageId, + userInfo: ["progress": 1.0, "completed": true] + ) TransportManager.uploadProgress.removeValue(forKey: messageId) // Bail out if send was cancelled during upload diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift index 1bc4f54..1269df0 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift @@ -2820,12 +2820,10 @@ final class NativeMessageCell: UICollectionViewCell { updatePhotoUploadingOverlay(isVisible: true) if uploadOverlayShowTime == 0 { uploadOverlayShowTime = CACurrentMediaTime() } } else { - // Don't hide overlay during fast reconfigure — keep visible for at least 2s - let elapsed = CACurrentMediaTime() - uploadOverlayShowTime - if elapsed > 2.0 || uploadOverlayShowTime == 0 { - updatePhotoUploadingOverlay(isVisible: false) - uploadOverlayShowTime = 0 - } + // Ring hides either here (deliveryStatus changed) or via "completed" + // notification in the observer. No 2-second guard needed. + updatePhotoUploadingOverlay(isVisible: false) + uploadOverlayShowTime = 0 } } @@ -3294,7 +3292,8 @@ final class NativeMessageCell: UICollectionViewCell { DialogRepository.shared.updateDialogFromMessages(opponentKey: opponentKey) } } - // Subscribe to real upload progress + // Subscribe to real upload progress (reads from notification userInfo + // to avoid race with TransportManager.uploadProgress dict cleanup) if uploadProgressObserver == nil { uploadProgressObserver = NotificationCenter.default.addObserver( forName: TransportManager.uploadProgressNotification, @@ -3303,12 +3302,18 @@ final class NativeMessageCell: UICollectionViewCell { guard let self, let msgId = notif.object as? String, msgId == self.message?.id, - let progress = TransportManager.uploadProgress[msgId] else { return } - // Cap visual progress at 85% — ring completes when deliveryStatus - // changes to .delivered and overlay hides. Gives perception of - // longer upload even on fast connections. - let visual = min(CGFloat(progress) * 0.85, 0.85) - self.photoUploadingRing.setProgress(visual) + let progress = notif.userInfo?["progress"] as? Double else { return } + + if notif.userInfo?["completed"] as? Bool == true { + // Upload done → show 100% then hide ring + self.photoUploadingRing.setProgress(1.0) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in + self?.updatePhotoUploadingOverlay(isVisible: false) + self?.uploadOverlayShowTime = 0 + } + } else { + self.photoUploadingRing.setProgress(CGFloat(progress)) + } } } } else { diff --git a/Rosetta/Features/Chats/ChatDetail/VoiceRecordingParityMath.swift b/Rosetta/Features/Chats/ChatDetail/VoiceRecordingParityMath.swift index e102052..dce52d6 100644 --- a/Rosetta/Features/Chats/ChatDetail/VoiceRecordingParityMath.swift +++ b/Rosetta/Features/Chats/ChatDetail/VoiceRecordingParityMath.swift @@ -22,7 +22,7 @@ enum VoiceRecordingParityConstants { static let cancelTransformThreshold: CGFloat = 8 static let sendAccessibilityHitSize: CGFloat = 120 - static let minVoiceDuration: TimeInterval = 1.0 + static let minVoiceDuration: TimeInterval = 0.5 static let minFreeDiskBytes: Int64 = 8 * 1024 * 1024 static func minTrimDuration(duration: TimeInterval, waveformWidth: CGFloat) -> TimeInterval { diff --git a/RosettaTests/AttachmentParityTests.swift b/RosettaTests/AttachmentParityTests.swift index c5cb06a..80af19e 100644 --- a/RosettaTests/AttachmentParityTests.swift +++ b/RosettaTests/AttachmentParityTests.swift @@ -168,6 +168,10 @@ private final class MockAttachmentFlowTransport: AttachmentFlowTransporting { return (tag: tag, server: "https://mock-transport.test") } + func uploadFile(id: String, content: Data, onProgress: (@MainActor (Double) -> Void)?) async throws -> (tag: String, server: String) { + try await uploadFile(id: id, content: content) + } + func downloadFile(tag: String, server: String?) async throws -> Data { Data() } diff --git a/RosettaTests/ForegroundNotificationTests.swift b/RosettaTests/ForegroundNotificationTests.swift index 543604b..d3dbdef 100644 --- a/RosettaTests/ForegroundNotificationTests.swift +++ b/RosettaTests/ForegroundNotificationTests.swift @@ -105,15 +105,17 @@ struct ForegroundNotificationTests { @Test("Muted chat is suppressed") func mutedChatSuppressed() { clearActiveDialogs() - // Set up muted chat in App Group - let shared = UserDefaults(suiteName: "group.com.rosetta.dev") - let originalMuted = shared?.stringArray(forKey: "muted_chats_keys") - shared?.set(["02muted"], forKey: "muted_chats_keys") + // Production checks DialogRepository (in-memory), not UserDefaults. + DialogRepository.shared.ensureDialog( + opponentKey: "02muted", title: "", username: "", myPublicKey: "me" + ) + DialogRepository.shared.toggleMute(opponentKey: "02muted") #expect(InAppNotificationManager.shouldSuppress(senderKey: "02muted") == true) - // Restore - shared?.set(originalMuted, forKey: "muted_chats_keys") + // Restore — toggleMute back to unmuted, then remove via ensureDialog pattern isn't needed + // since dialogs dict is in-memory and won't persist in test. + DialogRepository.shared.toggleMute(opponentKey: "02muted") } @Test("Non-muted chat is NOT suppressed") diff --git a/RosettaTests/PushNotificationAuditTests.swift b/RosettaTests/PushNotificationAuditTests.swift index 89cf88c..b71b882 100644 --- a/RosettaTests/PushNotificationAuditTests.swift +++ b/RosettaTests/PushNotificationAuditTests.swift @@ -667,11 +667,15 @@ struct PushInAppBannerSuppressionExtendedTests { @Test("Muted but NOT active — still suppressed") func mutedNotActiveSuppressed() { clearState() - shared?.set(["02muted_only"], forKey: "muted_chats_keys") + // Production checks DialogRepository (in-memory), not UserDefaults. + DialogRepository.shared.ensureDialog( + opponentKey: "02muted_only", title: "", username: "", myPublicKey: "me" + ) + DialogRepository.shared.toggleMute(opponentKey: "02muted_only") #expect(InAppNotificationManager.shouldSuppress(senderKey: "02muted_only") == true) - shared?.removeObject(forKey: "muted_chats_keys") + DialogRepository.shared.toggleMute(opponentKey: "02muted_only") } @Test("Active but NOT muted — suppressed (active chat open)") @@ -690,11 +694,12 @@ struct PushInAppBannerSuppressionExtendedTests { #expect(InAppNotificationManager.shouldSuppress(senderKey: "02normal") == false) } - @Test("System presentation returns banner+sound for non-suppressed chats") + @Test("System presentation suppresses all foreground notifications (Telegram parity)") func systemPresentationShowsBanner() { clearState() + // Telegram parity: all foreground notifications suppressed — messages arrive via WebSocket. let options = AppDelegate.foregroundPresentationOptions(for: ["dialog": "02any_user"]) - #expect(options == [.banner, .sound]) + #expect(options == []) } }