Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
125 changes: 124 additions & 1 deletion DevLog/App/Delegate/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

addStateDidChangeListener로 등록한 리스너는 앱이 종료될 때 해제하는 것이 좋습니다. AppDelegatedeinit을 추가하여 authStateListenerHandle을 제거하는 것을 고려해보세요. 이렇게 하면 잠재적인 리소스 누수를 방지할 수 있습니다.

deinit {
    if let handle = authStateListenerHandle {
        Auth.auth().removeStateDidChangeListener(handle)
    }
}

private var cancellable: AnyCancellable?

func application(
_ app: UIApplication,
open url: URL,
Expand Down Expand Up @@ -47,6 +52,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate {

// Firebase Messaging 설정
Messaging.messaging().delegate = self
observeAuthState()

// 앱이 완전 종료되어도, 알림을 통해 앱이 시작된 경우 처리
if let remoteNotification = launchOptions?[.remoteNotification] as? [AnyHashable: Any] {
Expand All @@ -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(
Expand All @@ -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 }
Expand All @@ -96,6 +108,117 @@ 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 startObservingBadgeCount() {
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)
}
)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

startObservingBadgeCount() 함수에서 try?를 사용하여 observeUnreadNotificationCount()에서 발생하는 오류를 암묵적으로 무시하고 있습니다. observeUnreadNotificationCount()는 사용자가 인증되지 않은 경우 오류를 발생시킬 수 있으며, 이 경우 cancellablenil이 되고 아무런 동작도 하지 않게 됩니다. 오류가 발생할 가능성이 있는 경우, do-catch 구문을 사용하여 명시적으로 오류를 처리하고 로그를 남기는 것이 더 안전한 방법입니다.

    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)
        }
    }


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
}
}

func observeUnreadNotificationCount() throws -> AnyPublisher<Int, Error> {
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<Int, Error>()
let listener = store.collection("users/\(uid)/notifications")
.whereField("isRead", isEqualTo: false)
.addSnapshotListener { [weak self] snapshot, error in
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)
}
Comment on lines +199 to +212

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

addSnapshotListener의 클로저 내에서 self?를 여러 번 사용하고 있습니다. 클로저 시작 부분에서 guard let self = self else { return }을 사용하여 self를 안전하게 언래핑하면 코드가 더 간결해지고, 클로저 실행 동안 self가 nil이 되지 않음을 보장할 수 있습니다.

            .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()
}

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)
}
}
}

@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 {
Expand Down
23 changes: 20 additions & 3 deletions Firebase/functions/src/fcm/notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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에는 기록했습니다.`);
Expand All @@ -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 {
Expand Down