[Notifications] Network photos in notifications
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
53
ios/App/NotificationService/COMMUNICATION_NOTIFICATIONS.md
Normal file
53
ios/App/NotificationService/COMMUNICATION_NOTIFICATIONS.md
Normal 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.
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user