Фикс: групповой пуш-навигация, in-app баннер Telegram parity, надёжность NSE аватарок

This commit is contained in:
2026-04-08 01:47:17 +05:00
parent f6af59ba11
commit f6fc34e7d9
5 changed files with 83 additions and 47 deletions

View File

@@ -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 = "";

View File

@@ -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 сек.
"""
)
]

View File

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

View File

@@ -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,7 +264,13 @@ final class NotificationService: UNNotificationServiceExtension {
}
override func serviceExtensionTimeWillExpire() {
if let handler = contentHandler, let content = bestAttemptContent {
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" {
@@ -274,6 +283,7 @@ final class NotificationService: UNNotificationServiceExtension {
handler(content)
}
}
}
// MARK: - Sender Dedup Helpers
@@ -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

View File

@@ -2,8 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.usernotifications.communication</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.rosetta.dev</string>