Files
bsky-social-app/modules/BlueskyNSE/NotificationService.swift
T
Claude 6153ec1ad1 Prune old NSE notification avatars
The avatar cache files written for Communication Notifications cannot be
deleted right after the handler returns (the system reads them lazily, e.g.
the Apple Watch fetching the image), and the NSE gets no dismissal callback.

Add best-effort, age-based cleanup that drops files older than 24 hours each
time a new avatar is written. Notifications are ephemeral, so any device that
needed an old file is long done. The cleanup tolerates concurrent NSE
instances removing the same file.
2026-06-14 15:07:44 +00:00

371 lines
13 KiB
Swift

import CryptoKit
import Intents
import UIKit
import UserNotifications
let APP_GROUP = "group.app.bsky"
typealias ContentHandler = (UNNotificationContent) -> Void
// This extension allows us to do some processing of the received notification
// data before displaying the notification to the user. In our use case, there
// are a few particular things that we want to do:
//
// - Determine whether we should play a sound for the notification
// - Download and display any images for the notification
// - Update the badge count accordingly
//
// The extension may or may not create a new process to handle a notification.
// It is also possible that multiple notifications will be processed by the
// same instance of `NotificationService`, though these will happen in
// parallel.
//
// Because multiple instances of `NotificationService` may exist, we should
// be careful in accessing preferences that will be mutated _by the
// extension itself_. For example, we should not worry about `playChatSound`
// changing, since we never mutate that value within the extension itself.
// However, since we mutate `badgeCount` frequently, we should ensure that
// these updates always run sync with each other and that the have access
// to the most recent values.
class NotificationService: UNNotificationServiceExtension {
private var contentHandler: ContentHandler?
private var bestAttempt: UNMutableNotificationContent?
override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) {
self.contentHandler = contentHandler
guard let bestAttempt = NSEUtil.createCopy(request.content),
let reason = request.content.userInfo["reason"] as? String
else {
contentHandler(request.content)
return
}
self.bestAttempt = bestAttempt
if reason == "chat-message" || reason == "chat-reaction" {
mutateWithChatMessage(bestAttempt)
// Only apply the title->body swap for chat-message. For chat-reaction,
// `messageKind` refers to the reacted-to message, so we'd clobber the
// descriptive reaction body with the title for reactions on system
// messages.
if reason == "chat-message" {
mutateChatMessageBody(bestAttempt, userInfo: request.content.userInfo)
}
mutateWithGroupSubtitle(bestAttempt, userInfo: request.content.userInfo)
let finalContent = createCommunicationNotification(
from: bestAttempt,
userInfo: request.content.userInfo
)
contentHandler(finalContent)
} else if reason == "chat-added-to-group"
|| reason == "chat-removed-from-group"
|| reason == "chat-join-request-rejected" {
mutateWithChatMessage(bestAttempt)
mutateWithGroupSubtitle(bestAttempt, userInfo: request.content.userInfo)
mutateWithBadge(bestAttempt)
contentHandler(bestAttempt)
} else {
mutateWithBadge(bestAttempt)
contentHandler(bestAttempt)
}
}
override func serviceExtensionTimeWillExpire() {
guard let contentHandler = self.contentHandler,
let bestAttempt = self.bestAttempt
else {
return
}
contentHandler(bestAttempt)
}
// MARK: Communication Notification
func createCommunicationNotification(
from content: UNMutableNotificationContent,
userInfo: [AnyHashable: Any]
) -> UNNotificationContent {
let senderDisplayName = userInfo["senderDisplayName"] as? String ?? "Unknown"
let convoId = userInfo["convoId"] as? String
var avatarImage: INImage?
if let avatarUrlString = userInfo["senderAvatarUrl"] as? String {
avatarImage = downloadAvatarImage(from: avatarUrlString)
}
let senderHandleValue = userInfo["senderHandle"] as? String
let senderHandle = INPersonHandle(value: senderHandleValue, type: .unknown)
let sender = INPerson(
personHandle: senderHandle,
nameComponents: nil,
displayName: senderDisplayName,
image: avatarImage,
contactIdentifier: nil,
customIdentifier: nil
)
var speakableGroupName: INSpeakableString?
if userInfo["convoKind"] as? String == "group",
let groupName = userInfo["groupName"] as? String,
!groupName.isEmpty {
speakableGroupName = INSpeakableString(spokenPhrase: groupName)
}
let intent = INSendMessageIntent(
recipients: nil,
outgoingMessageType: .outgoingMessageText,
content: content.body,
speakableGroupName: speakableGroupName,
conversationIdentifier: convoId,
serviceName: nil,
sender: sender,
attachments: nil
)
// The push payload omits the full recipient list for group convos. Without
// any signal of multiple recipients, iOS classifies the notification as
// `MessagingDirect` and ignores `speakableGroupName`. Setting
// `recipientCount` on the donation metadata is Apple's documented escape
// hatch for this case, and overrides the (zero-length) recipients array.
if userInfo["convoKind"] as? String == "group",
let groupMemberCount = userInfo["groupMemberCount"] as? Int,
groupMemberCount > 0 {
let metadata = INSendMessageIntentDonationMetadata()
metadata.recipientCount = groupMemberCount
intent.donationMetadata = metadata
}
// For group convos, attach the composite group avatar (rendered by the
// ogcard service) to the `speakableGroupName` parameter so iOS shows it
// alongside the sender on the Communication Notification.
// Disabled: the composite avatar renders poorly at notification size.
// if userInfo["convoKind"] as? String == "group",
// let convoAvatarUrlString = userInfo["convoAvatarUrl"] as? String,
// let groupImage = downloadAvatarImage(from: convoAvatarUrlString) {
// intent.setImage(groupImage, forParameterNamed: \.speakableGroupName)
// }
// Set the group image to the sender avatar instead
if userInfo["convoKind"] as? String == "group",
let avatarImage {
intent.setImage(avatarImage, forParameterNamed: \.speakableGroupName)
}
let interaction = INInteraction(intent: intent, response: nil)
interaction.direction = .incoming
interaction.donate(completion: nil)
do {
return try content.updating(from: intent)
} catch {
return content
}
}
func downloadAvatarImage(from urlString: String) -> INImage? {
let thumbnailUrlString = urlString.replacingOccurrences(
of: "/img/avatar/",
with: "/img/avatar_thumbnail/"
)
guard let url = URL(string: thumbnailUrlString) else { return nil }
var request = URLRequest(url: url)
request.timeoutInterval = 5
var imageData: Data?
let semaphore = DispatchSemaphore(value: 0)
let task = URLSession.shared.dataTask(with: request) { data, response, _ in
if let data = data,
let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 {
imageData = data
}
semaphore.signal()
}
task.resume()
semaphore.wait()
guard let data = imageData else { return nil }
// Back the INImage with a file in the shared App Group container rather
// than raw image data. `INImage(imageData:)` renders fine on the iPhone
// itself, but the in-memory bytes are not relayed to paired devices like
// the Apple Watch. The Watch then receives an INPerson with no usable
// image and falls back to drawing a monogram from the sender's initials.
// Pointing the INImage at a file URL the Watch can resolve lets it render
// the actual avatar.
if let fileURL = writeAvatarToSharedContainer(data: data, urlString: thumbnailUrlString) {
return INImage(url: fileURL)
}
// Fall back to in-memory data if we could not write to the container. The
// avatar still shows on the iPhone in that case.
return INImage(imageData: data)
}
// Writes avatar bytes to a file in the shared App Group container so the
// resulting INImage can be backed by a URL. The system reads this file
// lazily (including when relaying the notification to the Apple Watch), so
// it must live in the persistent shared container rather than a temporary
// directory. Files are named by a stable hash of the source URL, which is
// content-addressed (the CID changes when a user updates their avatar), so
// repeated notifications from the same sender reuse one file instead of
// accumulating duplicates.
func writeAvatarToSharedContainer(data: Data, urlString: String) -> URL? {
guard
let containerURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: APP_GROUP
)
else {
return nil
}
let avatarsDir = containerURL.appendingPathComponent(
"notification-avatars",
isDirectory: true
)
let fileName = Insecure.MD5.hash(data: Data(urlString.utf8))
.map { String(format: "%02x", $0) }
.joined()
let fileURL = avatarsDir.appendingPathComponent(fileName)
// Reuse an already-downloaded avatar if present.
if FileManager.default.fileExists(atPath: fileURL.path) {
return fileURL
}
do {
try FileManager.default.createDirectory(
at: avatarsDir,
withIntermediateDirectories: true
)
try data.write(to: fileURL, options: .atomic)
} catch {
return nil
}
pruneOldAvatars(in: avatarsDir)
return fileURL
}
// Best-effort, age-based cleanup of the avatar cache. We never delete files
// right after delivering a notification because the system reads them
// lazily (the Apple Watch may fetch the avatar seconds later), and the NSE
// gets no "notification dismissed" callback. Instead we drop files old
// enough that any device that needed them is long done. Notifications are
// ephemeral, so a one-day window is comfortably safe.
//
// This runs inside the time-limited extension, but the directory holds only
// a handful of small files. Every step is best-effort: multiple NSE
// instances may run this concurrently, so a file another instance just
// removed is expected and harmless (`try?`).
func pruneOldAvatars(
in directory: URL,
olderThan maxAge: TimeInterval = 24 * 60 * 60
) {
let fileManager = FileManager.default
guard
let entries = try? fileManager.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: [.contentModificationDateKey],
options: .skipsHiddenFiles
)
else {
return
}
let cutoff = Date().addingTimeInterval(-maxAge)
for fileURL in entries {
let modified = (try? fileURL.resourceValues(
forKeys: [.contentModificationDateKey]
))?.contentModificationDate
if let modified, modified < cutoff {
try? fileManager.removeItem(at: fileURL)
}
}
}
// MARK: Mutations
func mutateWithBadge(_ content: UNMutableNotificationContent) {
NSEUtil.shared.prefsQueue.sync {
var count = NSEUtil.shared.prefs?.integer(forKey: "badgeCount") ?? 0
count += 1
// Set the new badge number for the notification, then store that value for using later
content.badge = NSNumber(value: count)
NSEUtil.shared.prefs?.setValue(count, forKey: "badgeCount")
}
}
func mutateWithChatMessage(_ content: UNMutableNotificationContent) {
if NSEUtil.shared.prefs?.bool(forKey: "playSoundChat") == true {
mutateWithDmSound(content)
}
}
// For group convos, surface the group name as the notification subtitle.
// The sender's display name is shown as the title (overridden by
// `INSendMessageIntent` for chat-message/chat-reaction).
func mutateWithGroupSubtitle(
_ content: UNMutableNotificationContent,
userInfo: [AnyHashable: Any]
) {
guard userInfo["convoKind"] as? String == "group",
let groupName = userInfo["groupName"] as? String,
!groupName.isEmpty
else {
return
}
content.subtitle = groupName
}
// System messages (`add_member`, `convo_locked`, `edit_group`, etc.) are
// delivered through `chat-message` but have a server-rendered description
// in `title`. iOS overrides the title with the sender name once we apply
// `INSendMessageIntent`, so we move the title text into the body before
// building the intent.
func mutateChatMessageBody(
_ content: UNMutableNotificationContent,
userInfo: [AnyHashable: Any]
) {
guard let messageKind = userInfo["messageKind"] as? String,
messageKind != "message",
!content.title.isEmpty
else {
return
}
content.body = content.title
}
func mutateWithDefaultSound(_ content: UNMutableNotificationContent) {
content.sound = UNNotificationSound.default
}
func mutateWithDmSound(_ content: UNMutableNotificationContent) {
content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: "dm.aiff"))
}
}
// NSEUtil's purpose is to create a shared instance of `UserDefaults` across
// `NotificationService` instances. It also includes a queue so that we can process
// updates to `UserDefaults` in parallel.
private class NSEUtil {
static let shared = NSEUtil()
var prefs = UserDefaults(suiteName: APP_GROUP)
var prefsQueue = DispatchQueue(label: "NSEPrefsQueue")
static func createCopy(_ content: UNNotificationContent) -> UNMutableNotificationContent? {
return content.mutableCopy() as? UNMutableNotificationContent
}
}