diff --git a/Rosetta.xcodeproj/project.pbxproj b/Rosetta.xcodeproj/project.pbxproj index aa03ce4..2861608 100644 --- a/Rosetta.xcodeproj/project.pbxproj +++ b/Rosetta.xcodeproj/project.pbxproj @@ -7,7 +7,6 @@ objects = { /* Begin PBXBuildFile section */ - A1B2C3D4E5F60718293A4B5C /* DeliveryReliabilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5D /* DeliveryReliabilityTests.swift */; }; 3146EDCE68162995CB5D1034 /* BehaviorParityFixtureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9FC5C4F7E26FAFEC47C1D51 /* BehaviorParityFixtureTests.swift */; }; 3C4D5E6F708192A3B4C5D6E7 /* AttachmentParityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F708192A3B4C5 /* AttachmentParityTests.swift */; }; 4C9BDB443750F7003CFB705C /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 272B862BE4D99E7DD751CC3E /* Foundation.framework */; }; @@ -16,12 +15,9 @@ 853F29992F4B63D20092AD05 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 853F29982F4B63D20092AD05 /* Lottie */; }; 853F29A02F4B63D20092AD05 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 853F29A12F4B63D20092AD05 /* P256K */; }; 85E887F72F6DC9460032774C /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = D1DB00022F8C00010092AD05 /* GRDB */; }; + A1B2C3D4E5F60718293A4B5C /* DeliveryReliabilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5D /* DeliveryReliabilityTests.swift */; }; B7F1C2D34A5E67890ABCDEF1 /* CryptoParityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9A1B2C3D4E5F60718293A4B /* CryptoParityTests.swift */; }; C8E2D3F45B6A78901BCDEF12 /* MessageDecodeHardeningTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAF1B2C3D4E5F60718293A4B /* MessageDecodeHardeningTests.swift */; }; - F0B1C2D3E4F5061728394A41 /* PendingChatRouteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A1B2C3D4E5F60718293A41 /* PendingChatRouteTests.swift */; }; - F0B1C2D3E4F5061728394A42 /* PushNotificationPacketTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A1B2C3D4E5F60718293A42 /* PushNotificationPacketTests.swift */; }; - F0B1C2D3E4F5061728394A43 /* ForegroundNotificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A1B2C3D4E5F60718293A43 /* ForegroundNotificationTests.swift */; }; - F0B1C2D3E4F5061728394A44 /* PushNotificationAuditTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A1B2C3D4E5F60718293A44 /* PushNotificationAuditTests.swift */; }; CC5AD9236E3B3BA95A0C29EC /* DBTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D3AF08B754B66DE17AF486D /* DBTestSupport.swift */; }; D0BD72A9646880B604F1AC3C /* RosettaNotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A182B0EDE5C68E7C6F1FB6D1 /* RosettaNotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; D60B2E657D691F256B5B7FD4 /* SchemaParityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F4769EEC8ABADB3AA98D3A5 /* SchemaParityTests.swift */; }; @@ -29,6 +25,10 @@ E20000032F8D11110092AD05 /* WebRTC.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = E20000042F8D11110092AD05 /* WebRTC.xcframework */; }; E20000062F8D11110092AD05 /* WebRTC.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = E20000042F8D11110092AD05 /* WebRTC.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; EC5DFA298C697AE235323240 /* MigrationHarnessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAA4AD95B61886B5A22EF0D /* MigrationHarnessTests.swift */; }; + F0B1C2D3E4F5061728394A41 /* PendingChatRouteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A1B2C3D4E5F60718293A41 /* PendingChatRouteTests.swift */; }; + F0B1C2D3E4F5061728394A42 /* PushNotificationPacketTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A1B2C3D4E5F60718293A42 /* PushNotificationPacketTests.swift */; }; + F0B1C2D3E4F5061728394A43 /* ForegroundNotificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A1B2C3D4E5F60718293A43 /* ForegroundNotificationTests.swift */; }; + F0B1C2D3E4F5061728394A44 /* PushNotificationAuditTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A1B2C3D4E5F60718293A44 /* PushNotificationAuditTests.swift */; }; F1A000012F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000022F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport */; }; F1A000032F6F00010092AD05 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000042F6F00010092AD05 /* FirebaseMessaging */; }; F1A000062F6F00010092AD05 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000072F6F00010092AD05 /* FirebaseCrashlytics */; }; @@ -99,7 +99,6 @@ /* Begin PBXFileReference section */ 0F43A41D5496A62870E307FC /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; - A1B2C3D4E5F60718293A4B5D /* DeliveryReliabilityTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeliveryReliabilityTests.swift; sourceTree = ""; }; 1A2B3C4D5E6F708192A3B4C5 /* AttachmentParityTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AttachmentParityTests.swift; sourceTree = ""; }; 272B862BE4D99E7DD751CC3E /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; 2B3C4D5E6F708192A3B4C5D6 /* SearchParityTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SearchParityTests.swift; sourceTree = ""; }; @@ -109,6 +108,7 @@ 853F29622F4B50410092AD05 /* Rosetta.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Rosetta.app; sourceTree = BUILT_PRODUCTS_DIR; }; 93685A4F330DCD1B63EF121F /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; A182B0EDE5C68E7C6F1FB6D1 /* RosettaNotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = RosettaNotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + A1B2C3D4E5F60718293A4B5D /* DeliveryReliabilityTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeliveryReliabilityTests.swift; sourceTree = ""; }; C9FC5C4F7E26FAFEC47C1D51 /* BehaviorParityFixtureTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BehaviorParityFixtureTests.swift; sourceTree = ""; }; D9A1B2C3D4E5F60718293A4B /* CryptoParityTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CryptoParityTests.swift; sourceTree = ""; }; DBAA4AD95B61886B5A22EF0D /* MigrationHarnessTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MigrationHarnessTests.swift; sourceTree = ""; }; @@ -641,7 +641,7 @@ CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 32; + CURRENT_PROJECT_VERSION = 33; DEVELOPMENT_TEAM = QN8Z263QGX; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -657,7 +657,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.1; + MARKETING_VERSION = 1.3.2; PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -681,7 +681,7 @@ CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 32; + CURRENT_PROJECT_VERSION = 33; DEVELOPMENT_TEAM = QN8Z263QGX; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -697,7 +697,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.1; + MARKETING_VERSION = 1.3.2; PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -841,7 +841,7 @@ C19929D9466573F31997B2C0 /* Release */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Debug; + defaultConfigurationName = Release; }; 853F295D2F4B50410092AD05 /* Build configuration list for PBXProject "Rosetta" */ = { isa = XCConfigurationList; @@ -850,7 +850,7 @@ 853F296C2F4B50420092AD05 /* Release */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Debug; + defaultConfigurationName = Release; }; 853F296D2F4B50420092AD05 /* Build configuration list for PBXNativeTarget "Rosetta" */ = { isa = XCConfigurationList; @@ -859,7 +859,7 @@ 853F296F2F4B50420092AD05 /* Release */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Debug; + defaultConfigurationName = Release; }; B5D2E60ADEB8AE2E8F7615C6 /* Build configuration list for PBXNativeTarget "RosettaNotificationService" */ = { isa = XCConfigurationList; @@ -868,7 +868,7 @@ 0140D6320A9CF4B5E933E0B1 /* Debug */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Debug; + defaultConfigurationName = Release; }; LA00000062F8D22220092AD05 /* Build configuration list for PBXNativeTarget "RosettaLiveActivityWidget" */ = { isa = XCConfigurationList; @@ -877,7 +877,7 @@ LA00000082F8D22220092AD05 /* Release */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Debug; + defaultConfigurationName = Release; }; /* End XCConfigurationList section */ diff --git a/Rosetta.xcodeproj/xcshareddata/xcschemes/Rosetta.xcscheme b/Rosetta.xcodeproj/xcshareddata/xcschemes/Rosetta.xcscheme index 39a5209..a4e863c 100644 --- a/Rosetta.xcodeproj/xcshareddata/xcschemes/Rosetta.xcscheme +++ b/Rosetta.xcodeproj/xcshareddata/xcschemes/Rosetta.xcscheme @@ -66,7 +66,7 @@ ? - private var soundPlayer: AVAudioPlayer? - - private static let autoDismissSeconds: UInt64 = 5 - - private init() {} - - // MARK: - Data Model - - struct InAppNotification: Identifiable, Equatable { - let id: UUID - let senderKey: String - let senderName: String - let messagePreview: String - let avatar: UIImage? - let avatarColorIndex: Int - let initials: String - - static func == (lhs: InAppNotification, rhs: InAppNotification) -> Bool { - lhs.id == rhs.id - } - } - - // MARK: - Public API - - /// Called from `willPresent` — extracts sender info and shows banner if appropriate. - func show(userInfo: [AnyHashable: Any]) { - let senderKey = userInfo["dialog"] as? String - ?? AppDelegate.extractSenderKey(from: userInfo) - - // --- Suppression logic (Telegram parity) --- - guard !senderKey.isEmpty else { return } - guard !MessageRepository.shared.isDialogActive(senderKey) else { return } - - let mutedKeys = UserDefaults(suiteName: "group.com.rosetta.dev")? - .stringArray(forKey: "muted_chats_keys") ?? [] - guard !mutedKeys.contains(senderKey) else { return } - - // --- Resolve display data --- - let contactNames = UserDefaults(suiteName: "group.com.rosetta.dev")? - .dictionary(forKey: "contact_display_names") as? [String: String] ?? [:] - let name = contactNames[senderKey] - ?? firstString(userInfo, keys: ["title", "sender_name", "from_title", "name"]) - ?? "Rosetta" - - let preview = extractMessagePreview(from: userInfo) - let avatar = AvatarRepository.shared.loadAvatar(publicKey: senderKey) - let dialog = DialogRepository.shared.dialogs[senderKey] - - let notification = InAppNotification( - id: UUID(), - senderKey: senderKey, - senderName: name, - messagePreview: preview, - avatar: avatar, - avatarColorIndex: dialog?.avatarColorIndex ?? abs(senderKey.hashValue) % 11, - initials: dialog?.initials ?? String(name.prefix(1)).uppercased() - ) - - withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { - currentNotification = notification - } - - playNotificationSound() - playHaptic() - scheduleAutoDismiss() - } - - func dismiss() { - dismissTask?.cancel() - withAnimation(.easeOut(duration: 0.25)) { - currentNotification = nil - } - } - - /// Testable: checks whether a notification should be suppressed. + /// Returns `true` if the notification should be suppressed (no banner). static func shouldSuppress(senderKey: String) -> Bool { if senderKey.isEmpty { return true } if MessageRepository.shared.isDialogActive(senderKey) { return true } @@ -96,48 +13,4 @@ final class InAppNotificationManager: ObservableObject { if mutedKeys.contains(senderKey) { return true } return false } - - // MARK: - Private - - private func scheduleAutoDismiss() { - dismissTask?.cancel() - dismissTask = Task { - try? await Task.sleep(nanoseconds: Self.autoDismissSeconds * 1_000_000_000) - guard !Task.isCancelled else { return } - dismiss() - } - } - - private func playNotificationSound() { - // System "Tink" haptic feedback sound — lightweight, no custom mp3 needed. - AudioServicesPlaySystemSound(1057) - } - - private func playHaptic() { - // UIImpactFeedbackGenerator style tap via AudioServices. - let generator = UIImpactFeedbackGenerator(style: .light) - generator.impactOccurred() - } - - private func extractMessagePreview(from userInfo: [AnyHashable: Any]) -> String { - // Try notification body directly. - if let body = userInfo["body"] as? String, !body.isEmpty { return body } - // Try aps.alert (can be string or dict). - if let aps = userInfo["aps"] as? [String: Any] { - if let alert = aps["alert"] as? String, !alert.isEmpty { return alert } - if let alertDict = aps["alert"] as? [String: Any], - let body = alertDict["body"] as? String, !body.isEmpty { return body } - } - return "New message" - } - - private func firstString(_ dict: [AnyHashable: Any], keys: [String]) -> String? { - for key in keys { - if let value = dict[key] as? String, - !value.trimmingCharacters(in: .whitespaces).isEmpty { - return value - } - } - return nil - } } diff --git a/Rosetta/Core/Utils/ReleaseNotes.swift b/Rosetta/Core/Utils/ReleaseNotes.swift index 8af5d63..791d70f 100644 --- a/Rosetta/Core/Utils/ReleaseNotes.swift +++ b/Rosetta/Core/Utils/ReleaseNotes.swift @@ -12,11 +12,8 @@ enum ReleaseNotes { version: appVersion, body: """ - **Пуш-уведомления — Telegram-parity и стабильность** - Группировка по чатам (threadIdentifier). Фикс исчезновения части уведомлений при тапе по пушу. NSE фильтрует повторные уведомления от одного отправителя и использует приоритет реальной аватарки из App Group (fallback: letter-avatar). - - **Дедупликация и валидация протокола** - Трёхуровневая защита от дублей (queue + process + DB). Улучшена валидация входящих пакетов для защиты от некорректных данных при синхронизации. Forward Picker UI parity. + **Пуш-уведомления** + Только системные баннеры iOS — убраны кастомные in-app оверлеи, звуки и вибрации. Desktop-suppression: если читаешь на компьютере, телефон молчит 30 секунд. """ ) ] diff --git a/Rosetta/DesignSystem/Components/InAppNotificationBanner.swift b/Rosetta/DesignSystem/Components/InAppNotificationBanner.swift deleted file mode 100644 index bf64950..0000000 --- a/Rosetta/DesignSystem/Components/InAppNotificationBanner.swift +++ /dev/null @@ -1,62 +0,0 @@ -import SwiftUI - -/// Telegram-style in-app notification banner with glass background. -/// Shows sender avatar, name, and message preview. Supports swipe-up -/// to dismiss and tap to navigate to the chat. -struct InAppNotificationBanner: View { - - let notification: InAppNotificationManager.InAppNotification - let onTap: () -> Void - let onDismiss: () -> Void - - @State private var dragOffset: CGFloat = 0 - - var body: some View { - HStack(spacing: 12) { - AvatarView( - initials: notification.initials, - colorIndex: notification.avatarColorIndex, - size: 40, - image: notification.avatar - ) - - VStack(alignment: .leading, spacing: 2) { - Text(notification.senderName) - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(Color(RosettaColors.Adaptive.text)) - .lineLimit(1) - - Text(notification.messagePreview) - .font(.system(size: 14)) - .foregroundStyle(Color(RosettaColors.Adaptive.textSecondary)) - .lineLimit(2) - } - - Spacer(minLength: 0) - } - .padding(.horizontal, 14) - .padding(.vertical, 10) - .frame(maxWidth: .infinity) - .background { TelegramGlassRoundedRect(cornerRadius: 16) } - .padding(.horizontal, 8) - .offset(y: min(dragOffset, 0)) - .gesture( - DragGesture(minimumDistance: 8) - .onChanged { value in - // Only allow dragging upward (negative Y). - dragOffset = min(value.translation.height, 0) - } - .onEnded { value in - if value.translation.height < -30 { - onDismiss() - } else { - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { - dragOffset = 0 - } - } - } - ) - .contentShape(Rectangle()) - .onTapGesture { onTap() } - } -} diff --git a/Rosetta/Features/MainTabView.swift b/Rosetta/Features/MainTabView.swift index 5313a13..3b6036a 100644 --- a/Rosetta/Features/MainTabView.swift +++ b/Rosetta/Features/MainTabView.swift @@ -12,7 +12,6 @@ struct MainTabView: View { @State private var isSettingsEditPresented = false @State private var isSettingsDetailPresented = false @StateObject private var callManager = CallManager.shared - @StateObject private var notificationManager = InAppNotificationManager.shared // Add Account — presented as fullScreenCover so Settings stays alive. // Using optional AuthScreen as the item ensures the correct screen is @@ -71,39 +70,6 @@ struct MainTabView: View { } } - // In-app notification banner overlay (Telegram parity). - // Slides from top, auto-dismisses after 5s, tap navigates to chat. - Group { - if let notification = notificationManager.currentNotification { - VStack { - InAppNotificationBanner( - notification: notification, - onTap: { - let route = ChatRoute( - publicKey: notification.senderKey, - title: notification.senderName, - username: "", - verified: 0 - ) - notificationManager.dismiss() - if selectedTab != .chats { - selectedTab = .chats - } - NotificationCenter.default.post( - name: .openChatFromNotification, - object: route - ) - }, - onDismiss: { notificationManager.dismiss() } - ) - .padding(.top, 4) - Spacer() - } - .transition(.move(edge: .top).combined(with: .opacity)) - } - } - .zIndex(9) - // Full-screen device verification overlay (observation-isolated). // Covers nav bar, search bar, and tab bar — desktop parity. DeviceConfirmOverlay() diff --git a/Rosetta/RosettaApp.swift b/Rosetta/RosettaApp.swift index 0de524c..d716457 100644 --- a/Rosetta/RosettaApp.swift +++ b/Rosetta/RosettaApp.swift @@ -273,6 +273,21 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent dialogKey = String(dialogKey.dropFirst("#group:".count)) } + // Desktop-active suppression: mark this dialog as "recently read on another device". + // NSE checks this flag — if a new message arrives for the same dialog within 30s, + // it suppresses the notification (user is actively reading on Desktop). + // NOTE: When server sends mutable-content:1 for READ, NSE also writes this flag. + // Both writes are idempotent (same dialogKey → same timestamp). Badge decrement + // is safe: NSE removes notifications first, AppDelegate finds 0 remaining → no double-decrement. + if let shared = UserDefaults(suiteName: "group.com.rosetta.dev") { + let now = Date().timeIntervalSince1970 + var recentlyRead = shared.dictionary(forKey: "nse_recently_read_dialogs") as? [String: Double] ?? [:] + recentlyRead[dialogKey] = now + // Evict stale entries (> 60s) to prevent unbounded growth. + recentlyRead = recentlyRead.filter { now - $0.value < 60 } + shared.set(recentlyRead, forKey: "nse_recently_read_dialogs") + } + let center = UNUserNotificationCenter.current() center.getDeliveredNotifications { delivered in let idsToRemove = delivered @@ -474,8 +489,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent // MARK: - UNUserNotificationCenterDelegate - /// Handle foreground notifications — always suppress system banner, - /// show custom in-app overlay instead (Telegram parity). + /// Handle foreground notifications — show system banner unless chat is active or muted. func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification, @@ -483,14 +497,14 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent Void ) { let userInfo = notification.request.content.userInfo + let senderKey = userInfo["dialog"] as? String + ?? Self.extractSenderKey(from: userInfo) - // Always suppress system banner — custom in-app overlay handles display. - completionHandler([]) - - // Trigger in-app notification banner (suppression logic inside manager). - Task { @MainActor in - InAppNotificationManager.shared.show(userInfo: userInfo) + if InAppNotificationManager.shouldSuppress(senderKey: senderKey) { + completionHandler([]) + return } + completionHandler([.banner, .sound]) } /// Determines whether a foreground notification should be suppressed. @@ -501,14 +515,10 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent let senderKey = userInfo["dialog"] as? String ?? extractSenderKey(from: userInfo) - // Always suppress system banner — custom in-app overlay handles display. - // InAppNotificationManager.shouldSuppress() has the full suppression logic. if InAppNotificationManager.shouldSuppress(senderKey: senderKey) { return [] } - - // Even for non-suppressed notifications, return [] — we show our own banner. - return [] + return [.banner, .sound] } /// Handle notification tap — navigate to the sender's chat or expand call. diff --git a/RosettaNotificationService/NotificationService.swift b/RosettaNotificationService/NotificationService.swift index 2624222..a9a9c6d 100644 --- a/RosettaNotificationService/NotificationService.swift +++ b/RosettaNotificationService/NotificationService.swift @@ -260,7 +260,15 @@ final class NotificationService: UNNotificationServiceExtension { override func serviceExtensionTimeWillExpire() { if let handler = contentHandler, let content = bestAttemptContent { - content.sound = .default + // Read pushes must stay silent even on timeout — no sound, no alert. + let pushType = content.userInfo["type"] as? String ?? "" + if pushType == "read" { + content.sound = nil + content.title = "" + content.body = "" + } else { + content.sound = .default + } handler(content) } } diff --git a/RosettaTests/ForegroundNotificationTests.swift b/RosettaTests/ForegroundNotificationTests.swift index a1d9586..77110b0 100644 --- a/RosettaTests/ForegroundNotificationTests.swift +++ b/RosettaTests/ForegroundNotificationTests.swift @@ -4,10 +4,8 @@ import UserNotifications // MARK: - Foreground Notification Suppression Tests -/// Tests for the in-app notification banner suppression logic (Telegram parity). -/// System banners are always suppressed (`willPresent` returns `[]`). -/// `InAppNotificationManager.shouldSuppress()` decides whether the -/// custom in-app banner should be shown or hidden. +/// Tests for foreground notification suppression logic. +/// `willPresent` returns `[.banner, .sound]` by default, `[]` for active/muted chats. @MainActor struct ForegroundNotificationTests { @@ -17,21 +15,23 @@ struct ForegroundNotificationTests { } } - // MARK: - System Banner Always Suppressed + // MARK: - System Banner Presentation - @Test("System banner always suppressed — foregroundPresentationOptions returns []") - func systemBannerAlwaysSuppressed() { + @Test("Non-suppressed chat shows system banner with sound") + func nonSuppressedShowsBanner() { clearActiveDialogs() let userInfo: [AnyHashable: Any] = ["dialog": "02aaa", "title": "Alice"] let options = AppDelegate.foregroundPresentationOptions(for: userInfo) - #expect(options == []) + #expect(options == [.banner, .sound]) } - @Test("System banner suppressed even for inactive chats") - func systemBannerSuppressedInactive() { + @Test("Active chat suppresses system banner") + func activeChatSuppressesBanner() { clearActiveDialogs() + MessageRepository.shared.setDialogActive("02bbb", isActive: true) let userInfo: [AnyHashable: Any] = ["dialog": "02bbb"] #expect(AppDelegate.foregroundPresentationOptions(for: userInfo) == []) + MessageRepository.shared.setDialogActive("02bbb", isActive: false) } // MARK: - In-App Banner: Active Chat → Suppress diff --git a/RosettaTests/PushNotificationAuditTests.swift b/RosettaTests/PushNotificationAuditTests.swift index dd343db..89cf88c 100644 --- a/RosettaTests/PushNotificationAuditTests.swift +++ b/RosettaTests/PushNotificationAuditTests.swift @@ -297,6 +297,39 @@ struct PushNotificationDesktopSuppressionTests { let shouldSuppress = recentlyRead[senderKey].map { now - $0 < Self.recentlyReadWindow } ?? false #expect(shouldSuppress == false) } + + // MARK: - AppDelegate → App Group flag (READ push writes nse_recently_read_dialogs) + + @Test("handleReadPush stores recently-read flag in App Group for NSE") + func readPushStoresRecentlyReadFlagInAppGroup() { + let shared = UserDefaults(suiteName: "group.com.rosetta.dev") + let key = "nse_recently_read_dialogs" + let originalData = shared?.dictionary(forKey: key) + + // Simulate what handleReadPush now does: write the recently-read flag. + let dialogKey = "02test_desktop_read_flag" + let now = Date().timeIntervalSince1970 + var recentlyRead = shared?.dictionary(forKey: key) as? [String: Double] ?? [:] + recentlyRead[dialogKey] = now + recentlyRead = recentlyRead.filter { now - $0.value < 60 } + shared?.set(recentlyRead, forKey: key) + + // Verify flag exists and is recent. + let stored = shared?.dictionary(forKey: key) as? [String: Double] ?? [:] + #expect(stored[dialogKey] != nil) + if let ts = stored[dialogKey] { + #expect(abs(ts - now) < 2) + } + + // Verify NSE suppression logic would fire for this dialog. + if let lastReadTime = stored[dialogKey] { + let elapsed = now - lastReadTime + #expect(elapsed < Self.recentlyReadWindow) + } + + // Cleanup. + shared?.set(originalData, forKey: key) + } } // MARK: - Read Push Group Key Normalization Tests @@ -657,13 +690,11 @@ struct PushInAppBannerSuppressionExtendedTests { #expect(InAppNotificationManager.shouldSuppress(senderKey: "02normal") == false) } - @Test("System presentation options always return empty set") - func systemPresentationAlwaysEmpty() { + @Test("System presentation returns banner+sound for non-suppressed chats") + func systemPresentationShowsBanner() { clearState() - // Even for non-suppressed chats, system banner is always suppressed - // (custom in-app banner shown instead) let options = AppDelegate.foregroundPresentationOptions(for: ["dialog": "02any_user"]) - #expect(options == []) + #expect(options == [.banner, .sound]) } }