From 74802088d8b7579e603646c2c55b208eca6bca71 Mon Sep 17 00:00:00 2001 From: Volodymyr Chekyrta Date: Wed, 12 Jun 2024 10:58:42 +0200 Subject: [PATCH] feat: firebase cloud messaging --- .../Presentation/Login/SignInViewModel.swift | 2 + .../Registration/SignUpViewModel.swift | 3 +- Core/Core/Data/CoreStorage.swift | 2 + Core/Core/Extensions/Notification.swift | 11 ++- Core/Core/Network/RequestInterceptor.swift | 6 +- .../Presentation/ListDashboardViewModel.swift | 9 ++ .../PrimaryCourseDashboardViewModel.swift | 10 +++ .../Data/Model/Data_CommentsResponse.swift | 8 +- .../Data/Network/DiscussionEndpoint.swift | 5 +- .../Data/Network/DiscussionRepository.swift | 70 ++++++---------- OpenEdX.xcodeproj/project.pbxproj | 37 +++++++++ OpenEdX/AppDelegate.swift | 69 ++++++++++++---- OpenEdX/DI/AppAssembly.swift | 6 +- OpenEdX/Data/AppStorage.swift | 65 +++++++++------ .../Data/Network/NotificationsEndpoints.swift | 44 ++++++++++ .../DeepLinkManager/DeepLinkManager.swift | 79 ++++++++++++++---- .../DeepLinkRouter/DeepLinkRouter.swift | 6 +- .../DeepLinkManager/Link/DeepLink.swift | 16 +++- .../FirebaseAnalyticsService.swift | 8 +- .../Listeners/FCMListener.swift | 5 +- .../Providers/BrazeProvider.swift | 6 ++ .../Providers/FCMProvider.swift | 45 +++++++++- .../PushNotificationsManager.swift | 82 ++++++++++++++++--- OpenEdX/Router.swift | 4 + .../Settings/SettingsViewModel.swift | 5 ++ 25 files changed, 463 insertions(+), 140 deletions(-) create mode 100644 OpenEdX/Data/Network/NotificationsEndpoints.swift diff --git a/Authorization/Authorization/Presentation/Login/SignInViewModel.swift b/Authorization/Authorization/Presentation/Login/SignInViewModel.swift index 041c98ca7..ef573e047 100644 --- a/Authorization/Authorization/Presentation/Login/SignInViewModel.swift +++ b/Authorization/Authorization/Presentation/Login/SignInViewModel.swift @@ -82,6 +82,7 @@ public class SignInViewModel: ObservableObject { analytics.identify(id: "\(user.id)", username: user.username, email: user.email) analytics.userLogin(method: .password) router.showMainOrWhatsNewScreen(sourceScreen: sourceScreen) + NotificationCenter.default.post(name: .userAuthorized, object: nil) } catch let error { failure(error) } @@ -113,6 +114,7 @@ public class SignInViewModel: ObservableObject { analytics.identify(id: "\(user.id)", username: user.username, email: user.email) analytics.userLogin(method: authMethod) router.showMainOrWhatsNewScreen(sourceScreen: sourceScreen) + NotificationCenter.default.post(name: .userAuthorized, object: nil) } catch let error { failure(error, authMethod: authMethod) } diff --git a/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift b/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift index 1f57b8c02..5c41badef 100644 --- a/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift +++ b/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift @@ -136,7 +136,7 @@ public class SignUpViewModel: ObservableObject { analytics.registrationSuccess(method: authMetod.analyticsValue) isShowProgress = false router.showMainOrWhatsNewScreen(sourceScreen: sourceScreen) - + NotificationCenter.default.post(name: .userAuthorized, object: nil) } catch let error { isShowProgress = false if case APIError.invalidGrant = error { @@ -193,6 +193,7 @@ public class SignUpViewModel: ObservableObject { analytics.userLogin(method: authMethod) isShowProgress = false router.showMainOrWhatsNewScreen(sourceScreen: sourceScreen) + NotificationCenter.default.post(name: .userAuthorized, object: nil) } catch { update(fullName: response.name, email: response.email) self.externalToken = response.token diff --git a/Core/Core/Data/CoreStorage.swift b/Core/Core/Data/CoreStorage.swift index b3a8faca0..546a324eb 100644 --- a/Core/Core/Data/CoreStorage.swift +++ b/Core/Core/Data/CoreStorage.swift @@ -10,6 +10,7 @@ import Foundation public protocol CoreStorage { var accessToken: String? {get set} var refreshToken: String? {get set} + var pushToken: String? {get set} var appleSignFullName: String? {get set} var appleSignEmail: String? {get set} var cookiesDate: String? {get set} @@ -24,6 +25,7 @@ public protocol CoreStorage { public class CoreStorageMock: CoreStorage { public var accessToken: String? public var refreshToken: String? + public var pushToken: String? public var appleSignFullName: String? public var appleSignEmail: String? public var cookiesDate: String? diff --git a/Core/Core/Extensions/Notification.swift b/Core/Core/Extensions/Notification.swift index d8e99731b..1a4aeb1db 100644 --- a/Core/Core/Extensions/Notification.swift +++ b/Core/Core/Extensions/Notification.swift @@ -8,6 +8,8 @@ import Foundation public extension Notification.Name { + static let userAuthorized = Notification.Name("userAuthorized") + static let userLoggedOut = Notification.Name("userLoggedOut") static let onCourseEnrolled = Notification.Name("onCourseEnrolled") static let onblockCompletionRequested = Notification.Name("onblockCompletionRequested") static let onTokenRefreshFailed = Notification.Name("onTokenRefreshFailed") @@ -15,8 +17,15 @@ public extension Notification.Name { static let onAppUpgradeAccountSettingsTapped = Notification.Name("onAppUpgradeAccountSettingsTapped") static let onNewVersionAvaliable = Notification.Name("onNewVersionAvaliable") static let webviewReloadNotification = Notification.Name("webviewReloadNotification") - static let onBlockCompletion = Notification.Name.init("onBlockCompletion") + static let onBlockCompletion = Notification.Name("onBlockCompletion") static let shiftCourseDates = Notification.Name("shiftCourseDates") static let profileUpdated = Notification.Name("profileUpdated") static let getCourseDates = Notification.Name("getCourseDates") + static let refreshEnrollments = Notification.Name("refreshEnrollments") +} + +public extension Notification { + enum UserInfoKey: String { + case isForced + } } diff --git a/Core/Core/Network/RequestInterceptor.swift b/Core/Core/Network/RequestInterceptor.swift index f27b6f310..860fa070d 100644 --- a/Core/Core/Network/RequestInterceptor.swift +++ b/Core/Core/Network/RequestInterceptor.swift @@ -93,7 +93,11 @@ final public class RequestInterceptor: Alamofire.RequestInterceptor { } self.requestsToRetry.removeAll() } else { - NotificationCenter.default.post(name: .onTokenRefreshFailed, object: nil) + NotificationCenter.default.post( + name: .userLoggedOut, + object: nil, + userInfo: [Notification.UserInfoKey.isForced: true] + ) } } } diff --git a/Dashboard/Dashboard/Presentation/ListDashboardViewModel.swift b/Dashboard/Dashboard/Presentation/ListDashboardViewModel.swift index 4e79877c2..bef00add1 100644 --- a/Dashboard/Dashboard/Presentation/ListDashboardViewModel.swift +++ b/Dashboard/Dashboard/Presentation/ListDashboardViewModel.swift @@ -30,6 +30,7 @@ public class ListDashboardViewModel: ObservableObject { private let interactor: DashboardInteractorProtocol private let analytics: DashboardAnalytics private var onCourseEnrolledCancellable: AnyCancellable? + private var refreshEnrollmentsCancellable: AnyCancellable? public init(interactor: DashboardInteractorProtocol, connectivity: ConnectivityProtocol, @@ -46,6 +47,14 @@ public class ListDashboardViewModel: ObservableObject { await self.getMyCourses(page: 1, refresh: true) } } + refreshEnrollmentsCancellable = NotificationCenter.default + .publisher(for: .refreshEnrollments) + .sink { [weak self] _ in + guard let self = self else { return } + Task { + await self.getMyCourses(page: 1, refresh: true) + } + } } @MainActor diff --git a/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardViewModel.swift b/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardViewModel.swift index 7b3a51e37..60ffe9c0b 100644 --- a/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardViewModel.swift +++ b/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardViewModel.swift @@ -48,6 +48,7 @@ public class PrimaryCourseDashboardViewModel: ObservableObject { let enrollmentPublisher = NotificationCenter.default.publisher(for: .onCourseEnrolled) let completionPublisher = NotificationCenter.default.publisher(for: .onblockCompletionRequested) + let refreshEnrollmentsPublisher = NotificationCenter.default.publisher(for: .refreshEnrollments) enrollmentPublisher .sink { [weak self] _ in @@ -64,6 +65,15 @@ public class PrimaryCourseDashboardViewModel: ObservableObject { updateEnrollmentsIfNeeded() } .store(in: &cancellables) + + refreshEnrollmentsPublisher + .sink { [weak self] _ in + guard let self = self else { return } + Task { + await self.getEnrollments() + } + } + .store(in: &cancellables) } private func updateEnrollmentsIfNeeded() { diff --git a/Discussion/Discussion/Data/Model/Data_CommentsResponse.swift b/Discussion/Discussion/Data/Model/Data_CommentsResponse.swift index 0665d1cac..995917921 100644 --- a/Discussion/Discussion/Data/Model/Data_CommentsResponse.swift +++ b/Discussion/Discussion/Data/Model/Data_CommentsResponse.swift @@ -48,6 +48,7 @@ public extension DataLayer { public let childCount: Int public let children: [String] public let users: Users? + public let profileImage: ProfileImage? enum CodingKeys: String, CodingKey { case id = "id" @@ -71,6 +72,7 @@ public extension DataLayer { case childCount = "child_count" case children = "children" case users + case profileImage = "profile_image" } public init( @@ -94,7 +96,8 @@ public extension DataLayer { endorsedAt: String?, childCount: Int, children: [String], - users: Users? + users: Users?, + profileImage: ProfileImage? ) { self.id = id self.author = author @@ -117,6 +120,7 @@ public extension DataLayer { self.childCount = childCount self.children = children self.users = users + self.profileImage = profileImage } } } @@ -125,7 +129,7 @@ public extension DataLayer.Comments { var domain: UserComment { UserComment( authorName: author ?? DiscussionLocalization.anonymous, - authorAvatar: users?.userName?.profile?.image?.imageURLLarge ?? "", + authorAvatar: users?.userName?.profile?.image?.imageURLLarge ?? profileImage?.imageURLFull ?? "", postDate: Date(iso8601: createdAt), postTitle: "", postBody: rawBody, diff --git a/Discussion/Discussion/Data/Network/DiscussionEndpoint.swift b/Discussion/Discussion/Data/Network/DiscussionEndpoint.swift index 4409f22dc..1d433aa9b 100644 --- a/Discussion/Discussion/Data/Network/DiscussionEndpoint.swift +++ b/Discussion/Discussion/Data/Network/DiscussionEndpoint.swift @@ -186,7 +186,10 @@ enum DiscussionEndpoint: EndPointType { } return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) case .getThread: - return .requestParameters(parameters: [:], encoding: URLEncoding.queryString) + return .requestParameters( + parameters: ["requested_fields": "profile_image"], + encoding: URLEncoding.queryString + ) case .getTopics: return .requestParameters(encoding: URLEncoding.queryString) case let .getTopic(_, topicID): diff --git a/Discussion/Discussion/Data/Network/DiscussionRepository.swift b/Discussion/Discussion/Data/Network/DiscussionRepository.swift index 9ece98508..661ae603f 100644 --- a/Discussion/Discussion/Data/Network/DiscussionRepository.swift +++ b/Discussion/Discussion/Data/Network/DiscussionRepository.swift @@ -32,9 +32,6 @@ public protocol DiscussionRepositoryProtocol { func followThread(following: Bool, threadID: String) async throws func createNewThread(newThread: DiscussionNewThread) async throws func readBody(threadID: String) async throws - func renameThreadUser(data: Data) async throws -> DataLayer.ThreadListsResponse - func renameUsers(data: Data) async throws -> DataLayer.CommentsResponse - func renameUsersInJSON(stringJSON: String) -> String } public class DiscussionRepository: DiscussionRepositoryProtocol { @@ -66,21 +63,20 @@ public class DiscussionRepository: DiscussionRepositoryProtocol { let threads = try await api.requestData(DiscussionEndpoint .getThreads(courseID: courseID, type: type, sort: sort, filter: filter, page: page)) - return try await renameThreadUser(data: threads).domain + return try await renameThreadListUser(data: threads).domain } public func getThread(threadID: String) async throws -> UserThread { let thread = try await api.requestData(DiscussionEndpoint .getThread(threadID: threadID)) - .mapResponse(DataLayer.ThreadList.self) - return thread.userThread + return try await renameThreadUser(data: thread).userThread } public func searchThreads(courseID: String, searchText: String, pageNumber: Int) async throws -> ThreadLists { let posts = try await api.requestData(DiscussionEndpoint.searchThreads(courseID: courseID, searchText: searchText, pageNumber: pageNumber)) - return try await renameThreadUser(data: posts).domain + return try await renameThreadListUser(data: posts).domain } public func getTopics(courseID: String) async throws -> Topics { @@ -158,7 +154,7 @@ public class DiscussionRepository: DiscussionRepositoryProtocol { _ = try await api.request(DiscussionEndpoint.readBody(threadID: threadID)) } - public func renameThreadUser(data: Data) async throws -> DataLayer.ThreadListsResponse { + private func renameThreadListUser(data: Data) async throws -> DataLayer.ThreadListsResponse { var modifiedJSON = "" let parsed = try data.mapResponse(DataLayer.ThreadListsResponse.self) @@ -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 { + var modifiedJSON = "" + let parsed = try data.mapResponse(DataLayer.ThreadList.self) + + if let stringJSON = String(data: data, encoding: .utf8) { + modifiedJSON = renameUsersInJSON(stringJSON: stringJSON) + if let modifiedParsed = try modifiedJSON.data(using: .utf8)?.mapResponse( + DataLayer.ThreadList.self + ) { + return modifiedParsed + } else { + return parsed + } + } else { + return parsed + } + } + + private func renameUsers(data: Data) async throws -> DataLayer.CommentsResponse { var modifiedJSON = "" let parsed = try data.mapResponse(DataLayer.CommentsResponse.self) @@ -192,7 +206,7 @@ public class DiscussionRepository: DiscussionRepositoryProtocol { } } - public func renameUsersInJSON(stringJSON: String) -> String { + private func renameUsersInJSON(stringJSON: String) -> String { var modifiedJSON = stringJSON let userNames = stringJSON.find(from: "\"users\":{\"", to: "\":{\"profile\":") if userNames.count >= 1 { @@ -478,42 +492,6 @@ public class DiscussionRepositoryMock: DiscussionRepositoryProtocol { public func readBody(threadID: String) async throws { } - - public func renameThreadUser(data: Data) async throws -> DataLayer.ThreadListsResponse { - DataLayer.ThreadListsResponse(threads: [], - textSearchRewrite: "", - pagination: DataLayer.Pagination(next: "", previous: "", count: 0, numPages: 0) ) - } - - public func renameUsers(data: Data) async throws -> DataLayer.CommentsResponse { - DataLayer.CommentsResponse( - comments: [ - DataLayer.Comments(id: "", author: "Bill", - authorLabel: nil, - createdAt: "25.11.2043", - updatedAt: "25.11.2043", - rawBody: "Raw Body", - renderedBody: "Rendered body", - abuseFlagged: false, - voted: true, - voteCount: 2, - editableFields: [], - canDelete: true, - threadID: "", - parentID: nil, - endorsed: false, - endorsedBy: nil, - endorsedByLabel: nil, - endorsedAt: nil, - childCount: 0, - children: [], - users: nil) - ], pagination: DataLayer.Pagination(next: nil, previous: nil, count: 0, numPages: 0)) - } - - public func renameUsersInJSON(stringJSON: String) -> String { - return stringJSON - } } #endif // swiftlint:enable all diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index 8341e2233..5ab7602d4 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -40,6 +40,9 @@ 0770DE4C28D0A462006D8A5D /* Authorization.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 0770DE4A28D0A462006D8A5D /* Authorization.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 0770DE5028D0A707006D8A5D /* NetworkAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0770DE4F28D0A707006D8A5D /* NetworkAssembly.swift */; }; 0770DE6428D0BCC7006D8A5D /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 0770DE6628D0BCC7006D8A5D /* Localizable.strings */; }; + 0780ABE32BFBA2E40093A4A6 /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = 0780ABE22BFBA2E40093A4A6 /* FirebaseAnalytics */; }; + 0780ABE52BFBA2E40093A4A6 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = 0780ABE42BFBA2E40093A4A6 /* FirebaseMessaging */; }; + 0780ABE82BFCA1530093A4A6 /* NotificationsEndpoints.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0780ABE72BFCA1530093A4A6 /* NotificationsEndpoints.swift */; }; 07A7D78F28F5C9060000BE81 /* Core.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 07A7D78E28F5C9060000BE81 /* Core.framework */; }; 07A7D79028F5C9060000BE81 /* Core.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 07A7D78E28F5C9060000BE81 /* Core.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 07D5DA3528D075AA00752FD9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07D5DA3428D075AA00752FD9 /* AppDelegate.swift */; }; @@ -124,6 +127,7 @@ 0770DE4A28D0A462006D8A5D /* Authorization.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Authorization.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 0770DE4F28D0A707006D8A5D /* NetworkAssembly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkAssembly.swift; sourceTree = ""; }; 0770DE6528D0BCC7006D8A5D /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; + 0780ABE72BFCA1530093A4A6 /* NotificationsEndpoints.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsEndpoints.swift; sourceTree = ""; }; 07A7D78E28F5C9060000BE81 /* Core.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Core.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 07D5DA3128D075AA00752FD9 /* OpenEdX.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OpenEdX.app; sourceTree = BUILT_PRODUCTS_DIR; }; 07D5DA3428D075AA00752FD9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -165,7 +169,9 @@ A5462D9F2B865713003B96A5 /* Segment in Frameworks */, 072787B128D34D83002E9142 /* Discovery.framework in Frameworks */, 0218196428F734FA00202564 /* Discussion.framework in Frameworks */, + 0780ABE52BFBA2E40093A4A6 /* FirebaseMessaging in Frameworks */, 0219C67728F4347600D64452 /* Course.framework in Frameworks */, + 0780ABE32BFBA2E40093A4A6 /* FirebaseAnalytics in Frameworks */, 027DB33028D8A063002B6862 /* Dashboard.framework in Frameworks */, A5BD3E302B83B0F7006A8983 /* SegmentFirebase in Frameworks */, A51CDBED2B6D2BEE009B6D4E /* SegmentBraze in Frameworks */, @@ -180,6 +186,7 @@ 0293A2012A6FC9E30090A336 /* Data */ = { isa = PBXGroup; children = ( + 0780ABE62BFC9C9D0093A4A6 /* Network */, 025AD4AB2A6FB95C00AB8FA7 /* DatabaseManager.swift */, 0293A2022A6FCA590090A336 /* CorePersistence.swift */, 0293A2042A6FCD430090A336 /* CoursePersistence.swift */, @@ -209,6 +216,14 @@ path = DI; sourceTree = ""; }; + 0780ABE62BFC9C9D0093A4A6 /* Network */ = { + isa = PBXGroup; + children = ( + 0780ABE72BFCA1530093A4A6 /* NotificationsEndpoints.swift */, + ); + path = Network; + sourceTree = ""; + }; 07D5DA2828D075AA00752FD9 = { isa = PBXGroup; children = ( @@ -410,6 +425,8 @@ A51CDBEE2B6D2BEE009B6D4E /* SegmentBrazeUI */, A5BD3E2F2B83B0F7006A8983 /* SegmentFirebase */, A5462D9E2B865713003B96A5 /* Segment */, + 0780ABE22BFBA2E40093A4A6 /* FirebaseAnalytics */, + 0780ABE42BFBA2E40093A4A6 /* FirebaseMessaging */, ); productName = OpenEdX; productReference = 07D5DA3128D075AA00752FD9 /* OpenEdX.app */; @@ -445,6 +462,7 @@ A51CDBEB2B6D2BEE009B6D4E /* XCRemoteSwiftPackageReference "braze-segment-swift" */, A5BD3E2E2B83B0F7006A8983 /* XCRemoteSwiftPackageReference "analytics-swift-firebase" */, A5462D9D2B865713003B96A5 /* XCRemoteSwiftPackageReference "analytics-swift" */, + 0780ABE12BFBA2E40093A4A6 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, ); productRefGroup = 07D5DA3228D075AA00752FD9 /* Products */; projectDirPath = ""; @@ -560,6 +578,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 0780ABE82BFCA1530093A4A6 /* NotificationsEndpoints.swift in Sources */, A500668B2B613ED10024680B /* PushNotificationsManager.swift in Sources */, 0293A2052A6FCD430090A336 /* CoursePersistence.swift in Sources */, 020CA5D92AA0A25300970AAF /* AppStorage.swift in Sources */, @@ -1211,6 +1230,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + 0780ABE12BFBA2E40093A4A6 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/firebase/firebase-ios-sdk"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 10.26.0; + }; + }; A51CDBEB2B6D2BEE009B6D4E /* XCRemoteSwiftPackageReference "braze-segment-swift" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/braze-inc/braze-segment-swift"; @@ -1246,6 +1273,16 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 0780ABE22BFBA2E40093A4A6 /* FirebaseAnalytics */ = { + isa = XCSwiftPackageProductDependency; + package = 0780ABE12BFBA2E40093A4A6 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseAnalytics; + }; + 0780ABE42BFBA2E40093A4A6 /* FirebaseMessaging */ = { + isa = XCSwiftPackageProductDependency; + package = 0780ABE12BFBA2E40093A4A6 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseMessaging; + }; A51CDBEC2B6D2BEE009B6D4E /* SegmentBraze */ = { isa = XCSwiftPackageProductDependency; package = A51CDBEB2B6D2BEE009B6D4E /* XCRemoteSwiftPackageReference "braze-segment-swift" */; diff --git a/OpenEdX/AppDelegate.swift b/OpenEdX/AppDelegate.swift index 59138b48c..100c84016 100644 --- a/OpenEdX/AppDelegate.swift +++ b/OpenEdX/AppDelegate.swift @@ -12,6 +12,9 @@ import Profile import GoogleSignIn import FacebookCore import MSAL +import UserNotifications +import FirebaseCore +import FirebaseMessaging import Theme @UIApplicationMain @@ -32,6 +35,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { initDI() + if let config = Container.shared.resolve(ConfigProtocol.self) { Theme.Shapes.isRoundedCorners = config.theme.isRoundedCorners @@ -42,6 +46,20 @@ class AppDelegate: UIResponder, UIApplicationDelegate { ) } configureDeepLinkServices(launchOptions: launchOptions) + + 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() + } } Theme.Fonts.registerFonts() @@ -49,17 +67,20 @@ class AppDelegate: UIResponder, UIApplicationDelegate { window?.rootViewController = RouteController() window?.makeKeyAndVisible() window?.tintColor = Theme.UIColors.accentColor - + NotificationCenter.default.addObserver( self, - selector: #selector(forceLogoutUser), - name: .onTokenRefreshFailed, + selector: #selector(didUserAuthorize), + name: .userAuthorized, object: nil ) - if let pushManager = Container.shared.resolve(PushNotificationsManager.self) { - pushManager.performRegistration() - } + NotificationCenter.default.addObserver( + self, + selector: #selector(didUserLogout), + name: .userLoggedOut, + object: nil + ) return true } @@ -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 } + Container.shared.resolve(PushNotificationsManager.self)?.synchronizeToken() + } + + @objc func didUserLogout(_ notification: Notification) { guard Date().timeIntervalSince1970 - lastForceLogoutTime > 5 else { return } - let analyticsManager = Container.shared.resolve(AnalyticsManager.self) - analyticsManager?.userLogout(force: true) - - lastForceLogoutTime = Date().timeIntervalSince1970 + if let userInfo = notification.userInfo, + userInfo[Notification.UserInfoKey.isForced] as? Bool == true { + let analyticsManager = Container.shared.resolve(AnalyticsManager.self) + analyticsManager?.userLogout(force: true) + + lastForceLogoutTime = Date().timeIntervalSince1970 + + Container.shared.resolve(CoreStorage.self)?.clear() + Task { + await Container.shared.resolve(DownloadManagerProtocol.self)?.deleteAllFiles() + } + Container.shared.resolve(CoreDataHandlerProtocol.self)?.clear() + window?.rootViewController = RouteController() + } - Container.shared.resolve(CoreStorage.self)?.clear() - Task { - await Container.shared.resolve(DownloadManagerProtocol.self)?.deleteAllFiles() + if Container.shared.resolve(ConfigProtocol.self)?.firebase.cloudMessagingEnabled == true { + UNUserNotificationCenter.current().removeAllDeliveredNotifications() + Container.shared.resolve(PushNotificationsManager.self)?.refreshToken() } - Container.shared.resolve(CoreDataHandlerProtocol.self)?.clear() - window?.rootViewController = RouteController() } // Push Notifications @@ -151,8 +185,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void ) { - guard let pushManager = Container.shared.resolve(PushNotificationsManager.self) - else { + guard let pushManager = Container.shared.resolve(PushNotificationsManager.self) else { completionHandler(.newData) return } diff --git a/OpenEdX/DI/AppAssembly.swift b/OpenEdX/DI/AppAssembly.swift index 6b722f8c2..8e6447ac3 100644 --- a/OpenEdX/DI/AppAssembly.swift +++ b/OpenEdX/DI/AppAssembly.swift @@ -196,10 +196,8 @@ class AppAssembly: Assembly { ) }.inObjectScope(.container) - container.register(FirebaseAnalyticsService.self) { r in - FirebaseAnalyticsService( - config: r.resolve(ConfigProtocol.self)! - ) + container.register(FirebaseAnalyticsService.self) { _ in + FirebaseAnalyticsService() }.inObjectScope(.container) container.register(PipManagerProtocol.self) { r in diff --git a/OpenEdX/Data/AppStorage.swift b/OpenEdX/Data/AppStorage.swift index ff17e4128..32d51c301 100644 --- a/OpenEdX/Data/AppStorage.swift +++ b/OpenEdX/Data/AppStorage.swift @@ -34,45 +34,29 @@ public class AppStorage: CoreStorage, ProfileStorage, WhatsNewStorage, CourseSto } } } - - public var reviewLastShownVersion: String? { + + public var refreshToken: String? { get { - return userDefaults.string(forKey: KEY_REVIEW_LAST_SHOWN_VERSION) + return keychain.get(KEY_REFRESH_TOKEN) } set(newValue) { if let newValue { - userDefaults.set(newValue, forKey: KEY_REVIEW_LAST_SHOWN_VERSION) + keychain.set(newValue, forKey: KEY_REFRESH_TOKEN) } else { - userDefaults.removeObject(forKey: KEY_REVIEW_LAST_SHOWN_VERSION) + keychain.delete(KEY_REFRESH_TOKEN) } } } - public var lastReviewDate: Date? { - get { - guard let dateString = userDefaults.string(forKey: KEY_REVIEW_LAST_REVIEW_DATE) else { - return nil - } - return Date(iso8601: dateString) - } - set(newValue) { - if let newValue { - userDefaults.set(newValue.dateToString(style: .iso8601), forKey: KEY_REVIEW_LAST_REVIEW_DATE) - } else { - userDefaults.removeObject(forKey: KEY_REVIEW_LAST_REVIEW_DATE) - } - } - } - - public var refreshToken: String? { + public var pushToken: String? { get { - return keychain.get(KEY_REFRESH_TOKEN) + return keychain.get(KEY_PUSH_TOKEN) } set(newValue) { if let newValue { - keychain.set(newValue, forKey: KEY_REFRESH_TOKEN) + keychain.set(newValue, forKey: KEY_PUSH_TOKEN) } else { - keychain.delete(KEY_REFRESH_TOKEN) + keychain.delete(KEY_PUSH_TOKEN) } } } @@ -116,6 +100,35 @@ public class AppStorage: CoreStorage, ProfileStorage, WhatsNewStorage, CourseSto } } + public var reviewLastShownVersion: String? { + get { + return userDefaults.string(forKey: KEY_REVIEW_LAST_SHOWN_VERSION) + } + set(newValue) { + if let newValue { + userDefaults.set(newValue, forKey: KEY_REVIEW_LAST_SHOWN_VERSION) + } else { + userDefaults.removeObject(forKey: KEY_REVIEW_LAST_SHOWN_VERSION) + } + } + } + + public var lastReviewDate: Date? { + get { + guard let dateString = userDefaults.string(forKey: KEY_REVIEW_LAST_REVIEW_DATE) else { + return nil + } + return Date(iso8601: dateString) + } + set(newValue) { + if let newValue { + userDefaults.set(newValue.dateToString(style: .iso8601), forKey: KEY_REVIEW_LAST_REVIEW_DATE) + } else { + userDefaults.removeObject(forKey: KEY_REVIEW_LAST_REVIEW_DATE) + } + } + } + public var whatsNewVersion: String? { get { return userDefaults.string(forKey: KEY_WHATSNEW_VERSION) @@ -209,10 +222,12 @@ public class AppStorage: CoreStorage, ProfileStorage, WhatsNewStorage, CourseSto refreshToken = nil cookiesDate = nil user = nil + userProfile = nil } private let KEY_ACCESS_TOKEN = "accessToken" private let KEY_REFRESH_TOKEN = "refreshToken" + private let KEY_PUSH_TOKEN = "pushToken" private let KEY_COOKIES_DATE = "cookiesDate" private let KEY_USER_PROFILE = "userProfile" private let KEY_USER = "refreshToken" diff --git a/OpenEdX/Data/Network/NotificationsEndpoints.swift b/OpenEdX/Data/Network/NotificationsEndpoints.swift new file mode 100644 index 000000000..8b5639f6c --- /dev/null +++ b/OpenEdX/Data/Network/NotificationsEndpoints.swift @@ -0,0 +1,44 @@ +// +// NotificationsEndpoints.swift +// OpenEdX +// +// Created by Volodymyr Chekyrta on 21.05.24. +// + +import Foundation +import Core +import Alamofire + +enum NotificationsEndpoints: EndPointType { + + case syncFirebaseToken(token: String) + + var path: String { + switch self { + case .syncFirebaseToken: + return "/api/mobile/v4/notifications/create-token/" + } + } + + var httpMethod: HTTPMethod { + switch self { + case .syncFirebaseToken: + return .post + } + } + + var headers: HTTPHeaders? { + nil + } + + var task: HTTPTask { + switch self { + case let .syncFirebaseToken(token): + let params: [String: Encodable] = [ + "registration_id": token, + "active": true + ] + return .requestParameters(parameters: params, encoding: URLEncoding.httpBody) + } + } +} diff --git a/OpenEdX/Managers/DeepLinkManager/DeepLinkManager.swift b/OpenEdX/Managers/DeepLinkManager/DeepLinkManager.swift index be43a71c6..20262a0ef 100644 --- a/OpenEdX/Managers/DeepLinkManager/DeepLinkManager.swift +++ b/OpenEdX/Managers/DeepLinkManager/DeepLinkManager.swift @@ -38,11 +38,11 @@ public class DeepLinkManager { private let discussionInteractor: DiscussionInteractorProtocol private let courseInteractor: CourseInteractorProtocol private let profileInteractor: ProfileInteractorProtocol - + var userloggedIn: Bool { - return !(storage.user?.username?.isEmpty ?? true) - } - + return !(storage.user?.username?.isEmpty ?? true) + } + public init( config: ConfigProtocol, router: DeepLinkRouter, @@ -59,7 +59,7 @@ public class DeepLinkManager { self.discussionInteractor = discussionInteractor self.courseInteractor = courseInteractor self.profileInteractor = profileInteractor - + services = servicesFor(config: config) } @@ -102,9 +102,9 @@ public class DeepLinkManager { guard link.type != .none else { return } - + let isAppActive = UIApplication.shared.applicationState == .active - + Task { if isAppActive { await showNotificationAlert(link) @@ -124,11 +124,11 @@ public class DeepLinkManager { } } } - + @MainActor private func showNotificationAlert(_ link: PushLink) { router.dismissPresentedViewController() - + router.presentAlert( alertTitle: link.title ?? "", alertMessage: link.body ?? "", @@ -148,17 +148,19 @@ public class DeepLinkManager { type: .deepLink ) } - + private func isDiscovery(type: DeepLinkType) -> Bool { type == .discovery || type == .discoveryCourseDetail || type == .discoveryProgramDetail } - + private func isDiscussionThreads(type: DeepLinkType) -> Bool { type == .discussionPost || type == .discussionTopic || - type == .discussionComment + type == .discussionComment || + type == .forumResponse || + type == .forumComment } private func isHandout(type: DeepLinkType) -> Bool { @@ -195,9 +197,14 @@ public class DeepLinkManager { .courseAnnouncement, .discussionTopic, .discussionPost, + .forumResponse, + .forumComment, .discussionComment, .courseComponent: await showCourseScreen(with: type, link: link) + case .enroll, .addBetaTester: + await showCourseScreen(with: type, link: link) + NotificationCenter.default.post(name: .refreshEnrollments, object: nil) case .program, .programDetail: guard config.program.enabled else { return } if let pathID = link.pathID, !pathID.isEmpty { @@ -209,6 +216,9 @@ public class DeepLinkManager { router.showTabScreen(tab: .profile) case .userProfile: await showEditProfile() + case .unenroll, .removeBetaTester: + router.showTabScreen(tab: .dashboard) + NotificationCenter.default.post(name: .refreshEnrollments, object: nil) default: break } @@ -248,9 +258,8 @@ public class DeepLinkManager { link: link, courseDetails: courseDetails ) { [weak self] in - guard let self else { - return - } + guard let self else { return } + guard courseDetails.isEnrolled else { return } if self.isHandout(type: type) { self.router.showProgress() @@ -344,7 +353,6 @@ public class DeepLinkManager { isBlackedOut: isBlackedOut ) case .discussionPost: - if let topicID = link.topicID, !topicID.isEmpty, let topics = try? await discussionInteractor.getTopic( @@ -367,8 +375,7 @@ public class DeepLinkManager { isBlackedOut: isBlackedOut ) } - - case .discussionComment: + case .discussionComment, .forumResponse: if let topicID = link.topicID, !topicID.isEmpty, let topics = try? await discussionInteractor.getTopic( @@ -404,6 +411,42 @@ public class DeepLinkManager { isBlackedOut: isBlackedOut ) } + case .forumComment: + if let topicID = link.topicID, + !topicID.isEmpty, + let topics = try? await discussionInteractor.getTopic( + courseID: courseDetails.courseID, + topicID: topicID + ) { + router.showThreads( + topicID: topicID, + courseDetails: courseDetails, + topics: topics, + isBlackedOut: isBlackedOut + ) + } + + if let threadID = link.threadID, + !threadID.isEmpty, + let userThread = try? await discussionInteractor.getThread(threadID: threadID) { + router.showThread( + userThread: userThread, + isBlackedOut: isBlackedOut + ) + } + + if let parentID = link.parentID, + !parentID.isEmpty, + let comment = try? await self.discussionInteractor.getResponse(responseID: parentID), + let commentParentID = comment.parentID, + !commentParentID.isEmpty, + let parentComment = try? await self.discussionInteractor.getResponse(responseID: commentParentID) { + router.showComment( + comment: comment, + parentComment: parentComment.post, + isBlackedOut: isBlackedOut + ) + } default: break } diff --git a/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift b/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift index 0ae41e30a..15f026235 100644 --- a/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift +++ b/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift @@ -112,7 +112,7 @@ extension Router: DeepLinkRouter { courseEnd: courseDetails.courseEnd, enrollmentStart: courseDetails.enrollmentStart, enrollmentEnd: courseDetails.enrollmentEnd, - title: courseDetails.courseTitle, + title: courseDetails.courseTitle, showDates: false, lastVisitedBlockID: nil ) @@ -131,7 +131,9 @@ extension Router: DeepLinkRouter { .courseHandout, .courseAnnouncement, .courseDashboard, - .courseComponent: + .courseComponent, + .enroll, + .addBetaTester: popToCourseContainerView(animated: false) default: break diff --git a/OpenEdX/Managers/DeepLinkManager/Link/DeepLink.swift b/OpenEdX/Managers/DeepLinkManager/Link/DeepLink.swift index 1da67d716..90ca14792 100644 --- a/OpenEdX/Managers/DeepLinkManager/Link/DeepLink.swift +++ b/OpenEdX/Managers/DeepLinkManager/Link/DeepLink.swift @@ -26,6 +26,12 @@ enum DeepLinkType: String { case programDetail = "program_detail" case userProfile = "user_profile" case profile = "profile" + case forumResponse = "forum_response" + case forumComment = "forum_comment" + case enroll = "enroll" + case unenroll = "unenroll" + case addBetaTester = "add_beta_tester" + case removeBetaTester = "remove_beta_tester" case none } @@ -33,30 +39,38 @@ private enum DeepLinkKeys: String, RawStringExtractable { case courseID = "course_id" case pathID = "path_id" case screenName = "screen_name" + case notificationType = "notification_type" case topicID = "topic_id" case threadID = "thread_id" case commentID = "comment_id" + case parentID = "parent_id" case componentID = "component_id" } public class DeepLink { let courseID: String? let screenName: String? + let notificationType: String? let pathID: String? let topicID: String? let threadID: String? let commentID: String? + let parentID: String? let componentID: String? var type: DeepLinkType init(dictionary: [AnyHashable: Any]) { courseID = dictionary[DeepLinkKeys.courseID.rawValue] as? String screenName = dictionary[DeepLinkKeys.screenName.rawValue] as? String + notificationType = dictionary[DeepLinkKeys.notificationType.rawValue] as? String pathID = dictionary[DeepLinkKeys.pathID.rawValue] as? String topicID = dictionary[DeepLinkKeys.topicID.rawValue] as? String threadID = dictionary[DeepLinkKeys.threadID.rawValue] as? String commentID = dictionary[DeepLinkKeys.commentID.rawValue] as? String componentID = dictionary[DeepLinkKeys.componentID.rawValue] as? String - type = DeepLinkType(rawValue: screenName ?? DeepLinkType.none.rawValue) ?? .none + parentID = dictionary[DeepLinkKeys.parentID.rawValue] as? String + type = DeepLinkType( + rawValue: screenName ?? notificationType ?? DeepLinkType.none.rawValue + ) ?? .none } } diff --git a/OpenEdX/Managers/FirebaseAnalyticsService/FirebaseAnalyticsService.swift b/OpenEdX/Managers/FirebaseAnalyticsService/FirebaseAnalyticsService.swift index 12bd225e8..fe49bc007 100644 --- a/OpenEdX/Managers/FirebaseAnalyticsService/FirebaseAnalyticsService.swift +++ b/OpenEdX/Managers/FirebaseAnalyticsService/FirebaseAnalyticsService.swift @@ -6,19 +6,13 @@ // import Foundation -import Firebase import Core +import FirebaseAnalytics private let MaxParameterValueCharacters = 100 private let MaxNameValueCharacters = 40 class FirebaseAnalyticsService: AnalyticsService { - // Init manager - public init(config: ConfigProtocol) { - guard config.firebase.enabled && config.firebase.isAnalyticsSourceFirebase else { return } - - FirebaseApp.configure() - } func identify(id: String, username: String?, email: String?) { Analytics.setUserID(id) diff --git a/OpenEdX/Managers/PushNotificationsManager/Listeners/FCMListener.swift b/OpenEdX/Managers/PushNotificationsManager/Listeners/FCMListener.swift index b0ed7f5f8..fd3331f0b 100644 --- a/OpenEdX/Managers/PushNotificationsManager/Listeners/FCMListener.swift +++ b/OpenEdX/Managers/PushNotificationsManager/Listeners/FCMListener.swift @@ -9,5 +9,8 @@ import Foundation class FCMListener: PushNotificationsListener { // check if userinfo contains data for this Listener - func shouldListenNotification(userinfo: [AnyHashable: Any]) -> Bool { false } + func shouldListenNotification(userinfo: [AnyHashable: Any]) -> Bool { + let data = userinfo["gcm.message_id"] + return userinfo.count > 0 && data != nil + } } diff --git a/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift b/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift index 9e45059e3..43ca0c780 100644 --- a/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift +++ b/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift @@ -25,4 +25,10 @@ class BrazeProvider: PushNotificationsProvider { func didFailToRegisterForRemoteNotificationsWithError(error: Error) { } + + func synchronizeToken() { + } + + func refreshToken() { + } } diff --git a/OpenEdX/Managers/PushNotificationsManager/Providers/FCMProvider.swift b/OpenEdX/Managers/PushNotificationsManager/Providers/FCMProvider.swift index 4e66a2a30..c260da056 100644 --- a/OpenEdX/Managers/PushNotificationsManager/Providers/FCMProvider.swift +++ b/OpenEdX/Managers/PushNotificationsManager/Providers/FCMProvider.swift @@ -6,13 +6,54 @@ // import Foundation +import Core +import FirebaseCore +import FirebaseMessaging +import Swinject -class FCMProvider: PushNotificationsProvider { +class FCMProvider: NSObject, PushNotificationsProvider, MessagingDelegate { + + private var storage = Container.shared.resolve(CoreStorage.self)! + private let api = Container.shared.resolve(API.self)! + func didRegisterWithDeviceToken(deviceToken: Data) { - + Messaging.messaging().apnsToken = deviceToken } func didFailToRegisterForRemoteNotificationsWithError(error: Error) { + } + + func synchronizeToken() { + guard let fcmToken = storage.pushToken, storage.user != nil else { return } + sendFCMToken(fcmToken) + } + + func refreshToken() { + Messaging.messaging().deleteToken { error in + if let error = error { + debugLog("Error deleting FCM token: \(error.localizedDescription)") + } else { + Messaging.messaging().token { token, error in + if let error = error { + debugLog("Error fetching FCM token: \(error.localizedDescription)") + } else if let token = token { + debugLog("FCM token fetched: \(token)") + } + } + } + } + } + + func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { + storage.pushToken = fcmToken + guard let fcmToken, storage.user != nil else { return } + sendFCMToken(fcmToken) + } + + private func sendFCMToken(_ token: String) { + Task { + try? await api.request(NotificationsEndpoints.syncFirebaseToken(token: token)) + } } } diff --git a/OpenEdX/Managers/PushNotificationsManager/PushNotificationsManager.swift b/OpenEdX/Managers/PushNotificationsManager/PushNotificationsManager.swift index be832bcbb..fb81e0b1b 100644 --- a/OpenEdX/Managers/PushNotificationsManager/PushNotificationsManager.swift +++ b/OpenEdX/Managers/PushNotificationsManager/PushNotificationsManager.swift @@ -9,10 +9,15 @@ import Foundation import Core import UIKit import Swinject +import UserNotifications +import FirebaseCore +import FirebaseMessaging public protocol PushNotificationsProvider { func didRegisterWithDeviceToken(deviceToken: Data) func didFailToRegisterForRemoteNotificationsWithError(error: Error) + func synchronizeToken() + func refreshToken() } protocol PushNotificationsListener { @@ -31,35 +36,40 @@ extension PushNotificationsListener { } } -class PushNotificationsManager { +class PushNotificationsManager: NSObject { private var providers: [PushNotificationsProvider] = [] private var listeners: [PushNotificationsListener] = [] + public var hasProviders: Bool { + providers.count > 0 + } + // Init manager public init(config: ConfigProtocol) { + super.init() providers = providersFor(config: config) listeners = listenersFor(config: config) } private func providersFor(config: ConfigProtocol) -> [PushNotificationsProvider] { var pushProviders: [PushNotificationsProvider] = [] - if config.firebase.cloudMessagingEnabled { - pushProviders.append(FCMProvider()) - } if config.braze.pushNotificationsEnabled { pushProviders.append(BrazeProvider()) } + if config.firebase.cloudMessagingEnabled { + pushProviders.append(FCMProvider()) + } return pushProviders } private func listenersFor(config: ConfigProtocol) -> [PushNotificationsListener] { var pushListeners: [PushNotificationsListener] = [] - if config.firebase.cloudMessagingEnabled { - pushListeners.append(FCMListener()) - } if config.braze.pushNotificationsEnabled { pushListeners.append(BrazeListener()) } + if config.firebase.cloudMessagingEnabled { + pushListeners.append(FCMListener()) + } return pushListeners } @@ -67,9 +77,7 @@ class PushNotificationsManager { public func performRegistration() { UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { (granted, error) in if granted { - DispatchQueue.main.async { - UIApplication.shared.registerForRemoteNotifications() - } + debugLog("Permission for push notifications granted.") } else if let error = error { debugLog("Push notifications permission error: \(error.localizedDescription)") } else { @@ -94,4 +102,58 @@ class PushNotificationsManager { listener.didReceiveRemoteNotification(userInfo: userInfo) } } + + func synchronizeToken() { + for provider in providers { + provider.synchronizeToken() + } + } + + func refreshToken() { + for provider in providers { + provider.refreshToken() + } + } +} + +// MARK: - MessagingDelegate +extension PushNotificationsManager: MessagingDelegate { + func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { + for provider in providers where provider is MessagingDelegate { + (provider as? MessagingDelegate)?.messaging?(messaging, didReceiveRegistrationToken: fcmToken) + } + } +} + +// MARK: - UNUserNotificationCenterDelegate +extension PushNotificationsManager: UNUserNotificationCenterDelegate { + @MainActor + func userNotificationCenter( + _ center: UNUserNotificationCenter, + 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) + + // Show alert if application is active + if let pushManager = Container.shared.resolve(PushNotificationsManager.self), + UIApplication.shared.applicationState == .active { + pushManager.didReceiveRemoteNotification(userInfo: notification.request.content.userInfo) + } + + return [[.list, .banner, .sound]] + } + + @MainActor + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse + ) async { + let userInfo = response.notification.request.content.userInfo + + // With swizzling disabled you must let Messaging know about the message, for Analytics + Messaging.messaging().appDidReceiveMessage(userInfo) + + didReceiveRemoteNotification(userInfo: userInfo) + } } diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index 8dbbafa20..fce1cca5d 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -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() + } } public func getCourseScreensController( diff --git a/Profile/Profile/Presentation/Settings/SettingsViewModel.swift b/Profile/Profile/Presentation/Settings/SettingsViewModel.swift index e31e09eea..ce6c4477b 100644 --- a/Profile/Profile/Presentation/Settings/SettingsViewModel.swift +++ b/Profile/Profile/Presentation/Settings/SettingsViewModel.swift @@ -139,6 +139,11 @@ public class SettingsViewModel: ObservableObject { try? await downloadManager.cancelAllDownloading() router.showStartupScreen() analytics.userLogout(force: false) + NotificationCenter.default.post( + name: .userLoggedOut, + object: nil, + userInfo: [Notification.UserInfoKey.isForced: false] + ) } func trackProfileVideoSettingsClicked() {