diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 9b37c77..40be252 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -467,7 +467,7 @@ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; MARKETING_VERSION = 1.0.6; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; - PRODUCT_BUNDLE_IDENTIFIER = russell.sam.forum; + PRODUCT_BUNDLE_IDENTIFIER = russell.sam.forumtest; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_VERSION = 5.0; @@ -489,7 +489,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; MARKETING_VERSION = 1.0.6; - PRODUCT_BUNDLE_IDENTIFIER = russell.sam.forum; + PRODUCT_BUNDLE_IDENTIFIER = russell.sam.forumtest; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; SWIFT_VERSION = 5.0; @@ -521,7 +521,7 @@ MARKETING_VERSION = 1.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = russell.sam.forum.NotificationService; + PRODUCT_BUNDLE_IDENTIFIER = russell.sam.forumtest.nse; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; @@ -554,7 +554,7 @@ LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = russell.sam.forum.NotificationService; + PRODUCT_BUNDLE_IDENTIFIER = russell.sam.forumtest.nse; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/ios/App/App/App.entitlements b/ios/App/App/App.entitlements index 903def2..29326e3 100644 --- a/ios/App/App/App.entitlements +++ b/ios/App/App/App.entitlements @@ -4,5 +4,7 @@ aps-environment development + com.apple.developer.usernotifications.communication + diff --git a/ios/App/App/Info.plist b/ios/App/App/Info.plist index 8e8d560..768a855 100644 --- a/ios/App/App/Info.plist +++ b/ios/App/App/Info.plist @@ -35,6 +35,10 @@ Used to find forums and communities near you NSPhotoLibraryUsageDescription Access your photos to set a profile picture and share with others + NSUserActivityTypes + + INSendMessageIntent + UIBackgroundModes remote-notification diff --git a/ios/App/NotificationService/COMMUNICATION_NOTIFICATIONS.md b/ios/App/NotificationService/COMMUNICATION_NOTIFICATIONS.md new file mode 100644 index 0000000..1ad3435 --- /dev/null +++ b/ios/App/NotificationService/COMMUNICATION_NOTIFICATIONS.md @@ -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. diff --git a/ios/App/NotificationService/Info.plist b/ios/App/NotificationService/Info.plist index 57421eb..ad04f44 100644 --- a/ios/App/NotificationService/Info.plist +++ b/ios/App/NotificationService/Info.plist @@ -2,6 +2,10 @@ + NSUserActivityTypes + + INSendMessageIntent + NSExtension NSExtensionPointIdentifier diff --git a/ios/App/NotificationService/NotificationService.swift b/ios/App/NotificationService/NotificationService.swift index 37c2967..561405a 100644 --- a/ios/App/NotificationService/NotificationService.swift +++ b/ios/App/NotificationService/NotificationService.swift @@ -6,6 +6,7 @@ // import UserNotifications +import Intents class NotificationService: UNNotificationServiceExtension { @@ -25,21 +26,91 @@ class NotificationService: UNNotificationServiceExtension { return } - URLSession.shared.downloadTask(with: imageUrl) { location, _, error in - defer { contentHandler(content) } - guard let location = location, error == nil else { return } + let userInfo = request.content.userInfo - let tmpFile = URL(fileURLWithPath: NSTemporaryDirectory()) - .appendingPathComponent(imageUrl.lastPathComponent) + // Download the avatar (the sending network's photo), then render the + // notification. A data task lets us hand the bytes straight to INImage. + URLSession.shared.dataTask(with: imageUrl) { data, _, error in + guard let data = data, !data.isEmpty, error == nil else { + contentHandler(content) + return + } - try? FileManager.default.moveItem(at: location, to: tmpFile) - - if let attachment = try? UNNotificationAttachment(identifier: "avatar", url: tmpFile) { - content.attachments = [attachment] + // 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) { + content.attachments = [attachment] + } + contentHandler(content) } }.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() { if let contentHandler = contentHandler, let content = bestAttemptContent { contentHandler(content) diff --git a/ios/App/Podfile.lock b/ios/App/Podfile.lock index bf7b21b..a1a7e94 100644 --- a/ios/App/Podfile.lock +++ b/ios/App/Podfile.lock @@ -1,26 +1,28 @@ PODS: - - Capacitor (7.4.4): + - Capacitor (7.6.6): - CapacitorCordova - - CapacitorCamera (7.0.2): + - CapacitorCamera (7.0.5): - Capacitor - - CapacitorCordova (7.4.4) + - CapacitorCordova (7.6.6) - CapacitorFilesystem (7.1.8): - Capacitor - IONFilesystemLib (~> 1.1.1) - - CapacitorGeolocation (7.1.5): + - CapacitorGeolocation (7.1.8): - Capacitor - IONGeolocationLib (= 1.0.1) - - CapacitorGoogleMaps (7.2.0): + - CapacitorGoogleMaps (7.2.2): - Capacitor - Google-Maps-iOS-Utils (~> 5.0) - GoogleMaps (~> 8.4) - - CapacitorHaptics (7.0.3): + - CapacitorHaptics (7.0.5): - Capacitor - CapacitorPreferences (7.0.4): - Capacitor - CapacitorPushNotifications (7.0.6): - Capacitor - - CapacitorSplashScreen (7.0.3): + - CapacitorSplashScreen (7.0.5): + - Capacitor + - ForumCapacitorNativeInput (1.0.0): - Capacitor - Google-Maps-iOS-Utils (5.0.0): - GoogleMaps (~> 8.0) @@ -45,6 +47,7 @@ DEPENDENCIES: - "CapacitorPreferences (from `../../node_modules/@capacitor/preferences`)" - "CapacitorPushNotifications (from `../../node_modules/@capacitor/push-notifications`)" - "CapacitorSplashScreen (from `../../node_modules/@capacitor/splash-screen`)" + - ForumCapacitorNativeInput (from `../../native-input`) - "NotnotsamuelCapacitorSwipeBack (from `../../node_modules/@notnotsamuel/capacitor-swipe-back`)" SPEC REPOS: @@ -75,26 +78,29 @@ EXTERNAL SOURCES: :path: "../../node_modules/@capacitor/push-notifications" CapacitorSplashScreen: :path: "../../node_modules/@capacitor/splash-screen" + ForumCapacitorNativeInput: + :path: "../../native-input" NotnotsamuelCapacitorSwipeBack: :path: "../../node_modules/@notnotsamuel/capacitor-swipe-back" SPEC CHECKSUMS: - Capacitor: 09d9ff8e9618e8c4b3cab2bbee34a17215dd2fef - CapacitorCamera: 6e18d54c8ab30d7dc7b8cd93d96f9b4f57e9202a - CapacitorCordova: bf648a636f3c153f652d312ae145fb508b6ffced - CapacitorFilesystem: f54cd6b76be06fa7ceb219cf313d32e0d626ea87 - CapacitorGeolocation: b96474c3259dd4a294227ea8ec19140b1837cceb - CapacitorGoogleMaps: 20b5445a532f80dbb120fa99941fd094bcc88af6 - CapacitorHaptics: d17da7dd984cae34111b3f097ccd3e21f9feec62 - CapacitorPreferences: d82a7e3b95fcab43a553268b803356522910d153 - CapacitorPushNotifications: c6158ba6f3777f281a675aa43e4011e9723e822b - CapacitorSplashScreen: d06ae8804808e9f649a08e7bb7f283c77b688084 + Capacitor: 961efd309ab0e390a8081b63c561186f61a6714d + CapacitorCamera: 9e952270be355797f769aa835bb7643a96c871fe + CapacitorCordova: 3a4e0027a89a59421c918d19aee9d6ba1c7a3556 + CapacitorFilesystem: c63fc54df41e5a6761785a7f3c49dc696c22e296 + CapacitorGeolocation: ab54d708e2fcf99a7bafe0219eaa6f7a932f001a + CapacitorGoogleMaps: e3a1c0b501daa84a327b6ee807ee515a7f890403 + CapacitorHaptics: 1b145fa83c622e5383a3bac04ea81e2514f0b0f5 + CapacitorPreferences: 69d9991307507aeab8ef8019c10b9babfda0e9ca + CapacitorPushNotifications: b8e30637549ce91468b8c4dddbef2657be831f4c + CapacitorSplashScreen: 409eeaaeac603d264f209c0fcb718aa22fe1f7bb + ForumCapacitorNativeInput: 0f83e3b9755ed05fccc3eac1daaf9bf5ecf10f2b Google-Maps-iOS-Utils: 66d6de12be1ce6d3742a54661e7a79cb317a9321 GoogleMaps: 8939898920281c649150e0af74aa291c60f2e77d IONFilesystemLib: 21a63377696b2d8fab5632ecfb7d2ac67bddb68a IONGeolocationLib: 20f9d0248a0b5264511fb57a37e25dd2badf797a - NotnotsamuelCapacitorSwipeBack: 07a8985928db83b1d62a1d596246db466e69d149 + NotnotsamuelCapacitorSwipeBack: b3a00145209b368cdfe21816d7919484c3fe654e -PODFILE CHECKSUM: d7fbdcfecbb81edc39c9975b338d5d15e186424b +PODFILE CHECKSUM: 7d58799944232142d58ed433a27a383cd373780d -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2