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