diff --git a/.gitignore b/.gitignore index f021303..1fdee27 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,4 @@ playground.xcworkspace **/Package.resolved *.xcconfig +Hambug/GoogleService-Info.plist diff --git a/3rdParty/.swiftpm/xcode/xcshareddata/xcschemes/FCMService.xcscheme b/3rdParty/.swiftpm/xcode/xcshareddata/xcschemes/FCMService.xcscheme new file mode 100644 index 0000000..0e27c60 --- /dev/null +++ b/3rdParty/.swiftpm/xcode/xcshareddata/xcschemes/FCMService.xcscheme @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/3rdParty/.swiftpm/xcode/xcshareddata/xcschemes/KakaoLogin.xcscheme b/3rdParty/.swiftpm/xcode/xcshareddata/xcschemes/KakaoLogin.xcscheme new file mode 100644 index 0000000..669b053 --- /dev/null +++ b/3rdParty/.swiftpm/xcode/xcshareddata/xcschemes/KakaoLogin.xcscheme @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/3rdParty/Package.swift b/3rdParty/Package.swift index 03c2959..5ad9ae5 100644 --- a/3rdParty/Package.swift +++ b/3rdParty/Package.swift @@ -11,9 +11,19 @@ let package = Package( name: "KakaoLogin", targets: ["KakaoLogin"] ), + .library( + name: "FCMService", + targets: ["FCMService"] + ), ], dependencies: [ .package(url: "https://github.com/kakao/kakao-ios-sdk", branch: "master"), + .package( + url: "https://github.com/firebase/firebase-ios-sdk.git", + .upToNextMajor(from: "12.7.0") + ), + .package(name: "Infrastructure", path: "../Infrastructure"), + .package(name: "Common", path: "../Common") ], targets: [ .target( @@ -24,6 +34,16 @@ let package = Package( .product(name: "KakaoSDKUser", package: "kakao-ios-sdk"), ] ), + .target( + name: "FCMService", + dependencies: [ + .product(name: "FirebaseAnalytics", package: "firebase-ios-sdk"), + .product(name: "FirebaseCore", package: "firebase-ios-sdk"), + .product(name: "FirebaseMessaging", package: "firebase-ios-sdk"), + .product(name: "NetworkInterface", package: "Infrastructure"), + .product(name: "Util", package: "Common") + ] + ) ] ) diff --git a/3rdParty/Sources/FCMService/FCMEndpoint.swift b/3rdParty/Sources/FCMService/FCMEndpoint.swift new file mode 100644 index 0000000..67ef54e --- /dev/null +++ b/3rdParty/Sources/FCMService/FCMEndpoint.swift @@ -0,0 +1,32 @@ +// +// FCMEndpoint.swift +// 3rdParty +// +// Created by 강동영 on 1/13/26. +// + +import Foundation +import NetworkInterface + +struct FCMEndpoint: Endpoint { + let baseURL: String = NetworkConfig.baseURL + let path: String = "/api/v1/fcm/tokens" + + let method: NetworkInterface.HTTPMethod = .POST + + var headers: [String : String] = [:] + + let queryParameters: [String : Any] = [:] + + let body: Data? + + init(body: FCMRequest) { + let encoder = JSONEncoder() + self.body = try? encoder.encode(body) + } +} + +struct FCMRequest: Sendable, Encodable { + let token: String + let platform: String = "ios" +} diff --git a/3rdParty/Sources/FCMService/FCMManager.swift b/3rdParty/Sources/FCMService/FCMManager.swift new file mode 100644 index 0000000..9720814 --- /dev/null +++ b/3rdParty/Sources/FCMService/FCMManager.swift @@ -0,0 +1,96 @@ +// +// FCMManager.swift +// 3rdParty +// +// Created by 강동영 on 1/13/26. +// + +import Combine +import Foundation + +import NetworkInterface +import DataSources +import Util + +import FirebaseCore +import FirebaseMessaging + + +public final class FCMManager: NSObject { + private let service: NetworkServiceInterface + private let storage: FCMTokenStorageable + + public init( + service: NetworkServiceInterface, + storage: FCMTokenStorageable + ) { + self.service = service + self.storage = storage + } + + public func configure() { + FirebaseApp.configure() + Messaging.messaging().delegate = self + } + + public func registAPNsToken(_ deviceToken: Data) { + let token = deviceToken.map { String(format: "%02.2hhx", $0)}.joined() + print("device Token: \(token)") + Messaging.messaging().apnsToken = deviceToken + } + + public func sendPendingTokenToServer() async { + if let fcmToken = storage.load() { + await sendTokenToServer(fcmToken) + } + } + + private func sendTokenToServer(_ fcmToken: String) async { + let endpoint = FCMEndpoint(body: FCMRequest(token: fcmToken)) + + do { + let isSuccess = try await service.request( + endpoint, + responseType: SuccessResponse.self + ).async() + + print("FCM Token Send to Server Success: \(isSuccess)") + } catch { + print("FCM Token Send to Server Failed") + print("errorMessage: \(error.localizedDescription)") + } + } +} + +extension FCMManager: MessagingDelegate { + + // MARK: - 등록 토큰을 제공하는 메서드 + // 호출 되는 시점: FCM SDK는 최초 앱 시작 시 + // + // 토큰 업데이트 혹은 무효화될 때마다, + // 신규 또는 기존 토큰을 가져옴 + + // 등록 토큰 변경시점: + // + // 새 기기에서 앱 복원 + // 사용자가 앱 제거/재설치 + // 사용자가 앱 데이터 삭제 + public func messaging( + _ messaging: Messaging, + didReceiveRegistrationToken fcmToken: String? + ) { + guard let fcmToken = fcmToken else { return } + print("fcmToken: \(fcmToken)") + + let storedToken = storage.load() + // 기존 토큰 확인 및 fcm 토큰 변경여부 확인 + if let storedToken = storedToken, storedToken == fcmToken { + print("✅ FCM Token unchanged") + return + } + + // 저장 된 토큰이 없거나, 기존 토큰과 다른 경우 저장만 + // 로그인 정보가 없어서, 403떨어짐 + try? storage.save(fcmToken) + } +} diff --git a/Common/Sources/DataSources/Keychain/FCMTokenStorage.swift b/Common/Sources/DataSources/Keychain/FCMTokenStorage.swift new file mode 100644 index 0000000..4d4e4e5 --- /dev/null +++ b/Common/Sources/DataSources/Keychain/FCMTokenStorage.swift @@ -0,0 +1,38 @@ +// +// FCMTokenStorage.swift +// Common +// +// Created by 강동영 on 1/13/26. +// + + +public protocol FCMTokenStorageable { + func save(_ token: String) throws + func load() -> String? + func clear() throws + func exists() throws -> Bool +} + +public final class FCMTokenStorage: KeychainTokenStorage, FCMTokenStorageable { + private let service: String + + public override init(service: String = HambugKeychainKey.serviceID) { + self.service = service + } + + public func save(_ token: String) throws { + try set(token, for: .fcmToken) + } + + public func load() -> String? { + try? string(for: .fcmToken) + } + + public func clear() throws { + try delete(.fcmToken) + } + + public func exists() throws -> Bool { + try contains(.fcmToken) + } +} diff --git a/Common/Sources/DataSources/Keychain/HambugKeychainKey.swift b/Common/Sources/DataSources/Keychain/HambugKeychainKey.swift new file mode 100644 index 0000000..cf685b6 --- /dev/null +++ b/Common/Sources/DataSources/Keychain/HambugKeychainKey.swift @@ -0,0 +1,26 @@ +// +// HambugKeychainKey.swift +// Common +// +// Created by 강동영 on 1/13/26. +// + +import Foundation + +// MARK: - Supporting Types + +public enum HambugKeychainKey { + public static let serviceID = Bundle.main.bundleIdentifier ?? "com.hambug" + + case accessToken + case refreshToken + case fcmToken + + var toString: String { + switch self { + case .accessToken: return "access_token" + case .refreshToken: return "refresh_token" + case .fcmToken: return "fcm_token" + } + } +} diff --git a/Common/Sources/DataSources/Keychain/JWTTokenStorageable.swift b/Common/Sources/DataSources/Keychain/JWTTokenStorageable.swift new file mode 100644 index 0000000..9db7cf3 --- /dev/null +++ b/Common/Sources/DataSources/Keychain/JWTTokenStorageable.swift @@ -0,0 +1,46 @@ +// +// JWTTokenStorageable.swift +// Common +// +// Created by 강동영 on 1/13/26. +// + + +public protocol JWTTokenStorageable { + func save(accessToken: String, refreshToken: String?) throws + func load() -> (accessToken: String?, refreshToken: String?) + func clear() throws + func exists() throws -> Bool +} + +public final class JWTokenStorage: KeychainTokenStorage, JWTTokenStorageable { + private let service: String + + public override init(service: String = HambugKeychainKey.serviceID) { + self.service = service + } + + public func save(accessToken: String, refreshToken: String?) throws { + try set(accessToken, for: .accessToken) + + if let refresh = refreshToken { + try set(refresh, for: .refreshToken) + } + } + + public func load() -> (accessToken: String?, refreshToken: String?) { + let access = try? string(for: .accessToken) + let refresh = try? string(for: .refreshToken) + + return (access, refresh) + } + + public func exists() throws -> Bool { + try contains(.accessToken) + } + + public func clear() throws { + try delete(.accessToken) + try delete(.refreshToken) + } +} \ No newline at end of file diff --git a/Common/Sources/DataSources/Keychain/KeychainError.swift b/Common/Sources/DataSources/Keychain/KeychainError.swift new file mode 100644 index 0000000..1524979 --- /dev/null +++ b/Common/Sources/DataSources/Keychain/KeychainError.swift @@ -0,0 +1,33 @@ +// +// KeychainError.swift +// Common +// +// Created by 강동영 on 1/13/26. +// + +import Foundation + +public enum KeychainError: Error { + case itemNotFound + case duplicateItem + case invalidData + case unexpectedPasswordData + case unexpected(OSStatus) +} + +extension KeychainError: LocalizedError { + public var errorDescription: String? { + switch self { + case .itemNotFound: + return "아이템을 찾을 수 없습니다." + case .duplicateItem: + return "이미 존재하는 아이템입니다." + case .invalidData: + return "유효하지 않은 데이터입니다." + case .unexpectedPasswordData: + return "예상치 못한 패스워드 데이터입니다." + case .unexpected(let status): + return "Keychain 에러: \(status)" + } + } +} diff --git a/Common/Sources/DataSources/Keychain/KeychainTokenStorage.swift b/Common/Sources/DataSources/Keychain/KeychainTokenStorage.swift index 2e4b0da..abb0e5b 100644 --- a/Common/Sources/DataSources/Keychain/KeychainTokenStorage.swift +++ b/Common/Sources/DataSources/Keychain/KeychainTokenStorage.swift @@ -7,46 +7,38 @@ import Foundation -public protocol TokenStorage: Sendable { - func save(accessToken: String, refreshToken: String?) throws - func load() -> (accessToken: String?, refreshToken: String?) - func clear() throws - func exists() throws -> Bool +public protocol TokenStorage { + func save(_ token: String, key: HambugKeychainKey) throws + func load(_ key: HambugKeychainKey) -> String? + func clear(_ key: HambugKeychainKey) throws + func exists(_ key: HambugKeychainKey) throws -> Bool } -public final class KeychainTokenStorage: TokenStorage { +public class KeychainTokenStorage: TokenStorage { private let service: String public init(service: String = HambugKeychainKey.serviceID) { self.service = service } - public func save(accessToken: String, refreshToken: String?) throws { - try set(accessToken, for: .accessToken) - - if let refresh = refreshToken { - try set(refresh, for: .refreshToken) - } + public func save(_ token: String, key: HambugKeychainKey) throws { + try set(token, for: key) } - - public func load() -> (accessToken: String?, refreshToken: String?) { - let access = try? string(for: .accessToken) - let refresh = try? string(for: .refreshToken) - - return (access, refresh) + + public func load(_ key: HambugKeychainKey) -> String? { + try? string(for: key) } - - public func exists() throws -> Bool { - try contains(.accessToken) + + public func clear(_ key: HambugKeychainKey) throws { + try delete(key) } - - public func clear() throws { - try delete(.accessToken) - try delete(.refreshToken) + + public func exists(_ key: HambugKeychainKey) throws -> Bool { + try contains(key) } } -private extension KeychainTokenStorage { +extension KeychainTokenStorage { func set(_ value: String, for key: HambugKeychainKey) throws { let data = value.data(using: .utf8)! @@ -140,45 +132,3 @@ private extension KeychainTokenStorage { } } } - -// MARK: - Supporting Types - -public enum HambugKeychainKey { - public static let serviceID = Bundle.main.bundleIdentifier ?? "com.hambug" - - case accessToken - case refreshToken - - var toString: String { - switch self { - case .accessToken: return "access_token" - case .refreshToken: return "refresh_token" - } - } -} - -public enum KeychainError: Error { - case itemNotFound - case duplicateItem - case invalidData - case unexpectedPasswordData - case unexpected(OSStatus) -} - -extension KeychainError: LocalizedError { - public var errorDescription: String? { - switch self { - case .itemNotFound: - return "아이템을 찾을 수 없습니다." - case .duplicateItem: - return "이미 존재하는 아이템입니다." - case .invalidData: - return "유효하지 않은 데이터입니다." - case .unexpectedPasswordData: - return "예상치 못한 패스워드 데이터입니다." - case .unexpected(let status): - return "Keychain 에러: \(status)" - } - } -} - diff --git a/Common/Sources/Managers/AppStateManager.swift b/Common/Sources/Managers/AppStateManager.swift index 3831dfb..badb8cf 100644 --- a/Common/Sources/Managers/AppStateManager.swift +++ b/Common/Sources/Managers/AppStateManager.swift @@ -40,7 +40,8 @@ public final class AppStateManager { // Keychain으로 로그인 상태 확인 public var isLoginCompleted: Bool { do { - return try tokenStorage.exists() + let hasLoggedInBefore = try tokenStorage.exists(.accessToken) + return hasLoggedInBefore && isOnboardingCompleted } catch { print("⚠️ Failed to check login status: \(error)") return false @@ -69,9 +70,10 @@ public final class AppStateManager { // 로그아웃 public func logout() { do { - try tokenStorage.clear() + try tokenStorage.clear(.accessToken) + try tokenStorage.clear(.refreshToken) + try tokenStorage.clear(.fcmToken) UserDefaultsManager.shared.clearAll() - // TODO: - push key 추가시 삭제 해야할듯 ? state = .login } catch { print("⚠️ Failed to logout: \(error)") diff --git a/DI/Package.swift b/DI/Package.swift index 3bbaa27..36fb438 100644 --- a/DI/Package.swift +++ b/DI/Package.swift @@ -37,6 +37,7 @@ let package = Package( ) ], dependencies: [ + .package(name: "3rdParty", path: "../3rdParty"), .package(name: "Common", path: "../Common"), .package(name: "Infrastructure", path: "../Infrastructure"), .package(name: "Intro", path: "../Intro"), @@ -51,7 +52,8 @@ let package = Package( .product(name: "DataSources", package: "Common"), .product(name: "Managers", package: "Common"), .product(name: "NetworkInterface", package: "Infrastructure"), - .product(name: "NetworkImpl", package: "Infrastructure") + .product(name: "NetworkImpl", package: "Infrastructure"), + .product(name: "FCMService", package: "3rdParty"), ] ), .target( diff --git a/DI/Sources/AppDI/AppDIContainer.swift b/DI/Sources/AppDI/AppDIContainer.swift index 1fee18e..3bc00b0 100644 --- a/DI/Sources/AppDI/AppDIContainer.swift +++ b/DI/Sources/AppDI/AppDIContainer.swift @@ -13,6 +13,7 @@ import DataSources import Managers import NetworkInterface import NetworkImpl +import FCMService // MARK: - App Assembly struct AppAssembly: Assembly { @@ -21,6 +22,14 @@ struct AppAssembly: Assembly { container.register(TokenStorage.self, scope: .singleton) { _ in KeychainTokenStorage() } + + container.register(JWTTokenStorageable.self, scope: .singleton) { _ in + JWTokenStorage() + } + + container.register(FCMTokenStorageable.self, scope: .singleton) { _ in + FCMTokenStorage() + } // Register UserDefaultsManager as singleton container.register(UserDefaultsManager.self, scope: .singleton) { _ in @@ -29,7 +38,7 @@ struct AppAssembly: Assembly { // Register NetworkServiceInterface as singleton container.register(NetworkServiceInterface.self, scope: .singleton) { resolver in - let tokenStorage = resolver.resolve(TokenStorage.self) + let tokenStorage = resolver.resolve(JWTTokenStorageable.self) #if DEBUG let logger = NetworkLogger() @@ -44,7 +53,17 @@ struct AppAssembly: Assembly { #endif } - + container.register(FCMManager.self, scope: .transient) { resolver in + FCMManager( + service: resolver.resolve( + NetworkServiceInterface.self + ), + storage: resolver.resolve( + FCMTokenStorageable.self + ) + ) + } + // Register AppStateManager as singleton container.register(AppStateManager.self, scope: .singleton) { resolver in AppStateManager( @@ -81,6 +100,10 @@ public final class AppDIContainer: @unchecked Sendable { public func makeAppStateManager() -> AppStateManager { return container.resolve(AppStateManager.self) } + + public func makeFCMManager() -> FCMManager { + return container.resolve(FCMManager.self) + } /// Generic resolve for any registered type public func resolve(_ type: T.Type) -> T { diff --git a/DI/Sources/LoginDI/LoginDIContainer.swift b/DI/Sources/LoginDI/LoginDIContainer.swift index aedaa61..2c3e809 100644 --- a/DI/Sources/LoginDI/LoginDIContainer.swift +++ b/DI/Sources/LoginDI/LoginDIContainer.swift @@ -30,7 +30,7 @@ struct LoginAssembly: Assembly { container.register(LoginRepository.self) { resolver in LoginRepositoryImpl( networkService: resolver.resolve(NetworkServiceInterface.self), - tokenStorage: resolver.resolve(TokenStorage.self), + tokenStorage: resolver.resolve(JWTTokenStorageable.self), userDefaultsManager: resolver.resolve(UserDefaultsManager.self) ) } diff --git a/Hambug.xcodeproj/project.pbxproj b/Hambug.xcodeproj/project.pbxproj index 0e25409..aa140b2 100644 --- a/Hambug.xcodeproj/project.pbxproj +++ b/Hambug.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ B52285C92E88469C00678ECC /* Managers in Frameworks */ = {isa = PBXBuildFile; productRef = B52285C82E88469C00678ECC /* Managers */; }; B52286032E884E5B00678ECC /* DesignSystem in Frameworks */ = {isa = PBXBuildFile; productRef = B52286022E884E5B00678ECC /* DesignSystem */; }; B53086D12F115A3800586850 /* SharedUI in Frameworks */ = {isa = PBXBuildFile; productRef = B53086D02F115A3800586850 /* SharedUI */; }; + B53088172F15712500586850 /* FCMService in Frameworks */ = {isa = PBXBuildFile; productRef = B53088162F15712500586850 /* FCMService */; }; B54128772EF1CD3100CC4938 /* Login in Frameworks */ = {isa = PBXBuildFile; productRef = B54128762EF1CD3100CC4938 /* Login */; }; B5412A882EF277A200CC4938 /* NetworkImpl in Frameworks */ = {isa = PBXBuildFile; productRef = B5412A872EF277A200CC4938 /* NetworkImpl */; }; B5412A8A2EF277A200CC4938 /* NetworkInterface in Frameworks */ = {isa = PBXBuildFile; productRef = B5412A892EF277A200CC4938 /* NetworkInterface */; }; @@ -106,6 +107,7 @@ B52285C92E88469C00678ECC /* Managers in Frameworks */, B54128772EF1CD3100CC4938 /* Login in Frameworks */, B54135802EF5B4CC00CC4938 /* KakaoLogin in Frameworks */, + B53088172F15712500586850 /* FCMService in Frameworks */, B5412A882EF277A200CC4938 /* NetworkImpl in Frameworks */, B54134FD2EF5B09D00CC4938 /* Community in Frameworks */, B5412A8A2EF277A200CC4938 /* NetworkInterface in Frameworks */, @@ -211,6 +213,7 @@ B541357F2EF5B4CC00CC4938 /* KakaoLogin */, B5EDA8F72F0B9C0C002F72B9 /* DIKit */, B53086D02F115A3800586850 /* SharedUI */, + B53088162F15712500586850 /* FCMService */, ); productName = Hambug; productReference = 915BC5D22E3CB9B50062B78E /* Hambug.app */; @@ -719,6 +722,10 @@ isa = XCSwiftPackageProductDependency; productName = SharedUI; }; + B53088162F15712500586850 /* FCMService */ = { + isa = XCSwiftPackageProductDependency; + productName = FCMService; + }; B54128762EF1CD3100CC4938 /* Login */ = { isa = XCSwiftPackageProductDependency; productName = Login; diff --git a/Hambug.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Hambug.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index c407446..bcf519f 100644 --- a/Hambug.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Hambug.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,14 @@ { "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", @@ -9,6 +18,87 @@ "version" : "5.10.2" } }, + { + "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.git", + "state" : { + "revision" : "45210bd1ea695779e6de016ab00fea8c0b7eb2ef", + "version" : "12.7.0" + } + }, + { + "identity" : "google-ads-on-device-conversion-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", + "state" : { + "revision" : "35b601a60fbbea2de3ea461f604deaaa4d8bbd0c", + "version" : "3.2.0" + } + }, + { + "identity" : "googleappmeasurement", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleAppMeasurement.git", + "state" : { + "revision" : "c2d59acf17a8ba7ed80a763593c67c9c7c006ad1", + "version" : "12.5.0" + } + }, + { + "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" : "c756a29784521063b6a1202907e2cc47f41b667c", + "version" : "4.5.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", @@ -17,6 +107,42 @@ "branch" : "master", "revision" : "f04b5655f3528e8c21a0ff7db047eeb138135394" } + }, + { + "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" + } + }, + { + "identity" : "swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-protobuf.git", + "state" : { + "revision" : "c169a5744230951031770e27e475ff6eefe51f9d", + "version" : "1.33.3" + } } ], "version" : 2 diff --git a/Hambug/AppDelegate.swift b/Hambug/AppDelegate.swift new file mode 100644 index 0000000..504156d --- /dev/null +++ b/Hambug/AppDelegate.swift @@ -0,0 +1,47 @@ +// +// AppDelegate.swift +// Hambug +// +// Created by 강동영 on 1/13/26. +// + +import UIKit +import FCMService +import AppDI + +class AppDelegate: NSObject, UIApplicationDelegate { + private let appDIContainer: AppDIContainer = .shared + lazy var fcmManager: FCMManager = appDIContainer.makeFCMManager() + private let notificationService = NotificationService.shared + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication + .LaunchOptionsKey: Any]? = nil + ) -> Bool { + fcmManager.configure() + + // MARK: APNS 등록 + UNUserNotificationCenter.current().delegate = notificationService + + + Task { @MainActor in + let authOptions: UNAuthorizationOptions = [.alert, .badge, .sound] + let granted = try await UNUserNotificationCenter.current().requestAuthorization(options: authOptions) + if granted { + application.registerForRemoteNotifications() + } + } + + return true + } + + // MARK: - APNS 등록 성공/실패 메서드 + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + fcmManager.registAPNsToken(deviceToken) + } + + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: any Error) { + print("❌ Token 등록에 실패했습니다.") + } +} diff --git a/Hambug/Hambug.entitlements b/Hambug/Hambug.entitlements index 4210463..490796e 100644 --- a/Hambug/Hambug.entitlements +++ b/Hambug/Hambug.entitlements @@ -2,6 +2,8 @@ + aps-environment + development com.apple.developer.applesignin Default diff --git a/Hambug/HambugApp.swift b/Hambug/HambugApp.swift index 804b266..8917daf 100644 --- a/Hambug/HambugApp.swift +++ b/Hambug/HambugApp.swift @@ -13,7 +13,7 @@ import AppDI @main struct HambugApp: App { - // Initialize AppDIContainer singleton first + @UIApplicationDelegateAdaptor(AppDelegate.self) var appdelegate private let appContainer = AppDIContainer.shared // Get AppStateManager from AppDIContainer @@ -30,7 +30,7 @@ struct HambugApp: App { var body: some Scene { WindowGroup { - RootView() + RootView(fcmManager: appdelegate.fcmManager) .environment(appStateManager) .environment(appContainer) .onOpenURL { url in diff --git a/Hambug/Info.plist b/Hambug/Info.plist index 3a07739..58fca4c 100644 --- a/Hambug/Info.plist +++ b/Hambug/Info.plist @@ -2,6 +2,10 @@ + FirebaseMessagingAutoInitEnabled + + FirebaseAppDelegateProxyEnabled + CFBundleURLTypes diff --git a/Hambug/NotificationService.swift b/Hambug/NotificationService.swift new file mode 100644 index 0000000..4c66d5b --- /dev/null +++ b/Hambug/NotificationService.swift @@ -0,0 +1,29 @@ +// +// NotificationService.swift +// Hambug +// +// Created by 강동영 on 1/13/26. +// + +import UserNotifications + +final class NotificationService: NSObject { + static let shared = NotificationService() + private override init() {} +} + +extension NotificationService: UNUserNotificationCenterDelegate { + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { + let userInfo = notification.request.content.userInfo + + print("userInfo: \(userInfo)") + + return [.list, .banner, .sound] + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { + let userInfo = response.notification.request.content.userInfo + + print("userInfo: \(userInfo)") + } +} diff --git a/Hambug/RootView.swift b/Hambug/RootView.swift index e864d8b..6e081f7 100644 --- a/Hambug/RootView.swift +++ b/Hambug/RootView.swift @@ -16,11 +16,17 @@ import AppDI import IntroDI import NetworkImpl import Util +import FCMService struct RootView: View { @Environment(AppStateManager.self) var appStateManager @Environment(AppDIContainer.self) var appContainer - + private let fcmManager: FCMManager + + init(fcmManager: FCMManager) { + self.fcmManager = fcmManager + } + var body: some View { let _ = Self._printChanges() Group { @@ -51,6 +57,9 @@ struct RootView: View { case .main: ContentView() + .task { + await fcmManager.sendPendingTokenToServer() + } } } .environment(appContainer) diff --git a/Infrastructure/Sources/NetworkImpl/AuthInterceptor.swift b/Infrastructure/Sources/NetworkImpl/AuthInterceptor.swift index abba0f7..cae60e1 100644 --- a/Infrastructure/Sources/NetworkImpl/AuthInterceptor.swift +++ b/Infrastructure/Sources/NetworkImpl/AuthInterceptor.swift @@ -18,10 +18,10 @@ public final class AuthInterceptor: RequestInterceptor { private let refreshTokenKey = "Authorization" private let maxRetryCount = 1 - private let tokenManager: TokenStorage + private let tokenManager: JWTTokenStorageable private let session: Session - public init(tokenManager: TokenStorage) { + public init(tokenManager: JWTTokenStorageable) { self.tokenManager = tokenManager self.session = Session() } @@ -77,7 +77,8 @@ public final class AuthInterceptor: RequestInterceptor { ) { print(#function) guard let response = request.task?.response as? HTTPURLResponse, - response.statusCode == 403 else { + response.statusCode == 401 + else { completion(.doNotRetryWithError(error)) return } diff --git a/Login/Sources/Data/LoginRepositoryImpl.swift b/Login/Sources/Data/LoginRepositoryImpl.swift index 5a60fde..0cc49f6 100644 --- a/Login/Sources/Data/LoginRepositoryImpl.swift +++ b/Login/Sources/Data/LoginRepositoryImpl.swift @@ -14,12 +14,12 @@ import Managers public final class LoginRepositoryImpl: LoginRepository { private let networkService: NetworkServiceInterface - private let tokenStorage: TokenStorage + private let tokenStorage: JWTTokenStorageable private let userDefaultsManager: UserDefaultsManager public init( networkService: NetworkServiceInterface, - tokenStorage: TokenStorage, + tokenStorage: JWTTokenStorageable, userDefaultsManager: UserDefaultsManager ) { self.networkService = networkService