Skip to content

Commit

Permalink
Add notification status to firebase (#36)
Browse files Browse the repository at this point in the history
# Timestamp Notification Status to Firebase, Update Notification
Handling

## ♻️ Current situation & Problem

With the release of Spezi 1.2, new support for Notifications was added,
breaking the current implementation of push notifications. Additionally,
we required support for notification handling on the client side, for
background, foreground, and user-actions regarding notifications. To log
data about notification status, such as "received" and "opened", these
handlers were necessary, alongside updates to the standard for the
delivery of timestamps to firestore and dealing with notifications for
the path. "Sent" status is logged to firestore on the server side.


## ⚙️ Release Notes 
Updates

- Migrated to Spezi 1.2 and updated support for notifications, as
outlined in the [Spezi
documentation](https://swiftpackageindex.com/stanfordspezi/spezi/1.2.0/documentation/spezi/notifications).
This required updating many of the functions and ordering of necessary
steps to conform to the FirebaseMessaging protocol.
- New support for using `getPath()` in `PrismaStandard` for anything
related to the notifications collection in firestore, as well as the
type of data "logs" or schedule"
- Extended the standard for PushNotifications, adding functions for
`addNotificationOpened()` and `addNotificationReceived()` to Firestore
- Added new support for specifying timezones to the `.toISOFormat()`
method extension of the `Date()` class


Next Steps

- Currently, notification handling distinguishes between foreground
notifications and clicking on the notification, meaning that in
firestore "opening" and "receiving" can't happen at the same time. Will
follow up with the Spezi team on how to get this to work


## 📚 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).

---------

Co-authored-by: Paul Schmiedmayer <[email protected]>
  • Loading branch information
bryant-jimenez and PSchmiedmayer authored Mar 7, 2024
1 parent 2873e1e commit f41a4ce
Show file tree
Hide file tree
Showing 14 changed files with 139 additions and 70 deletions.
8 changes: 6 additions & 2 deletions Prisma.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
5661552E2AB854C000209B80 /* PackageHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5661552D2AB854C000209B80 /* PackageHelper.swift */; };
5680DD392AB8983D004E6D4A /* PackageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5680DD382AB8983D004E6D4A /* PackageCell.swift */; };
56F6F2A02AB441930022FE5A /* ContributionsList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56F6F29F2AB441930022FE5A /* ContributionsList.swift */; };
5FBBD2B62B875DB800B75E9F /* PrismaStandard+PushNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FBBD2B52B875DB800B75E9F /* PrismaStandard+PushNotifications.swift */; };
5FECE9562B6C9A5F00C06B13 /* PushNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FECE9552B6C9A5F00C06B13 /* PushNotifications.swift */; };
5FECE9592B6CCF0B00C06B13 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = 5FECE9582B6CCF0B00C06B13 /* FirebaseMessaging */; };
653A2551283387FE005D4D48 /* Prisma.swift in Sources */ = {isa = PBXBuildFile; fileRef = 653A2550283387FE005D4D48 /* Prisma.swift */; };
Expand Down Expand Up @@ -143,6 +144,7 @@
5661552D2AB854C000209B80 /* PackageHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PackageHelper.swift; sourceTree = "<group>"; };
5680DD382AB8983D004E6D4A /* PackageCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PackageCell.swift; sourceTree = "<group>"; };
56F6F29F2AB441930022FE5A /* ContributionsList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContributionsList.swift; sourceTree = "<group>"; };
5FBBD2B52B875DB800B75E9F /* PrismaStandard+PushNotifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PrismaStandard+PushNotifications.swift"; sourceTree = "<group>"; };
5FECE9552B6C9A5F00C06B13 /* PushNotifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotifications.swift; sourceTree = "<group>"; };
653A254D283387FE005D4D48 /* Prisma.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Prisma.app; sourceTree = BUILT_PRODUCTS_DIR; };
653A2550283387FE005D4D48 /* Prisma.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Prisma.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -444,6 +446,7 @@
isa = PBXGroup;
children = (
2FF53D8C2A8729D600042B76 /* PrismaStandard.swift */,
5FBBD2B52B875DB800B75E9F /* PrismaStandard+PushNotifications.swift */,
F8AF6FB82B5F72650011C32D /* PrismaStandard+HealthKit.swift */,
F8AF6FB32B5F6EDC0011C32D /* PrismaModule.swift */,
F8AF6FB52B5F71460011C32D /* PrismaStandard+Extension.swift */,
Expand Down Expand Up @@ -663,6 +666,7 @@
2FE5DC4129EDD7EE004B9AB4 /* StorageKeys.swift in Sources */,
2FE5DCB129EE6107004B9AB4 /* AccountOnboarding.swift in Sources */,
2F4FC8D729EE69D300BFFE26 /* MockUpload.swift in Sources */,
5FBBD2B62B875DB800B75E9F /* PrismaStandard+PushNotifications.swift in Sources */,
2FE5DC3A29EDD7CA004B9AB4 /* Welcome.swift in Sources */,
2FE5DC3829EDD7CA004B9AB4 /* Features.swift in Sources */,
E4C766262B72D50500C1DEDA /* WebView.swift in Sources */,
Expand Down Expand Up @@ -1023,7 +1027,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = "";
DEVELOPMENT_TEAM = 637867499T;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Prisma/Supporting Files/Info.plist";
Expand Down Expand Up @@ -1247,7 +1251,7 @@
repositoryURL = "https://github.com/StanfordSpezi/Spezi";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.1.0;
minimumVersion = 1.2.0;
};
};
2FB099B42A875E2B00B20952 /* XCRemoteSwiftPackageReference "HealthKitOnFHIR" */ = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/StanfordSpezi/Spezi",
"state" : {
"revision" : "c4bf0e99de40acfdd2baf0fa02769f06a4c3f0eb",
"version" : "1.1.0"
"revision" : "0ced3efbc2af9513c07ac913ad762c773a00a6c8",
"version" : "1.2.1"
}
},
{
Expand Down Expand Up @@ -185,8 +185,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/StanfordSpezi/SpeziFoundation.git",
"state" : {
"revision" : "683c66f922a4cfe0882c4a86a43854f613b48541",
"version" : "1.0.0"
"revision" : "01af5b91a54f30ddd121258e81aff2ddc2a99ff9",
"version" : "1.0.4"
}
},
{
Expand Down Expand Up @@ -311,8 +311,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/StanfordBDHG/XCTRuntimeAssertions",
"state" : {
"revision" : "bb2a287c2544aa846e53670d1ece35e5949567be",
"version" : "1.0.0"
"revision" : "51da3403f128b120705571ce61e0fe190f8889e6",
"version" : "1.0.1"
}
}
],
Expand Down
4 changes: 1 addition & 3 deletions Prisma/Onboarding/NotificationPermissions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@ struct NotificationPermissions: View {

@State private var notificationProcessing = false

@AppStorage(StorageKeys.pushNotificationsAllowed) var pushNotificationsAllowed = false


var body: some View {
OnboardingView(
Expand Down Expand Up @@ -49,7 +47,7 @@ struct NotificationPermissions: View {
if ProcessInfo.processInfo.isPreviewSimulator {
try await _Concurrency.Task.sleep(for: .seconds(5))
} else {
try await pushNotifications.requestNotificationAuthorization()
try await pushNotifications.handleNotificationsAllowed()
}
} catch {
print("Could not request notification permissions.")
Expand Down
5 changes: 1 addition & 4 deletions Prisma/Onboarding/OnboardingFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ struct OnboardingFlow: View {
@Environment(PrismaScheduler.self) private var scheduler

@AppStorage(StorageKeys.onboardingFlowComplete) private var completedOnboardingFlow = false
@AppStorage(StorageKeys.pushNotificationsAllowed) var pushNotificationsAllowed = false


private var healthKitAuthorization: Bool {
Expand All @@ -44,9 +43,7 @@ struct OnboardingFlow: View {
if HKHealthStore.isHealthDataAvailable() && !healthKitAuthorization {
HealthKitPermissions()
}
if !pushNotificationsAllowed {
NotificationPermissions()
}
NotificationPermissions()
}
.interactiveDismissDisabled(!completedOnboardingFlow)
}
Expand Down
16 changes: 0 additions & 16 deletions Prisma/PrismaDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,20 +111,4 @@ class PrismaDelegate: SpeziAppDelegate {
)
}
}


/// When the app successfully registers for remote notifications, it receives a device
/// token from Apple's push notification service (APNs). The deviceToken parameter
/// contains a unique identifier for the device, which the app uses to receive remote
/// notifications.
///
/// We assign the APNs token received from Apple to the apnsToken property of the
/// Messaging class provided by the Firebase SDK. Firebase uses this token to communicate with
/// APNs and send notifications to the device.
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
Messaging.messaging().apnsToken = deviceToken
}
}
72 changes: 42 additions & 30 deletions Prisma/PushNotifications/PushNotifications.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,20 @@
// Created by Bryant Jimenez on 2/1/24.
//

import Firebase
import FirebaseCore
import FirebaseMessaging
import Spezi
import SpeziFirebaseConfiguration
import SwiftUI


class PrismaPushNotifications: NSObject, Module, LifecycleHandler, MessagingDelegate, UNUserNotificationCenterDelegate, EnvironmentAccessible {
class PrismaPushNotifications: NSObject, Module, NotificationHandler, NotificationTokenHandler, MessagingDelegate,
UNUserNotificationCenterDelegate, EnvironmentAccessible {
@Application(\.registerRemoteNotifications) var registerRemoteNotifications
@StandardActor var standard: PrismaStandard

@Dependency private var configureFirebaseApp: ConfigureFirebaseApp

@AppStorage(StorageKeys.pushNotificationsAllowed) var pushNotificationsAllowed = false


override init() {}

Expand All @@ -34,21 +34,47 @@ class PrismaPushNotifications: NSObject, Module, LifecycleHandler, MessagingDele
Messaging.messaging().delegate = self
}


/// Prompts the user to allow notifications on their device, storing that result on disk to reference on app startup.
func requestNotificationAuthorization() async throws {
func handleNotificationsAllowed() async throws {
let authOptions: UNAuthorizationOptions = [.alert, .badge, .sound]
// prompt the user to allow notifications
if try await UNUserNotificationCenter.current().requestAuthorization(options: authOptions) {
self.pushNotificationsAllowed = true
// Generate apns token, triggers didRegisterForRemoteNotificationsWithDeviceToken()
await UIApplication.shared.registerForRemoteNotifications()
try await registerRemoteNotifications()
}
}

func receiveUpdatedDeviceToken(_ deviceToken: Data) {
Messaging.messaging().apnsToken = deviceToken
}

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.
let actionIdentifier = 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)
} else {
self.pushNotificationsAllowed = false
print("Sent timestamp is not a string or is nil")
}
}

func receiveIncomingNotification(_ notification: UNNotification) async -> UNNotificationPresentationOptions? {
let receivedTimestamp = Date().toISOFormat(timezone: TimeZone(abbreviation: "UTC"))
if let sentTimestamp = notification.request.content.userInfo["sent_timestamp"] as? String {
Task {
await standard.addNotificationReceivedTimestamp(timeSent: sentTimestamp, timeReceived: receivedTimestamp)
}
} else {
print("Sent timestamp is not a string or is nil")
}

return [.badge, .banner, .list, .sound]
}

func receiveRemoteNotification(_ remoteNotification: [AnyHashable: Any]) async -> BackgroundFetchResult {
print("bg")
return .noData
}

/// This function listens for token refreshes and updates the specific user token to Firestore.
/// This callback is fired at each app startup and whenever a new token is generated.
///
Expand All @@ -57,25 +83,11 @@ class PrismaPushNotifications: NSObject, Module, LifecycleHandler, MessagingDele
/// - the user uninstalls/reinstall the app
/// - the user clears app data.
func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
if pushNotificationsAllowed {
print("Firebase registration token: \(String(describing: fcmToken))")

let tokenDict: [String: String] = ["apns_token": fcmToken ?? ""]
NotificationCenter.default.post(
name: Notification.Name("FCMToken"),
object: nil,
userInfo: tokenDict
)

// Update the token in Firestore:

// The standard is an actor, which protects against data races and conforms to
// immutable data practice

// get into new asynchronous context and execute
Task {
await standard.storeToken(token: fcmToken)
}
// Update the token in Firestore:
// The standard is an actor, which protects against data races and conforms to
// immutable data practice. Therefore we get into new asynchronous context and execute
Task {
await standard.storeToken(token: fcmToken)
}
}
}
3 changes: 3 additions & 0 deletions Prisma/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,9 @@
},
"Invalid URL" : {

},
"Invalid URL" : {

},
"JAMES_LANDAY_BIO" : {
"localizations" : {
Expand Down
2 changes: 0 additions & 2 deletions Prisma/SharedContext/StorageKeys.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ enum StorageKeys {
static let onboardingFlowComplete = "onboardingFlow.complete"
/// A `Step` flag indicating the current step in the onboarding process.
static let onboardingFlowStep = "onboardingFlow.step"
/// A `Bool` flag indicating whether or not push notifications are allowed.
static let pushNotificationsAllowed = "pushNotifications.allowed"

// MARK: - Home
/// The currently selected home tab.
Expand Down
6 changes: 5 additions & 1 deletion Prisma/Standard/PrismaModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@
import HealthKit


/// A `Module` is a type of data that can be uploaded to the Firestore.
/// A `PrismaModule` is a type of data that can be uploaded to the Firestore database.
enum PrismaModule {
/// The questionnaire type with the `String` id.
case questionnaire(String)
/// The health type with the `HKQuantityTypeIdentifier` as a String.
case health(String)
/// The notification type with the timestamp as a `String`.
case notifications(String)

/// The `String` description of the module.
var description: String {
Expand All @@ -23,6 +25,8 @@ enum PrismaModule {
return "questionnaire"
case .health:
return "health"
case .notifications:
return "notifications"
}
}
}
11 changes: 8 additions & 3 deletions Prisma/Standard/PrismaStandard+Extension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,16 @@ extension String {
}

extension Date {
/// converts Date obejct to local time.
func localISOFormat() -> String {
/// 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]
formatter.timeZone = TimeZone.current
if let timezone = timezone {
formatter.timeZone = timezone
} else {
formatter.timeZone = TimeZone.current
}
return formatter.string(from: self)
}

Expand Down
4 changes: 2 additions & 2 deletions Prisma/Standard/PrismaStandard+HealthKit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,8 @@ extension PrismaStandard {

// convert the startDate of the HKSample to local time
let startDatetime = sample.startDate
let effectiveTimestamp = startDatetime.localISOFormat()
let endDatetime = sample.endDate.localISOFormat()
let effectiveTimestamp = startDatetime.toISOFormat()
// let endDatetime = sample.endDate.toISOFormat()

let path: String
// path = HEALTH_KIT_PATH/raw/YYYY-MM-DDThh:mm:ss.mss
Expand Down
57 changes: 57 additions & 0 deletions Prisma/Standard/PrismaStandard+PushNotifications.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
//
// 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
//
// Created by Bryant Jimenez on 2/22/24.
//

import FirebaseFirestore
import Foundation

extension PrismaStandard {
/// Stores the timestamp when a notification was received by
/// the user's device to the specific notification document.
///
/// - Parameter timestamp: The time which the notification was received by the device
func addNotificationReceivedTimestamp(timeSent: String, timeReceived: String) async {
// path = user_id/notifications/data/logs/YYYY-MM-DDThh:mm:ss.mss
let path: String
do {
path = try await getPath(module: .notifications("logs")) + "\(timeSent)"
} catch {
print("Failed to define path: \(error.localizedDescription)")
return
}

// try push to Firestore.
do {
try await Firestore.firestore().document(path).setData(["received": timeReceived])
} 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.
func addNotificationOpenedTimestamp(timeSent: String, timeOpened: String) async {
// path = user_id/notifications/data/logs/YYYY-MM-DDThh:mm:ss.mss
let path: String
do {
path = try await getPath(module: .notifications("logs")) + "\(timeSent)"
} catch {
print("Failed to define path: \(error.localizedDescription)")
return
}

// try push to Firestore.
do {
try await Firestore.firestore().document(path).setData(["opened": timeOpened])
} catch {
print("Failed to set data in Firestore: \(error.localizedDescription)")
}
}
}
2 changes: 1 addition & 1 deletion Prisma/Standard/PrismaStandard+Questionnaire.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ extension PrismaStandard {

// extracts the first item as that is the id.
let rootTag = String("\(tag)".split(separator: "/")[0])
let effectiveTimestamp = Date().localISOFormat()
let effectiveTimestamp = Date().toISOFormat()

let path: String
do {
Expand Down
Loading

0 comments on commit f41a4ce

Please sign in to comment.