Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const config: ExpoConfig = {
* That affects eas updates and makes sure the app doesn't
* break when updating Over The Air
*/
version: "1.4.0",
version: "1.5.0",
runtimeVersion: {
policy: "appVersion",
},
Expand Down Expand Up @@ -161,6 +161,11 @@ const config: ExpoConfig = {
cameraPermission: "The app allows you to take photos for your doggie's profile.",
},
],
// Wires every directory under `targets/` into the Xcode project at
// prebuild time. Currently just the notification-service extension that
// renders chat pushes as iOS communication notifications (sender dog
// avatar + name); see targets/notification-service/.
"@bacons/apple-targets",
// Wires the source-controlled `Pegada.storekit` fixture into the iOS
// scheme so simulator runs (local + CI) can resolve real product pricing
// without an App Store sandbox session. Plugin is a no-op when the file
Expand Down Expand Up @@ -242,6 +247,13 @@ const config: ExpoConfig = {
usesNonExemptEncryption: false,
},
bundleIdentifier: "app.pegada",
entitlements: {
// Communication-notification styling for chat pushes (the
// notification-service target carries the same entitlement; both need
// the capability enabled on their App IDs in the Apple Developer
// portal for device builds).
"com.apple.developer.usernotifications.communication": true,
},
// associatedDomains: [
// 'applinks:pegada.app',
// 'applinks:www.pegada.app',
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"preinstall": "pnpm setup:secret:files"
},
"dependencies": {
"@bacons/apple-targets": "4.0.7",
"@expo/config-types": "^55.0.5",
"@expo/metro-runtime": "~55.0.11",
"@gorhom/bottom-sheet": "^5.2.14",
Expand Down
25 changes: 25 additions & 0 deletions apps/mobile/targets/notification-service/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!--
Hand-maintained (apple-targets only generates this file when absent).
IntentsSupported/INSendMessageIntent is what marks the extension as
capable of upgrading pushes into communication notifications.
-->
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>IntentsSupported</key>
<array>
<string>INSendMessageIntent</string>
</array>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.usernotifications.service</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
</dict>
</dict>
</plist>
162 changes: 162 additions & 0 deletions apps/mobile/targets/notification-service/NotificationService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import Intents
import UserNotifications

/// Upgrades incoming chat pushes into iOS communication notifications
/// (sender dog avatar + name, iMessage-style) by donating an
/// `INSendMessageIntent` and rebuilding the notification content from it.
///
/// The server marks chat pushes with `mutable-content: 1` and ships
/// `senderName` / `senderAvatarUrl` / `url` in the Expo push `data` payload
/// (see packages/api/src/services/MessageService.ts).
///
/// NSE contract: no matter what fails (missing fields, avatar download,
/// intent donation), the original notification content is still delivered.
/// This class must never swallow a notification or hand back broken content.
final class NotificationService: UNNotificationServiceExtension {
private var contentHandler: ((UNNotificationContent) -> Void)?
private var bestAttemptContent: UNMutableNotificationContent?
private var avatarTask: URLSessionDataTask?

override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) {
self.contentHandler = contentHandler

guard let bestAttempt = request.content.mutableCopy() as? UNMutableNotificationContent else {
contentHandler(request.content)
return
}
bestAttemptContent = bestAttempt

let data = Self.expoData(from: request.content.userInfo)

guard
let senderName = data["senderName"] as? String, !senderName.isEmpty,
let url = data["url"] as? String, !url.isEmpty
else {
// Not a payload we know how to upgrade: deliver as-is.
contentHandler(bestAttempt)
return
}

// Deep-link convention from the server: chat/<matchId>/<senderDogId>.
let parts = url.split(separator: "/").map(String.init)
let conversationId = parts.count > 1 ? parts[1] : url
let senderId = parts.count > 2 ? parts[2] : senderName

// Group notifications per conversation even if the intent rebuild fails.
bestAttempt.threadIdentifier = conversationId

let avatarUrl = (data["senderAvatarUrl"] as? String).flatMap(URL.init(string:))

downloadAvatar(from: avatarUrl) { [weak self] avatar in
guard let self else { return }
let upgraded = Self.communicationContent(
from: bestAttempt,
senderId: senderId,
senderName: senderName,
conversationId: conversationId,
avatar: avatar
)
self.deliver(upgraded ?? bestAttempt)
}
}

override func serviceExtensionTimeWillExpire() {
// Out of time: cancel the avatar download and ship the best we have.
avatarTask?.cancel()
if let bestAttemptContent {
deliver(bestAttemptContent)
}
}

private func deliver(_ content: UNNotificationContent) {
contentHandler?(content)
contentHandler = nil
}

// MARK: - Payload parsing

/// Expo's push gateway delivers the message's `data` field under the
/// top-level `body` key of the APNs payload (dictionary, or JSON string in
/// older gateway versions). Handle both shapes defensively.
private static func expoData(from userInfo: [AnyHashable: Any]) -> [String: Any] {
if let dict = userInfo["body"] as? [String: Any] {
return dict
}
if let string = userInfo["body"] as? String,
let data = string.data(using: .utf8),
let dict = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
{
return dict
}
return [:]
}

// MARK: - Avatar download

/// Fetches the sender's avatar. Always calls `completion`, with `nil` on
/// any failure so the notification still upgrades (just without a photo).
private func downloadAvatar(from url: URL?, completion: @escaping (INImage?) -> Void) {
guard let url, url.scheme == "https" else {
completion(nil)
return
}

// Stay well inside the NSE's ~30s budget so the intent donation and
// handoff still happen even on a slow network.
let task = URLSession.shared.dataTask(with: url) { data, response, _ in
guard
let data, !data.isEmpty,
let httpResponse = response as? HTTPURLResponse,
(200..<300).contains(httpResponse.statusCode)
else {
completion(nil)
return
}
completion(INImage(imageData: data))
}
avatarTask = task
task.resume()
}

// MARK: - Communication styling

/// Donates an incoming `INSendMessageIntent` for the sender and rebuilds
/// the notification content from it. Returns `nil` on any failure so the
/// caller can fall back to the untouched content.
private static func communicationContent(
from content: UNMutableNotificationContent,
senderId: String,
senderName: String,
conversationId: String,
avatar: INImage?
) -> UNNotificationContent? {
let sender = INPerson(
personHandle: INPersonHandle(value: senderId, type: .unknown),
nameComponents: nil,
displayName: senderName,
image: avatar,
contactIdentifier: nil,
customIdentifier: senderId
)

let intent = INSendMessageIntent(
recipients: nil,
outgoingMessageType: .outgoingMessageText,
content: content.body,
speakableGroupName: nil,
conversationIdentifier: conversationId,
serviceName: nil,
sender: sender,
attachments: nil
)

let interaction = INInteraction(intent: intent, response: nil)
interaction.direction = .incoming
interaction.donate(completion: nil)

return try? content.updating(from: intent)
}
}
26 changes: 26 additions & 0 deletions apps/mobile/targets/notification-service/expo-target.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Notification Service Extension (NSE) for chat pushes. Intercepts pushes
// sent with `mutable-content: 1` (see packages/api MessageService) and
// restyles them as iOS communication notifications: sender dog avatar +
// name, iMessage-style. See NotificationService.swift for the actual work.
//
// Wired into the Xcode project at prebuild time by @bacons/apple-targets
// (the `targets/` folder is CNG-safe: nothing under ios/ is committed).

/** @type {import('@bacons/apple-targets/app.plugin').Config} */
module.exports = {
type: "notification-service",
name: "PegadaNotificationService",
displayName: "Pegada Notification Service",
// Appended to the main app's bundle id -> app.pegada.notificationservice
bundleIdentifier: ".notificationservice",
// INSendMessageIntent + UNNotificationContent.updating(from:) need iOS 15+.
deploymentTarget: "15.1",
frameworks: ["UserNotifications", "Intents"],
entitlements: {
// Communication-notification styling. Must also be enabled on the App ID
// in the Apple Developer portal for device builds (simulator doesn't
// enforce it). The main app carries the same entitlement via
// `ios.entitlements` in app.config.ts.
"com.apple.developer.usernotifications.communication": true,
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.usernotifications.communication</key>
<true/>
</dict>
</plist>
16 changes: 15 additions & 1 deletion packages/api/src/services/MessageService.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import prisma from "@pegada/database";
import { Language } from "@pegada/shared/i18n/types/types";
import { IMAGE_STATUS } from "@pegada/shared/schemas/dogSchema";

import { PushNotificationService } from "./PushNotificationService";
import { TranslationService } from "./TranslationService";
Expand Down Expand Up @@ -64,7 +65,15 @@ class MessageService {
sender: {
select: {
name: true,
images: true,
// First approved photo only: it rides along in the push payload so
// the iOS Notification Service Extension can render the sender's
// avatar on communication notifications.
images: {
orderBy: { position: "asc" },
where: { status: IMAGE_STATUS.APPROVED },
take: 1,
select: { url: true },
},
},
},
receiver: {
Expand Down Expand Up @@ -93,8 +102,13 @@ class MessageService {
}),
channelId: "messages",
categoryId: "chat-message",
// Lets the iOS Notification Service Extension intercept the push and
// restyle it as a communication notification (sender avatar + name).
mutableContent: true,
data: {
url: `chat/${matchId}/${newMessage.senderId}`,
senderName: newMessage.sender.name,
senderAvatarUrl: newMessage.sender.images[0]?.url,
},
});
}
Expand Down
Loading
Loading