diff --git a/today-s-sound/App/TodaySSoundApp.swift b/today-s-sound/App/TodaySSoundApp.swift index 544de7d..0138d04 100644 --- a/today-s-sound/App/TodaySSoundApp.swift +++ b/today-s-sound/App/TodaySSoundApp.swift @@ -5,6 +5,7 @@ // Created by 하승연 on 9/28/25. // +import Combine import FirebaseCore import FirebaseMessaging import SwiftUI @@ -12,6 +13,13 @@ import UserNotifications class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate, MessagingDelegate { // 3. Delegate 프로토콜 3개 추가 + // APNs 등록 상태 추적 + private var hasRegisteredForRemoteNotifications = false + + // FCM 토큰 업데이트를 위한 API 서비스 + private let apiService = APIService() + private var cancellables = Set() + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { @@ -28,7 +36,23 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele ) // 6. APNs에 기기 등록 요청 - application.registerForRemoteNotifications() + // FCM 토큰이 있으면 = 이미 APNs 등록 완료 + FCM 토큰 생성 완료 + // 따라서 APNs를 다시 등록할 필요 없음 + let hasFCMToken = Keychain.getString(for: KeychainKey.fcmToken) != nil + + if hasFCMToken { + // FCM 토큰이 있으면 이미 APNs도 등록되어 있고 FCM 토큰도 생성되어 있음 + // APNs를 다시 등록할 필요 없음 + print("ℹ️ [APNs] FCM 토큰이 이미 있으므로 APNs 등록 생략 (이미 등록 완료)") + } else if !hasRegisteredForRemoteNotifications { + // FCM 토큰이 없고, 아직 등록 요청하지 않았으면 등록 요청 + // APNs 등록 → deviceToken → FCM 토큰 생성 순서로 진행됨 + print("📱 [APNs] 기기 등록 요청 (FCM 토큰이 없으므로 등록 필요)") + application.registerForRemoteNotifications() + } else { + // 이미 등록 요청했지만 아직 완료되지 않음 + print("ℹ️ [APNs] 이미 등록 요청했으므로 대기 중") + } // 7. FCM 메시징 대리자 설정 Messaging.messaging().delegate = self @@ -39,26 +63,88 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele // 8. FCM 토큰을 수신했을 때 호출되는 함수 (이 토큰을 Firebase 콘솔에 입력!) func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { print("====================================") - print("Firebase (FCM) 등록 토큰: \(fcmToken ?? "토큰 없음")") + print("🔔 [FCM] 토큰 수신 콜백 호출") print("====================================") + print("Firebase (FCM) 등록 토큰: \(fcmToken ?? "토큰 없음")") - // FCM 토큰을 키체인에 저장 - if let fcmToken { - Keychain.setString(fcmToken, for: KeychainKey.fcmToken) - print("✅ FCM 토큰을 키체인에 저장했습니다") + guard let fcmToken else { + print("⚠️ FCM 토큰이 nil이므로 저장하지 않음") + print("====================================\n") + return } + + // 기존 토큰 확인 + let existingToken = Keychain.getString(for: KeychainKey.fcmToken) + + #if DEBUG + if let existingToken { + print("📋 [FCM] 저장 전 기존 토큰: \(existingToken.prefix(50))...") + } else { + print("📋 [FCM] 저장 전 기존 토큰: (없음)") + } + #endif + + // 등록된 사용자인지 먼저 확인 + let userId = Keychain.getString(for: KeychainKey.userId) + let deviceSecret = Keychain.getString(for: KeychainKey.deviceSecret) + let isRegistered = userId != nil && deviceSecret != nil + + // 등록되지 않은 사용자면 FCM 토큰 저장하지 않음 (앱 초기화 후 상태) + guard isRegistered else { + print("ℹ️ [FCM] 등록되지 않은 사용자 - FCM 토큰 저장하지 않음 (앱 초기화 상태)") + print("====================================\n") + return + } + + // 토큰이 실제로 변경되었는지 확인 + let isTokenChanged = existingToken != fcmToken + + if isTokenChanged { + // 토큰이 변경되었을 때만 저장 + let saved = Keychain.setString(fcmToken, for: KeychainKey.fcmToken) + if saved { + print("✅ FCM 토큰이 변경되어 키체인에 저장했습니다") + + // 이미 등록된 사용자이므로 서버에 토큰 업데이트 + if let userId, let deviceSecret { + print("📤 [FCM] 서버에 FCM 토큰 업데이트 요청 (userId: \(userId))") + apiService.updateFCMToken(userId: userId, deviceSecret: deviceSecret, fcmToken: fcmToken) + .sink( + receiveCompletion: { completion in + switch completion { + case .finished: + print("✅ [FCM] 서버 토큰 업데이트 성공") + case let .failure(error): + print("❌ [FCM] 서버 토큰 업데이트 실패: \(error)") + } + }, + receiveValue: { _ in } + ) + .store(in: &cancellables) + } + } else { + print("❌ FCM 토큰 저장 실패!") + } + } else { + // 동일한 토큰이면 저장 생략 + print("ℹ️ [FCM] 동일한 토큰이므로 저장 생략") + } + + print("====================================\n") } // 9. APNs 등록에 성공하여 deviceToken을 받았을 때 // (FCM이 APNs 토큰을 자동으로 FCM 토큰으로 매핑하므로 이 함수 자체는 필수) func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { - print("APNs device token: \(deviceToken)") + print("✅ [APNs] 기기 등록 성공") + hasRegisteredForRemoteNotifications = true Messaging.messaging().apnsToken = deviceToken } // 10. APNs 등록에 실패했을 때 func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { - print("APNs 등록 실패: \(error.localizedDescription)") + print("❌ [APNs] 기기 등록 실패: \(error.localizedDescription)") + // 실패해도 다음에 다시 시도할 수 있도록 플래그는 유지 } } diff --git a/today-s-sound/Core/AppState/SessionStore.swift b/today-s-sound/Core/AppState/SessionStore.swift index 1303355..bedfb24 100644 --- a/today-s-sound/Core/AppState/SessionStore.swift +++ b/today-s-sound/Core/AppState/SessionStore.swift @@ -147,9 +147,51 @@ final class SessionStore: ObservableObject { /// 로그아웃 (키체인 초기화) func logout() { - Keychain.delete(for: KeychainKey.userId) - Keychain.delete(for: KeychainKey.deviceSecret) - Keychain.delete(for: KeychainKey.fcmToken) + #if DEBUG + print("━━━━━━━━━━━━━━━━━━━━━━━━━━") + print("🚪 [logout] 키체인 삭제 시작") + print("━━━━━━━━━━━━━━━━━━━━━━━━━━") + #endif + + // 삭제 전 상태 확인 + #if DEBUG + let userIdBefore = Keychain.getString(for: KeychainKey.userId) + let deviceSecretBefore = Keychain.getString(for: KeychainKey.deviceSecret) + let fcmTokenBefore = Keychain.getString(for: KeychainKey.fcmToken) + + print("📋 [logout] 삭제 전 키체인 상태:") + print(" - userId: \(userIdBefore != nil ? "있음" : "(없음)")") + print(" - deviceSecret: \(deviceSecretBefore != nil ? "있음" : "(없음)")") + print(" - fcmToken: \(fcmTokenBefore != nil ? "있음" : "(없음)")") + + #endif + + // 삭제 실행 + let userIdDeleted = Keychain.delete(for: KeychainKey.userId) + let deviceSecretDeleted = Keychain.delete(for: KeychainKey.deviceSecret) + let fcmTokenDeleted = Keychain.delete(for: KeychainKey.fcmToken) + + #if DEBUG + print("📋 [logout] 삭제 결과:") + print(" - userId 삭제: \(userIdDeleted ? "✅ 성공" : "❌ 실패")") + print(" - deviceSecret 삭제: \(deviceSecretDeleted ? "✅ 성공" : "❌ 실패")") + print(" - fcmToken 삭제: \(fcmTokenDeleted)") + + // 삭제 후 상태 확인 + let userIdAfter = Keychain.getString(for: KeychainKey.userId) + let deviceSecretAfter = Keychain.getString(for: KeychainKey.deviceSecret) + let fcmTokenAfter = Keychain.getString(for: KeychainKey.fcmToken) + + print("📋 [logout] 삭제 후 키체인 상태:") + print(" - userId: \(userIdAfter != nil ? "⚠️ 여전히 존재!" : "✅ 삭제됨")") + print(" - deviceSecret: \(deviceSecretAfter != nil ? "⚠️ 여전히 존재!" : "✅ 삭제됨")") + print(" - fcmToken: \(fcmTokenAfter != nil ? "⚠️ 여전히 존재!" : "✅ 삭제됨")") + + if fcmTokenAfter != nil { + print(" ⚠️⚠️⚠️ fcmToken 삭제 실패! 값: \(fcmTokenAfter!.prefix(50))...") + } + print("━━━━━━━━━━━━━━━━━━━━━━━━━━\n") + #endif userId = nil isRegistered = false diff --git a/today-s-sound/Core/Network/Service/APIService.swift b/today-s-sound/Core/Network/Service/APIService.swift index 38ccac3..d3fdc4e 100644 --- a/today-s-sound/Core/Network/Service/APIService.swift +++ b/today-s-sound/Core/Network/Service/APIService.swift @@ -7,6 +7,7 @@ protocol APIServiceType { func request(_ target: some TargetType) -> AnyPublisher func registerAnonymous(request: RegisterAnonymousRequest) -> AnyPublisher func withdrawUser(userId: String, deviceSecret: String) -> AnyPublisher + func updateFCMToken(userId: String, deviceSecret: String, fcmToken: String) -> AnyPublisher func getSubscriptions( userId: String, deviceSecret: String, page: Int, size: Int ) -> AnyPublisher @@ -186,6 +187,34 @@ class APIService: APIServiceType { .eraseToAnyPublisher() } + func updateFCMToken(userId: String, deviceSecret: String, fcmToken: String) -> AnyPublisher { + let request = UpdateFCMTokenRequest(fcmToken: fcmToken) + return userProvider.requestPublisher(.updateFCMToken(userId: userId, deviceSecret: deviceSecret, request: request)) + .mapError { moyaError -> NetworkError in + .requestFailed(moyaError) + } + .tryMap { response in + // 상태 코드만 확인 (200-299면 성공) + guard (200 ... 299).contains(response.statusCode) else { + throw NetworkError.serverError(statusCode: response.statusCode) + } + + #if DEBUG + print("✅ FCM 토큰 업데이트 성공") + #endif + + return () + } + .mapError { error -> NetworkError in + if let networkError = error as? NetworkError { + return networkError + } else { + return .requestFailed(error) + } + } + .eraseToAnyPublisher() + } + // MARK: - Subscription API func getSubscriptions( diff --git a/today-s-sound/Core/Network/Targets/UserAPI.swift b/today-s-sound/Core/Network/Targets/UserAPI.swift index 5ae1872..fc31a56 100644 --- a/today-s-sound/Core/Network/Targets/UserAPI.swift +++ b/today-s-sound/Core/Network/Targets/UserAPI.swift @@ -11,6 +11,7 @@ import Moya enum UserAPI { case registerAnonymous(request: RegisterAnonymousRequest) case withdraw(userId: String, deviceSecret: String) + case updateFCMToken(userId: String, deviceSecret: String, request: UpdateFCMTokenRequest) } extension UserAPI: APITargetType { @@ -20,6 +21,8 @@ extension UserAPI: APITargetType { "/api/users/anonymous" case .withdraw: "/api/users/withdraw" + case .updateFCMToken: + "/api/fcm" } } @@ -29,6 +32,8 @@ extension UserAPI: APITargetType { .post case .withdraw: .delete + case .updateFCMToken: + .put } } @@ -38,6 +43,8 @@ extension UserAPI: APITargetType { .requestJSONEncodable(request) case .withdraw: .requestPlain + case let .updateFCMToken(_, _, request): + .requestJSONEncodable(request) } } @@ -55,6 +62,13 @@ extension UserAPI: APITargetType { "X-User-ID": userId, "X-Device-Secret": deviceSecret ] + case let .updateFCMToken(userId, deviceSecret, _): + [ + "Content-Type": "application/json", + "Accept": "application/json", + "X-User-ID": userId, + "X-Device-Secret": deviceSecret + ] } } } diff --git a/today-s-sound/Data/Models/Subscription.swift b/today-s-sound/Data/Models/Subscription.swift index b143620..7ff3d0e 100644 --- a/today-s-sound/Data/Models/Subscription.swift +++ b/today-s-sound/Data/Models/Subscription.swift @@ -53,6 +53,7 @@ struct SubscriptionItem: Codable, Identifiable { let url: String let alias: String let isUrgent: Bool + let isAlarmEnabled: Bool let keywords: [KeywordItem] enum CodingKeys: String, CodingKey { @@ -60,6 +61,7 @@ struct SubscriptionItem: Codable, Identifiable { case url case alias case isUrgent + case isAlarmEnabled case keywords } } diff --git a/today-s-sound/Data/Models/User.swift b/today-s-sound/Data/Models/User.swift index b452e6b..e02aa4e 100644 --- a/today-s-sound/Data/Models/User.swift +++ b/today-s-sound/Data/Models/User.swift @@ -22,6 +22,12 @@ struct AnonymousUserResult: Codable { /// 익명 사용자 등록 응답 typealias RegisterAnonymousResponse = APIResponse +// MARK: - FCM 토큰 업데이트 + +struct UpdateFCMTokenRequest: Codable { + let fcmToken: String +} + // MARK: - 에러 응답 struct APIErrorResponse: Codable, Error { diff --git a/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionCardView.swift b/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionCardView.swift index aa082a9..e6a4a52 100644 --- a/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionCardView.swift +++ b/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionCardView.swift @@ -85,7 +85,7 @@ struct SubscriptionCardView: View { Button { onToggleAlarm?(subscription) } label: { - Image(subscription.isUrgent ? "Bell" : "Bell off") + Image(subscription.isAlarmEnabled ? "Bell" : "Bell off") .resizable() .scaledToFit() .frame(width: 44, height: 44) @@ -93,8 +93,8 @@ struct SubscriptionCardView: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .accessibilityLabel("긴급 알림") - .accessibilityValue(subscription.isUrgent ? "켜짐" : "꺼짐") + .accessibilityLabel("알림") + .accessibilityValue(subscription.isAlarmEnabled ? "켜짐" : "꺼짐") } .padding(16) .background( @@ -114,6 +114,7 @@ struct SubscriptionCardView_Previews: PreviewProvider { url: "https://newsroom.apple.com", alias: "애플 뉴스룸", isUrgent: false, + isAlarmEnabled: true, keywords: [ KeywordItem(id: 1, name: "아이폰"), KeywordItem(id: 2, name: "접근성"), diff --git a/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift b/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift index f2ada31..cc15b26 100644 --- a/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift +++ b/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift @@ -73,7 +73,7 @@ struct SubscriptionListView: View { subscription: subscription, theme: appTheme.theme, onToggleAlarm: { sub in - if sub.isUrgent { + if sub.isAlarmEnabled { viewModel.blockAlarm(sub) } else { viewModel.unblockAlarm(sub) diff --git a/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListViewModel.swift b/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListViewModel.swift index b9d98dc..3fb4bc9 100644 --- a/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListViewModel.swift +++ b/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListViewModel.swift @@ -97,6 +97,12 @@ class SubscriptionListViewModel: ObservableObject { let newItems = response.subscriptions + // 🔍 서버 응답 상세 로깅 + print("📥 서버 응답 상세:") + for item in newItems { + print(" - id: \(item.id), alias: \(item.alias), isUrgent: \(item.isUrgent), isAlarmEnabled: \(item.isAlarmEnabled)") + } + // 기존 목록에 추가 (서버에서 이미 정렬됨!) subscriptions.append(contentsOf: newItems) @@ -215,12 +221,13 @@ class SubscriptionListViewModel: ObservableObject { // 차단 성공 시 로컬 상태 업데이트 (종 모양 변경) if let index = subscriptions.firstIndex(where: { $0.id == subscription.id }) { let updated = subscriptions[index] - // isUrgent를 false로 변경 (구조체이므로 새로 생성) + // isAlarmEnabled를 false로 변경 (구조체이므로 새로 생성) subscriptions[index] = SubscriptionItem( id: updated.id, url: updated.url, alias: updated.alias, - isUrgent: false, + isUrgent: updated.isUrgent, + isAlarmEnabled: false, keywords: updated.keywords ) } @@ -280,12 +287,13 @@ class SubscriptionListViewModel: ObservableObject { // 차단 해제 성공 시 로컬 상태 업데이트 (종 모양 변경) if let index = subscriptions.firstIndex(where: { $0.id == subscription.id }) { let updated = subscriptions[index] - // isUrgent를 true로 변경 + // isAlarmEnabled를 true로 변경 subscriptions[index] = SubscriptionItem( id: updated.id, url: updated.url, alias: updated.alias, - isUrgent: true, + isUrgent: updated.isUrgent, + isAlarmEnabled: true, keywords: updated.keywords ) } @@ -329,6 +337,7 @@ class SubscriptionListViewModel: ObservableObject { url: "https://newsroom.apple.com", alias: "애플 뉴스룸", isUrgent: false, + isAlarmEnabled: true, keywords: [ KeywordItem(id: 1, name: "아이폰"), KeywordItem(id: 2, name: "애플워치") @@ -339,6 +348,7 @@ class SubscriptionListViewModel: ObservableObject { url: "https://blog.naver.com/accessibility", alias: "접근성 블로그", isUrgent: true, + isAlarmEnabled: false, keywords: [ KeywordItem(id: 3, name: "시각"), KeywordItem(id: 4, name: "보이스오버"),