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

feat: [FC-0047] FCM #461

Merged
merged 3 commits into from
Jun 21, 2024
Merged

Conversation

volodymyr-chekyrta
Copy link
Contributor

@volodymyr-chekyrta volodymyr-chekyrta commented Jun 12, 2024

This pull request introduces Firebase Cloud Messaging (FCM) to the project, enabling the functionality for receiving push notifications from Firebase.

Testing:
• Verified push notifications are received when the app is in the foreground, background, and terminated states.
• Confirmed the app correctly handles notification taps and displays relevant content.
• Ensured token generation and handling work as expected, including handling token refreshes.

Please use the Firebase console for testing; here is a set of all the required fields for navigation:

Notifications:

  1. User has been enrolled
    notification_type: enroll
    course_id: Your course ID (example: course-v1:future+f1+2024)
  2. User has been unenrolled
    notification_type: unenroll
  3. User has been added as a beta tester
    notification_type: add_beta_tester
    course_id: Your course ID (example: course-v1:future+f1+2024)
  4. User has been removed from beta testers
    notification_type: remove_beta_tester
    course_id: Your course ID (example: course-v1:future+f1+2024)
  5. Someone left a response in the user discussion/question
    notification_type: forum_response
    topic_id: Your Topic ID (example: course)
    course_id: Your course ID (example: course-v1:future+f1+2024)
    thread_id: Your thread ID (example: 66584e96f2195c001ad219c7)
    comment_id: Your comment ID (example: 66585c9df2195c001ad219ce)
  6. Someone left a comment in the user discussion/question
    notification_type: forum_comment
    topic_id: Your Topic ID (example: course)
    course_id: Your course ID (example: course-v1:future+f1+2024)
    thread_id: Your thread ID (example: 66583d13f2195c001a56a182)
    parent_id: Your response ID (example: 66586579f2195c001ad219d8)
    comment_id: Your comment ID (example: 6659ac92f2195c001bf37f77)

@openedx-webhooks openedx-webhooks added the open-source-contribution PR author is not from Axim or 2U label Jun 12, 2024
@openedx-webhooks
Copy link

Thanks for the pull request, @volodymyr-chekyrta! Please note that it may take us up to several weeks or months to complete a review and merge your PR.

Feel free to add as much of the following information to the ticket as you can:

  • supporting documentation
  • Open edX discussion forum threads
  • timeline information ("this must be merged by XX date", and why that is)
  • partner information ("this is a course on edx.org")
  • any other information that can help Product understand the context for the PR

All technical communication about the code itself will be done via the GitHub pull request interface. As a reminder, our process documentation is here.

Please let us know once your PR is ready for our review and all tests are green.

let pushManager = Container.shared.resolve(PushNotificationsManager.self)
Messaging.messaging().delegate = pushManager
UNUserNotificationCenter.current().delegate = pushManager
UIApplication.shared.registerForRemoteNotifications()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@volodymyr-chekyrta This way didRegisterForRemoteNotificationsWithDeviceToken delegate method will fired only if cloudMessagingEnabled==true. If FCM is disabled then all other providers will unable to register device token.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense 👍
I've updated it for any provider ✅

let pushManager = Container.shared.resolve(PushNotificationsManager.self)

if config.firebase.enabled {
    FirebaseApp.configure()
    if config.firebase.cloudMessagingEnabled {
        Messaging.messaging().delegate = pushManager
        UNUserNotificationCenter.current().delegate = pushManager
    }
}

if pushManager?.hasProviders == true {
    UIApplication.shared.registerForRemoteNotifications()
}

rnr
rnr previously approved these changes Jun 13, 2024
Copy link
Contributor

@rnr rnr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

private var providers: [PushNotificationsProvider] = []
private var listeners: [PushNotificationsListener] = []

public var hasProviders: Bool {
return providers.count > 0
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'return' isn't necessary in this case.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. Done ✅

rnr
rnr previously approved these changes Jun 13, 2024
@volodymyr-chekyrta
Copy link
Contributor Author

@saeedbashir, Kindly review the changes made in this pull request.
Thank you 🙌

@saeedbashir
Copy link
Contributor

Please use the Firebase console for testing; here is a set of all the required fields for navigation:

Will review it tomorrow.

@saeedbashir
Copy link
Contributor

@volodymyr-chekyrta Do I need to test this PR against the sandbox because the API https://courses.edx.org/api/mobile/v4/notifications/create-token/ (404) is returning 404 on the edX backend?

Comment on lines 193 to +196
analytics.userLogin(method: authMethod)
isShowProgress = false
router.showMainOrWhatsNewScreen(sourceScreen: sourceScreen)
NotificationCenter.default.post(name: .userAuthorized, object: nil)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This piece of code (register success) is redendant, how about moving it to a function, thoughts?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand why it is redundant.
It looks reasonable for me to be placed here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The following code is same in both the success methods loginOrRegister and registerUser. Anyhow we can live with it because it's not changed under the scope of this PR.

let user = try await interactor.login(externalToken: response.token, backend: backend)
            analytics.identify(id: "\(user.id)", username: user.username, email: user.email)
            analytics.userLogin(method: authMethod)
            isShowProgress = false
            router.showMainOrWhatsNewScreen(sourceScreen: sourceScreen)
            NotificationCenter.default.post(name: .userAuthorized, object: nil)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, we can address this later in some refactoring

NotificationCenter.default.post(
name: .userLoggedOut,
object: nil,
userInfo: [Notification.UserInfoKey.isForced: false]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess this is the forced logout case, if so then the value of isForced should be true.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right. Fixed ✅

@@ -176,7 +172,25 @@ public class DiscussionRepository: DiscussionRepositoryProtocol {
}
}

public func renameUsers(data: Data) async throws -> DataLayer.CommentsResponse {
private func renameThreadUser(data: Data) async throws -> DataLayer.ThreadList {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we doing this to format data for parsing in mobile?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it enables easier parsing of user avatar.

@@ -120,21 +141,34 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
)
}

@objc private func forceLogoutUser() {
@objc private func didUserAuthorize() {
guard Container.shared.resolve(ConfigProtocol.self)?.firebase.cloudMessagingEnabled == true else { return }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regardless of the provider, if any provider available the synchronizeToken should be called and then the provider will decide what to do with the call.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense 👍

if Container.shared.resolve(ConfigProtocol.self)?.firebase.cloudMessagingEnabled == true {
UNUserNotificationCenter.current().removeAllDeliveredNotifications()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regardless of the provider, if any provider available the refreshToken() should be called and then the provider will decide what to do with the call.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense 👍

Comment on lines +31 to +32
func refreshToken() {
Messaging.messaging().deleteToken { error in
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason to delete the old token on logout, and create a new one? Will the user be still able to receive pushes while not login?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

User should not receive push notifications after logging out.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure about the requirements but in this case user may miss important notifications. Like if somehow user is logout, and an assignment due notification is sent from the server, the user won't be receiving the notification. The other case is, moderator replied to the user query/response in the discussion threads, the user won't be receiving the response.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By the current flow, if user is logging out, they are no longer interested in receiving notifications.
Users can still rely on email or calendar notifications.
We can change this flow in the future if we add some vital push notifications.

let userInfo = response.notification.request.content.userInfo

// With swizzling disabled you must let Messaging know about the message, for Analytics
Messaging.messaging().appDidReceiveMessage(userInfo)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess the right place for this is FCMListener.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It requires extra abstraction for PushNotificationsProvider; it can create overhead.
For me, it seems ok to leave just 1 line here.
wdyt?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It won't required any abstraction as the abstraction is already there. The FCMListener: PushNotificationsListener is implementing the PushNotificationsListener protocol, you just need to implement optional method didReceiveRemoteNotification and thats it.

Copy link
Contributor Author

@volodymyr-chekyrta volodymyr-chekyrta Jun 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@saeedbashir, but if we override didReceiveRemoteNotification in FCMListener, the extension with default implementation (navigation) will stop working 🤷‍♂️.
image

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need one more function in PushNotificationsListener for this or refactor didReceiveRemoteNotification and not use it for navigation.

Copy link
Contributor

@saeedbashir saeedbashir Jun 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strange, I guess it got missed while writing the abstraction layer. Ideally, every PushNotificationsListener should implement this piece of code. So first, the listener will check whether the push is for that listener (Braze, FCM etc) or not, and then do the required work before starting the navigation.

The other reason for giving each listener its own implementation is that maybe data will be received in a different format (hierarchy), and a parsing may be required to generate a PushLink from the data.

Thoughts?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, I'll update it now, I think it makes sense

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@saeedbashir I've updated implementation of the PushNotificationsManager, please check.

willPresent notification: UNNotification
) async -> UNNotificationPresentationOptions {
// With swizzling disabled you must let Messaging know about the message, for Analytics
Messaging.messaging().appDidReceiveMessage(notification.request.content.userInfo)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess the right place for this is FCMListner.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It requires extra abstraction for PushNotificationsProvider; it can create overhead.
For me, it seems ok to leave just 1 line here.
wdyt?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@@ -380,6 +380,10 @@ public class Router: AuthorizationRouter,
lastVisitedBlockID: lastVisitedBlockID
)
navigationController.pushViewController(controller, animated: true)

DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
Container.shared.resolve(PushNotificationsManager.self)?.performRegistration()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The push notification registration is happening from the course home screen, is that intended behavior?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it's a UX improvement.
Users automatically reject a push notification request if it appears when the app is launched.

@volodymyr-chekyrta
Copy link
Contributor Author

@volodymyr-chekyrta Do I need to test this PR against the sandbox because the API https://courses.edx.org/api/mobile/v4/notifications/create-token/ (404) is returning 404 on the edX backend?

You cannot test this endpoint; it has not been merged yet.
For now, please use the Firebase console.

@volodymyr-chekyrta
Copy link
Contributor Author

Hi @saeedbashir, thank you for the prompt feedback!
Feedback has been addressed, and PR is ready for the next pass 🙌

Comment on lines 14 to 16
private lazy var deepLinkManager: DeepLinkManager? = {
Container.shared.resolve(DeepLinkManager.self)
}()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about passing this as a parameter, thoughts?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, why not
Done ✅


func didReceiveRemoteNotification(userInfo: [AnyHashable: Any]) {
// With swizzling disabled you must let Messaging know about the message, for Analytics
Messaging.messaging().appDidReceiveMessage(userInfo)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess it should move below the guard statement as the notification can be from the braze.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeap ✅

@volodymyr-chekyrta
Copy link
Contributor Author

@saeedbashir, PR is ready for the next pass 🙌

@@ -6,11 +6,26 @@
//

import Foundation
import Swinject
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove this unused import.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

@@ -6,8 +6,30 @@
//

import Foundation
import Swinject
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove this unused import and after that we are good to go.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

@@ -9,56 +9,58 @@ import Foundation
import Core
import UIKit
import Swinject
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess we are safe to remove this import

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Comment on lines 16 to 17
private var storage = Container.shared.resolve(CoreStorage.self)!
private let api = Container.shared.resolve(API.self)!
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm fine with this, but as we changed the dependency injection in the FCMListner, do we need to change it as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it makes sense to provide them as dependencies on the init lever 👍

@saeedbashir
Copy link
Contributor

@saeedbashir, PR is ready for the next pass 🙌

@volodymyr-chekyrta I've left few comments, please take a look. After that we are good to go with this PR.

@volodymyr-chekyrta
Copy link
Contributor Author

@saeedbashir, PR is ready for the next pass 🙌

@volodymyr-chekyrta I've left few comments, please take a look. After that we are good to go with this PR.

@saeedbashir, thanks for the feedback; PR is ready for final pass 🚀

saeedbashir
saeedbashir previously approved these changes Jun 21, 2024
Copy link
Contributor

@saeedbashir saeedbashir left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove the unused import import Swinject in FCMProvider.swift and you are good to go 👍

rnr
rnr previously approved these changes Jun 21, 2024
@volodymyr-chekyrta
Copy link
Contributor Author

Remove the unused import import Swinject in FCMProvider.swift and you are good to go 👍

Sure, done ✅

@volodymyr-chekyrta
Copy link
Contributor Author

@rnr @saeedbashir approval was dismissed after the fix, could you please provide the approval and we'll merge it

@volodymyr-chekyrta volodymyr-chekyrta merged commit b2539a6 into openedx:develop Jun 21, 2024
3 checks passed
@openedx-webhooks
Copy link

@volodymyr-chekyrta 🎉 Your pull request was merged! Please take a moment to answer a two question survey so we can improve your experience in the future.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
open-source-contribution PR author is not from Axim or 2U
Projects
Archived in project
Development

Successfully merging this pull request may close these issues.

4 participants