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