diff --git a/DevLog/App/Delegate/AppDelegate.swift b/DevLog/App/Delegate/AppDelegate.swift index 2ee5924b..af8c6185 100644 --- a/DevLog/App/Delegate/AppDelegate.swift +++ b/DevLog/App/Delegate/AppDelegate.swift @@ -11,10 +11,15 @@ import FirebaseAuth import FirebaseFirestore import FirebaseMessaging import GoogleSignIn +import Combine import UserNotifications class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate { private let logger = Logger(category: "AppDelegate") + private var store: Firestore { Firestore.firestore() } + private var authStateListenerHandle: AuthStateDidChangeListenerHandle? + private var cancellable: AnyCancellable? + func application( _ app: UIApplication, open url: URL, @@ -47,6 +52,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate { // Firebase Messaging 설정 Messaging.messaging().delegate = self + observeAuthState() // 앱이 완전 종료되어도, 알림을 통해 앱이 시작된 경우 처리 if let remoteNotification = launchOptions?[.remoteNotification] as? [AnyHashable: Any] { @@ -73,6 +79,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate { ) { logger.error("Failed to register APNs token", error: error) } + + func applicationDidBecomeActive(_ application: UIApplication) { + syncBadgeCount() + } // FCMToken 갱신 func messaging( @@ -83,8 +93,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate { NotificationCenter.default.post(name: .fcmToken, object: nil, userInfo: ["fcmToken": fcmToken]) } } +} - private func updateUserTimeZone() { +private extension AppDelegate { + func updateUserTimeZone() { Task { do { guard let uid = Auth.auth().currentUser?.uid else { return } @@ -96,6 +108,122 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate { } } } + + func observeAuthState() { + authStateListenerHandle = Auth.auth().addStateDidChangeListener { [weak self] _, user in + guard let self else { return } + + self.cancellable?.cancel() + + guard user != nil else { + self.updateBadgeCount(0) + return + } + + self.startObservingBadgeCount() + self.syncBadgeCount() + } + } + + func syncBadgeCount() { + Task { @MainActor [weak self] in + guard let self else { return } + guard Auth.auth().currentUser != nil else { + self.updateBadgeCount(0) + return + } + + do { + let unreadNotificationCount = try await self.fetchUnreadNotificationCount() + self.updateBadgeCount(unreadNotificationCount) + } catch { + self.logger.error("Failed to fetch unread notification count", error: error) + } + } + } + + private func startObservingBadgeCount() { + do { + cancellable = try observeUnreadNotificationCount() + .receive(on: DispatchQueue.main) + .sink( + receiveCompletion: { [weak self] completion in + guard let self else { return } + + if case .failure(let error) = completion { + self.logger.error("Failed to observe unread notification count", error: error) + } + }, + receiveValue: { [weak self] count in + self?.updateBadgeCount(count) + } + ) + } catch { + logger.error("Failed to start observing badge count", error: error) + } + } + + private func fetchUnreadNotificationCount() async throws -> Int { + logger.info("Fetching unread notification count") + + guard let uid = Auth.auth().currentUser?.uid else { + logger.error("User not authenticated") + throw AuthError.notAuthenticated + } + + do { + let snapshot = try await store.collection("users/\(uid)/notifications") + .whereField("isRead", isEqualTo: false) + .getDocuments() + + let unreadNotificationCount = snapshot.documents.count + logger.info("Unread notification count: \(unreadNotificationCount)") + return unreadNotificationCount + } catch { + logger.error("Failed to fetch unread notification count", error: error) + throw error + } + } + + private func observeUnreadNotificationCount() throws -> AnyPublisher { + logger.info("Observing unread notification count") + + guard let uid = Auth.auth().currentUser?.uid else { + logger.error("User not authenticated") + throw AuthError.notAuthenticated + } + + let subject = PassthroughSubject() + let listener = store.collection("users/\(uid)/notifications") + .whereField("isRead", isEqualTo: false) + .addSnapshotListener { [weak self] snapshot, error in + guard let self else { return } + if let error { + self.logger.error("Failed to observe unread notification count", error: error) + subject.send(completion: .failure(error)) + return + } + + guard let snapshot else { return } + + let unreadNotificationCount = snapshot.documents.count + self.logger.info("Observed unread notification count: \(unreadNotificationCount)") + subject.send(unreadNotificationCount) + } + + return subject + .handleEvents(receiveCancel: { listener.remove() }) + .eraseToAnyPublisher() + } + + @MainActor + private func updateBadgeCount(_ count: Int) { + UNUserNotificationCenter.current().setBadgeCount(count) { [weak self] error in + if let error { + self?.logger.error("Failed to update badge count", error: error) + } + } + } } extension AppDelegate: UNUserNotificationCenterDelegate { diff --git a/Firebase/functions/src/fcm/notification.ts b/Firebase/functions/src/fcm/notification.ts index 228dea09..16d3c9ac 100644 --- a/Firebase/functions/src/fcm/notification.ts +++ b/Firebase/functions/src/fcm/notification.ts @@ -97,9 +97,19 @@ export const sendPushNotification = onTaskDispatched({ }; await notificationDocRef.set(notificationData, { merge: true }); - // 1. 사용자 FCM 토큰 가져오기 - const tokenDoc = await admin.firestore().doc(`users/${userId}/userData/tokens`).get(); + // 1. 사용자 FCM 토큰과 읽지 않은 알림 수 가져오기 + const unreadCountPromise = admin.firestore() + .collection(`users/${userId}/notifications`) + .where("isRead", "==", false) + .count() + .get(); + const tokenDocPromise = admin.firestore().doc(`users/${userId}/userData/tokens`).get(); + const [tokenDoc, unreadCountSnapshot] = await Promise.all([ + tokenDocPromise, + unreadCountPromise + ]); const fcmToken = tokenDoc.data()?.fcmToken; + const unreadNotificationCount = unreadCountSnapshot.data().count; if (!fcmToken) { logger.warn(`사용자 ${userId}의 fcmToken이 없어 푸시 발송은 건너뜁니다. Firestore에는 기록했습니다.`); @@ -113,7 +123,14 @@ export const sendPushNotification = onTaskDispatched({ todoId: todoId, todoKind: todoKind }, - apns: { payload: { aps: { sound: "default" } } }, + apns: { + payload: { + aps: { + sound: "default", + badge: unreadNotificationCount + } + } + }, token: fcmToken, }; try {