Fix missing chat notification avatar on Apple Watch

Chat notifications are upgraded to Communication Notifications and attach
the sender's avatar to the INPerson via downloadAvatarImage. The image was
built with INImage(imageData:), which renders on the iPhone but is not
relayed to paired devices like the Apple Watch. The Watch then falls back to
drawing a monogram from the sender's initials instead of the avatar.

Write the downloaded avatar bytes to a file in the shared App Group
container and back the INImage with INImage(url:) so the system can resolve
the image lazily across devices. Files are named by a stable hash of the
content-addressed thumbnail URL so notifications from the same sender reuse
one file.
This commit is contained in:
Claude
2026-06-14 14:29:48 +00:00
parent 02a47fb4a6
commit b5f61182c5
2 changed files with 68 additions and 0 deletions
@@ -1,3 +1,4 @@
import CryptoKit
import Intents
import UIKit
import UserNotifications
@@ -190,9 +191,68 @@ class NotificationService: UNNotificationServiceExtension {
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
}
return fileURL
}
// MARK: Mutations
func mutateWithBadge(_ content: UNMutableNotificationContent) {
+8
View File
@@ -47,6 +47,14 @@ Two sound types are supported:
DM sound only plays if the user has enabled the `playSoundChat` preference in the main app's chat settings.
### Communication Notifications (Avatars)
Chat notifications (`reason == "chat-message"` / `"chat-reaction"`) are upgraded to iOS Communication Notifications via `INSendMessageIntent`, which lets the sender's avatar appear alongside the message.
The avatar is downloaded from `senderAvatarUrl` (rewritten to the `avatar_thumbnail` variant to keep it small) and attached to the `INPerson` sender. The downloaded bytes are written to a file in the shared App Group container and the `INImage` is created with `INImage(url:)`, **not** `INImage(imageData:)`.
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.
## Key Files
| File | Purpose |