diff --git a/Application/DevLogApp/Sources/App/Assembler/AppLayerAssembler.swift b/Application/DevLogApp/Sources/App/Assembler/AppLayerAssembler.swift index 8dbc0bee..cd2ecbc2 100644 --- a/Application/DevLogApp/Sources/App/Assembler/AppLayerAssembler.swift +++ b/Application/DevLogApp/Sources/App/Assembler/AppLayerAssembler.swift @@ -12,6 +12,7 @@ final class AppLayerAssembler: Assembler { func assemble(_ container: any DIContainer) { container.register(FCMTokenSyncHandler.self) { FCMTokenSyncHandler( + messagingService: container.resolve(PushMessagingService.self), userService: container.resolve(UserService.self) ) } diff --git a/Application/DevLogApp/Sources/App/Delegate/AppDelegate.swift b/Application/DevLogApp/Sources/App/Delegate/AppDelegate.swift index c65a68d2..befab07d 100644 --- a/Application/DevLogApp/Sources/App/Delegate/AppDelegate.swift +++ b/Application/DevLogApp/Sources/App/Delegate/AppDelegate.swift @@ -32,6 +32,18 @@ class AppDelegate: UIResponder, UIApplicationDelegate { _ = container.resolve(UserTimeZoneSyncHandler.self) _ = container.resolve(WidgetSyncEventHandler.self) _ = container.resolve(WidgetSessionSyncHandler.self) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleSceneDidActivate), + name: UIScene.didActivateNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleRemoteNotificationRegistrationRequest), + name: .didRequestRemoteNotificationRegistration, + object: nil + ) // 알림 권한 요청 UNUserNotificationCenter.current().delegate = self @@ -40,9 +52,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { self.logger.error("Notification authorization error", error: error) } else { self.logger.info("Notification permission granted: \(granted)") - DispatchQueue.main.async { - UIApplication.shared.registerForRemoteNotifications() - } + NotificationCenter.default.post(name: .didRequestFCMTokenSync, object: nil) } } @@ -63,13 +73,27 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return true } + @objc private func handleSceneDidActivate() { + NotificationCenter.default.post(name: .didRequestFCMTokenSync, object: nil) + } + + @objc private func handleRemoteNotificationRegistrationRequest() { + Task { @MainActor in + UIApplication.shared.registerForRemoteNotifications() + } + } + // APNs 등록 성공 func application( _ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data ) { logger.info("APNs token: \(deviceToken.map { String(format: "%02.2hhx", $0) }.joined())") - container.resolve(PushMessagingService.self).setAPNSToken(deviceToken) + NotificationCenter.default.post( + name: .didReceiveAPNSToken, + object: nil, + userInfo: ["deviceToken": deviceToken] + ) } // APNs 등록 실패 diff --git a/Application/DevLogApp/Sources/App/Handler/FCMTokenSyncHandler.swift b/Application/DevLogApp/Sources/App/Handler/FCMTokenSyncHandler.swift index b631c8a1..557bb11d 100644 --- a/Application/DevLogApp/Sources/App/Handler/FCMTokenSyncHandler.swift +++ b/Application/DevLogApp/Sources/App/Handler/FCMTokenSyncHandler.swift @@ -11,27 +11,80 @@ import DevLogData import Foundation final class FCMTokenSyncHandler { + private let messagingService: PushMessagingService private let userService: UserService + private let notificationCenter: NotificationCenter private let logger = Logger(category: "FCMTokenSyncHandler") private var cancellables = Set() init( + messagingService: PushMessagingService, userService: UserService, notificationCenter: NotificationCenter = .default ) { + self.messagingService = messagingService self.userService = userService + self.notificationCenter = notificationCenter notificationCenter.publisher(for: .didRefreshFCMToken) .compactMap { $0.userInfo?["fcmToken"] as? String } .sink { [weak self] fcmToken in - Task { - do { - try await self?.userService.updateFCMToken(fcmToken) - } catch { - self?.logger.error("Failed to sync refreshed FCM token", error: error) - } - } + self?.syncFCMToken(fcmToken) } .store(in: &cancellables) + + notificationCenter.publisher(for: .didRequestFCMTokenSync) + .sink { [weak self] _ in + self?.requestFCMTokenSync() + } + .store(in: &cancellables) + + notificationCenter.publisher(for: .didReceiveAPNSToken) + .compactMap { $0.userInfo?["deviceToken"] as? Data } + .sink { [weak self] deviceToken in + self?.handleAPNSToken(deviceToken) + } + .store(in: &cancellables) + } +} + +private extension FCMTokenSyncHandler { + func requestFCMTokenSync() { + Task { [weak self] in + guard let self else { return } + guard await messagingService.isNotificationAuthorized() else { + return + } + notificationCenter.post(name: .didRequestRemoteNotificationRegistration, object: nil) + await syncCurrentFCMToken() + } + } + + func handleAPNSToken(_ deviceToken: Data) { + messagingService.setAPNSToken(deviceToken) + Task { [weak self] in + await self?.syncCurrentFCMToken() + } + } + + func syncCurrentFCMToken() async { + do { + guard let fcmToken = try await messagingService.fetchFCMToken() else { + return + } + try await userService.updateFCMToken(fcmToken) + } catch { + logger.error("Failed to sync current FCM token", error: error) + } + } + + func syncFCMToken(_ fcmToken: String) { + Task { [weak self] in + do { + try await self?.userService.updateFCMToken(fcmToken) + } catch { + self?.logger.error("Failed to sync refreshed FCM token", error: error) + } + } } } diff --git a/Application/DevLogApp/Sources/App/Notification/NotificationName+.swift b/Application/DevLogApp/Sources/App/Notification/NotificationName+.swift index 41edfbc6..84aaef68 100644 --- a/Application/DevLogApp/Sources/App/Notification/NotificationName+.swift +++ b/Application/DevLogApp/Sources/App/Notification/NotificationName+.swift @@ -9,5 +9,8 @@ import Foundation extension Notification.Name { static let didRefreshFCMToken = Notification.Name("didRefreshFCMToken") + static let didReceiveAPNSToken = Notification.Name("didReceiveAPNSToken") + static let didRequestFCMTokenSync = Notification.Name("didRequestFCMTokenSync") + static let didRequestRemoteNotificationRegistration = Notification.Name("didRequestRemoteNotificationRegistration") static let didRequestUserTimeZoneSync = Notification.Name("didRequestUserTimeZoneSync") } diff --git a/Application/DevLogApp/Tests/PushNotification/FCMTokenSyncHandlerTests.swift b/Application/DevLogApp/Tests/PushNotification/FCMTokenSyncHandlerTests.swift new file mode 100644 index 00000000..67be8fe6 --- /dev/null +++ b/Application/DevLogApp/Tests/PushNotification/FCMTokenSyncHandlerTests.swift @@ -0,0 +1,176 @@ +// +// FCMTokenSyncHandlerTests.swift +// DevLogAppTests +// +// Created by opfic on 6/13/26. +// + +import Foundation +import Testing +import DevLogData +@testable import DevLogApp + +struct FCMTokenSyncHandlerTests { + @Test("현재 FCM token 동기화 요청 시 token이 있으면 저장한다") + func 현재_FCM_token_동기화_요청_시_token이_있으면_저장한다() async throws { + let notificationCenter = NotificationCenter() + let registrationObserver = NotificationObserver( + notificationCenter: notificationCenter, + name: .didRequestRemoteNotificationRegistration + ) + let messagingService = PushMessagingServiceSpy(currentFCMToken: "current-token") + let userService = UserServiceSpy() + let handler = FCMTokenSyncHandler( + messagingService: messagingService, + userService: userService, + notificationCenter: notificationCenter + ) + + notificationCenter.post(name: .didRequestFCMTokenSync, object: nil) + + try await waitUntil { + await userService.updatedFCMTokens == ["current-token"] + } + #expect(registrationObserver.didReceiveNotification) + _ = handler + } + + @Test("현재 FCM token 동기화 요청 시 token이 없으면 저장하지 않는다") + func 현재_FCM_token_동기화_요청_시_token이_없으면_저장하지_않는다() async throws { + let notificationCenter = NotificationCenter() + let messagingService = PushMessagingServiceSpy(currentFCMToken: nil) + let userService = UserServiceSpy() + let handler = FCMTokenSyncHandler( + messagingService: messagingService, + userService: userService, + notificationCenter: notificationCenter + ) + + notificationCenter.post(name: .didRequestFCMTokenSync, object: nil) + + try await Task.sleep(for: .milliseconds(100)) + #expect(await userService.updatedFCMTokens.isEmpty) + _ = handler + } + + @Test("갱신된 FCM token 이벤트 수신 시 저장한다") + func 갱신된_FCM_token_이벤트_수신_시_저장한다() async throws { + let notificationCenter = NotificationCenter() + let messagingService = PushMessagingServiceSpy(currentFCMToken: nil) + let userService = UserServiceSpy() + let handler = FCMTokenSyncHandler( + messagingService: messagingService, + userService: userService, + notificationCenter: notificationCenter + ) + + notificationCenter.post( + name: .didRefreshFCMToken, + object: nil, + userInfo: ["fcmToken": "refreshed-token"] + ) + + try await waitUntil { + await userService.updatedFCMTokens == ["refreshed-token"] + } + _ = handler + } + + @Test("APNs token 이벤트 수신 시 APNs token을 적용하고 현재 FCM token을 저장한다") + func APNs_token_이벤트_수신_시_APNs_token을_적용하고_현재_FCM_token을_저장한다() async throws { + let notificationCenter = NotificationCenter() + let messagingService = PushMessagingServiceSpy(currentFCMToken: "current-token") + let userService = UserServiceSpy() + let handler = FCMTokenSyncHandler( + messagingService: messagingService, + userService: userService, + notificationCenter: notificationCenter + ) + let deviceToken = Data([0x01, 0x02, 0x03]) + + notificationCenter.post( + name: .didReceiveAPNSToken, + object: nil, + userInfo: ["deviceToken": deviceToken] + ) + + try await waitUntil { + await userService.updatedFCMTokens == ["current-token"] + } + #expect(messagingService.apnsTokens == [deviceToken]) + _ = handler + } +} + +private actor UserServiceSpy: UserService { + private(set) var updatedFCMTokens = [String]() + + func upsertUser(_ response: AuthDataResponse) async throws { } + func fetchUserProfile() async throws -> UserProfileResponse { fatalError() } + func upsertStatusMessage(_ message: String) async throws { } + + func updateFCMToken(_ fcmToken: String) async throws { + updatedFCMTokens.append(fcmToken) + } + + func updateUserTimeZone() async throws { } +} + +private final class PushMessagingServiceSpy: PushMessagingService { + private let currentFCMToken: String? + private(set) var apnsTokens = [Data]() + + init(currentFCMToken: String?) { + self.currentFCMToken = currentFCMToken + } + + func setDelegate(_ delegate: PushMessagingServiceDelegate?) { } + func setAPNSToken(_ deviceToken: Data) { + apnsTokens.append(deviceToken) + } + func isNotificationAuthorized() async -> Bool { true } + + func fetchFCMToken() async throws -> String? { + currentFCMToken + } +} + +private final class NotificationObserver { + private(set) var didReceiveNotification = false + private var token: NSObjectProtocol? + private let notificationCenter: NotificationCenter + + init(notificationCenter: NotificationCenter, name: Notification.Name) { + self.notificationCenter = notificationCenter + self.token = notificationCenter.addObserver( + forName: name, + object: nil, + queue: nil + ) { [weak self] _ in + self?.didReceiveNotification = true + } + } + + deinit { + if let token { + notificationCenter.removeObserver(token) + } + } +} + +private func waitUntil( + timeout: Duration = .seconds(1), + pollInterval: Duration = .milliseconds(10), + condition: @escaping @Sendable () async -> Bool +) async throws { + let deadline = ContinuousClock.now + timeout + + while ContinuousClock.now < deadline { + if await condition() { + return + } + try await Task.sleep(for: pollInterval) + } + + Issue.record("조건을 만족하지 못함") +} diff --git a/Application/DevLogData/Sources/DTO/AuthDataResponse.swift b/Application/DevLogData/Sources/DTO/AuthDataResponse.swift index 758a1ffc..3628fcff 100644 --- a/Application/DevLogData/Sources/DTO/AuthDataResponse.swift +++ b/Application/DevLogData/Sources/DTO/AuthDataResponse.swift @@ -14,7 +14,7 @@ public struct AuthDataResponse { public let email: String? public let providers: [String] public let providerID: String - public let fcmToken: String + public let fcmToken: String? public let accessToken: String? public init( @@ -23,7 +23,7 @@ public struct AuthDataResponse { email: String?, providers: [String], providerID: String, - fcmToken: String, + fcmToken: String? = nil, accessToken: String? = nil ) { self.uid = uid diff --git a/Application/DevLogData/Sources/Protocol/PushMessagingService.swift b/Application/DevLogData/Sources/Protocol/PushMessagingService.swift index d5f61f24..cdb9c0e0 100644 --- a/Application/DevLogData/Sources/Protocol/PushMessagingService.swift +++ b/Application/DevLogData/Sources/Protocol/PushMessagingService.swift @@ -10,6 +10,8 @@ import Foundation public protocol PushMessagingService: AnyObject { func setDelegate(_ delegate: PushMessagingServiceDelegate?) func setAPNSToken(_ deviceToken: Data) + func isNotificationAuthorized() async -> Bool + func fetchFCMToken() async throws -> String? } public protocol PushMessagingServiceDelegate: AnyObject { diff --git a/Application/DevLogInfra/Sources/Extension/FirebaseAuthUser+.swift b/Application/DevLogInfra/Sources/Extension/FirebaseAuthUser+.swift index af7a8e35..e3e7951a 100644 --- a/Application/DevLogInfra/Sources/Extension/FirebaseAuthUser+.swift +++ b/Application/DevLogInfra/Sources/Extension/FirebaseAuthUser+.swift @@ -12,7 +12,6 @@ import DevLogData extension FirebaseAuth.User { func makeResponse( providerID: AuthProviderID, - fcmToken: String, accessToken: String? = nil ) -> AuthDataResponse { return AuthDataResponse( @@ -21,7 +20,6 @@ extension FirebaseAuth.User { email: self.email, providers: self.providerData.map { $0.providerID }, providerID: providerID.rawValue, - fcmToken: fcmToken, accessToken: accessToken ) } diff --git a/Application/DevLogInfra/Sources/Service/PushMessagingServiceImpl.swift b/Application/DevLogInfra/Sources/Service/PushMessagingServiceImpl.swift index 44a29d7d..0c1ff320 100644 --- a/Application/DevLogInfra/Sources/Service/PushMessagingServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/PushMessagingServiceImpl.swift @@ -8,6 +8,7 @@ import Foundation import DevLogData import FirebaseMessaging +import UserNotifications final class PushMessagingServiceImpl: NSObject, PushMessagingService { private weak var delegate: PushMessagingServiceDelegate? @@ -20,6 +21,30 @@ final class PushMessagingServiceImpl: NSObject, PushMessagingService { func setAPNSToken(_ deviceToken: Data) { Messaging.messaging().apnsToken = deviceToken } + + func isNotificationAuthorized() async -> Bool { + let settings = await UNUserNotificationCenter.current().notificationSettings() + + switch settings.authorizationStatus { + case .authorized, .provisional, .ephemeral: + return true + case .denied, .notDetermined: + return false + @unknown default: + return false + } + } + + func fetchFCMToken() async throws -> String? { + do { + return try await Messaging.messaging().token() + } catch { + if error.isMissingAPNSTokenForFCMToken { + return nil + } + throw error + } + } } extension PushMessagingServiceImpl: MessagingDelegate { @@ -27,3 +52,10 @@ extension PushMessagingServiceImpl: MessagingDelegate { delegate?.pushMessagingService(self, didReceiveRegistrationToken: fcmToken) } } + +private extension Error { + var isMissingAPNSTokenForFCMToken: Bool { + let nsError = self as NSError + return nsError.domain == "com.google.fcm" && nsError.code == 505 + } +} diff --git a/Application/DevLogInfra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift b/Application/DevLogInfra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift index 2a7e7270..914d1b6c 100644 --- a/Application/DevLogInfra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift @@ -90,10 +90,8 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { try await result.user.link(with: appleCredential) } - let fcmToken = try await messaging.token() - logger.info("Successfully signed in with Apple") - return result.user.makeResponse(providerID: .apple, fcmToken: fcmToken) + return result.user.makeResponse(providerID: .apple) } catch { logger.error("Failed to sign in with Apple", error: error) throw error diff --git a/Application/DevLogInfra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift b/Application/DevLogInfra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift index f0482b80..e3b0a847 100644 --- a/Application/DevLogInfra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift @@ -72,12 +72,9 @@ final class GithubAuthenticationServiceImpl: NSObject, AuthenticationService { try await result.user.link(with: credential) } - let fcmToken = try await messaging.token() - logger.info("Successfully signed in with GitHub") return result.user.makeResponse( providerID: .gitHub, - fcmToken: fcmToken, accessToken: accessToken ) } catch { diff --git a/Application/DevLogInfra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift b/Application/DevLogInfra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift index b575c04c..50d63525 100644 --- a/Application/DevLogInfra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift @@ -51,10 +51,8 @@ final class GoogleAuthenticationServiceImpl: AuthenticationService { try await changeRequest.commitChanges() } - let fcmToken = try await messaging.token() - logger.info("Successfully signed in with Google") - return result.user.makeResponse(providerID: .google, fcmToken: fcmToken) + return result.user.makeResponse(providerID: .google) } catch { logger.error("Failed to sign in with Google", error: error) throw error diff --git a/Application/DevLogInfra/Sources/Service/UserServiceImpl.swift b/Application/DevLogInfra/Sources/Service/UserServiceImpl.swift index cdedbb61..eefdceca 100644 --- a/Application/DevLogInfra/Sources/Service/UserServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/UserServiceImpl.swift @@ -56,7 +56,11 @@ final class UserServiceImpl: UserService { userField["createdAt"] = FieldValue.serverTimestamp() } - var settingField = ["fcmToken": response.fcmToken] + var settingField: [String: Any] = [:] + + if let fcmToken = response.fcmToken { + settingField["fcmToken"] = fcmToken + } // 깃헙 로그인 시 추가 정보 저장 if response.providerID == "github.com", let accessToken = response.accessToken { @@ -73,7 +77,10 @@ final class UserServiceImpl: UserService { merge: true ) async let infoUpdate: Void = infoRef.setData(userFieldSnapshot, merge: true) - async let tokensUpdate: Void = tokensRef.setData(settingFieldSnapshot, merge: true) + async let tokensUpdate: Void = { + guard !settingFieldSnapshot.isEmpty else { return } + try await tokensRef.setData(settingFieldSnapshot, merge: true) + }() let settingsDocument = try await settingsRef.getDocument() var settingsField: [String: Any] = [ diff --git a/Application/Shared/Version.xcconfig b/Application/Shared/Version.xcconfig index 050e30a3..894ea785 100644 --- a/Application/Shared/Version.xcconfig +++ b/Application/Shared/Version.xcconfig @@ -1,2 +1,2 @@ -MARKETING_VERSION = 1.2.5 +MARKETING_VERSION = 1.2.6 IPHONEOS_DEPLOYMENT_TARGET = 17.0