Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
}
Expand Down
32 changes: 28 additions & 4 deletions Application/DevLogApp/Sources/App/Delegate/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
}

Expand All @@ -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 등록 실패
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnyCancellable>()

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)
}
}
Comment thread
opficdev marked this conversation as resolved.

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)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Original file line number Diff line number Diff line change
@@ -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("조건을 만족하지 못함")
}
4 changes: 2 additions & 2 deletions Application/DevLogData/Sources/DTO/AuthDataResponse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -23,7 +23,7 @@ public struct AuthDataResponse {
email: String?,
providers: [String],
providerID: String,
fcmToken: String,
fcmToken: String? = nil,
accessToken: String? = nil
) {
self.uid = uid
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import DevLogData
extension FirebaseAuth.User {
func makeResponse(
providerID: AuthProviderID,
fcmToken: String,
accessToken: String? = nil
) -> AuthDataResponse {
return AuthDataResponse(
Expand All @@ -21,7 +20,6 @@ extension FirebaseAuth.User {
email: self.email,
providers: self.providerData.map { $0.providerID },
providerID: providerID.rawValue,
fcmToken: fcmToken,
accessToken: accessToken
)
}
Expand Down
Loading
Loading