Фикс: групповой пуш-навигация, 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

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

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>