Фикс: upload ring progress доходит до 100% + completion notification через userInfo
This commit is contained in:
@@ -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 }
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 == [])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user