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.
This commit is contained in:
Claude
2026-06-14 15:07:44 +00:00
parent b5f61182c5
commit 6153ec1ad1
2 changed files with 41 additions and 0 deletions
@@ -250,9 +250,48 @@ class NotificationService: UNNotificationServiceExtension {
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) {
+2
View File
@@ -55,6 +55,8 @@ The avatar is downloaded from `senderAvatarUrl` (rewritten to the `avatar_thumbn
This distinction matters for paired devices: an `INImage` backed by in-memory data renders on the iPhone but is not relayed to the Apple Watch, which then falls back to drawing a monogram from the sender's initials. Backing the image with a file URL the system can resolve lazily lets the Watch render the real avatar. Avatar files are written under `notification-avatars/` in the container and named by a stable hash of the (content-addressed) source URL, so notifications from the same sender reuse one file.
Because the system reads these files lazily, they cannot be deleted as soon as the handler returns, and the extension gets no "notification dismissed" callback. Instead `pruneOldAvatars` performs best-effort, age-based cleanup (files older than 24 hours) each time a new avatar is written. Notifications are ephemeral, so by then any device that needed the file is done. The cleanup tolerates concurrent NSE instances removing the same file.
## Key Files
| File | Purpose |