Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
102 changes: 94 additions & 8 deletions today-s-sound/App/TodaySSoundApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,21 @@
// Created by 하승연 on 9/28/25.
//

import Combine
import FirebaseCore
import FirebaseMessaging
import SwiftUI
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<AnyCancellable>()

func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool
{
Expand All @@ -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
Expand All @@ -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)")
// 실패해도 다음에 다시 시도할 수 있도록 플래그는 유지
}
}

Expand Down
48 changes: 45 additions & 3 deletions today-s-sound/Core/AppState/SessionStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions today-s-sound/Core/Network/Service/APIService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ protocol APIServiceType {
func request<T: Decodable>(_ target: some TargetType) -> AnyPublisher<T, NetworkError>
func registerAnonymous(request: RegisterAnonymousRequest) -> AnyPublisher<RegisterAnonymousResponse, NetworkError>
func withdrawUser(userId: String, deviceSecret: String) -> AnyPublisher<Void, NetworkError>
func updateFCMToken(userId: String, deviceSecret: String, fcmToken: String) -> AnyPublisher<Void, NetworkError>
func getSubscriptions(
userId: String, deviceSecret: String, page: Int, size: Int
) -> AnyPublisher<SubscriptionListResponse, NetworkError>
Expand Down Expand Up @@ -186,6 +187,34 @@ class APIService: APIServiceType {
.eraseToAnyPublisher()
}

func updateFCMToken(userId: String, deviceSecret: String, fcmToken: String) -> AnyPublisher<Void, NetworkError> {
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(
Expand Down
14 changes: 14 additions & 0 deletions today-s-sound/Core/Network/Targets/UserAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -20,6 +21,8 @@ extension UserAPI: APITargetType {
"/api/users/anonymous"
case .withdraw:
"/api/users/withdraw"
case .updateFCMToken:
"/api/fcm"
}
}

Expand All @@ -29,6 +32,8 @@ extension UserAPI: APITargetType {
.post
case .withdraw:
.delete
case .updateFCMToken:
.put
}
}

Expand All @@ -38,6 +43,8 @@ extension UserAPI: APITargetType {
.requestJSONEncodable(request)
case .withdraw:
.requestPlain
case let .updateFCMToken(_, _, request):
.requestJSONEncodable(request)
}
}

Expand All @@ -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
]
}
}
}
2 changes: 2 additions & 0 deletions today-s-sound/Data/Models/Subscription.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,15 @@ struct SubscriptionItem: Codable, Identifiable {
let url: String
let alias: String
let isUrgent: Bool
let isAlarmEnabled: Bool
let keywords: [KeywordItem]

enum CodingKeys: String, CodingKey {
case id
case url
case alias
case isUrgent
case isAlarmEnabled
case keywords
}
}
Expand Down
6 changes: 6 additions & 0 deletions today-s-sound/Data/Models/User.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ struct AnonymousUserResult: Codable {
/// 익명 사용자 등록 응답
typealias RegisterAnonymousResponse = APIResponse<AnonymousUserResult>

// MARK: - FCM 토큰 업데이트

struct UpdateFCMTokenRequest: Codable {
let fcmToken: String
}

// MARK: - 에러 응답

struct APIErrorResponse: Codable, Error {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,16 +85,16 @@ 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)
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel("긴급 알림")
.accessibilityValue(subscription.isUrgent ? "켜짐" : "꺼짐")
.accessibilityLabel("알림")
.accessibilityValue(subscription.isAlarmEnabled ? "켜짐" : "꺼짐")
}
.padding(16)
.background(
Expand All @@ -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: "접근성"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading