diff --git a/KillingPart.xcodeproj/project.pbxproj b/KillingPart.xcodeproj/project.pbxproj index 196ba03..5411cb9 100644 --- a/KillingPart.xcodeproj/project.pbxproj +++ b/KillingPart.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 1224698B2FA765FE00A6EF76 /* FirebaseCore in Frameworks */ = {isa = PBXBuildFile; productRef = 1224698A2FA765FE00A6EF76 /* FirebaseCore */; }; + 1224698D2FA765FE00A6EF76 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = 1224698C2FA765FE00A6EF76 /* FirebaseMessaging */; }; 12CE25AA2F3DC9BE0057A858 /* Development.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 12CE25A92F3DC9BE0057A858 /* Development.xcconfig */; }; 12CE25AC2F3DCA940057A858 /* Release.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 12CE25AB2F3DCA940057A858 /* Release.xcconfig */; }; 12F7A0012F3F010000A00001 /* KakaoSDKCommon in Frameworks */ = {isa = PBXBuildFile; productRef = 12F7A0112F3F010000A00001 /* KakaoSDKCommon */; }; @@ -77,6 +79,8 @@ files = ( 12F7A0012F3F010000A00001 /* KakaoSDKCommon in Frameworks */, 12F7A0022F3F010000A00001 /* KakaoSDKAuth in Frameworks */, + 1224698D2FA765FE00A6EF76 /* FirebaseMessaging in Frameworks */, + 1224698B2FA765FE00A6EF76 /* FirebaseCore in Frameworks */, 12F7A0032F3F010000A00001 /* KakaoSDKUser in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -143,6 +147,8 @@ 12F7A0112F3F010000A00001 /* KakaoSDKCommon */, 12F7A0122F3F010000A00001 /* KakaoSDKAuth */, 12F7A0132F3F010000A00001 /* KakaoSDKUser */, + 1224698A2FA765FE00A6EF76 /* FirebaseCore */, + 1224698C2FA765FE00A6EF76 /* FirebaseMessaging */, ); productName = KillingPart; productReference = 1231F11D2F372E5B00CFA51D /* KillingPart.app */; @@ -228,6 +234,7 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( 12F7A0212F3F010000A00001 /* XCRemoteSwiftPackageReference "kakao-ios-sdk" */, + 122469892FA765FE00A6EF76 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, ); preferredProjectObjectVersion = 77; productRefGroup = 1231F11E2F372E5B00CFA51D /* Products */; @@ -653,6 +660,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + 122469892FA765FE00A6EF76 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/firebase/firebase-ios-sdk"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 12.12.1; + }; + }; 12F7A0212F3F010000A00001 /* XCRemoteSwiftPackageReference "kakao-ios-sdk" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/kakao/kakao-ios-sdk"; @@ -664,6 +679,16 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 1224698A2FA765FE00A6EF76 /* FirebaseCore */ = { + isa = XCSwiftPackageProductDependency; + package = 122469892FA765FE00A6EF76 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseCore; + }; + 1224698C2FA765FE00A6EF76 /* FirebaseMessaging */ = { + isa = XCSwiftPackageProductDependency; + package = 122469892FA765FE00A6EF76 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseMessaging; + }; 12F7A0112F3F010000A00001 /* KakaoSDKCommon */ = { isa = XCSwiftPackageProductDependency; package = 12F7A0212F3F010000A00001 /* XCRemoteSwiftPackageReference "kakao-ios-sdk" */; diff --git a/KillingPart.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/KillingPart.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index fdc59d5..48c7c5c 100644 --- a/KillingPart.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/KillingPart.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "0a9a0565c23645eacdcd0d177a036429567ab510b257a7f2538ea8bb318bf8d2", + "originHash" : "580789ee205443bfdae8cdb4179b819fc700618c7aed6870050932d8f2bde16d", "pins" : [ + { + "identity" : "abseil-cpp-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/abseil-cpp-binary.git", + "state" : { + "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", + "version" : "1.2024072200.0" + } + }, { "identity" : "alamofire", "kind" : "remoteSourceControl", @@ -10,6 +19,87 @@ "version" : "5.11.1" } }, + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "61b85103a1aeed8218f17c794687781505fbbef5", + "version" : "11.2.0" + } + }, + { + "identity" : "firebase-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/firebase-ios-sdk", + "state" : { + "revision" : "46579c364d39b86ec9fb8f613e19f023700a929c", + "version" : "12.12.1" + } + }, + { + "identity" : "google-ads-on-device-conversion-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", + "state" : { + "revision" : "19dffda9a9caf8d86570ff846535902d8509d7bf", + "version" : "3.5.0" + } + }, + { + "identity" : "googleappmeasurement", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleAppMeasurement.git", + "state" : { + "revision" : "5100f946bd32778662047a823bf145c6da32d85d", + "version" : "12.12.1" + } + }, + { + "identity" : "googledatatransport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleDataTransport.git", + "state" : { + "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", + "version" : "10.1.0" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "60da361632d0de02786f709bdc0c4df340f7613e", + "version" : "8.1.0" + } + }, + { + "identity" : "grpc-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/grpc-binary.git", + "state" : { + "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", + "version" : "1.69.1" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "0be208810d2e8b90fcd2464d1816723b47819fe7", + "version" : "5.2.0" + } + }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version" : "101.0.0" + } + }, { "identity" : "kakao-ios-sdk", "kind" : "remoteSourceControl", @@ -18,6 +108,33 @@ "branch" : "master", "revision" : "5978979157a5a0521c9c56fd0156aec794caa21c" } + }, + { + "identity" : "leveldb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/leveldb.git", + "state" : { + "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", + "version" : "1.22.5" + } + }, + { + "identity" : "nanopb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/nanopb.git", + "state" : { + "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", + "version" : "2.30910.0" + } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", + "version" : "2.4.0" + } } ], "version" : 3 diff --git a/KillingPart/GoogleService-Info.plist b/KillingPart/GoogleService-Info.plist new file mode 100644 index 0000000..ddec767 --- /dev/null +++ b/KillingPart/GoogleService-Info.plist @@ -0,0 +1,30 @@ + + + + + API_KEY + AIzaSyDLAvDujYcW3s5rxSomgZ1aGu-jLfUcGrA + GCM_SENDER_ID + 795169340848 + PLIST_VERSION + 1 + BUNDLE_ID + com.killingpoint.killingpart + PROJECT_ID + killing-part-32870 + STORAGE_BUCKET + killing-part-32870.firebasestorage.app + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + 1:795169340848:ios:4db009d458c7ff7ddaaa75 + + \ No newline at end of file diff --git a/KillingPart/Info.plist b/KillingPart/Info.plist index a8c3add..cf23ebc 100644 --- a/KillingPart/Info.plist +++ b/KillingPart/Info.plist @@ -2,20 +2,10 @@ - SPOTIFY_BASIC_AUTH - $(SPOTIFY_BASIC_AUTH) - BASE_URL - $(BASE_URL) - KAKAO_NATIVE_APP_KEY - $(KAKAO_NATIVE_APP_KEY) - MUSIC_BASE_URL - $(MUSIC_BASE_URL) APP_STORE_URL $(APP_STORE_URL) - LSApplicationQueriesSchemes - - kakaokompassauth - + BASE_URL + $(BASE_URL) CFBundleURLTypes @@ -27,6 +17,16 @@ + KAKAO_NATIVE_APP_KEY + $(KAKAO_NATIVE_APP_KEY) + LSApplicationQueriesSchemes + + kakaokompassauth + + MUSIC_BASE_URL + $(MUSIC_BASE_URL) + SPOTIFY_BASIC_AUTH + $(SPOTIFY_BASIC_AUTH) UIAppFonts Paperlogy-1Thin.ttf @@ -39,5 +39,11 @@ Paperlogy-8ExtraBold.ttf Paperlogy-9Black.ttf + UIBackgroundModes + + remote-notification + + FirebaseAppDelegateProxyEnabled + diff --git a/KillingPart/KillingPart.entitlements b/KillingPart/KillingPart.entitlements index a812db5..906bbc3 100644 --- a/KillingPart/KillingPart.entitlements +++ b/KillingPart/KillingPart.entitlements @@ -2,9 +2,13 @@ + aps-environment + development com.apple.developer.applesignin Default + com.apple.developer.aps-environment + development diff --git a/KillingPart/KillingPartApp.swift b/KillingPart/KillingPartApp.swift index a6e4109..c8f2268 100644 --- a/KillingPart/KillingPartApp.swift +++ b/KillingPart/KillingPartApp.swift @@ -11,6 +11,8 @@ import KakaoSDKCommon @main struct KillingPartApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + init() { if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] != "1" { configureKakaoSDK() diff --git a/KillingPart/Services/AppDelegate.swift b/KillingPart/Services/AppDelegate.swift new file mode 100644 index 0000000..0d948a8 --- /dev/null +++ b/KillingPart/Services/AppDelegate.swift @@ -0,0 +1,123 @@ +import UIKit +import FirebaseCore +import FirebaseMessaging +import UserNotifications + +final class AppDelegate: NSObject, UIApplicationDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + print("[FCM][1] didFinishLaunching — Firebase 초기화 시작") + FirebaseApp.configure() + UNUserNotificationCenter.current().delegate = self + Messaging.messaging().delegate = self + print("[FCM][1] Firebase 초기화 완료, 알림 권한 요청 시작") + requestNotificationAuthorization() + return true + } + + // APNs 토큰 수신 — swizzling 비활성화 시 필수, 활성화 시에도 명시적으로 처리 + func application( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + let tokenString = deviceToken.map { String(format: "%02x", $0) }.joined() + print("[FCM][3] ✅ APNs 토큰 수신: \(tokenString.prefix(20))...") + Messaging.messaging().apnsToken = deviceToken + print("[FCM][3] APNs 토큰 → Firebase 전달 완료") + } + + func application( + _ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: Error + ) { + print("[FCM][3] ❌ APNs 등록 실패: \(error.localizedDescription)") + } + + // 백그라운드/포그라운드 데이터 메시지 수신 — 이 메서드가 없으면 data-only 메시지가 전달되지 않음 + func application( + _ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void + ) { + print("[FCM][Background] 원격 알림 수신 — payload: \(userInfo)") + completionHandler(.newData) + } + + private func requestNotificationAuthorization() { + UNUserNotificationCenter.current().getNotificationSettings { settings in + print("[FCM][2] 현재 알림 권한 상태: \(settings.authorizationStatus.debugDescription)") + } + + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in + if let error { + print("[FCM][2] ❌ 알림 권한 요청 오류: \(error.localizedDescription)") + return + } + print("[FCM][2] 알림 권한 \(granted ? "✅ 허용" : "❌ 거부")") + if granted { + DispatchQueue.main.async { + print("[FCM][2] APNs 등록 요청 시작") + UIApplication.shared.registerForRemoteNotifications() + } + } else { + print("[FCM][2] ⚠️ 권한 거부 — 설정 앱에서 알림을 허용해야 합니다") + } + } + } +} + +extension AppDelegate: UNUserNotificationCenterDelegate { + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + let userInfo = notification.request.content.userInfo + print("[FCM][Foreground] 알림 수신 — payload: \(userInfo)") + completionHandler([.banner, .sound, .badge]) + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + print("[FCM][Tap] 알림 탭 감지") + FCMManager.shared.handleNotificationResponse(response) + completionHandler() + } +} + +extension AppDelegate: MessagingDelegate { + func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { + guard let token = fcmToken else { + print("[FCM][4] ❌ FCM 토큰 nil") + return + } + + if let apnsToken = messaging.apnsToken { + let apnsHex = apnsToken.map { String(format: "%02x", $0) }.joined() + print("[FCM][4] ✅ APNs 연결됨: \(apnsHex.prefix(20))...") + } else { + print("[FCM][4] ⚠️ APNs 토큰 nil") + } + + print("[FCM][4] FCM 토큰: \(token)") + FCMManager.shared.didReceiveToken(token) + } +} + +private extension UNAuthorizationStatus { + var debugDescription: String { + switch self { + case .notDetermined: return "notDetermined" + case .denied: return "denied ❌" + case .authorized: return "authorized ✅" + case .provisional: return "provisional" + case .ephemeral: return "ephemeral" + @unknown default: return "unknown" + } + } +} diff --git a/KillingPart/Services/AuthenticationService.swift b/KillingPart/Services/AuthenticationService.swift index c7f7440..274d19d 100644 --- a/KillingPart/Services/AuthenticationService.swift +++ b/KillingPart/Services/AuthenticationService.swift @@ -155,6 +155,7 @@ struct AuthenticationService: AuthenticationServicing { func logout() async throws { do { + try? await FCMManager.shared.deleteToken() let request = APIRequest( path: "/users/logout", method: .post, diff --git a/KillingPart/Services/FCMManager.swift b/KillingPart/Services/FCMManager.swift new file mode 100644 index 0000000..f28970f --- /dev/null +++ b/KillingPart/Services/FCMManager.swift @@ -0,0 +1,80 @@ +import UserNotifications +import FirebaseMessaging + +final class FCMManager { + static let shared = FCMManager() + + private let fcmService: FCMServicing + private let tokenStore: TokenStoring + private let pendingTokenKey = "fcm.pendingToken" + + init( + fcmService: FCMServicing = FCMService(), + tokenStore: TokenStoring = TokenStore.shared + ) { + self.fcmService = fcmService + self.tokenStore = tokenStore + } + + func didReceiveToken(_ token: String) { + guard Messaging.messaging().apnsToken != nil else { + print("[FCM] APNs 미연결 — 토큰 무시 (APNs 연결 후 재발급됨)") + return + } + + print("[FCM] APNs 연결 확인 — 토큰 처리 시작") + + if tokenStore.hasSessionTokens { + Task { + do { + try await fcmService.registerToken(token) + } catch { + print("[FCM] 토큰 서버 등록 실패: \(error.localizedDescription)") + UserDefaults.standard.set(token, forKey: pendingTokenKey) + } + } + } else { + UserDefaults.standard.set(token, forKey: pendingTokenKey) + print("[FCM] 비로그인 상태 — 토큰 로컬 저장") + } + } + + func registerPendingTokenIfNeeded() { + Task { + let token: String? + if let pending = UserDefaults.standard.string(forKey: pendingTokenKey) { + token = pending + } else { + token = try? await Messaging.messaging().token() + } + + guard let token else { + print("[FCM] 등록할 토큰 없음") + return + } + + do { + try await fcmService.registerToken(token) + UserDefaults.standard.removeObject(forKey: pendingTokenKey) + } catch { + print("[FCM] 로그인 후 토큰 서버 등록 실패: \(error.localizedDescription)") + } + } + } + + func deleteToken() async throws { + try await fcmService.deleteToken() + UserDefaults.standard.removeObject(forKey: pendingTokenKey) + } + + func handleNotificationResponse(_ response: UNNotificationResponse) { + let userInfo = response.notification.request.content.userInfo + print("[FCM] 알림 클릭 payload: \(userInfo)") + parsePayload(userInfo) + } + + private func parsePayload(_ userInfo: [AnyHashable: Any]) { + // 추후 화면 이동 연결 포인트 + // 예: if let screen = userInfo["screen"] as? String { ... } + } +} diff --git a/KillingPart/Services/FCMService.swift b/KillingPart/Services/FCMService.swift new file mode 100644 index 0000000..eb7a7ad --- /dev/null +++ b/KillingPart/Services/FCMService.swift @@ -0,0 +1,93 @@ +import Foundation + +protocol FCMServicing { + func registerToken(_ token: String) async throws + func deleteToken() async throws +} + +enum FCMServiceError: LocalizedError { + case requestEncodingFailed + case sessionExpired + case serverError(statusCode: Int, message: String?) + case networkFailure(message: String) + + var errorDescription: String? { + switch self { + case .requestEncodingFailed: + return "요청 생성에 실패했어요." + case .sessionExpired: + return "세션이 만료되었어요. 다시 로그인해 주세요." + case .serverError(_, let message): + return message ?? "요청 처리에 실패했어요." + case .networkFailure(let message): + return message + } + } +} + +struct FCMService: FCMServicing { + private let apiClient: APIClienting + + init(apiClient: APIClienting = APIClient.shared) { + self.apiClient = apiClient + } + + func registerToken(_ token: String) async throws { + let requestBody: Data + do { + requestBody = try JSONEncoder().encode(FCMTokenRequest(token: token)) + } catch { + throw FCMServiceError.requestEncodingFailed + } + + do { + let request = APIRequest( + path: "/fcm/tokens", + method: .post, + requiresAuthorization: true, + body: requestBody + ) + try await apiClient.request(request) + print("[FCM] 서버에 토큰 등록 완료") + } catch { + throw mapError(error) + } + } + + func deleteToken() async throws { + do { + let request = APIRequest( + path: "/fcm/tokens", + method: .delete, + requiresAuthorization: true + ) + try await apiClient.request(request) + print("[FCM] 서버에서 토큰 삭제 완료") + } catch { + throw mapError(error) + } + } + + private func mapError(_ error: Error) -> FCMServiceError { + if let fcmError = error as? FCMServiceError { + return fcmError + } + + if let apiError = error as? APIClientError { + switch apiError { + case .missingAccessToken, .missingRefreshToken, .unauthorized: + return .sessionExpired + case .serverError(let statusCode, let message): + return .serverError(statusCode: statusCode, message: message) + default: + break + } + } + + return .networkFailure(message: "네트워크 요청 중 오류가 발생했어요.") + } +} + +private struct FCMTokenRequest: Encodable { + let token: String +} diff --git a/KillingPart/ViewModels/AppViewModel.swift b/KillingPart/ViewModels/AppViewModel.swift index af3702b..4ffc629 100644 --- a/KillingPart/ViewModels/AppViewModel.swift +++ b/KillingPart/ViewModels/AppViewModel.swift @@ -73,6 +73,7 @@ final class AppViewModel: ObservableObject { self.loginViewModel = loginViewModel self.loginViewModel.onLoginSuccess = { [weak self] _ in Task { @MainActor [weak self] in + FCMManager.shared.registerPendingTokenIfNeeded() await self?.resolvePostLoginFlow() } }