Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add iOS Communication Notifications #32765

Merged
merged 51 commits into from
Dec 27, 2023
Merged
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
523578f
add notification service extension
arosiclair Dec 8, 2023
4335e95
move embed extensions build phase to fix build
arosiclair Dec 8, 2023
53272e9
use airship's UANotificationServiceExtension
arosiclair Dec 11, 2023
9f0e214
use iOS 13 minimum target
arosiclair Dec 11, 2023
794eb84
re-add default implementations
arosiclair Dec 11, 2023
5ff5826
check for payload properties
arosiclair Dec 12, 2023
6b192da
Merge branch 'arosiclair-clear-read-notifications' into arosiclair-io…
arosiclair Dec 12, 2023
83c0b93
use os_log instead of print
arosiclair Dec 13, 2023
315cf25
Add ExpError for throwing runtime errors with messages
arosiclair Dec 14, 2023
75bdc80
parse notification data
arosiclair Dec 14, 2023
daf5876
Merge branch 'arosiclair-clear-read-notifications' into arosiclair-io…
arosiclair Dec 14, 2023
91d7c7d
parse accountID
arosiclair Dec 14, 2023
73a49b6
parse the user's name
arosiclair Dec 14, 2023
b7c5cdb
add communication notification entitlement
arosiclair Dec 14, 2023
4707aba
add INSendMessageIntent
arosiclair Dec 14, 2023
de5d126
move NSUserActivityTypes to the main bundle
arosiclair Dec 15, 2023
fbc2eb1
create message intents and configure communication notifications
arosiclair Dec 15, 2023
c0d636e
parse message text
arosiclair Dec 15, 2023
e61d242
use updated INSendMessageIntent
arosiclair Dec 15, 2023
82245cf
fetch and use avatar
arosiclair Dec 15, 2023
2e22250
error handling
arosiclair Dec 15, 2023
718222e
parse and use roomName
arosiclair Dec 15, 2023
bad32ec
use formatted title
arosiclair Dec 17, 2023
b57be15
remove unused NotificationData properties
arosiclair Dec 17, 2023
8942cd0
remove unused var
arosiclair Dec 17, 2023
640eaa0
rename to just NotificationServiceExtension
arosiclair Dec 17, 2023
67d8610
fix pod install warnings for overriden settings
arosiclair Dec 17, 2023
2fe775f
configure the group/room name
arosiclair Dec 17, 2023
b8e343a
Merge branch 'main' of github.com:Expensify/App into arosiclair-ios-c…
arosiclair Dec 17, 2023
e5b8b3f
update bundle IDs for adhoc and prod
arosiclair Dec 17, 2023
055e64c
use manual signing to match the main app target
arosiclair Dec 17, 2023
fd11e2d
podfile lock update
arosiclair Dec 17, 2023
506ac9e
use iphone distribution like the main target
arosiclair Dec 19, 2023
99c4c50
Merge branch 'main' of github.com:Expensify/App into arosiclair-ios-c…
arosiclair Dec 19, 2023
a7ca53f
podfile update
arosiclair Dec 19, 2023
f734310
comment pointing to docs for comms notifications
arosiclair Dec 19, 2023
9115cc8
Update provisioning profiles in xcode project
AndrewGable Dec 19, 2023
44d09e5
Update with new profiles for Notification Service
AndrewGable Dec 19, 2023
ae48bdc
comment updates
arosiclair Dec 19, 2023
13beb5f
Tweaking configs for test and AdHoc
AndrewGable Dec 19, 2023
c39c67e
Tweak provisioning profile config
AndrewGable Dec 19, 2023
7707911
remove xcargs override
arosiclair Dec 19, 2023
2b451b2
use new profile names
arosiclair Dec 19, 2023
73a37f1
set provisioning profiles for AppStore build
arosiclair Dec 19, 2023
d09f9d7
fix grep check
arosiclair Dec 19, 2023
5b9810d
Merge branch 'main' of github.com:Expensify/App into arosiclair-ios-c…
arosiclair Dec 20, 2023
9788965
rename mock steps and assertions
arosiclair Dec 20, 2023
5b0ec5b
Merge branch 'main' into arosiclair-ios-comms-notifications
roryabraham Dec 24, 2023
8725cd2
Clean build after merge
roryabraham Dec 24, 2023
672d6ff
Fix workflow_tests
roryabraham Dec 25, 2023
a39f711
fix dev bundle IDs for NSE
arosiclair Dec 26, 2023
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
609 changes: 606 additions & 3 deletions ios/NewExpensify.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions ios/NewExpensify/Chat.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,7 @@
<string>applinks:staging.new.expensify.com</string>
<string>webcredentials:new.expensify.com</string>
</array>
<key>com.apple.developer.usernotifications.communication</key>
<true/>
</dict>
</plist>
4 changes: 4 additions & 0 deletions ios/NewExpensify/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -122,5 +122,9 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>NSUserActivityTypes</key>
<array>
<string>INSendMessageIntent</string>
</array>
</dict>
</plist>
12 changes: 12 additions & 0 deletions ios/NotificationServiceExtension/ExpError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//
// ExpError.swift
// NotificationServiceExtension
//
// Created by Andrew Rosiclair on 12/13/23.
//

import Foundation

enum ExpError: Error {
case runtimeError(String)
}
13 changes: 13 additions & 0 deletions ios/NotificationServiceExtension/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?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>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.usernotifications.service</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
</dict>
</dict>
</plist>
236 changes: 236 additions & 0 deletions ios/NotificationServiceExtension/NotificationService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
//
// NotificationService.swift
// NotificationServiceExtension
//
// Created by Andrew Rosiclair on 12/8/23.
//

import AirshipServiceExtension
import os.log
import Intents

class NotificationService: UANotificationServiceExtension {

var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
let log = OSLog(subsystem: Bundle.main.bundleIdentifier ?? "com.expensify.chat.dev.NotificationServiceExtension", category: "NotificationService")

override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
os_log("[NotificationService] didReceive() - received notification", log: log)

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

if #available(iOSApplicationExtension 15.0, *) {
configureCommunicationNotification(notificationContent: bestAttemptContent, contentHandler: contentHandler)
} else {
contentHandler(bestAttemptContent)
}
}

@available(iOSApplicationExtension 15.0, *)
func configureCommunicationNotification(notificationContent: UNMutableNotificationContent, contentHandler: @escaping (UNNotificationContent) -> Void) {
var notificationData: NotificationData
do {
notificationData = try parsePayload(notificationContent: notificationContent)
} catch ExpError.runtimeError(let errorMessage) {
os_log("[NotificationService] configureCommunicationNotification() - couldn't parse the payload '%@'", log: log, type: .error, errorMessage)
contentHandler(notificationContent)
return
} catch {
os_log("[NotificationService] configureCommunicationNotification() - unexpected error while parsing payload", log: log, type: .error)
contentHandler(notificationContent)
return
}

// Create an intent for the incoming message
let intent: INSendMessageIntent = createMessageIntent(notificationData: notificationData)

// Use the intent to initialize the interaction.
let interaction = INInteraction(intent: intent, response: nil)


// Interaction direction is incoming because the user is
// receiving this message.
interaction.direction = .incoming


// Donate the interaction before updating notification content.
interaction.donate { error in
if error != nil {
os_log("[NotificationService] configureCommunicationNotification() - failed to donate the message intent", log: self.log, type: .error)
contentHandler(notificationContent)
return
}

// After donation, update the notification content.
do {
// Update notification content before displaying the
// communication notification.
let updatedContent = try notificationContent.updating(from: intent)

// Call the content handler with the updated content
// to display the communication notification.
contentHandler(updatedContent)
} catch {
os_log("[NotificationService] configureCommunicationNotification() - failed to update the notification with send message intent", log: self.log, type: .error)
contentHandler(notificationContent)
}
}
}

func parsePayload(notificationContent: UNMutableNotificationContent) throws -> NotificationData {
guard let payload = notificationContent.userInfo["payload"] as? NSDictionary else {
throw ExpError.runtimeError("payload missing")
}

guard let reportID = payload["reportID"] as? Int64 else {
throw ExpError.runtimeError("payload.reportID missing")
}

guard let reportActionID = payload["reportActionID"] as? String else {
throw ExpError.runtimeError("payload.reportActionID missing")
}

guard let onyxData = payload["onyxData"] as? NSArray else {
throw ExpError.runtimeError("payload.onyxData missing" + reportActionID)
}

guard let reportActionOnyxUpdate = onyxData[1] as? NSDictionary else {
throw ExpError.runtimeError("payload.onyxData[1] missing" + reportActionID)
}

guard let reportActionCollection = reportActionOnyxUpdate["value"] as? NSDictionary else {
throw ExpError.runtimeError("payload.onyxData[1].value (report action onyx update) missing" + reportActionID)
}

guard let reportAction = reportActionCollection[reportActionID] as? NSDictionary else {
throw ExpError.runtimeError("payload.onyxData[1].value['\(reportActionID)'] (report action) missing" + reportActionID)
}

guard let avatarURL = reportAction["avatar"] as? String else {
throw ExpError.runtimeError("reportAction.avatar missing. reportActionID: " + reportActionID)
}

guard let accountID = reportAction["actorAccountID"] as? Int else {
throw ExpError.runtimeError("reportAction.actorAccountID missing. reportActionID: " + reportActionID)
}

guard let person = reportAction["person"] as? NSArray else {
throw ExpError.runtimeError("reportAction.person missing. reportActionID: " + reportActionID)
}

guard let personObject = person[0] as? NSDictionary else {
throw ExpError.runtimeError("reportAction.person[0] missing. reportActionID: " + reportActionID)
}

guard let userName = personObject["text"] as? String else {
throw ExpError.runtimeError("reportAction.person[0].text missing. reportActionID: " + reportActionID)
}

return NotificationData(
reportID: reportID,
reportActionID: reportActionID,
avatarURL: avatarURL,
accountID: accountID,
userName: userName,
title: notificationContent.title,
messageText: notificationContent.body,
roomName: payload["roomName"] as? String
)
}

@available(iOSApplicationExtension 14.0, *)
func createMessageIntent(notificationData: NotificationData) -> INSendMessageIntent {
// Initialize only the sender for a one-to-one message intent.
let handle = INPersonHandle(value: String(notificationData.accountID), type: .unknown)
let avatar = fetchINImage(imageURL: notificationData.avatarURL, reportActionID: notificationData.reportActionID)
let sender = INPerson(personHandle: handle,
nameComponents: nil,
displayName: notificationData.userName,
image: avatar,
contactIdentifier: nil,
customIdentifier: nil)

// Configure the group/room name if there is one
var speakableGroupName: INSpeakableString? = nil
var recipients: [INPerson]? = nil
if (notificationData.roomName != nil) {
speakableGroupName = INSpeakableString(spokenPhrase: notificationData.roomName ?? "")

// To add the group name subtitle there must be multiple recipients set. However, we do not have
// data on the participatns in the room/group chat so we just add a placeholder here. This shouldn't
// appear anywhere in the UI
let placeholderPerson = INPerson(personHandle: INPersonHandle(value: "placeholder", type: .unknown),
nameComponents: nil,
displayName: "placeholder",
image: nil,
contactIdentifier: nil,
customIdentifier: nil)
recipients = [sender, placeholderPerson]
}

// Because this communication is incoming, you can infer that the current user is
// a recipient. Don't include the current user when initializing the intent.
let intent = INSendMessageIntent(recipients: recipients,
outgoingMessageType: .outgoingMessageText,
content: notificationData.messageText,
speakableGroupName: speakableGroupName,
conversationIdentifier: String(notificationData.reportID),
serviceName: nil,
sender: sender,
attachments: nil)

// When the group name is set, we force the avatar to just be the sender's avatar
intent.setImage(avatar, forParameterNamed: \.speakableGroupName)

return intent
}

override func serviceExtensionTimeWillExpire() {
// Called just before the extension will be terminated by the system.
// Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used.
if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent {
contentHandler(bestAttemptContent)
}
}

func fetchINImage(imageURL: String, reportActionID: String) -> INImage? {
guard let url = URL(string: imageURL) else {
return nil
}

do {
let data = try Data(contentsOf: url)
return INImage(imageData: data)
} catch {
os_log("[NotificationService] fetchINImage() - failed to fetch avatar. reportActionID: %@", log: self.log, type: .error, reportActionID)
return nil
}
}
}

class NotificationData {
public var reportID: Int64
public var reportActionID: String
public var avatarURL: String
public var accountID: Int
public var userName: String
public var title: String
public var messageText: String
public var roomName: String?

public init (reportID: Int64, reportActionID: String, avatarURL: String, accountID: Int, userName: String, title: String, messageText: String, roomName: String?) {
self.reportID = reportID
self.reportActionID = reportActionID
self.avatarURL = avatarURL
self.accountID = accountID
self.userName = userName
self.title = title
self.messageText = messageText
self.roomName = roomName
}
}
4 changes: 4 additions & 0 deletions ios/Podfile
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,7 @@ target 'NewExpensify' do
end
end
end

target 'NotificationServiceExtension' do
pod 'AirshipServiceExtension'
end
37 changes: 33 additions & 4 deletions ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ PODS:
- Airship (= 16.12.1)
- Airship/MessageCenter (= 16.12.1)
- Airship/PreferenceCenter (= 16.12.1)
- AirshipServiceExtension (16.12.5)
- AppAuth (1.6.2):
- AppAuth/Core (= 1.6.2)
- AppAuth/ExternalUserAgent (= 1.6.2)
Expand Down Expand Up @@ -777,10 +778,35 @@ PODS:
- React-Core
- RNReactNativeHapticFeedback (1.14.0):
- React-Core
- RNReanimated (3.6.1):
- RCT-Folly (= 2021.07.22.00)
- RNReanimated (3.5.4):
- DoubleConversion
- FBLazyVector
- glog
- hermes-engine
- RCT-Folly
- RCTRequired
- RCTTypeSafety
- React-callinvoker
- React-Core
- React-Core/DevSupport
- React-Core/RCTWebSocket
- React-CoreModules
- React-cxxreact
- React-hermes
- React-jsi
- React-jsiexecutor
- React-jsinspector
- React-RCTActionSheet
- React-RCTAnimation
- React-RCTAppDelegate
- React-RCTBlob
- React-RCTImage
- React-RCTLinking
- React-RCTNetwork
- React-RCTSettings
- React-RCTText
- ReactCommon/turbomodule/core
- Yoga
- RNScreens (3.21.0):
- React-Core
- React-RCTImage
Expand All @@ -803,6 +829,7 @@ PODS:
- Yoga (~> 1.14)

DEPENDENCIES:
- AirshipServiceExtension
- boost (from `../node_modules/react-native/third-party-podspecs/boost.podspec`)
- BVLinearGradient (from `../node_modules/react-native-linear-gradient`)
- DoubleConversion (from `../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`)
Expand Down Expand Up @@ -917,6 +944,7 @@ SPEC REPOS:
trunk:
- Airship
- AirshipFrameworkProxy
- AirshipServiceExtension
- AppAuth
- CocoaAsyncSocket
- Firebase
Expand Down Expand Up @@ -1137,6 +1165,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
Airship: 2f4510b497a8200780752a5e0304a9072bfffb6d
AirshipFrameworkProxy: ea1b6c665c798637b93c465b5e505be3011f1d9d
AirshipServiceExtension: 89c6e25a69f3458d9dbd581c700cffb196b61930
AppAuth: 3bb1d1cd9340bd09f5ed189fb00b1cc28e1e8570
boost: 57d2868c099736d80fcd648bf211b4431e51a558
BVLinearGradient: 421743791a59d259aec53f4c58793aad031da2ca
Expand Down Expand Up @@ -1255,7 +1284,7 @@ SPEC CHECKSUMS:
rnmapbox-maps: 6f638ec002aa6e906a6f766d69cd45f968d98e64
RNPermissions: 9b086c8f05b2e2faa587fdc31f4c5ab4509728aa
RNReactNativeHapticFeedback: 1e3efeca9628ff9876ee7cdd9edec1b336913f8c
RNReanimated: fdbaa9c964bbab7fac50c90862b6cc5f041679b9
RNReanimated: ab2e96c6d5591c3dfbb38a464f54c8d17fb34a87
RNScreens: d037903436160a4b039d32606668350d2a808806
RNSVG: d00c8f91c3cbf6d476451313a18f04d220d4f396
SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d
Expand All @@ -1266,6 +1295,6 @@ SPEC CHECKSUMS:
Yoga: 3efc43e0d48686ce2e8c60f99d4e6bd349aff981
YogaKit: f782866e155069a2cca2517aafea43200b01fd5a

PODFILE CHECKSUM: ff769666b7221c15936ebc5576a8c8e467dc6879
PODFILE CHECKSUM: 39399f883f6ae62edb4cc9e4fab8d4143eab59a7

COCOAPODS: 1.12.1