Фикс: групповой пуш-навигация, 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_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 35; CURRENT_PROJECT_VERSION = 36;
DEVELOPMENT_TEAM = QN8Z263QGX; DEVELOPMENT_TEAM = QN8Z263QGX;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -657,7 +657,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.3.4; MARKETING_VERSION = 1.3.5;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev; PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -681,7 +681,7 @@
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements; CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 35; CURRENT_PROJECT_VERSION = 36;
DEVELOPMENT_TEAM = QN8Z263QGX; DEVELOPMENT_TEAM = QN8Z263QGX;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -697,7 +697,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.3.4; MARKETING_VERSION = 1.3.5;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev; PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";

View File

@@ -13,7 +13,7 @@ enum ReleaseNotes {
body: """ 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. // 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 { if !senderKey.isEmpty {
let senderName = Self.firstNonBlank(userInfo, keys: [ let senderName = Self.firstNonBlank(userInfo, keys: [

View File

@@ -39,6 +39,8 @@ final class NotificationService: UNNotificationServiceExtension {
private var contentHandler: ((UNNotificationContent) -> Void)? private var contentHandler: ((UNNotificationContent) -> Void)?
private var bestAttemptContent: UNMutableNotificationContent? private var bestAttemptContent: UNMutableNotificationContent?
/// Stores Communication Notification result so serviceExtensionTimeWillExpire can use it.
private var communicationContent: UNNotificationContent?
override func didReceive( override func didReceive(
_ request: UNNotificationRequest, _ request: UNNotificationRequest,
@@ -247,6 +249,7 @@ final class NotificationService: UNNotificationServiceExtension {
senderKey: senderKey, senderKey: senderKey,
isGroup: isGroup isGroup: isGroup
) )
self.communicationContent = finalContent
// Android parity: for duplicate bursts, keep only the latest notification // Android parity: for duplicate bursts, keep only the latest notification
// for this sender instead of stacking multiple identical entries. // for this sender instead of stacking multiple identical entries.
@@ -261,7 +264,13 @@ final class NotificationService: UNNotificationServiceExtension {
} }
override func serviceExtensionTimeWillExpire() { 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. // Read pushes must stay silent even on timeout no sound, no alert.
let pushType = content.userInfo["type"] as? String ?? "" let pushType = content.userInfo["type"] as? String ?? ""
if pushType == "read" { if pushType == "read" {
@@ -274,6 +283,7 @@ final class NotificationService: UNNotificationServiceExtension {
handler(content) handler(content)
} }
} }
}
// MARK: - Sender Dedup Helpers // MARK: - Sender Dedup Helpers
@@ -471,16 +481,33 @@ final class NotificationService: UNNotificationServiceExtension {
let colors = avatarColors[colorIndex] let colors = avatarColors[colorIndex]
let text = initials(name: name, publicKey: key) let text = initials(name: name, publicKey: key)
UIGraphicsBeginImageContextWithOptions(CGSize(width: size, height: size), false, 2.0) // Try 2.0 scale first; fallback to 1.0 if NSE memory is constrained.
guard let ctx = UIGraphicsGetCurrentContext() else { return nil } 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. guard let pngData = image?.pngData() else { return nil }
ctx.setFillColor(uiColor(hex: mantineDarkBody).cgColor) if let tempURL = storeTemporaryImage(data: pngData, key: "letter-\(key)", fileExtension: "png") {
ctx.fillEllipse(in: CGRect(x: 0, y: 0, width: size, height: size)) return INImage(url: tempURL)
ctx.setFillColor(uiColor(hex: colors.tint, alpha: 0.15).cgColor) }
ctx.fillEllipse(in: CGRect(x: 0, y: 0, width: size, height: size)) 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 textColor = uiColor(hex: colors.text)
let font = UIFont.systemFont(ofSize: size * 0.38, weight: .bold) let font = UIFont.systemFont(ofSize: size * 0.38, weight: .bold)
let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: textColor] let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: textColor]
@@ -495,12 +522,7 @@ final class NotificationService: UNNotificationServiceExtension {
let image = UIGraphicsGetImageFromCurrentImageContext() let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext() UIGraphicsEndImageContext()
return image
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)
} }
/// Generates a 50x50 group avatar with person.2.fill icon on solid tint circle. /// 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 colorIndex = avatarColorIndex(for: name, publicKey: key)
let colors = avatarColors[colorIndex] let colors = avatarColors[colorIndex]
UIGraphicsBeginImageContextWithOptions(CGSize(width: size, height: size), false, 2.0) // Try 2.0 scale first; fallback to 1.0 if NSE memory is constrained.
guard let ctx = UIGraphicsGetCurrentContext() else { return nil } 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). guard let pngData = image?.pngData() else { return nil }
ctx.setFillColor(uiColor(hex: colors.tint).cgColor) if let tempURL = storeTemporaryImage(data: pngData, key: "group-\(key)", fileExtension: "png") {
ctx.fillEllipse(in: CGRect(x: 0, y: 0, width: size, height: size)) 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) let config = UIImage.SymbolConfiguration(pointSize: 20, weight: .medium)
if let symbol = UIImage(systemName: "person.2.fill", withConfiguration: config)? if let symbol = UIImage(systemName: "person.2.fill", withConfiguration: config)?
.withTintColor(.white.withAlphaComponent(0.9), renderingMode: .alwaysOriginal) { .withTintColor(.white.withAlphaComponent(0.9), renderingMode: .alwaysOriginal) {
@@ -533,12 +567,7 @@ final class NotificationService: UNNotificationServiceExtension {
let image = UIGraphicsGetImageFromCurrentImageContext() let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext() UIGraphicsEndImageContext()
return image
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)
} }
/// Loads sender avatar from shared App Group cache written by the main app. /// 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 fileName = key.replacingOccurrences(of: "/", with: "_")
let tempURL = URL(fileURLWithPath: imagesPath) let tempURL = URL(fileURLWithPath: imagesPath)
.appendingPathComponent("\(fileName).\(fileExtension)") .appendingPathComponent("\(fileName).\(fileExtension)")
if FileManager.default.fileExists(atPath: tempURL.path) { // Always overwrite iOS cleans NSTemporaryDirectory() between notifications,
return tempURL // so a stale fileExists check would return a URL to a deleted file.
}
do { do {
try data.write(to: tempURL, options: [.atomic]) try data.write(to: tempURL, options: [.atomic])
return tempURL 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"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>com.apple.developer.usernotifications.communication</key>
<true/>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>
<string>group.com.rosetta.dev</string> <string>group.com.rosetta.dev</string>