[Notifications] Network photos in notifications

This commit is contained in:
metacryst
2026-06-21 05:00:55 -05:00
parent 8453819f50
commit 21cc89f0af
7 changed files with 173 additions and 33 deletions

View File

@@ -467,7 +467,7 @@
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0.6; MARKETING_VERSION = 1.0.6;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = russell.sam.forum; PRODUCT_BUNDLE_IDENTIFIER = russell.sam.forumtest;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@@ -489,7 +489,7 @@
IPHONEOS_DEPLOYMENT_TARGET = 14.0; IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0.6; MARKETING_VERSION = 1.0.6;
PRODUCT_BUNDLE_IDENTIFIER = russell.sam.forum; PRODUCT_BUNDLE_IDENTIFIER = russell.sam.forumtest;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@@ -521,7 +521,7 @@
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = russell.sam.forum.NotificationService; PRODUCT_BUNDLE_IDENTIFIER = russell.sam.forumtest.nse;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
@@ -554,7 +554,7 @@
LOCALIZATION_PREFERS_STRING_CATALOGS = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = russell.sam.forum.NotificationService; PRODUCT_BUNDLE_IDENTIFIER = russell.sam.forumtest.nse;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;

View File

@@ -4,5 +4,7 @@
<dict> <dict>
<key>aps-environment</key> <key>aps-environment</key>
<string>development</string> <string>development</string>
<key>com.apple.developer.usernotifications.communication</key>
<true/>
</dict> </dict>
</plist> </plist>

View File

@@ -35,6 +35,10 @@
<string>Used to find forums and communities near you</string> <string>Used to find forums and communities near you</string>
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>
<string>Access your photos to set a profile picture and share with others</string> <string>Access your photos to set a profile picture and share with others</string>
<key>NSUserActivityTypes</key>
<array>
<string>INSendMessageIntent</string>
</array>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>remote-notification</string> <string>remote-notification</string>

View File

@@ -0,0 +1,53 @@
# Communication Notifications (avatar + app-icon badge)
The Notification Service Extension (`NotificationService.swift`) turns incoming
pushes into **communication notifications** on iOS 15+: a large circular avatar
(the sending network's photo) with the app icon badged in the corner.
## How it works
1. The server (`frm.so`, `notifications.js`) sets `mutableContent: 1` and adds to
the push payload:
- `imageUrl` — a PNG of the network's logo (the server rasterizes SVG logos to
PNG via `sharp`, since iOS can't render SVG avatars).
- `senderName` — the network name (used as the conversation title; falls back
to the notification's own title).
- `isGroup` — `true`, so iOS uses the group-avatar style.
2. The extension downloads `imageUrl`, builds an `INSendMessageIntent` with the
image as the sender/group avatar, donates it, and returns
`content.updating(from: intent)`.
3. On iOS 14 the extension falls back to attaching the image as a plain thumbnail.
## Required setup (both pieces are mandatory)
Two things must be in place or the avatar silently won't render:
### 1. `NSUserActivityTypes` declares the intent (already in this repo)
`INSendMessageIntent` is declared under `NSUserActivityTypes` in **both**
`App/Info.plist` and `NotificationService/Info.plist`. Without this the donation
fails with *"Donating intent 'INSendMessageIntent' is not supported by this
extension"* and the communication styling never applies.
### 2. Communication Notifications capability (one-time, in Xcode — cannot be scripted)
The capability must be registered on the provisioning profile, not just present
in the entitlements file:
1. Open `ios/App/App.xcworkspace` in Xcode.
2. Select the **App** target → **Signing & Capabilities** → **+ Capability** →
add **Communication Notifications**.
- This adds `com.apple.developer.usernotifications.communication` to
`App.entitlements` (already in this repo) and, crucially, enables the
capability on the App ID / provisioning profile. Requires a paid Apple
Developer account.
3. If the avatar still doesn't appear, add the same capability to the
**NotificationService** target as well.
4. Rebuild and reinstall on a device (communication notifications don't reliably
render on the Simulator).
## Notes
- Requires iOS 15.0+ for the communication style; older versions get the plain
attachment fallback automatically.
- The app's deployment target is currently 14.0, so no bump is required.

View File

@@ -2,6 +2,10 @@
<!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>NSUserActivityTypes</key>
<array>
<string>INSendMessageIntent</string>
</array>
<key>NSExtension</key> <key>NSExtension</key>
<dict> <dict>
<key>NSExtensionPointIdentifier</key> <key>NSExtensionPointIdentifier</key>

View File

@@ -6,6 +6,7 @@
// //
import UserNotifications import UserNotifications
import Intents
class NotificationService: UNNotificationServiceExtension { class NotificationService: UNNotificationServiceExtension {
@@ -25,21 +26,91 @@ class NotificationService: UNNotificationServiceExtension {
return return
} }
URLSession.shared.downloadTask(with: imageUrl) { location, _, error in let userInfo = request.content.userInfo
defer { contentHandler(content) }
guard let location = location, error == nil else { return }
let tmpFile = URL(fileURLWithPath: NSTemporaryDirectory()) // Download the avatar (the sending network's photo), then render the
.appendingPathComponent(imageUrl.lastPathComponent) // notification. A data task lets us hand the bytes straight to INImage.
URLSession.shared.dataTask(with: imageUrl) { data, _, error in
try? FileManager.default.moveItem(at: location, to: tmpFile) guard let data = data, !data.isEmpty, error == nil else {
contentHandler(content)
return
}
// iOS 15+: render as a communication notification — large circular
// avatar with the app icon badged in the corner.
if #available(iOS 15.0, *) {
self.deliverAsCommunicationNotification(content: content, imageData: data, userInfo: userInfo, contentHandler: contentHandler)
} else {
// iOS 14 fallback: attach the image as a plain thumbnail.
let tmpFile = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("avatar.png")
try? data.write(to: tmpFile)
if let attachment = try? UNNotificationAttachment(identifier: "avatar", url: tmpFile) { if let attachment = try? UNNotificationAttachment(identifier: "avatar", url: tmpFile) {
content.attachments = [attachment] content.attachments = [attachment]
} }
contentHandler(content)
}
}.resume() }.resume()
} }
@available(iOS 15.0, *)
private func deliverAsCommunicationNotification(
content: UNMutableNotificationContent,
imageData: Data,
userInfo: [AnyHashable: Any],
contentHandler: @escaping (UNNotificationContent) -> Void
) {
// The conversation title shown in bold. Defaults to the notification's own
// title so existing copy (e.g. "Group | Alice") is preserved.
let senderName = (userInfo["senderName"] as? String) ?? content.title
let isGroup = (userInfo["isGroup"] as? Bool) ?? false
// Group related notifications into one conversation so iOS stacks them.
let conversationId = (userInfo["conversationId"] as? String)
?? (userInfo["threadId"] as? String)
?? content.threadIdentifier
let avatar = INImage(imageData: imageData)
let handle = INPersonHandle(value: conversationId, type: .unknown)
let sender = INPerson(
personHandle: handle,
nameComponents: nil,
displayName: senderName,
image: avatar,
contactIdentifier: nil,
customIdentifier: conversationId
)
let intent = INSendMessageIntent(
recipients: nil,
outgoingMessageType: .outgoingMessageText,
content: content.body,
speakableGroupName: isGroup ? INSpeakableString(spokenPhrase: senderName) : nil,
conversationIdentifier: conversationId,
serviceName: nil,
sender: sender,
attachments: nil
)
// For a group conversation the avatar comes from the group image; for a
// 1:1 it comes from the sender's INPerson image (set above).
if isGroup {
intent.setImage(avatar, forParameterNamed: \.speakableGroupName)
}
let interaction = INInteraction(intent: intent, response: nil)
interaction.direction = .incoming
interaction.donate(completion: nil)
do {
let updated = try content.updating(from: intent)
contentHandler(updated)
} catch {
// If the intent can't be applied, fall back to the unmodified content
// so the notification still shows.
contentHandler(content)
}
}
override func serviceExtensionTimeWillExpire() { override func serviceExtensionTimeWillExpire() {
if let contentHandler = contentHandler, let content = bestAttemptContent { if let contentHandler = contentHandler, let content = bestAttemptContent {
contentHandler(content) contentHandler(content)

View File

@@ -1,26 +1,28 @@
PODS: PODS:
- Capacitor (7.4.4): - Capacitor (7.6.6):
- CapacitorCordova - CapacitorCordova
- CapacitorCamera (7.0.2): - CapacitorCamera (7.0.5):
- Capacitor - Capacitor
- CapacitorCordova (7.4.4) - CapacitorCordova (7.6.6)
- CapacitorFilesystem (7.1.8): - CapacitorFilesystem (7.1.8):
- Capacitor - Capacitor
- IONFilesystemLib (~> 1.1.1) - IONFilesystemLib (~> 1.1.1)
- CapacitorGeolocation (7.1.5): - CapacitorGeolocation (7.1.8):
- Capacitor - Capacitor
- IONGeolocationLib (= 1.0.1) - IONGeolocationLib (= 1.0.1)
- CapacitorGoogleMaps (7.2.0): - CapacitorGoogleMaps (7.2.2):
- Capacitor - Capacitor
- Google-Maps-iOS-Utils (~> 5.0) - Google-Maps-iOS-Utils (~> 5.0)
- GoogleMaps (~> 8.4) - GoogleMaps (~> 8.4)
- CapacitorHaptics (7.0.3): - CapacitorHaptics (7.0.5):
- Capacitor - Capacitor
- CapacitorPreferences (7.0.4): - CapacitorPreferences (7.0.4):
- Capacitor - Capacitor
- CapacitorPushNotifications (7.0.6): - CapacitorPushNotifications (7.0.6):
- Capacitor - Capacitor
- CapacitorSplashScreen (7.0.3): - CapacitorSplashScreen (7.0.5):
- Capacitor
- ForumCapacitorNativeInput (1.0.0):
- Capacitor - Capacitor
- Google-Maps-iOS-Utils (5.0.0): - Google-Maps-iOS-Utils (5.0.0):
- GoogleMaps (~> 8.0) - GoogleMaps (~> 8.0)
@@ -45,6 +47,7 @@ DEPENDENCIES:
- "CapacitorPreferences (from `../../node_modules/@capacitor/preferences`)" - "CapacitorPreferences (from `../../node_modules/@capacitor/preferences`)"
- "CapacitorPushNotifications (from `../../node_modules/@capacitor/push-notifications`)" - "CapacitorPushNotifications (from `../../node_modules/@capacitor/push-notifications`)"
- "CapacitorSplashScreen (from `../../node_modules/@capacitor/splash-screen`)" - "CapacitorSplashScreen (from `../../node_modules/@capacitor/splash-screen`)"
- ForumCapacitorNativeInput (from `../../native-input`)
- "NotnotsamuelCapacitorSwipeBack (from `../../node_modules/@notnotsamuel/capacitor-swipe-back`)" - "NotnotsamuelCapacitorSwipeBack (from `../../node_modules/@notnotsamuel/capacitor-swipe-back`)"
SPEC REPOS: SPEC REPOS:
@@ -75,26 +78,29 @@ EXTERNAL SOURCES:
:path: "../../node_modules/@capacitor/push-notifications" :path: "../../node_modules/@capacitor/push-notifications"
CapacitorSplashScreen: CapacitorSplashScreen:
:path: "../../node_modules/@capacitor/splash-screen" :path: "../../node_modules/@capacitor/splash-screen"
ForumCapacitorNativeInput:
:path: "../../native-input"
NotnotsamuelCapacitorSwipeBack: NotnotsamuelCapacitorSwipeBack:
:path: "../../node_modules/@notnotsamuel/capacitor-swipe-back" :path: "../../node_modules/@notnotsamuel/capacitor-swipe-back"
SPEC CHECKSUMS: SPEC CHECKSUMS:
Capacitor: 09d9ff8e9618e8c4b3cab2bbee34a17215dd2fef Capacitor: 961efd309ab0e390a8081b63c561186f61a6714d
CapacitorCamera: 6e18d54c8ab30d7dc7b8cd93d96f9b4f57e9202a CapacitorCamera: 9e952270be355797f769aa835bb7643a96c871fe
CapacitorCordova: bf648a636f3c153f652d312ae145fb508b6ffced CapacitorCordova: 3a4e0027a89a59421c918d19aee9d6ba1c7a3556
CapacitorFilesystem: f54cd6b76be06fa7ceb219cf313d32e0d626ea87 CapacitorFilesystem: c63fc54df41e5a6761785a7f3c49dc696c22e296
CapacitorGeolocation: b96474c3259dd4a294227ea8ec19140b1837cceb CapacitorGeolocation: ab54d708e2fcf99a7bafe0219eaa6f7a932f001a
CapacitorGoogleMaps: 20b5445a532f80dbb120fa99941fd094bcc88af6 CapacitorGoogleMaps: e3a1c0b501daa84a327b6ee807ee515a7f890403
CapacitorHaptics: d17da7dd984cae34111b3f097ccd3e21f9feec62 CapacitorHaptics: 1b145fa83c622e5383a3bac04ea81e2514f0b0f5
CapacitorPreferences: d82a7e3b95fcab43a553268b803356522910d153 CapacitorPreferences: 69d9991307507aeab8ef8019c10b9babfda0e9ca
CapacitorPushNotifications: c6158ba6f3777f281a675aa43e4011e9723e822b CapacitorPushNotifications: b8e30637549ce91468b8c4dddbef2657be831f4c
CapacitorSplashScreen: d06ae8804808e9f649a08e7bb7f283c77b688084 CapacitorSplashScreen: 409eeaaeac603d264f209c0fcb718aa22fe1f7bb
ForumCapacitorNativeInput: 0f83e3b9755ed05fccc3eac1daaf9bf5ecf10f2b
Google-Maps-iOS-Utils: 66d6de12be1ce6d3742a54661e7a79cb317a9321 Google-Maps-iOS-Utils: 66d6de12be1ce6d3742a54661e7a79cb317a9321
GoogleMaps: 8939898920281c649150e0af74aa291c60f2e77d GoogleMaps: 8939898920281c649150e0af74aa291c60f2e77d
IONFilesystemLib: 21a63377696b2d8fab5632ecfb7d2ac67bddb68a IONFilesystemLib: 21a63377696b2d8fab5632ecfb7d2ac67bddb68a
IONGeolocationLib: 20f9d0248a0b5264511fb57a37e25dd2badf797a IONGeolocationLib: 20f9d0248a0b5264511fb57a37e25dd2badf797a
NotnotsamuelCapacitorSwipeBack: 07a8985928db83b1d62a1d596246db466e69d149 NotnotsamuelCapacitorSwipeBack: b3a00145209b368cdfe21816d7919484c3fe654e
PODFILE CHECKSUM: d7fbdcfecbb81edc39c9975b338d5d15e186424b PODFILE CHECKSUM: 7d58799944232142d58ed433a27a383cd373780d
COCOAPODS: 1.15.2 COCOAPODS: 1.16.2