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
7 changes: 7 additions & 0 deletions Projects/App/Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ let appTarget = Target.target(
"UILaunchScreen": [:],
"NSAlarmKitUsageDescription": "막차 시간에 맞춰 알람을 울리기 위해 권한이 필요합니다.",
"NSLocationWhenInUseUsageDescription": "현재 위치를 출발지로 사용하기 위해 위치 정보 접근 권한이 필요합니다.",
// 사일런트 푸시(content-available=1) 수신용 — 사용자 알림 권한과 무관.
"UIBackgroundModes": ["remote-notification"],
"UIApplicationSceneManifest": [
"UIApplicationSupportsMultipleScenes": false,
"UISceneConfigurations": [
Expand All @@ -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"),
Expand Down
30 changes: 30 additions & 0 deletions Projects/App/Resources/GoogleService-Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>API_KEY</key>
<string>AIzaSyDPexsKmlRAz9ghUGrGKIvrAvSaH5mMz9o</string>
<key>GCM_SENDER_ID</key>
<string>104855490976</string>
<key>PLIST_VERSION</key>
<string>1</string>
<key>BUNDLE_ID</key>
<string>com.atcha.iOS</string>
<key>PROJECT_ID</key>
<string>atcha-3b0e4</string>
<key>STORAGE_BUCKET</key>
<string>atcha-3b0e4.firebasestorage.app</string>
<key>IS_ADS_ENABLED</key>
<false></false>
<key>IS_ANALYTICS_ENABLED</key>
<false></false>
<key>IS_APPINVITE_ENABLED</key>
<true></true>
<key>IS_GCM_ENABLED</key>
<true></true>
<key>IS_SIGNIN_ENABLED</key>
<true></true>
<key>GOOGLE_APP_ID</key>
<string>1:104855490976:ios:e93a557692dabde3fdd4c5</string>
</dict>
</plist>
97 changes: 97 additions & 0 deletions Projects/App/Sources/AlarmSyncService.swift
Original file line number Diff line number Diff line change
@@ -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<AlarmInfo>.Continuation] = [:]
/// 구독 전에 끝난 동기화를 놓치지 않기 위한 replay-1. 홈은 앱 시작 동기화와
/// 거의 동시에 구독하므로 순서에 기대지 않는다.
private var lastInfo: AlarmInfo?
/// 진행 중 동기화 — 트리거가 겹치면(예: 앱 시작 직후 포그라운드 노티) 합류한다.
private var inFlight: Task<AlarmInfo?, Never>?
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<AlarmInfo> {
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)
}
}
}
}
}
2 changes: 2 additions & 0 deletions Projects/App/Sources/AppCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
44 changes: 29 additions & 15 deletions Projects/App/Sources/AppDIContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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초 타임아웃 대기로
Expand All @@ -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),
Expand All @@ -92,10 +109,7 @@ final class AppDIContainer {
repository: alarmRepository,
scheduler: alarmScheduler
),
refreshAlarmUseCase: DefaultRefreshAlarmUseCase(
repository: alarmRepository,
scheduler: alarmScheduler
),
observeAlarmUseCase: DefaultObserveAlarmUseCase(events: alarmSyncService),
searchCoordinatorBuildable: searchContainer
)
}
Expand Down
55 changes: 55 additions & 0 deletions Projects/App/Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
@@ -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
}

Expand All @@ -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)")
}
}
7 changes: 5 additions & 2 deletions Projects/App/Sources/SceneDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions Projects/Domain/Sources/Interfaces/AlarmSyncEvents.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import Foundation

/// 알람 동기화 결과 포트 — 발행 주체는 App의 AlarmSyncService(앱 시작·포그라운드
/// 복귀·사일런트 푸시 3경로를 RefreshAlarmUseCase 한 곳으로 모으는 유일한 호출자).
/// 실패는 흘리지 않는다 — 구독자는 성공 결과만 받고, 실패 시 기존 상태를 유지한다.
public protocol AlarmSyncEvents: Sendable {
/// 구독자마다 독립 스트림. 구독 시 마지막 동기화 결과가 있으면 즉시 방출한다
/// (구독 전에 끝난 앱 시작 동기화를 놓치지 않도록).
func updates() -> AsyncStream<AlarmInfo>
}
17 changes: 17 additions & 0 deletions Projects/Domain/Sources/UseCases/ObserveAlarmUseCase.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Foundation

public protocol ObserveAlarmUseCase: Sendable {
func execute() -> AsyncStream<AlarmInfo>
}

public struct DefaultObserveAlarmUseCase: ObserveAlarmUseCase {
private let events: any AlarmSyncEvents

public init(events: any AlarmSyncEvents) {
self.events = events
}

public func execute() -> AsyncStream<AlarmInfo> {
events.updates()
}
}
Loading
Loading