From f6fc34e7d9c25f9ffa43a577b4751c3997d51f29 Mon Sep 17 00:00:00 2001 From: senseiGai Date: Wed, 8 Apr 2026 01:47:17 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=B8=D0=BA=D1=81:=20=D0=B3=D1=80=D1=83?= =?UTF-8?q?=D0=BF=D0=BF=D0=BE=D0=B2=D0=BE=D0=B9=20=D0=BF=D1=83=D1=88-?= =?UTF-8?q?=D0=BD=D0=B0=D0=B2=D0=B8=D0=B3=D0=B0=D1=86=D0=B8=D1=8F,=20in-ap?= =?UTF-8?q?p=20=D0=B1=D0=B0=D0=BD=D0=BD=D0=B5=D1=80=20Telegram=20parity,?= =?UTF-8?q?=20=D0=BD=D0=B0=D0=B4=D1=91=D0=B6=D0=BD=D0=BE=D1=81=D1=82=D1=8C?= =?UTF-8?q?=20NSE=20=D0=B0=D0=B2=D0=B0=D1=82=D0=B0=D1=80=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Rosetta.xcodeproj/project.pbxproj | 8 +- Rosetta/Core/Utils/ReleaseNotes.swift | 2 +- Rosetta/RosettaApp.swift | 12 +- .../NotificationService.swift | 106 +++++++++++------- .../RosettaNotificationService.entitlements | 2 - 5 files changed, 83 insertions(+), 47 deletions(-) diff --git a/Rosetta.xcodeproj/project.pbxproj b/Rosetta.xcodeproj/project.pbxproj index dd42e79..c797e56 100644 --- a/Rosetta.xcodeproj/project.pbxproj +++ b/Rosetta.xcodeproj/project.pbxproj @@ -641,7 +641,7 @@ CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 35; + CURRENT_PROJECT_VERSION = 36; DEVELOPMENT_TEAM = QN8Z263QGX; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -657,7 +657,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.4; + MARKETING_VERSION = 1.3.5; 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 = 35; + CURRENT_PROJECT_VERSION = 36; DEVELOPMENT_TEAM = QN8Z263QGX; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -697,7 +697,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.4; + MARKETING_VERSION = 1.3.5; PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Rosetta/Core/Utils/ReleaseNotes.swift b/Rosetta/Core/Utils/ReleaseNotes.swift index 03237de..ca94b5a 100644 --- a/Rosetta/Core/Utils/ReleaseNotes.swift +++ b/Rosetta/Core/Utils/ReleaseNotes.swift @@ -13,7 +13,7 @@ enum ReleaseNotes { body: """ **Пуш-уведомления** - Аватарки отправителей в системных пушах (Communication Notification). In-app баннер переделан 1-в-1 как в Telegram (glass-фон, жесты, анимации). Исправлен спам вибраций при входе. Desktop-suppression 30 сек. + Аватарки отправителей в системных пушах. In-app баннер 1-в-1 Telegram (glass-фон, жесты, анимации). Тап по групповому пушу теперь открывает группу, а не пустой чат. Надёжность аватарок: fallback scale, timeout safety. Desktop-suppression 30 сек. """ ) ] diff --git a/Rosetta/RosettaApp.swift b/Rosetta/RosettaApp.swift index 75dcc2c..cc1ef83 100644 --- a/Rosetta/RosettaApp.swift +++ b/Rosetta/RosettaApp.swift @@ -562,7 +562,17 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent } // Android parity: try multiple key names for sender identification. - let senderKey = Self.extractSenderKey(from: userInfo) + var senderKey = Self.extractSenderKey(from: userInfo) + + // Server sends group ID without #group: prefix (CLAUDE.md line 480). + // Add prefix so ChatRoute.isGroup detects it correctly. + let pushType = userInfo["type"] as? String + if pushType == "group_message", + !senderKey.isEmpty, + !senderKey.hasPrefix("#group:"), + !senderKey.hasPrefix("group:") { + senderKey = "#group:\(senderKey)" + } if !senderKey.isEmpty { let senderName = Self.firstNonBlank(userInfo, keys: [ diff --git a/RosettaNotificationService/NotificationService.swift b/RosettaNotificationService/NotificationService.swift index 399500b..fdb88eb 100644 --- a/RosettaNotificationService/NotificationService.swift +++ b/RosettaNotificationService/NotificationService.swift @@ -39,6 +39,8 @@ final class NotificationService: UNNotificationServiceExtension { private var contentHandler: ((UNNotificationContent) -> Void)? private var bestAttemptContent: UNMutableNotificationContent? + /// Stores Communication Notification result so serviceExtensionTimeWillExpire can use it. + private var communicationContent: UNNotificationContent? override func didReceive( _ request: UNNotificationRequest, @@ -247,6 +249,7 @@ final class NotificationService: UNNotificationServiceExtension { senderKey: senderKey, isGroup: isGroup ) + self.communicationContent = finalContent // Android parity: for duplicate bursts, keep only the latest notification // for this sender instead of stacking multiple identical entries. @@ -261,17 +264,24 @@ final class NotificationService: UNNotificationServiceExtension { } override func serviceExtensionTimeWillExpire() { - if let handler = contentHandler, let content = bestAttemptContent { - // 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 + if let handler = contentHandler { + // Prefer Communication Notification result (has avatar) over raw bestAttemptContent. + if let comm = communicationContent { + handler(comm) + return + } + if let content = bestAttemptContent { + // 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) } - handler(content) } } @@ -471,16 +481,33 @@ final class NotificationService: UNNotificationServiceExtension { let colors = avatarColors[colorIndex] let text = initials(name: name, publicKey: key) - UIGraphicsBeginImageContextWithOptions(CGSize(width: size, height: size), false, 2.0) - guard let ctx = UIGraphicsGetCurrentContext() else { return nil } + // Try 2.0 scale first; fallback to 1.0 if NSE memory is constrained. + let image = renderLetterAvatar(size: size, colors: colors, text: text, scale: 2.0) + ?? renderLetterAvatar(size: size, colors: colors, text: text, scale: 1.0) - // Mantine "light" variant: dark base + semi-transparent tint overlay. - ctx.setFillColor(uiColor(hex: mantineDarkBody).cgColor) - ctx.fillEllipse(in: CGRect(x: 0, y: 0, width: size, height: size)) - ctx.setFillColor(uiColor(hex: colors.tint, alpha: 0.15).cgColor) - ctx.fillEllipse(in: CGRect(x: 0, y: 0, width: size, height: size)) + guard let pngData = image?.pngData() else { return nil } + if let tempURL = storeTemporaryImage(data: pngData, key: "letter-\(key)", fileExtension: "png") { + return INImage(url: tempURL) + } + return INImage(imageData: pngData) + } + + /// Renders letter avatar at given scale. Returns nil if UIGraphics context can't be allocated. + private static func renderLetterAvatar( + size: CGFloat, + colors: (tint: UInt32, text: UInt32), + text: String, + scale: CGFloat + ) -> UIImage? { + UIGraphicsBeginImageContextWithOptions(CGSize(width: size, height: size), false, scale) + guard UIGraphicsGetCurrentContext() != nil else { return nil } + + let rect = CGRect(x: 0, y: 0, width: size, height: size) + uiColor(hex: mantineDarkBody).setFill() + UIBezierPath(ovalIn: rect).fill() + uiColor(hex: colors.tint, alpha: 0.15).setFill() + UIBezierPath(ovalIn: rect).fill() - // Initials in shade-3 text color. let textColor = uiColor(hex: colors.text) let font = UIFont.systemFont(ofSize: size * 0.38, weight: .bold) let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: textColor] @@ -495,12 +522,7 @@ final class NotificationService: UNNotificationServiceExtension { let image = UIGraphicsGetImageFromCurrentImageContext() UIGraphicsEndImageContext() - - guard let pngData = image?.pngData() else { return nil } - if let tempURL = storeTemporaryImage(data: pngData, key: "letter-\(key)", fileExtension: "png") { - return INImage(url: tempURL) - } - return INImage(imageData: pngData) + return image } /// Generates a 50x50 group avatar with person.2.fill icon on solid tint circle. @@ -510,14 +532,26 @@ final class NotificationService: UNNotificationServiceExtension { let colorIndex = avatarColorIndex(for: name, publicKey: key) let colors = avatarColors[colorIndex] - UIGraphicsBeginImageContextWithOptions(CGSize(width: size, height: size), false, 2.0) - guard let ctx = UIGraphicsGetCurrentContext() else { return nil } + // Try 2.0 scale first; fallback to 1.0 if NSE memory is constrained. + let image = renderGroupAvatar(size: size, tintHex: colors.tint, scale: 2.0) + ?? renderGroupAvatar(size: size, tintHex: colors.tint, scale: 1.0) - // Solid tint color circle (ChatRowView parity). - ctx.setFillColor(uiColor(hex: colors.tint).cgColor) - ctx.fillEllipse(in: CGRect(x: 0, y: 0, width: size, height: size)) + guard let pngData = image?.pngData() else { return nil } + if let tempURL = storeTemporaryImage(data: pngData, key: "group-\(key)", fileExtension: "png") { + return INImage(url: tempURL) + } + return INImage(imageData: pngData) + } + + /// Renders group avatar at given scale. Returns nil if UIGraphics context can't be allocated. + private static func renderGroupAvatar(size: CGFloat, tintHex: UInt32, scale: CGFloat) -> UIImage? { + UIGraphicsBeginImageContextWithOptions(CGSize(width: size, height: size), false, scale) + guard UIGraphicsGetCurrentContext() != nil else { return nil } + + let rect = CGRect(x: 0, y: 0, width: size, height: size) + uiColor(hex: tintHex).setFill() + UIBezierPath(ovalIn: rect).fill() - // person.2.fill SF Symbol centered, white 90% opacity. let config = UIImage.SymbolConfiguration(pointSize: 20, weight: .medium) if let symbol = UIImage(systemName: "person.2.fill", withConfiguration: config)? .withTintColor(.white.withAlphaComponent(0.9), renderingMode: .alwaysOriginal) { @@ -533,12 +567,7 @@ final class NotificationService: UNNotificationServiceExtension { let image = UIGraphicsGetImageFromCurrentImageContext() UIGraphicsEndImageContext() - - guard let pngData = image?.pngData() else { return nil } - if let tempURL = storeTemporaryImage(data: pngData, key: "group-\(key)", fileExtension: "png") { - return INImage(url: tempURL) - } - return INImage(imageData: pngData) + return image } /// Loads sender avatar from shared App Group cache written by the main app. @@ -613,9 +642,8 @@ final class NotificationService: UNNotificationServiceExtension { let fileName = key.replacingOccurrences(of: "/", with: "_") let tempURL = URL(fileURLWithPath: imagesPath) .appendingPathComponent("\(fileName).\(fileExtension)") - if FileManager.default.fileExists(atPath: tempURL.path) { - return tempURL - } + // Always overwrite — iOS cleans NSTemporaryDirectory() between notifications, + // so a stale fileExists check would return a URL to a deleted file. do { try data.write(to: tempURL, options: [.atomic]) return tempURL diff --git a/RosettaNotificationService/RosettaNotificationService.entitlements b/RosettaNotificationService/RosettaNotificationService.entitlements index fcb2c96..e07eedf 100644 --- a/RosettaNotificationService/RosettaNotificationService.entitlements +++ b/RosettaNotificationService/RosettaNotificationService.entitlements @@ -2,8 +2,6 @@ - com.apple.developer.usernotifications.communication - com.apple.security.application-groups group.com.rosetta.dev