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
78 changes: 70 additions & 8 deletions Projects/App/Sources/Application/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import LogMacro
import Mixpanel
import MixpanelSessionReplay
import UIKit
import UserNotifications
import WeaveDI

import DomainInterface

class AppDelegate: UIResponder, UIApplicationDelegate {
let mixPanelKey = Bundle.main.object(forInfoDictionaryKey: "MIXPANEL_TOKEN") as? String

func application(
_: UIApplication,
didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?
Expand All @@ -29,14 +31,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
initializeMixpanelSessionReplay()
MobileAds.shared.start()

// 🧠 메모리 관리 시스템 초기화 (우선) - 모듈 분리 완료시 활성화
// Task { @MainActor in
// _ = MemoryPressureManager.shared
// #if DEBUG
// _ = MemoryLeakDetector.shared
// print("🚀 [AppDelegate] Memory management systems initialized")
// #endif
// }
// 🔔 푸시 알림(APNs) 초기화
configurePushNotifications()

// DI 관리자 초기화
WeaveDI.Container.bootstrapInTask { @DIContainerActor _ in
Expand Down Expand Up @@ -68,6 +64,48 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
didDiscardSceneSessions _: Set<UISceneSession>
) {}

// MARK: - Push Notifications (APNs)

private func configurePushNotifications() {
let center = UNUserNotificationCenter.current()
center.delegate = self

center.requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in
if let error {
#logError("[Push] 권한 요청 실패: \(error.localizedDescription)")
return
}
guard granted else {
#logDebug("[Push] 알림 권한 거부됨")
return
}
Task { @MainActor in
UIApplication.shared.registerForRemoteNotifications()
}
}
}

// APNs 디바이스 토큰 수신 → 저장 후 로그인 상태면 서버 등록.
func application(
_: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
let tokenString = deviceToken.map { String(format: "%02x", $0) }.joined()
PushTokenStore.current = tokenString
#logDebug("[Push] APNs 토큰 수신: \(tokenString.prefix(12))…")

guard let keychainManager = UnifiedDI.resolve(KeychainManaging.self),
keychainManager.accessToken()?.isEmpty == false else { return }
Task { await PushTokenStore.register() }
}

func application(
_: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error
) {
#logError("[Push] APNs 등록 실패: \(error.localizedDescription)")
}

// MARK: - Image Caching Configuration

private func initializeMixpanelSessionReplay() {
Expand All @@ -83,3 +121,27 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
)
}
}

// MARK: - UNUserNotificationCenterDelegate

extension AppDelegate: @preconcurrency UNUserNotificationCenterDelegate {
// 포그라운드 수신 → 배너/사운드 표시.
func userNotificationCenter(
_: UNUserNotificationCenter,
willPresent _: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
completionHandler([.banner, .badge, .sound])
}

// 알림 탭 → 페이로드를 딥링크로 변환해 라우팅 브로드캐스트.
func userNotificationCenter(
_: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
let userInfo = response.notification.request.content.userInfo
PushDeeplinkBridge.handlePushPayload(userInfo)
completionHandler()
}
}
43 changes: 43 additions & 0 deletions Projects/App/Sources/Application/Deeplink/PushDeeplinkBridge.swift
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)
Comment on lines +27 to +28

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear pending deeplinks after warm routing

broadcast persists every push deeplink before posting it, but when the app is already in mainTab the notification observer routes it immediately and nothing removes PickePendingDeeplink until 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 .deeplinkReceived is handled, or only persist when the app cannot route yet.

Useful? React with 👍 / 👎.

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)
}
}
44 changes: 44 additions & 0 deletions Projects/App/Sources/Application/PushTokenStore.swift
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)")
}
}
}
1 change: 1 addition & 0 deletions Projects/App/Sources/Di/DiRegister.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ public final class AppDIManager: Sendable {
.register { AudioPlayerRepositoryImpl() as AudioPlayerInterface }
.register { ProfileRepositoryImpl() as ProfileInterface }
.register { NotificationRepositoryImpl() as NotificationInterface }
.register { DeviceRepositoryImpl() as DeviceInterface }
// .register { AppUpdateRepositoryImpl() as AppUpdateInterface }

// 🔐 OAuth Provider 계층 (PFW 조합 패턴)
Expand Down
54 changes: 49 additions & 5 deletions Projects/App/Sources/Reducer/AppReducer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ public struct AppReducer: Sendable {
public enum AsyncAction: Equatable {
case startNotificationListener
case refreshTokenExpired
case observeDeeplink
case deeplinkReceived(PickeDeeplink)
}

// MARK: - 네비게이션 연결 액션
Expand All @@ -85,6 +87,7 @@ public struct AppReducer: Sendable {
case coordinator(CoordinatorType)
case transition
case refreshTokenListener
case deeplinkListener

enum CoordinatorType: Hashable {
case auth
Expand Down Expand Up @@ -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)
Expand All @@ -167,7 +173,7 @@ public struct AppReducer: Sendable {
}

private func handleAsyncAction(
state _: inout State,
state: inout State,
action: AsyncAction
) -> Effect<Action> {
switch action {
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle perspective deeplinks instead of dropping them

When a COMMENT_LIKE or NEW_COMMENT item is tapped, NotificationFeature now parses it into .perspective(...) and posts .pickeDeeplink, but the app-level router reaches this branch and returns .none. In that scenario the notification may be marked read, but the user never leaves the notification list, so comment-related push/in-app notification deep links are effectively broken.

Useful? React with 👍 / 👎.

}
}
}

Expand All @@ -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
}
}
Expand Down Expand Up @@ -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)):
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions Projects/Data/API/Sources/Base/PieckeDomain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public enum PieckeDomain {
case perspective
case search
case notification
case device
}

extension PieckeDomain: DomainType {
Expand Down Expand Up @@ -46,6 +47,8 @@ extension PieckeDomain: DomainType {
return "api/v1/search/"
case .notification:
return "api/v1/notifications"
case .device:
return "api/v1/devices"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ public struct NotificationItemDTO: Decodable {
public let title: String?
public let body: String?
public let referenceId: Int?
/// COMMENT_LIKE / NEW_COMMENT 에서 관점(댓글) 화면 이동용. 그 외 null.
public let perspectiveId: Int?
public let isRead: Bool?
public let createdAt: String?
}
Expand All @@ -31,6 +33,8 @@ public struct NotificationDetailDTO: Decodable {
public let title: String?
public let body: String?
public let referenceId: Int?
/// COMMENT_LIKE / NEW_COMMENT 에서 관점(댓글) 화면 이동용. 그 외 null.
public let perspectiveId: Int?
public let isRead: Bool?
public let createdAt: String?
public let readAt: String?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public extension NotificationItemDTO {
title: title ?? "",
body: body ?? "",
referenceId: referenceId,
perspectiveId: perspectiveId,
isRead: isRead ?? false,
createdAt: createdAt.flatMap(NotificationDateParser.parseISO8601)
)
Expand All @@ -39,6 +40,7 @@ public extension NotificationDetailDTO {
title: title ?? "",
body: body ?? "",
referenceId: referenceId,
perspectiveId: perspectiveId,
isRead: isRead ?? false,
createdAt: createdAt.flatMap(NotificationDateParser.parseISO8601),
readAt: readAt.flatMap(NotificationDateParser.parseISO8601)
Expand Down
Loading
Loading