-
Notifications
You must be signed in to change notification settings - Fork 0
feat: 푸시 알림·딥링크 + 디바이스 등록 + 회원 탈퇴 화면 + 알림 읽음 수정 #40
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
412e884
9f57f8d
6ba9653
cd9781d
8f2623a
5fa8d52
1945b9a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| // | ||
| // PushDeeplinkBridge.swift | ||
| // Picke | ||
| // | ||
| // AppDelegate(푸시 탭) → TCA(AppReducer) 사이 딥링크 전달 브리지. (TimeSpot-iOS 패턴) | ||
| // 파싱한 딥링크를 NotificationCenter 로 브로드캐스트하고, 콜드 스타트 대비 UserDefaults 에도 보관. | ||
| // | ||
|
|
||
| import Foundation | ||
|
|
||
| import Entity | ||
| import LogMacro | ||
|
|
||
| enum PushDeeplinkBridge { | ||
| static let pendingKey = "PickePendingDeeplink" | ||
|
|
||
| /// 푸시 payload(userInfo) 를 딥링크로 변환 → 대기열 저장 + 브로드캐스트. | ||
| static func handlePushPayload(_ userInfo: [AnyHashable: Any]) { | ||
| guard let deeplink = PickeDeeplinkParser.parse(pushPayload: userInfo) else { | ||
| #logDebug("[Deeplink] 처리 가능한 푸시 페이로드 없음") | ||
| return | ||
| } | ||
| broadcast(deeplink) | ||
| } | ||
|
|
||
| /// 딥링크를 대기열에 저장하고 즉시 알림. | ||
| static func broadcast(_ deeplink: PickeDeeplink) { | ||
| UserDefaults.standard.set(deeplink.encoded, forKey: pendingKey) | ||
| NotificationCenter.default.post( | ||
| name: .pickeDeeplink, | ||
| object: nil, | ||
| userInfo: ["deeplink": deeplink.encoded] | ||
| ) | ||
| #logDebug("[Deeplink] 브로드캐스트: \(deeplink.encoded)") | ||
| } | ||
|
|
||
| /// AppReducer 가 라우팅을 끝낸 뒤 대기열 비움. | ||
| static func consumePending() -> PickeDeeplink? { | ||
| guard let encoded = UserDefaults.standard.string(forKey: pendingKey) else { return nil } | ||
| UserDefaults.standard.removeObject(forKey: pendingKey) | ||
| return PickeDeeplinkParser.parse(urlString: encoded) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| // | ||
| // PushTokenStore.swift | ||
| // Picke | ||
| // | ||
| // APNs 디바이스 토큰 보관 + 서버 등록/해제 헬퍼. (FCM 미사용) | ||
| // - current: 마지막으로 발급받은 APNs 토큰 (UserDefaults 캐시). | ||
| // - register/unregister: POST/DELETE /api/v1/devices (DeviceUseCase 경유). | ||
| // 로그아웃 해제는 Keychain 초기화 전에 호출해야 인증 헤더가 살아있다. | ||
| // | ||
|
|
||
| import Foundation | ||
|
|
||
| import Entity | ||
| import LogMacro | ||
| import UseCase | ||
|
|
||
| enum PushTokenStore { | ||
| static var current: String? { | ||
| get { DeviceTokenStorage.token } | ||
| set { DeviceTokenStorage.token = newValue } | ||
| } | ||
|
|
||
| /// 로그인 직후 / 토큰 갱신 시 디바이스 등록. | ||
| static func register() async { | ||
| guard let token = current, !token.isEmpty else { return } | ||
| do { | ||
| try await DeviceUseCaseImpl().registerDevice(fcmToken: token, platform: .ios) | ||
| #logDebug("[Push] 디바이스 등록 완료") | ||
| } catch { | ||
| #logError("[Push] 디바이스 등록 실패: \(error.localizedDescription)") | ||
| } | ||
| } | ||
|
|
||
| /// 로그아웃 / 탈퇴 시 디바이스 해제 (Keychain 초기화 전에 호출). | ||
| static func unregister() async { | ||
| guard let token = current, !token.isEmpty else { return } | ||
| do { | ||
| try await DeviceUseCaseImpl().unregisterDevice(fcmToken: token) | ||
| #logDebug("[Push] 디바이스 해제 완료") | ||
| } catch { | ||
| #logError("[Push] 디바이스 해제 실패: \(error.localizedDescription)") | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -63,6 +63,8 @@ public struct AppReducer: Sendable { | |
| public enum AsyncAction: Equatable { | ||
| case startNotificationListener | ||
| case refreshTokenExpired | ||
| case observeDeeplink | ||
| case deeplinkReceived(PickeDeeplink) | ||
| } | ||
|
|
||
| // MARK: - 네비게이션 연결 액션 | ||
|
|
@@ -85,6 +87,7 @@ public struct AppReducer: Sendable { | |
| case coordinator(CoordinatorType) | ||
| case transition | ||
| case refreshTokenListener | ||
| case deeplinkListener | ||
|
|
||
| enum CoordinatorType: Hashable { | ||
| case auth | ||
|
|
@@ -154,9 +157,12 @@ public struct AppReducer: Sendable { | |
| ) -> Effect<Action> { | ||
| switch action { | ||
| case .presentView: | ||
| return .run { send in | ||
| await send(.scope(.splash(.view(.onAppear)))) | ||
| } | ||
| return .merge( | ||
| .run { send in | ||
| await send(.scope(.splash(.view(.onAppear)))) | ||
| }, | ||
| observeDeeplink() | ||
| ) | ||
|
|
||
| case .presentRoot: | ||
| return startTransition(.completeMainTabTransition) | ||
|
|
@@ -167,7 +173,7 @@ public struct AppReducer: Sendable { | |
| } | ||
|
|
||
| private func handleAsyncAction( | ||
| state _: inout State, | ||
| state: inout State, | ||
| action: AsyncAction | ||
| ) -> Effect<Action> { | ||
| switch action { | ||
|
|
@@ -177,6 +183,24 @@ public struct AppReducer: Sendable { | |
|
|
||
| case .refreshTokenExpired: | ||
| return startTransition(.completeAuthTransition) | ||
|
|
||
| case .observeDeeplink: | ||
| return observeDeeplink() | ||
|
|
||
| case let .deeplinkReceived(deeplink): | ||
| // 메인 진입 상태에서만 즉시 라우팅. 그 외에는 pending(UserDefaults)으로 보류. | ||
| guard case .mainTab = state else { return .none } | ||
| switch deeplink { | ||
| case let .battle(battleId): | ||
| // 홈 탭 전환 후 HomeCoordinator(Chat 보유)가 배틀 상세 push. | ||
| return .merge( | ||
| .send(.scope(.mainTab(.selectTab(MainTabCoordinator.Tab.home.rawValue)))), | ||
| .send(.scope(.mainTab(.home(.view(.openBattle(battleId: battleId)))))) | ||
| ) | ||
| case .perspective: | ||
| // 관점(댓글) 단독 진입로는 Chat 모듈에 perspectiveId 기반 진입 추가 후 연결. | ||
| return .none | ||
|
Comment on lines
+200
to
+202
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a Useful? React with 👍 / 👎. |
||
| } | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -191,6 +215,10 @@ public struct AppReducer: Sendable { | |
|
|
||
| case .completeMainTabTransition: | ||
| state = .mainTab(.init()) | ||
| // 콜드 스타트/로그인 직후 대기 중이던 딥링크 소비. | ||
| if let pending = PushDeeplinkBridge.consumePending() { | ||
| return .send(.async(.deeplinkReceived(pending))) | ||
| } | ||
| return .none | ||
| } | ||
| } | ||
|
|
@@ -247,7 +275,11 @@ public struct AppReducer: Sendable { | |
| } | ||
|
|
||
| case .auth(.navigation(.presentMainTab)): | ||
| return .send(.view(.presentRoot)) | ||
| // 로그인 성공 → 메인 진입 + APNs 디바이스 토큰 서버 등록. | ||
| return .merge( | ||
| .send(.view(.presentRoot)), | ||
| .run { _ in await PushTokenStore.register() } | ||
| ) | ||
|
|
||
| // 로그아웃/탈퇴 → 로그인 화면으로 복귀 | ||
| case .mainTab(.delegate(.sessionEnded)): | ||
|
|
@@ -263,6 +295,18 @@ public struct AppReducer: Sendable { | |
| return true | ||
| } | ||
|
|
||
| /// 푸시/인앱 알림 탭으로 브로드캐스트된 딥링크를 수신해 라우팅 액션으로 변환. | ||
| private func observeDeeplink() -> Effect<Action> { | ||
| .run { send in | ||
| for await notification in NotificationCenter.default.notifications(named: .pickeDeeplink) { | ||
| guard let encoded = notification.userInfo?["deeplink"] as? String, | ||
| let deeplink = PickeDeeplinkParser.parse(urlString: encoded) else { continue } | ||
| await send(.async(.deeplinkReceived(deeplink))) | ||
| } | ||
| } | ||
| .cancellable(id: CancelID.deeplinkListener, cancelInFlight: true) | ||
| } | ||
|
|
||
| private func setupRefreshTokenExpiredListener() -> Effect<Action> { | ||
| return .publisher { | ||
| NotificationCenter.default | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
broadcastpersists every push deeplink before posting it, but when the app is already inmainTabthe notification observer routes it immediately and nothing removesPickePendingDeeplinkuntil a future.completeMainTabTransition. After a warm/background push tap succeeds, the same old link can therefore be replayed on a later logout/login or root transition; clear the pending value once.deeplinkReceivedis handled, or only persist when the app cannot route yet.Useful? React with 👍 / 👎.