Фикс: upload ring progress доходит до 100% + completion notification через userInfo

This commit is contained in:
2026-04-17 08:28:39 +05:00
parent 88126c8673
commit 2adce86528
7 changed files with 109 additions and 25 deletions

View File

@@ -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<String> = []
/// 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 }

View File

@@ -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

View File

@@ -2820,14 +2820,12 @@ 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 {
// Ring hides either here (deliveryStatus changed) or via "completed"
// notification in the observer. No 2-second guard needed.
updatePhotoUploadingOverlay(isVisible: false)
uploadOverlayShowTime = 0
}
}
}
/// Downloads forwarded images from CDN using forwardChachaKeyPlain for decryption.
/// Converts ReplyAttachmentData images MessageAttachment and reuses existing photo pipeline.
@@ -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 {

View File

@@ -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 {

View File

@@ -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()
}

View File

@@ -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")

View File

@@ -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 == [])
}
}