diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift index d74f566..7c28719 100644 --- a/Projects/App/Project.swift +++ b/Projects/App/Project.swift @@ -12,6 +12,8 @@ let appTarget = Target.target( "UILaunchScreen": [:], "NSAlarmKitUsageDescription": "막차 시간에 맞춰 알람을 울리기 위해 권한이 필요합니다.", "NSLocationWhenInUseUsageDescription": "현재 위치를 출발지로 사용하기 위해 위치 정보 접근 권한이 필요합니다.", + // 사일런트 푸시(content-available=1) 수신용 — 사용자 알림 권한과 무관. + "UIBackgroundModes": ["remote-notification"], "UIApplicationSceneManifest": [ "UIApplicationSupportsMultipleScenes": false, "UISceneConfigurations": [ @@ -28,6 +30,11 @@ let appTarget = Target.target( ]), sources: ["Sources/**"], resources: ["Resources/**"], + // APNs 등록에 필수 (없으면 didFailToRegister: "aps-environment 없음"). + // 배포 서명 시 프로비저닝이 production으로 치환한다. + entitlements: .dictionary([ + "aps-environment": "development", + ]), dependencies: [ .project(target: "HomeFeature", path: "../Feature/Home"), .project(target: "HomeFeatureInterface", path: "../Feature/Home"), diff --git a/Projects/App/Resources/GoogleService-Info.plist b/Projects/App/Resources/GoogleService-Info.plist new file mode 100644 index 0000000..210bdc7 --- /dev/null +++ b/Projects/App/Resources/GoogleService-Info.plist @@ -0,0 +1,30 @@ + + + + + API_KEY + AIzaSyDPexsKmlRAz9ghUGrGKIvrAvSaH5mMz9o + GCM_SENDER_ID + 104855490976 + PLIST_VERSION + 1 + BUNDLE_ID + com.atcha.iOS + PROJECT_ID + atcha-3b0e4 + STORAGE_BUCKET + atcha-3b0e4.firebasestorage.app + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + 1:104855490976:ios:e93a557692dabde3fdd4c5 + + \ No newline at end of file diff --git a/Projects/App/Sources/AlarmSyncService.swift b/Projects/App/Sources/AlarmSyncService.swift new file mode 100644 index 0000000..67296cc --- /dev/null +++ b/Projects/App/Sources/AlarmSyncService.swift @@ -0,0 +1,97 @@ +import Domain +import Foundation +import UIKit +import os + +/// 서버발 알람 시각 갱신의 단일 진입점. 앱 시작(인증 부트스트랩 직후)·포그라운드 +/// 복귀·사일런트 푸시 3경로가 전부 여기의 `RefreshAlarmUseCase` 호출 한 곳으로 +/// 모인다 — 시각 변경 시 재스케줄은 UseCase 내부 정책이고, 성공 결과는 +/// `AlarmSyncEvents` 스트림으로 구독자(HomeViewModel)에게 전파돼 배너를 갱신한다. +// Sendable 프로토콜(AlarmSyncEvents) 채택이 기본 MainActor 격리를 nonisolated로 +// 추론시키므로 명시한다 — 상태(subscribers 등)는 전부 메인 액터에서만 만진다. +@MainActor +final class AlarmSyncService: AlarmSyncEvents { + private let refreshAlarmUseCase: any RefreshAlarmUseCase + private static let logger = Logger(subsystem: "com.atcha.iOS.v2", category: "AlarmSync") + + private var subscribers: [UUID: AsyncStream.Continuation] = [:] + /// 구독 전에 끝난 동기화를 놓치지 않기 위한 replay-1. 홈은 앱 시작 동기화와 + /// 거의 동시에 구독하므로 순서에 기대지 않는다. + private var lastInfo: AlarmInfo? + /// 진행 중 동기화 — 트리거가 겹치면(예: 앱 시작 직후 포그라운드 노티) 합류한다. + private var inFlight: Task? + private var foregroundObserver: (any NSObjectProtocol)? + + // 앱 수명 객체(조합 루트 소유) — 해제 경로가 없어 관찰 해지/태스크 취소 정리가 없다. + init(refreshAlarmUseCase: any RefreshAlarmUseCase) { + self.refreshAlarmUseCase = refreshAlarmUseCase + } + + /// 인증 부트스트랩 완료 후 1회 호출: 즉시 동기화(앱 시작 경로) + 포그라운드 + /// 관찰 시작. 그 전의 포그라운드 전환은 무시된다 — 세션 없이 refresh를 쏘지 않는다. + func activate() { + guard foregroundObserver == nil else { return } + foregroundObserver = NotificationCenter.default.addObserver( + forName: UIScene.willEnterForegroundNotification, + object: nil, + queue: .main + ) { _ in + // Sendable 클로저 → 메인 액터 복귀. 서비스는 앱과 수명이 같아 강한 캡처로 충분하다. + Task { @MainActor in _ = await self.sync() } + } + Task { _ = await sync() } + } + + /// 사일런트 푸시(content-available=1) 경로 — 백그라운드 fetch 결과 매핑까지 담당. + func syncFromPush() async -> UIBackgroundFetchResult { + await sync() != nil ? .newData : .failed + } + + /// 3경로 공용 동기화. 실패는 스트림에 흘리지 않는다 — 구독자는 상태를 유지하고, + /// 다음 트리거(포그라운드·푸시)가 자연 재시도가 된다. + @discardableResult + private func sync() async -> AlarmInfo? { + if let inFlight { + return await inFlight.value + } + Self.logger.info("알람 동기화 시작") + let task = Task { [refreshAlarmUseCase] () -> AlarmInfo? in + do { + return try await refreshAlarmUseCase.execute() + } catch { + Self.logger.info("알람 동기화 실패(상태 유지): \(error)") + return nil + } + } + inFlight = task + let info = await task.value + inFlight = nil + if let info { + Self.logger.info("알람 동기화 성공: route=\(info.lastRouteId, privacy: .public)") + lastInfo = info + for continuation in subscribers.values { + continuation.yield(info) + } + } + return info + } + + // MARK: - AlarmSyncEvents + + nonisolated func updates() -> AsyncStream { + AsyncStream { continuation in + let id = UUID() + Task { @MainActor in + if let last = self.lastInfo { + continuation.yield(last) + } + self.subscribers[id] = continuation + } + continuation.onTermination = { _ in + Task { @MainActor in + self.subscribers.removeValue(forKey: id) + } + } + } + } +} diff --git a/Projects/App/Sources/AppCoordinator.swift b/Projects/App/Sources/AppCoordinator.swift index 3284cac..a8ddddc 100644 --- a/Projects/App/Sources/AppCoordinator.swift +++ b/Projects/App/Sources/AppCoordinator.swift @@ -38,6 +38,8 @@ final class AppCoordinator: Coordinator, CoordinatorFinishDelegate { try await self.container.authSessionManager.bootstrap() guard !Task.isCancelled else { return } self.startHome() + // 앱 시작 동기화 + 포그라운드 관찰 시작 — 세션이 준비된 뒤에만. + self.container.alarmSyncService.activate() } catch { guard !Task.isCancelled else { return } self.splashViewController?.showRetry() diff --git a/Projects/App/Sources/AppDIContainer.swift b/Projects/App/Sources/AppDIContainer.swift index 33deca3..3319dfe 100644 --- a/Projects/App/Sources/AppDIContainer.swift +++ b/Projects/App/Sources/AppDIContainer.swift @@ -16,6 +16,14 @@ final class AppDIContainer { private let networkClient: any NetworkClient let authSessionManager: AuthSessionManager + // 알람 스택은 1회 생성해 공유한다 — AlarmSyncService(갱신 일원화)와 + // 홈의 등록/해제 UseCase가 같은 리포지토리·스케줄러를 봐야 한다. + private let alarmRepository: any AlarmRepository + private let placeRepository: any PlaceRepository + private let lastRouteRepository: any LastRouteRepository + private let alarmScheduler: any AlarmScheduler + let alarmSyncService: AlarmSyncService + init() { #if DEV // 검수용 임시 우회: 실서버(미확정 #1·#2)가 죽어 있어도 기본 60초 타임아웃 대기로 @@ -42,37 +50,46 @@ final class AppDIContainer { issuer: UnconfiguredAnonymousSessionIssuer() ) self.authSessionManager = sessionManager - self.networkClient = AuthenticatedNetworkClient( + let networkClient = AuthenticatedNetworkClient( base: baseClient, sessionManager: sessionManager ) - } + self.networkClient = networkClient - func makeHomeDIContainer() -> any HomeCoordinatorBuildable { #if DEV // 검수용 임시 우회 — 실서버(미확정 #1·#2) 부재 시에만 데모 데이터로 폴백. // 실서버 확정 시 이 블록과 DevDemoFallbacks.swift를 제거한다. - let placeRepository: any PlaceRepository = DevDemoFallbackPlaceRepository( + self.placeRepository = DevDemoFallbackPlaceRepository( base: PlaceRepositoryImpl(networkClient: networkClient) ) - let lastRouteRepository: any LastRouteRepository = DevDemoFallbackLastRouteRepository( + self.lastRouteRepository = DevDemoFallbackLastRouteRepository( base: LastRouteRepositoryImpl(networkClient: networkClient) ) - let alarmRepository: any AlarmRepository = DevDemoTolerantAlarmRepository( + self.alarmRepository = DevDemoTolerantAlarmRepository( base: AlarmRepositoryImpl(networkClient: networkClient) ) #else - let placeRepository: any PlaceRepository = PlaceRepositoryImpl(networkClient: networkClient) - let lastRouteRepository: any LastRouteRepository = LastRouteRepositoryImpl(networkClient: networkClient) - let alarmRepository: any AlarmRepository = AlarmRepositoryImpl(networkClient: networkClient) + self.placeRepository = PlaceRepositoryImpl(networkClient: networkClient) + self.lastRouteRepository = LastRouteRepositoryImpl(networkClient: networkClient) + self.alarmRepository = AlarmRepositoryImpl(networkClient: networkClient) #endif - let recentSearchRepository = RecentSearchRepositoryImpl(store: UserDefaultsKeyValueStore()) // 디바이스 포트 어댑터 — CoreLocation/AlarmKit을 아는 곳은 App의 어댑터뿐. + let alarmScheduler = CoreAlarmSchedulerAdapter() + self.alarmScheduler = alarmScheduler + self.alarmSyncService = AlarmSyncService( + refreshAlarmUseCase: DefaultRefreshAlarmUseCase( + repository: alarmRepository, + scheduler: alarmScheduler + ) + ) + } + + func makeHomeDIContainer() -> any HomeCoordinatorBuildable { + let recentSearchRepository = RecentSearchRepositoryImpl(store: UserDefaultsKeyValueStore()) let locationService = CoreLocationServiceAdapter() let getCurrentLocation: any GetCurrentLocationUseCase = DefaultGetCurrentLocationUseCase(locationService: locationService) - let alarmScheduler = CoreAlarmSchedulerAdapter() let searchContainer = SearchDIContainer( searchPlacesUseCase: DefaultSearchPlacesUseCase(repository: placeRepository), @@ -92,10 +109,7 @@ final class AppDIContainer { repository: alarmRepository, scheduler: alarmScheduler ), - refreshAlarmUseCase: DefaultRefreshAlarmUseCase( - repository: alarmRepository, - scheduler: alarmScheduler - ), + observeAlarmUseCase: DefaultObserveAlarmUseCase(events: alarmSyncService), searchCoordinatorBuildable: searchContainer ) } diff --git a/Projects/App/Sources/AppDelegate.swift b/Projects/App/Sources/AppDelegate.swift index f72936b..034e602 100644 --- a/Projects/App/Sources/AppDelegate.swift +++ b/Projects/App/Sources/AppDelegate.swift @@ -1,13 +1,31 @@ import FirebaseCore +import FirebaseMessaging import UIKit +import os @main final class AppDelegate: UIResponder, UIApplicationDelegate { + // 조합 루트는 AppDelegate가 소유한다 — 사일런트 푸시가 scene 없이 백그라운드로 + // 앱을 깨워도 AlarmSyncService에 닿을 수 있어야 한다. + let container = AppDIContainer() + + /// plist 부재 = FCM 경로 완전 비활성 (등록·델리게이트·수신 전부 dead-path). + /// 갱신은 폴링(앱 시작·포그라운드 복귀)만으로 성립한다. + private var isFirebaseEnabled = false + + private static let fcmLogger = Logger(subsystem: "com.atcha.iOS.v2", category: "FCM") + func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { configureFirebaseIfAvailable() + if isFirebaseEnabled { + Messaging.messaging().delegate = self + // 사일런트 푸시는 사용자 알림 권한이 필요 없다 — + // UNUserNotificationCenter.requestAuthorization을 호출하지 않는다. + application.registerForRemoteNotifications() + } return true } @@ -26,5 +44,42 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { return } FirebaseApp.configure() + isFirebaseEnabled = true + } + + // MARK: - 원격 알림 (사일런트 푸시) + + func application( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + guard isFirebaseEnabled else { return } + Messaging.messaging().apnsToken = deviceToken + } + + func application( + _ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: any Error + ) { + Self.fcmLogger.error("APNs 등록 실패: \(error.localizedDescription, privacy: .public)") + } + + /// content-available=1 수신 → 갱신 일원화 지점(AlarmSyncService)으로 위임. + /// 서버는 레거시와 같은 payload(`type: REFRESH`)를 보낸다 — 그 외 타입은 무시. + func application( + _ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any] + ) async -> UIBackgroundFetchResult { + guard isFirebaseEnabled else { return .noData } + guard userInfo["type"] as? String == "REFRESH" else { return .noData } + Self.fcmLogger.info("사일런트 푸시 수신(REFRESH) → 알람 동기화 시작") + return await container.alarmSyncService.syncFromPush() + } +} + +extension AppDelegate: MessagingDelegate { + nonisolated func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { + // TODO: [미확정 #5] 익명 체계에서의 FCM 토큰 서버 전달 방식 확정 전까지 로깅만 한다. + Self.fcmLogger.info("FCM 토큰 수신(서버 전달 보류): \(fcmToken ?? "nil", privacy: .private)") } } diff --git a/Projects/App/Sources/SceneDelegate.swift b/Projects/App/Sources/SceneDelegate.swift index 997b901..181fe50 100644 --- a/Projects/App/Sources/SceneDelegate.swift +++ b/Projects/App/Sources/SceneDelegate.swift @@ -9,11 +9,14 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions ) { - guard let windowScene = scene as? UIWindowScene else { return } + guard let windowScene = scene as? UIWindowScene, + // 조합 루트는 AppDelegate 소유 — 푸시 경로(scene 없음)와 공유한다. + let container = (UIApplication.shared.delegate as? AppDelegate)?.container + else { return } let navigationController = UINavigationController() let coordinator = AppCoordinator( navigationController: navigationController, - container: AppDIContainer() + container: container ) let window = UIWindow(windowScene: windowScene) diff --git a/Projects/Domain/Sources/Interfaces/AlarmSyncEvents.swift b/Projects/Domain/Sources/Interfaces/AlarmSyncEvents.swift new file mode 100644 index 0000000..3d6b02f --- /dev/null +++ b/Projects/Domain/Sources/Interfaces/AlarmSyncEvents.swift @@ -0,0 +1,10 @@ +import Foundation + +/// 알람 동기화 결과 포트 — 발행 주체는 App의 AlarmSyncService(앱 시작·포그라운드 +/// 복귀·사일런트 푸시 3경로를 RefreshAlarmUseCase 한 곳으로 모으는 유일한 호출자). +/// 실패는 흘리지 않는다 — 구독자는 성공 결과만 받고, 실패 시 기존 상태를 유지한다. +public protocol AlarmSyncEvents: Sendable { + /// 구독자마다 독립 스트림. 구독 시 마지막 동기화 결과가 있으면 즉시 방출한다 + /// (구독 전에 끝난 앱 시작 동기화를 놓치지 않도록). + func updates() -> AsyncStream +} diff --git a/Projects/Domain/Sources/UseCases/ObserveAlarmUseCase.swift b/Projects/Domain/Sources/UseCases/ObserveAlarmUseCase.swift new file mode 100644 index 0000000..6778c4b --- /dev/null +++ b/Projects/Domain/Sources/UseCases/ObserveAlarmUseCase.swift @@ -0,0 +1,17 @@ +import Foundation + +public protocol ObserveAlarmUseCase: Sendable { + func execute() -> AsyncStream +} + +public struct DefaultObserveAlarmUseCase: ObserveAlarmUseCase { + private let events: any AlarmSyncEvents + + public init(events: any AlarmSyncEvents) { + self.events = events + } + + public func execute() -> AsyncStream { + events.updates() + } +} diff --git a/Projects/Domain/Tests/DefaultObserveAlarmUseCaseTests.swift b/Projects/Domain/Tests/DefaultObserveAlarmUseCaseTests.swift new file mode 100644 index 0000000..a31c532 --- /dev/null +++ b/Projects/Domain/Tests/DefaultObserveAlarmUseCaseTests.swift @@ -0,0 +1,35 @@ +@testable import Domain +import Foundation +import Testing + +private struct StubAlarmSyncEvents: AlarmSyncEvents { + let handler: @Sendable () -> AsyncStream + func updates() -> AsyncStream { handler() } +} + +struct DefaultObserveAlarmUseCaseTests { + @Test + func execute_forwardsPortStream() async { + let info = AlarmInfo( + lastRouteId: "r1", + departureTime: Date(timeIntervalSince1970: 1_755_800_000), + updatedAt: nil, + isReal: true + ) + let sut = DefaultObserveAlarmUseCase( + events: StubAlarmSyncEvents { + AsyncStream { continuation in + continuation.yield(info) + continuation.finish() + } + } + ) + + var received: [AlarmInfo] = [] + for await update in sut.execute() { + received.append(update) + } + + #expect(received == [info]) + } +} diff --git a/Projects/Feature/Home/Example/ExampleApp.swift b/Projects/Feature/Home/Example/ExampleApp.swift index ab40aa4..afdd7e0 100644 --- a/Projects/Feature/Home/Example/ExampleApp.swift +++ b/Projects/Feature/Home/Example/ExampleApp.swift @@ -39,7 +39,7 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { reverseGeocodeUseCase: PreviewReverseGeocodeUseCase(), registerAlarmUseCase: PreviewRegisterAlarmUseCase(), cancelAlarmUseCase: PreviewCancelAlarmUseCase(), - refreshAlarmUseCase: PreviewRefreshAlarmUseCase(), + observeAlarmUseCase: PreviewObserveAlarmUseCase(), searchCoordinatorBuildable: PreviewSearchCoordinatorBuildable() ) let coordinator = container.makeHomeCoordinator(navigationController: navigationController) @@ -81,11 +81,10 @@ struct PreviewCancelAlarmUseCase: CancelAlarmUseCase { } } -/// 등록된 알람이 없는 서버 상태를 흉내 낸다 — 포그라운드 복귀가 화면을 건드리지 않는다. -struct PreviewRefreshAlarmUseCase: RefreshAlarmUseCase { - struct NoRegisteredAlarm: Error {} - func execute() async throws -> AlarmInfo { - throw NoRegisteredAlarm() +/// 등록된 알람이 없는 서버 상태를 흉내 낸다 — 동기화 이벤트가 오지 않으므로 화면을 건드리지 않는다. +struct PreviewObserveAlarmUseCase: ObserveAlarmUseCase { + func execute() -> AsyncStream { + AsyncStream { _ in } } } diff --git a/Projects/Feature/Home/Sources/HomeDIContainer.swift b/Projects/Feature/Home/Sources/HomeDIContainer.swift index 6509ff8..1bfb2e1 100644 --- a/Projects/Feature/Home/Sources/HomeDIContainer.swift +++ b/Projects/Feature/Home/Sources/HomeDIContainer.swift @@ -12,7 +12,7 @@ public final class HomeDIContainer: HomeCoordinatorBuildable { private let reverseGeocodeUseCase: any ReverseGeocodeUseCase private let registerAlarmUseCase: any RegisterAlarmUseCase private let cancelAlarmUseCase: any CancelAlarmUseCase - private let refreshAlarmUseCase: any RefreshAlarmUseCase + private let observeAlarmUseCase: any ObserveAlarmUseCase private let searchCoordinatorBuildable: any SearchCoordinatorBuildable public init( @@ -20,14 +20,14 @@ public final class HomeDIContainer: HomeCoordinatorBuildable { reverseGeocodeUseCase: any ReverseGeocodeUseCase, registerAlarmUseCase: any RegisterAlarmUseCase, cancelAlarmUseCase: any CancelAlarmUseCase, - refreshAlarmUseCase: any RefreshAlarmUseCase, + observeAlarmUseCase: any ObserveAlarmUseCase, searchCoordinatorBuildable: any SearchCoordinatorBuildable ) { self.getCurrentLocationUseCase = getCurrentLocationUseCase self.reverseGeocodeUseCase = reverseGeocodeUseCase self.registerAlarmUseCase = registerAlarmUseCase self.cancelAlarmUseCase = cancelAlarmUseCase - self.refreshAlarmUseCase = refreshAlarmUseCase + self.observeAlarmUseCase = observeAlarmUseCase self.searchCoordinatorBuildable = searchCoordinatorBuildable } @@ -43,7 +43,7 @@ public final class HomeDIContainer: HomeCoordinatorBuildable { reverseGeocodeUseCase: reverseGeocodeUseCase, registerAlarmUseCase: registerAlarmUseCase, cancelAlarmUseCase: cancelAlarmUseCase, - refreshAlarmUseCase: refreshAlarmUseCase + observeAlarmUseCase: observeAlarmUseCase ) viewModel.onSearchRequested = onSearchRequested return HomeViewController(viewModel: viewModel) diff --git a/Projects/Feature/Home/Sources/HomeViewController.swift b/Projects/Feature/Home/Sources/HomeViewController.swift index 94b8b67..2b128d5 100644 --- a/Projects/Feature/Home/Sources/HomeViewController.swift +++ b/Projects/Feature/Home/Sources/HomeViewController.swift @@ -46,18 +46,6 @@ final class HomeViewController: UIViewController { configureUI() bind() viewModel.viewDidLoad() - // "SceneDelegate → 알림 경유": scene 포그라운드 전환마다 시스템이 게시하는 - // 노티를 관찰한다 (최초 진입 포함 — 알람 상태 복원을 겸한다). - NotificationCenter.default.addObserver( - self, - selector: #selector(sceneWillEnterForeground), - name: UIScene.willEnterForegroundNotification, - object: nil - ) - } - - @objc private func sceneWillEnterForeground() { - viewModel.appWillEnterForeground() } override func viewWillAppear(_ animated: Bool) { diff --git a/Projects/Feature/Home/Sources/HomeViewModel.swift b/Projects/Feature/Home/Sources/HomeViewModel.swift index 147b2e3..902906b 100644 --- a/Projects/Feature/Home/Sources/HomeViewModel.swift +++ b/Projects/Feature/Home/Sources/HomeViewModel.swift @@ -54,16 +54,16 @@ final class HomeViewModel { private let reverseGeocodeUseCase: any ReverseGeocodeUseCase private let registerAlarmUseCase: any RegisterAlarmUseCase private let cancelAlarmUseCase: any CancelAlarmUseCase - private let refreshAlarmUseCase: any RefreshAlarmUseCase + private let observeAlarmUseCase: any ObserveAlarmUseCase private let now: @Sendable () -> Date private let bannerTickInterval: Duration private var selectedRoute: LastRoute? - /// 서버에 알람이 등록된 경로 id — 해제 버튼·포그라운드 복원의 기준. + /// 서버에 알람이 등록된 경로 id — 해제 버튼·동기화 복원의 기준. private var registeredRouteId: String? private var locationTask: Task? private var alarmTask: Task? - private var refreshTask: Task? + private var observeTask: Task? private var bannerTask: Task? init( @@ -71,7 +71,7 @@ final class HomeViewModel { reverseGeocodeUseCase: any ReverseGeocodeUseCase, registerAlarmUseCase: any RegisterAlarmUseCase, cancelAlarmUseCase: any CancelAlarmUseCase, - refreshAlarmUseCase: any RefreshAlarmUseCase, + observeAlarmUseCase: any ObserveAlarmUseCase, now: @escaping @Sendable () -> Date = { Date() }, bannerTickInterval: Duration = .seconds(60) ) { @@ -79,7 +79,7 @@ final class HomeViewModel { self.reverseGeocodeUseCase = reverseGeocodeUseCase self.registerAlarmUseCase = registerAlarmUseCase self.cancelAlarmUseCase = cancelAlarmUseCase - self.refreshAlarmUseCase = refreshAlarmUseCase + self.observeAlarmUseCase = observeAlarmUseCase self.now = now self.bannerTickInterval = bannerTickInterval } @@ -87,7 +87,7 @@ final class HomeViewModel { deinit { locationTask?.cancel() alarmTask?.cancel() - refreshTask?.cancel() + observeTask?.cancel() bannerTask?.cancel() } @@ -95,6 +95,7 @@ final class HomeViewModel { func viewDidLoad() { loadCurrentLocation() + observeAlarmUpdates() } /// 출발지/도착지 어느 필드를 탭해도 동일하게 검색 플로우로 진입한다. @@ -171,31 +172,31 @@ final class HomeViewModel { } } - /// 포그라운드 복귀(최초 진입 포함) 시 서버 알람을 재조회한다. 시각 변경 재스케줄은 - /// RefreshAlarmUseCase 내부 정책이고, 여기서는 배너·버튼 상태만 갱신한다. - /// Phase 8에서 앱 시작·푸시 수신 경로와 함께 AlarmSyncService로 일원화될 예정이라 - /// App이 아닌 ViewModel에 최소 구현으로 둔다. - func appWillEnterForeground() { - refreshTask?.cancel() - refreshTask = Task { [weak self] in - guard let useCase = self?.refreshAlarmUseCase else { return } - do { - let info = try await useCase.execute() + // MARK: - 내부 전이 + + /// 서버 알람 동기화(재조회·재스케줄)는 App의 AlarmSyncService가 앱 시작·포그라운드 + /// 복귀·푸시 수신 3경로를 일원화해 수행한다. 여기서는 성공 결과 스트림만 구독해 + /// 배너·버튼 상태를 갱신한다. 실패는 스트림에 흐르지 않는다 — 상태 유지. + /// TODO: [미확정] 서버의 "등록된 알람 없음" 표현이 확정되면 그 경우에만 + /// 배너·버튼을 정리하는 이벤트를 추가한다. + private func observeAlarmUpdates() { + observeTask?.cancel() + observeTask = Task { [weak self] in + guard let stream = self?.observeAlarmUseCase.execute() else { return } + for await info in stream { guard !Task.isCancelled else { return } - self?.registeredRouteId = info.lastRouteId - self?.refreshAlarmButton() - if let departure = info.departureTime { - self?.startBannerTimer(departure: departure) - } - } catch { - // TODO: [미확정] 서버의 "등록된 알람 없음" 표현이 확정되면 그 경우에만 - // 배너·버튼을 정리한다. 지금은 네트워크 오류와 구분할 수 없어 - // 상태를 유지한다 (Phase 8 하드닝에서 세분화). + self?.alarmSynced(info) } } } - // MARK: - 내부 전이 + private func alarmSynced(_ info: AlarmInfo) { + registeredRouteId = info.lastRouteId + refreshAlarmButton() + if let departure = info.departureTime { + startBannerTimer(departure: departure) + } + } private func loadCurrentLocation() { locationTask?.cancel() diff --git a/Projects/Feature/Home/Tests/HomeViewModelTests.swift b/Projects/Feature/Home/Tests/HomeViewModelTests.swift index 8a738c6..a4d44e1 100644 --- a/Projects/Feature/Home/Tests/HomeViewModelTests.swift +++ b/Projects/Feature/Home/Tests/HomeViewModelTests.swift @@ -25,9 +25,9 @@ private struct StubCancelAlarmUseCase: CancelAlarmUseCase { func execute(lastRouteId: String) async throws { try await handler(lastRouteId) } } -private struct StubRefreshAlarmUseCase: RefreshAlarmUseCase { - let handler: @Sendable () async throws -> AlarmInfo - func execute() async throws -> AlarmInfo { try await handler() } +private struct StubObserveAlarmUseCase: ObserveAlarmUseCase { + let handler: @Sendable () -> AsyncStream + func execute() -> AsyncStream { handler() } } /// 테스트 도중 `now()`를 전진시키기 위한 가변 시계. @@ -110,7 +110,9 @@ private func makeSUT( }, register: @escaping @Sendable (LastRoute) async throws -> Void = { _ in }, cancel: @escaping @Sendable (String) async throws -> Void = { _ in }, - refresh: @escaping @Sendable () async throws -> AlarmInfo = { throw StubError() }, + alarmUpdates: @escaping @Sendable () -> AsyncStream = { + AsyncStream { $0.finish() } + }, now: @escaping @Sendable () -> Date = { fixedNow }, bannerTickInterval: Duration = .seconds(60) ) -> HomeViewModel { @@ -119,7 +121,7 @@ private func makeSUT( reverseGeocodeUseCase: StubReverseGeocodeUseCase(handler: geocode), registerAlarmUseCase: StubRegisterAlarmUseCase(handler: register), cancelAlarmUseCase: StubCancelAlarmUseCase(handler: cancel), - refreshAlarmUseCase: StubRefreshAlarmUseCase(handler: refresh), + observeAlarmUseCase: StubObserveAlarmUseCase(handler: alarmUpdates), now: now, bannerTickInterval: bannerTickInterval ) @@ -327,16 +329,19 @@ struct HomeViewModelTests { } @Test - func foregroundRefresh_restoresBannerAndCancelButtonWithoutCard() async { - // 앱 재실행 복원 시나리오: 카드 없이 서버 알람만 있는 상태. + func alarmSync_restoresBannerAndCancelButtonWithoutCard() async { + // 앱 재실행 복원 시나리오: 카드 없이 서버 알람만 있는 상태 — 앱 시작 동기화가 + // 스트림으로 도착한다. let departure = fixedNow.addingTimeInterval(30 * 60) - let sut = makeSUT(refresh: { - AlarmInfo(lastRouteId: "r1", departureTime: departure, updatedAt: nil, isReal: true) - }) + let (stream, continuation) = AsyncStream.makeStream() + let sut = makeSUT(alarmUpdates: { stream }) let recorder = StateRecorder() recorder.attach(to: sut) - sut.appWillEnterForeground() + sut.viewDidLoad() + continuation.yield( + AlarmInfo(lastRouteId: "r1", departureTime: departure, updatedAt: nil, isReal: true) + ) await recorder.waitUntilLast { $0.banner != nil } #expect(sut.state.banner == .init(text: "막차 출발까지 30분", isUrgent: false)) @@ -345,37 +350,39 @@ struct HomeViewModelTests { } @Test - func foregroundRefresh_updatesBannerToNewDeparture() async { + func alarmSync_updatesBannerToNewDeparture() async { let route = makeRoute(id: "r1", departure: fixedNow.addingTimeInterval(42 * 60)) - // 서버가 15분 당긴 시각을 돌려준다. - let sut = makeSUT(refresh: { - AlarmInfo( - lastRouteId: "r1", - departureTime: fixedNow.addingTimeInterval(27 * 60), - updatedAt: nil, - isReal: true - ) - }) + let (stream, continuation) = AsyncStream.makeStream() + let sut = makeSUT(alarmUpdates: { stream }) let recorder = StateRecorder() recorder.attach(to: sut) + sut.viewDidLoad() sut.routeSelected(route) sut.registerAlarmTapped() await recorder.waitUntilLast { $0.banner?.text == "막차 출발까지 42분" } - sut.appWillEnterForeground() + // 서버가 15분 당긴 시각을 돌려준다 (포그라운드 복귀·푸시 동기화 공용 경로). + continuation.yield( + AlarmInfo( + lastRouteId: "r1", + departureTime: fixedNow.addingTimeInterval(27 * 60), + updatedAt: nil, + isReal: true + ) + ) await recorder.waitUntilLast { $0.banner?.text == "막차 출발까지 27분" } #expect(sut.state.alarmButton == .cancel) } @Test - func foregroundRefresh_failure_keepsState() async { - let sut = makeSUT(refresh: { throw StubError() }) + func alarmSync_noEvent_keepsState() async { + // 동기화 실패는 스트림에 흐르지 않는다 — 이벤트 없음 = 상태 유지. + let sut = makeSUT(alarmUpdates: { AsyncStream { $0.finish() } }) let recorder = StateRecorder() recorder.attach(to: sut) - sut.appWillEnterForeground() - // 실패는 상태를 건드리지 않는다 — 드레인만 하고 확인한다. + sut.viewDidLoad() for _ in 0..<20 { await Task.yield() } #expect(sut.state.banner == nil)