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 @@ -14,8 +14,7 @@ final class AppLayerAssembler: Assembler {
FCMTokenSyncHandler(
authService: container.resolve(AuthService.self),
messagingService: container.resolve(PushMessagingService.self),
userService: container.resolve(UserService.self),
store: container.resolve(UserDefaultsStore.self)
userService: container.resolve(UserService.self)
)
}
container.register(UserTimeZoneSyncHandler.self) {
Expand Down
4 changes: 4 additions & 0 deletions Application/App/Sources/App/Delegate/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
}
}

func applicationWillEnterForeground(_ application: UIApplication) {
NotificationCenter.default.post(name: .didRequestAPNsRegistration, object: nil)
}

// APNs 등록 성공
func application(
_ application: UIApplication,
Expand Down
47 changes: 17 additions & 30 deletions Application/App/Sources/App/Handler/FCMTokenSyncHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,13 @@

import Combine
import Core
import CryptoKit
import Data
import Foundation
import Infra

final class FCMTokenSyncHandler {
private enum Key {
static let fcmTokenHash = "FCMTokenSyncHandler.fcmTokenHash"
}

private let authService: AuthService
private let messagingService: PushMessagingService
private let userService: UserService
private let store: UserDefaultsStore
private let notificationCenter: NotificationCenter
private let logger = Logger(category: "FCMTokenSyncHandler")
private var cancellables = Set<AnyCancellable>()
Comment thread
opficdev marked this conversation as resolved.
Expand All @@ -29,13 +22,11 @@ final class FCMTokenSyncHandler {
authService: AuthService,
messagingService: PushMessagingService,
userService: UserService,
store: UserDefaultsStore,
notificationCenter: NotificationCenter = .default
) {
self.authService = authService
self.messagingService = messagingService
self.userService = userService
self.store = store
self.notificationCenter = notificationCenter

authService.observeSignedIn()
Expand All @@ -58,6 +49,14 @@ final class FCMTokenSyncHandler {
}
.store(in: &cancellables)

notificationCenter.publisher(for: .didRequestAPNsRegistration)
.sink { [weak self] _ in
Task {
await self?.requestAPNsRegistrationIfAuthorized()
}
}
.store(in: &cancellables)

notificationCenter.publisher(for: .didReceiveAPNSToken)
.compactMap { $0.userInfo?["deviceToken"] as? Data }
.sink { [weak self] deviceToken in
Expand All @@ -69,23 +68,25 @@ final class FCMTokenSyncHandler {

private extension FCMTokenSyncHandler {
func handleSessionUpdate(isSignedIn: Bool) {
guard isSignedIn else {
store.setString(nil, forKey: Key.fcmTokenHash)
return
}
guard isSignedIn else { return }

requestFCMTokenSync()
}
Comment thread
opficdev marked this conversation as resolved.

func requestFCMTokenSync() {
Task { [weak self] in
guard let self else { return }
guard await messagingService.isNotificationAuthorized() else { return }
notificationCenter.post(name: .didRequestRemoteNotificationRegistration, object: nil)
guard await requestAPNsRegistrationIfAuthorized() else { return }
await syncCurrentFCMToken()
}
}

func requestAPNsRegistrationIfAuthorized() async -> Bool {
guard await messagingService.isNotificationAuthorized() else { return false }
notificationCenter.post(name: .didRequestRemoteNotificationRegistration, object: nil)
return true
}

func handleAPNSToken(_ deviceToken: Data) {
messagingService.setAPNSToken(deviceToken)
Task { [weak self] in
Expand Down Expand Up @@ -113,25 +114,11 @@ private extension FCMTokenSyncHandler {
}

func syncFCMTokenIfNeeded(_ fcmToken: String) async throws {
guard let uid = authService.uid else {
store.setString(nil, forKey: Key.fcmTokenHash)
guard authService.uid != nil else {
logger.info("Skipping FCM token update because no authenticated user exists")
return
}

let tokenHash = makeTokenHash(uid: uid, fcmToken: fcmToken)
guard store.string(forKey: Key.fcmTokenHash) != tokenHash else {
logger.info("Skipping FCM token update because token hash is unchanged")
return
}

try await userService.updateFCMToken(fcmToken)
store.setString(tokenHash, forKey: Key.fcmTokenHash)
}

func makeTokenHash(uid: String, fcmToken: String) -> String {
let value = "\(uid)|\(FirebaseConfiguration.databaseID)|\(fcmToken)"
let digest = SHA256.hash(data: Data(value.utf8))
return digest.map { String(format: "%02x", $0) }.joined()
}
Comment thread
opficdev marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import Foundation
extension Notification.Name {
static let didRefreshFCMToken = Notification.Name("didRefreshFCMToken")
static let didReceiveAPNSToken = Notification.Name("didReceiveAPNSToken")
static let didRequestAPNsRegistration = Notification.Name("didRequestAPNsRegistration")
static let didRequestFCMTokenSync = Notification.Name("didRequestFCMTokenSync")
static let didRequestRemoteNotificationRegistration = Notification.Name("didRequestRemoteNotificationRegistration")
static let didRequestUserTimeZoneSync = Notification.Name("didRequestUserTimeZoneSync")
Expand Down
4 changes: 2 additions & 2 deletions Application/App/Sources/Resource/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -1176,13 +1176,13 @@
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Separate from your Settings app notification preference."
"value" : "Separate from the push notification permission in Settings.\nNotification list items are still created even when device notification permission is off."
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "설정에서의 푸시 알람 설정과는 별개예요."
"value" : "설정의 푸시 알람 권한과는 별개예요.\n기기 알림 권한을 꺼도 알림 리스트는 생성돼요."
}
}
}
Expand Down
118 changes: 32 additions & 86 deletions Application/App/Tests/PushNotification/FCMTokenSyncHandlerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,10 @@ struct FCMTokenSyncHandlerTests {
let messagingService = PushMessagingServiceSpy(currentFCMToken: "current-token")
let userService = UserServiceSpy()
let authService = AuthServiceSpy()
let store = UserDefaultsStoreSpy()
let handler = FCMTokenSyncHandler(
authService: authService,
messagingService: messagingService,
userService: userService,
store: store,
notificationCenter: notificationCenter
)

Expand All @@ -40,18 +38,43 @@ struct FCMTokenSyncHandlerTests {
_ = handler
}

@Test("foreground 복귀 시 알림 권한이 있으면 APNs 등록을 요청하고 FCM token은 직접 저장하지 않는다")
func foreground_복귀_시_알림_권한이_있으면_APNs_등록을_요청하고_FCM_token은_직접_저장하지_않는다() async throws {
let notificationCenter = NotificationCenter()
let registrationObserver = NotificationObserver(
notificationCenter: notificationCenter,
name: .didRequestRemoteNotificationRegistration
)
let messagingService = PushMessagingServiceSpy(currentFCMToken: "current-token")
let userService = UserServiceSpy()
let authService = AuthServiceSpy()
let handler = FCMTokenSyncHandler(
authService: authService,
messagingService: messagingService,
userService: userService,
notificationCenter: notificationCenter
)

notificationCenter.post(name: .didRequestAPNsRegistration, object: nil)

try await waitUntil {
registrationObserver.didReceiveNotification
}
try await Task.sleep(for: .milliseconds(100))
#expect(await userService.updatedFCMTokens.isEmpty)
_ = handler
}

@Test("현재 FCM token 동기화 요청 시 token이 없으면 저장하지 않는다")
func 현재_FCM_token_동기화_요청_시_token이_없으면_저장하지_않는다() async throws {
let notificationCenter = NotificationCenter()
let messagingService = PushMessagingServiceSpy(currentFCMToken: nil)
let userService = UserServiceSpy()
let authService = AuthServiceSpy()
let store = UserDefaultsStoreSpy()
let handler = FCMTokenSyncHandler(
authService: authService,
messagingService: messagingService,
userService: userService,
store: store,
notificationCenter: notificationCenter
)

Expand All @@ -68,12 +91,10 @@ struct FCMTokenSyncHandlerTests {
let messagingService = PushMessagingServiceSpy(currentFCMToken: nil)
let userService = UserServiceSpy()
let authService = AuthServiceSpy()
let store = UserDefaultsStoreSpy()
let handler = FCMTokenSyncHandler(
authService: authService,
messagingService: messagingService,
userService: userService,
store: store,
notificationCenter: notificationCenter
)

Expand All @@ -95,12 +116,10 @@ struct FCMTokenSyncHandlerTests {
let messagingService = PushMessagingServiceSpy(currentFCMToken: "current-token")
let userService = UserServiceSpy()
let authService = AuthServiceSpy()
let store = UserDefaultsStoreSpy()
let handler = FCMTokenSyncHandler(
authService: authService,
messagingService: messagingService,
userService: userService,
store: store,
notificationCenter: notificationCenter
)
let deviceToken = Data([0x01, 0x02, 0x03])
Expand All @@ -118,18 +137,16 @@ struct FCMTokenSyncHandlerTests {
_ = handler
}

@Test("같은 사용자와 같은 FCM token은 한 번만 저장한다")
func 같은_사용자와_같은_FCM_token은_한_번만_저장한다() async throws {
@Test("같은 사용자와 같은 FCM token도 매번 저장한다")
func 같은_사용자와_같은_FCM_token도_매번_저장한다() async throws {
let notificationCenter = NotificationCenter()
let messagingService = PushMessagingServiceSpy(currentFCMToken: "current-token")
let userService = UserServiceSpy()
let authService = AuthServiceSpy()
let store = UserDefaultsStoreSpy()
let handler = FCMTokenSyncHandler(
authService: authService,
messagingService: messagingService,
userService: userService,
store: store,
notificationCenter: notificationCenter
)

Expand All @@ -139,9 +156,10 @@ struct FCMTokenSyncHandlerTests {
}

notificationCenter.post(name: .didRequestFCMTokenSync, object: nil)
try await Task.sleep(for: .milliseconds(100))
try await waitUntil {
await userService.updatedFCMTokens == ["current-token", "current-token"]
}

#expect(await userService.updatedFCMTokens == ["current-token"])
_ = handler
}

Expand All @@ -151,12 +169,10 @@ struct FCMTokenSyncHandlerTests {
let messagingService = PushMessagingServiceSpy(currentFCMToken: "current-token")
let userService = UserServiceSpy()
let authService = AuthServiceSpy(uid: "first-user")
let store = UserDefaultsStoreSpy()
let handler = FCMTokenSyncHandler(
authService: authService,
messagingService: messagingService,
userService: userService,
store: store,
notificationCenter: notificationCenter
)

Expand All @@ -179,12 +195,10 @@ struct FCMTokenSyncHandlerTests {
let messagingService = PushMessagingServiceSpy(currentFCMToken: "current-token")
let userService = UserServiceSpy()
let authService = AuthServiceSpy(uid: nil)
let store = UserDefaultsStoreSpy()
let handler = FCMTokenSyncHandler(
authService: authService,
messagingService: messagingService,
userService: userService,
store: store,
notificationCenter: notificationCenter
)

Expand All @@ -196,34 +210,6 @@ struct FCMTokenSyncHandlerTests {
_ = handler
}

@Test("로그아웃 세션 전이 시 저장된 FCM token hash를 제거한다")
func 로그아웃_세션_전이_시_저장된_FCM_token_hash를_제거한다() async throws {
let notificationCenter = NotificationCenter()
let messagingService = PushMessagingServiceSpy(currentFCMToken: "current-token")
let userService = UserServiceSpy()
let authService = AuthServiceSpy()
let store = UserDefaultsStoreSpy()
let handler = FCMTokenSyncHandler(
authService: authService,
messagingService: messagingService,
userService: userService,
store: store,
notificationCenter: notificationCenter
)

notificationCenter.post(name: .didRequestFCMTokenSync, object: nil)
try await waitUntil {
store.hasStoredString
}

authService.updateSession(uid: nil)

try await waitUntil {
!store.hasStoredString
}
_ = handler
}

}

private actor UserServiceSpy: UserService {
Expand Down Expand Up @@ -287,46 +273,6 @@ private final class PushMessagingServiceSpy: PushMessagingService {
}
}

private final class UserDefaultsStoreSpy: UserDefaultsStore {
private var strings = [String: String]()

var hasStoredString: Bool {
!strings.isEmpty
}

func value<T: Codable>(forKey key: String) -> T? {
nil
}

func setValue<T: Codable>(_ value: T?, forKey key: String) { }

func removeValues(withPrefix prefix: String) {
strings.keys
.filter { $0.hasPrefix(prefix) }
.forEach { strings.removeValue(forKey: $0) }
}

func string(forKey key: String) -> String? {
strings[key]
}

func setString(_ value: String?, forKey key: String) {
strings[key] = value
}

func stringArray(forKey key: String) -> [String] {
[]
}

func setStringArray(_ value: [String], forKey key: String) { }

func bool(forKey key: String) -> Bool {
false
}

func setBool(_ value: Bool, forKey key: String) { }
}

private final class NotificationObserver {
private(set) var didReceiveNotification = false
private var token: NSObjectProtocol?
Expand Down
Loading
Loading