Skip to content

Commit a85996c

Browse files
authored
[#270 푸시 알람 배지가 앱 아이콘에 뜨도록 구현한다 (#271)
* feat: 백그라운드 혹은 앱이 종료되었을 때의 푸시 배지 구현 * feat: 온그라운드 상태에서의 푸시 배지 구현 * refactor: Logger 추가 및 메서드 정리 * refactor: self guard 문 처리
1 parent 298d2fe commit a85996c

2 files changed

Lines changed: 149 additions & 4 deletions

File tree

DevLog/App/Delegate/AppDelegate.swift

Lines changed: 129 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,15 @@ import FirebaseAuth
1111
import FirebaseFirestore
1212
import FirebaseMessaging
1313
import GoogleSignIn
14+
import Combine
1415
import UserNotifications
1516

1617
class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate {
1718
private let logger = Logger(category: "AppDelegate")
19+
private var store: Firestore { Firestore.firestore() }
20+
private var authStateListenerHandle: AuthStateDidChangeListenerHandle?
21+
private var cancellable: AnyCancellable?
22+
1823
func application(
1924
_ app: UIApplication,
2025
open url: URL,
@@ -47,6 +52,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate {
4752

4853
// Firebase Messaging 설정
4954
Messaging.messaging().delegate = self
55+
observeAuthState()
5056

5157
// 앱이 완전 종료되어도, 알림을 통해 앱이 시작된 경우 처리
5258
if let remoteNotification = launchOptions?[.remoteNotification] as? [AnyHashable: Any] {
@@ -73,6 +79,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate {
7379
) {
7480
logger.error("Failed to register APNs token", error: error)
7581
}
82+
83+
func applicationDidBecomeActive(_ application: UIApplication) {
84+
syncBadgeCount()
85+
}
7686

7787
// FCMToken 갱신
7888
func messaging(
@@ -83,8 +93,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate {
8393
NotificationCenter.default.post(name: .fcmToken, object: nil, userInfo: ["fcmToken": fcmToken])
8494
}
8595
}
96+
}
8697

87-
private func updateUserTimeZone() {
98+
private extension AppDelegate {
99+
func updateUserTimeZone() {
88100
Task {
89101
do {
90102
guard let uid = Auth.auth().currentUser?.uid else { return }
@@ -96,6 +108,122 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate {
96108
}
97109
}
98110
}
111+
112+
func observeAuthState() {
113+
authStateListenerHandle = Auth.auth().addStateDidChangeListener { [weak self] _, user in
114+
guard let self else { return }
115+
116+
self.cancellable?.cancel()
117+
118+
guard user != nil else {
119+
self.updateBadgeCount(0)
120+
return
121+
}
122+
123+
self.startObservingBadgeCount()
124+
self.syncBadgeCount()
125+
}
126+
}
127+
128+
func syncBadgeCount() {
129+
Task { @MainActor [weak self] in
130+
guard let self else { return }
131+
guard Auth.auth().currentUser != nil else {
132+
self.updateBadgeCount(0)
133+
return
134+
}
135+
136+
do {
137+
let unreadNotificationCount = try await self.fetchUnreadNotificationCount()
138+
self.updateBadgeCount(unreadNotificationCount)
139+
} catch {
140+
self.logger.error("Failed to fetch unread notification count", error: error)
141+
}
142+
}
143+
}
144+
145+
private func startObservingBadgeCount() {
146+
do {
147+
cancellable = try observeUnreadNotificationCount()
148+
.receive(on: DispatchQueue.main)
149+
.sink(
150+
receiveCompletion: { [weak self] completion in
151+
guard let self else { return }
152+
153+
if case .failure(let error) = completion {
154+
self.logger.error("Failed to observe unread notification count", error: error)
155+
}
156+
},
157+
receiveValue: { [weak self] count in
158+
self?.updateBadgeCount(count)
159+
}
160+
)
161+
} catch {
162+
logger.error("Failed to start observing badge count", error: error)
163+
}
164+
}
165+
166+
private func fetchUnreadNotificationCount() async throws -> Int {
167+
logger.info("Fetching unread notification count")
168+
169+
guard let uid = Auth.auth().currentUser?.uid else {
170+
logger.error("User not authenticated")
171+
throw AuthError.notAuthenticated
172+
}
173+
174+
do {
175+
let snapshot = try await store.collection("users/\(uid)/notifications")
176+
.whereField("isRead", isEqualTo: false)
177+
.getDocuments()
178+
179+
let unreadNotificationCount = snapshot.documents.count
180+
logger.info("Unread notification count: \(unreadNotificationCount)")
181+
return unreadNotificationCount
182+
} catch {
183+
logger.error("Failed to fetch unread notification count", error: error)
184+
throw error
185+
}
186+
}
187+
188+
private func observeUnreadNotificationCount() throws -> AnyPublisher<Int, Error> {
189+
logger.info("Observing unread notification count")
190+
191+
guard let uid = Auth.auth().currentUser?.uid else {
192+
logger.error("User not authenticated")
193+
throw AuthError.notAuthenticated
194+
}
195+
196+
let subject = PassthroughSubject<Int, Error>()
197+
let listener = store.collection("users/\(uid)/notifications")
198+
.whereField("isRead", isEqualTo: false)
199+
.addSnapshotListener { [weak self] snapshot, error in
200+
guard let self else { return }
201+
if let error {
202+
self.logger.error("Failed to observe unread notification count", error: error)
203+
subject.send(completion: .failure(error))
204+
return
205+
}
206+
207+
guard let snapshot else { return }
208+
209+
let unreadNotificationCount = snapshot.documents.count
210+
self.logger.info("Observed unread notification count: \(unreadNotificationCount)")
211+
subject.send(unreadNotificationCount)
212+
}
213+
214+
return subject
215+
.handleEvents(receiveCancel: { listener.remove() })
216+
.eraseToAnyPublisher()
217+
}
218+
219+
@MainActor
220+
private func updateBadgeCount(_ count: Int) {
221+
UNUserNotificationCenter.current().setBadgeCount(count) { [weak self] error in
222+
if let error {
223+
self?.logger.error("Failed to update badge count", error: error)
224+
}
225+
}
226+
}
99227
}
100228

101229
extension AppDelegate: UNUserNotificationCenterDelegate {

Firebase/functions/src/fcm/notification.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,19 @@ export const sendPushNotification = onTaskDispatched({
9797
};
9898
await notificationDocRef.set(notificationData, { merge: true });
9999

100-
// 1. 사용자 FCM 토큰 가져오기
101-
const tokenDoc = await admin.firestore().doc(`users/${userId}/userData/tokens`).get();
100+
// 1. 사용자 FCM 토큰과 읽지 않은 알림 수 가져오기
101+
const unreadCountPromise = admin.firestore()
102+
.collection(`users/${userId}/notifications`)
103+
.where("isRead", "==", false)
104+
.count()
105+
.get();
106+
const tokenDocPromise = admin.firestore().doc(`users/${userId}/userData/tokens`).get();
107+
const [tokenDoc, unreadCountSnapshot] = await Promise.all([
108+
tokenDocPromise,
109+
unreadCountPromise
110+
]);
102111
const fcmToken = tokenDoc.data()?.fcmToken;
112+
const unreadNotificationCount = unreadCountSnapshot.data().count;
103113

104114
if (!fcmToken) {
105115
logger.warn(`사용자 ${userId}의 fcmToken이 없어 푸시 발송은 건너뜁니다. Firestore에는 기록했습니다.`);
@@ -113,7 +123,14 @@ export const sendPushNotification = onTaskDispatched({
113123
todoId: todoId,
114124
todoKind: todoKind
115125
},
116-
apns: { payload: { aps: { sound: "default" } } },
126+
apns: {
127+
payload: {
128+
aps: {
129+
sound: "default",
130+
badge: unreadNotificationCount
131+
}
132+
}
133+
},
117134
token: fcmToken,
118135
};
119136
try {

0 commit comments

Comments
 (0)