Фикс: групповой пуш-навигация, in-app баннер Telegram parity, надёжность NSE аватарок
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user