Skip to content

Commit

Permalink
Handling Background Notifications (#40)
Browse files Browse the repository at this point in the history
# *Handling Background Notifications*

## ♻️ Current situation & Problem
- #31
- #36

Currently, Prisma is only configured to handle notifications in the
foreground. Handling notifications requires writing timestamps to
Firestore when notifications are received and opened on the user's
device, but this was not possible with the current configuration of
background notification handling due to Apple throttling the amount of
requests per hour [(see this
link)](https://developer.apple.com/documentation/usernotifications/pushing-background-updates-to-your-app).


## ⚙️ Release Notes 

As a workaround, we utilize the UNNotificationServiceExtension class to
provide an entry-point for pushing updates to Firestore on the arrival
of background notifications. Normally we would use this extension to
modify the notification’s content or download content related to the
extension, but we are taking advantage of the fact that we are allowed a
30 second period to do so, in order to write to Firestore. Doing so
required [creating a shared keychain for cross-app
authentication](https://firebase.google.com/docs/auth/ios/single-sign-on),
allowing access of the auth state for Firebase between the application
and the extension, utilized for authorizing writes to Firestore using
the same instance of Firebase as the application upon notification
arrival.

The following additions/changes were made to the Prisma application:

- Added the Notification Service Extension
`PrismaPushNotificationsExtension/NotificationsService.swift`
- `didReceive` implements functionality for writing to Firestore, using
the logs collection path passed in the notification payload. Because the
main application and app extension run independently of each other, we
needed to introduce an authorization mechanism that is checked in the
function before actually doing any writes.

- We also added a keychain access group, allowing for keychain sharing
between the main app and the extension. We subsequently implemented the
authorization function `authorizeAccessGroupForCurrentUser()` which
checks for the shared access group instance for a user, documented in
`PrismaStandard.swift`.
- This function is called within both the Home view and the
AccountOnboarding flow, ensuring that a user will always need to be
authorized and logged in in order to receive background notification
updates and have those writable to Firestore.



## 📚 Documentation
*Please ensure that you properly document any additions in conformance
to [Spezi Documentation
Guide](https://github.com/StanfordSpezi/.github/blob/main/DOCUMENTATIONGUIDE.md).*
*You can use this section to describe your solution, but we encourage
contributors to document your reasoning and changes using in-line
documentation.*


## ✅ Testing
*Please ensure that the PR meets the testing requirements set by CodeCov
and that new functionality is appropriately tested.*
*This section describes important information about the tests and why
some elements might not be testable.*


## 📝 Code of Conduct & Contributing Guidelines 

By submitting creating this pull request, you agree to follow our [Code
of
Conduct](https://github.com/CS342/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/CS342/.github/blob/main/CONTRIBUTING.md):
- [ x] I agree to follow the [Code of
Conduct](https://github.com/CS342/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/CS342/.github/blob/main/CONTRIBUTING.md).
  • Loading branch information
bryant-jimenez authored Mar 12, 2024
1 parent 581e524 commit 4939632
Show file tree
Hide file tree
Showing 13 changed files with 555 additions and 20 deletions.
386 changes: 384 additions & 2 deletions Prisma.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions Prisma/Home.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
// SPDX-License-Identifier: MIT
//

import FirebaseAuth
import SpeziAccount
import SpeziMockWebService
import SwiftUI
Expand All @@ -26,6 +27,7 @@ struct HomeView: View {


@AppStorage(StorageKeys.homeTabSelection) private var selectedTab = Tabs.schedule
@Environment(PrismaStandard.self) private var standard
@State private var presentingAccount = false


Expand Down Expand Up @@ -66,6 +68,9 @@ struct HomeView: View {
AccountSheet()
}
.verifyRequiredAccountDetails(Self.accountEnabled)
.task {
await standard.authorizeAccessGroupForCurrentUser()
}
}
}

Expand Down
16 changes: 9 additions & 7 deletions Prisma/Onboarding/AccountOnboarding.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
// SPDX-License-Identifier: MIT
//

import FirebaseAuth
import SpeziAccount
import SpeziOnboarding
import SwiftUI
Expand All @@ -20,6 +21,7 @@ struct AccountOnboarding: View {
var body: some View {
AccountSetup { _ in
Task {
await standard.authorizeAccessGroupForCurrentUser()
// Placing the nextStep() call inside this task will ensure that the sheet dismiss animation is
// played till the end before we navigate to the next step.
await standard.setAccountTimestamp()
Expand All @@ -44,11 +46,11 @@ struct AccountOnboarding: View {
OnboardingStack {
AccountOnboarding()
}
.previewWith {
AccountConfiguration {
MockUserIdPasswordAccountService()
}
.previewWith {
AccountConfiguration {
MockUserIdPasswordAccountService()
}
}
}

#Preview("Account Onboarding") {
Expand All @@ -59,8 +61,8 @@ struct AccountOnboarding: View {
return OnboardingStack {
AccountOnboarding()
}
.previewWith {
AccountConfiguration(building: details, active: MockUserIdPasswordAccountService())
}
.previewWith {
AccountConfiguration(building: details, active: MockUserIdPasswordAccountService())
}
}
#endif
11 changes: 8 additions & 3 deletions Prisma/PushNotifications/PushNotifications.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ class PrismaPushNotifications: NSObject, Module, NotificationHandler, Notificati
@StandardActor var standard: PrismaStandard
@Dependency private var configureFirebaseApp: ConfigureFirebaseApp


override init() {}


Expand All @@ -48,7 +47,6 @@ class PrismaPushNotifications: NSObject, Module, NotificationHandler, Notificati

func handleNotificationAction(_ response: UNNotificationResponse) async {
// right now the default action is when a user taps on the notification. functionality can be expanded in the future.
_ = response.actionIdentifier
if let sentTimestamp = response.notification.request.content.userInfo["sent_timestamp"] as? String {
let openedTimestamp = Date().toISOFormat(timezone: TimeZone(abbreviation: "UTC"))
await standard.addNotificationOpenedTimestamp(timeSent: sentTimestamp, timeOpened: openedTimestamp)
Expand All @@ -71,7 +69,14 @@ class PrismaPushNotifications: NSObject, Module, NotificationHandler, Notificati
}

func receiveRemoteNotification(_ remoteNotification: [AnyHashable: Any]) async -> BackgroundFetchResult {
print("bg")
let receivedTimestamp = Date().toISOFormat(timezone: TimeZone(abbreviation: "UTC"))
if let sentTimestamp = remoteNotification["sent_timestamp"] as? String {
Task {
await standard.addNotificationReceivedTimestamp(timeSent: sentTimestamp, timeReceived: receivedTimestamp)
}
} else {
print("Sent timestamp is not a string or is nil")
}
return .noData
}

Expand Down
10 changes: 5 additions & 5 deletions Prisma/SharedContext/FeatureFlags.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@ enum FeatureFlags {
static let showOnboarding = CommandLine.arguments.contains("--showOnboarding")
/// Disables the Firebase interactions, including the login/sign-up step and the Firebase Firestore upload.
static let disableFirebase = CommandLine.arguments.contains("--disableFirebase")
// if targetEnvironment(simulator)
// Defines if the application should connect to the local firebase emulator. Always set to true when using the iOS simulator.
// static let useFirebaseEmulator = true
// else
#if targetEnvironment(simulator)
/// Defines if the application should connect to the local firebase emulator. Always set to true when using the iOS simulator.
static let useFirebaseEmulator = true
#else
/// Defines if the application should connect to the local firebase emulator. Always set to true when using the iOS simulator.
static let useFirebaseEmulator = CommandLine.arguments.contains("--useFirebaseEmulator")
// endif
#endif
/// Adds a test task to the schedule at the current time
static let testSchedule = CommandLine.arguments.contains("--testSchedule")
}
9 changes: 6 additions & 3 deletions Prisma/Standard/PrismaStandard+PushNotifications.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,18 @@ extension PrismaStandard {

// try push to Firestore.
do {
try await Firestore.firestore().document(path).setData(["received": timeReceived])
try await Firestore.firestore().document(path).setData(["received": timeReceived], merge: true)
} catch {
print("Failed to set data in Firestore: \(error.localizedDescription)")
}
}


/// Stores the timestamp when a notification was opened by
/// the user to the specific notification document.
/// the user's device to the specific notification document.
///
/// - Parameter timeSent: The time which the notification was sent, used for the path in Firestore.
/// - Parameter timeOpened: The time which the notification was opened, generated when the user opens the notification.
func addNotificationOpenedTimestamp(timeSent: String, timeOpened: String) async {
// path = user_id/notifications/data/logs/YYYY-MM-DDThh:mm:ss.mss
let path: String
Expand All @@ -49,7 +52,7 @@ extension PrismaStandard {

// try push to Firestore.
do {
try await Firestore.firestore().document(path).setData(["opened": timeOpened])
try await Firestore.firestore().document(path).setData(["opened": timeOpened], merge: true)
} catch {
print("Failed to set data in Firestore: \(error.localizedDescription)")
}
Expand Down
33 changes: 33 additions & 0 deletions Prisma/Standard/PrismaStandard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
// SPDX-License-Identifier: MIT
//

import FirebaseAuth
import FirebaseFirestore
import FirebaseMessaging
import FirebaseStorage
Expand Down Expand Up @@ -221,4 +222,36 @@ actor PrismaStandard: Standard, EnvironmentAccessible, HealthKitConstraint, Onbo
}
try await accountStorage.delete(identifier)
}

/// Authorizes access to the Prisma keychain access group for the currently signed-in user.
///
/// If the current user is signed in, this function authorizes their access to the Prisma notifications keychain access group identifier.
/// If the user is not signed in, or if an error occurs during the authorization process, appropriate error handling is performed, and the user may be logged out.
///
/// - Parameters:
/// - user: The current user object.
/// - accessGroup: The identifier of the access group to authorize.
///
/// - Throws: An error if an issue occurs during the authorization process.
func authorizeAccessGroupForCurrentUser() async {
guard let user = Auth.auth().currentUser else {
print("No signed in user.")
return
}
let accessGroup = "637867499T.edu.stanford.cs342.2024.behavior"

guard (try? Auth.auth().getStoredUser(forAccessGroup: accessGroup)) == nil else {
print("Access group already shared ...")
return
}

do {
try Auth.auth().useUserAccessGroup(accessGroup)
try await Auth.auth().updateCurrentUser(user)
} catch let error as NSError {
print("Error changing user access group: %@", error)
// log out the user if fails
try? Auth.auth().signOut()
}
}
}
4 changes: 4 additions & 0 deletions Prisma/Supporting Files/Prisma.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,9 @@
<array/>
<key>com.apple.developer.healthkit.background-delivery</key>
<true/>
<key>keychain-access-groups</key>
<array>
<string>637867499T.edu.stanford.cs342.2024.behavior</string>
</array>
</dict>
</plist>
13 changes: 13 additions & 0 deletions PrismaPushNotificationsExtension/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>
6 changes: 6 additions & 0 deletions PrismaPushNotificationsExtension/Info.plist.license
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@

This source file is part of the Stanford Prisma Application based on the Stanford Spezi Template Application project

SPDX-FileCopyrightText: 2023 Stanford University

SPDX-License-Identifier: MIT
66 changes: 66 additions & 0 deletions PrismaPushNotificationsExtension/NotificationService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
//
// This source file is part of the Stanford Prisma Application based on the Stanford Spezi Template Application project.
//
// SPDX-FileCopyrightText: 2023 Stanford University
//
// SPDX-License-Identifier: MIT
//
// This file implements an extension to the Notification Service class, which is used to upload timestamps to
// Firestore on receival of background notifications.
//
// Created by Bryant Jimenez on 2/1/24.
//

import Firebase
import FirebaseAuth
import FirebaseCore
import FirebaseFirestore
import UserNotifications

class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?

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

let accessGroup = "637867499T.edu.stanford.cs342.2024.behavior"
do {
try Auth.auth().useUserAccessGroup(accessGroup)
} catch let error as NSError {
print("Error changing user access group: %@", error)
}

self.contentHandler = contentHandler
bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
if let bestAttemptContent = bestAttemptContent {
let path = request.content.userInfo["logs_path"] as? String ?? ""
let receivedTimestamp = Date().toISOFormat(timezone: TimeZone(abbreviation: "UTC"))
Firestore.firestore().document(path).setData(["received": receivedTimestamp], merge: true)
contentHandler(bestAttemptContent)
}
}

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)
}
}
}

extension Date {
/// converts Date object to ISO Format string. Can optionally pass in a time zone to convert it to.
/// If no timezone is passed, it converts the Date object using the local time zone.
func toISOFormat(timezone: TimeZone? = nil) -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withFullDate, .withTime, .withColonSeparatorInTime, .withFractionalSeconds]
if let timezone = timezone {
formatter.timeZone = timezone
} else {
formatter.timeZone = TimeZone.current
}
return formatter.string(from: self)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?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>keychain-access-groups</key>
<array>
<string>637867499T.edu.stanford.cs342.2024.behavior</string>
</array>
</dict>
</plist>
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@

This source file is part of the Stanford Prisma Application based on the Stanford Spezi Template Application project

SPDX-FileCopyrightText: 2023 Stanford University

SPDX-License-Identifier: MIT

0 comments on commit 4939632

Please sign in to comment.