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
16 changes: 11 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,13 @@ xcodebuild -workspace Atcha.xcworkspace -scheme Atcha-Dev -configuration Debug \
## 아키텍처 (AtchaV2 — uFeatures + 클린아키텍처)

```
AtchaV2(앱, 조합 루트: 어댑터·스플래시) ─► HomeFeature ─► {HomeFeatureInterface, SearchFeatureInterface, Domain, DesignSystem, CoreCoordinator, SnapKit}
AtchaV2(앱, 조합 루트: 어댑터·스플래시·AlarmSyncService) ─► HomeFeature ─► {HomeFeatureInterface, SearchFeatureInterface, Domain, DesignSystem, CoreCoordinator, SnapKit}
├─► SearchFeature ─► {SearchFeatureInterface, Domain, DesignSystem, CoreCoordinator, SnapKit}
├─► AtchaData ─► {Domain, CoreNetwork, CoreStorage}
└─► CoreAuth ─► {CoreNetwork, CoreStorage}
├─► CoreAuth ─► {CoreNetwork, CoreStorage}
├─► CoreAlarm (무의존 — AlarmKit 유일 import 지점, App만 import)
├─► CoreLiveActivity (무의존 — ActivityAttributes 계약, 앱·위젯 공유)
└─► AtchaWidget (위젯 익스텐션, 앱에 임베드 — Live Activity UI, UIKit 규약의 유일한 SwiftUI 예외)
```

의존 규칙(위반 금지, `tuist graph`로 검증 가능):
Expand All @@ -76,6 +79,9 @@ AtchaV2(앱, 조합 루트: 어댑터·스플래시) ─► HomeFeature ─► {

### 미완 상태 (작업 시 참고)

- `AppEnvironment`의 API base URL은 플레이스홀더 — 실서버 주소 미정.
- `com.atcha.iOS.v2`용 GoogleService-Info.plist 미발급 — `AppDelegate`가 파일 존재를 가드한 뒤에만 `FirebaseApp.configure()` 호출. plist를 `Projects/App/Resources/`에 넣으면 자동 활성화.
- AtchaV2는 iOS 26 전용(AlarmKit 사용 예정). AlarmKit의 커스텀 알람 UI(Live Activity)는 추후 위젯 익스텐션 타겟이 별도로 필요.
- **익명 인증 발급 엔드포인트 미확정** — `UnconfiguredAnonymousSessionIssuer` 스텁이 주입돼 있어 실서버에서는 토큰 없이 동작한다(알람 서버 기능 전부 불가). Debug(DEV)는 `DevDemoFallbacks`가 데모 데이터로 가린다 — 실서버 스펙 확정 시 이 폴백과 `AppDIContainer`의 `#if DEV` 주입을 제거할 것.
- `AppEnvironment`의 base URL은 dev/live 실주소 반영 완료. **Stage는 dev 호스트를 공유 중** — 전용 호스트만 미정.
- `Projects/App/Resources/GoogleService-Info.plist`는 **레거시 번들 ID(`com.atcha.iOS`)용 파일**이라 존재 가드만 통과할 뿐 V2(`com.atcha.iOS.v2`)로의 사일런트 푸시가 성립하지 않는다 — V2용 재발급·교체 필요. FCM 토큰 서버 전달도 미구현(로깅만)이라 갱신 채널은 현재 폴링(앱 시작·포그라운드 복귀)뿐.
- AtchaV2는 iOS 26 전용. AlarmKit(CoreAlarm)·Live Activity(CoreLiveActivity + AtchaWidget 익스텐션)는 Phase 9~12에서 구축 완료.
- Phase 12 이후의 갭 분석·후속 로드맵: `docs/planning/atcha-v2-post12-roadmap.md` / Phase 13·14(알람 이후 + 재실행 정합성) 구현 프롬프트: `docs/prompts/atcha-v2-session-lifecycle-prompt.md`.
- Phase 검수는 사람 검수 대신 **자동 검수 규약**(`docs/prompts/atcha-v2-auto-verification.md`)을 따른다 — 에이전트가 computer use로 시뮬레이터 검수를 직접 수행·증적 보고하고, 실기기 잔여 항목만 사용자에게 이관.
57 changes: 56 additions & 1 deletion Projects/App/Sources/Adapters/LastTrainLiveActivityAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,25 @@ nonisolated protocol LastTrainChangeAlerting: Sendable {
func end(final state: LastTrainActivityState) async
}

/// Phase 13 발화 확인 경로 — stopIntent가 조합 루트(세션 수명 서비스)를 거쳐 LA를
/// departed로 내리는 통로. Domain 포트(`LastTrainActivityPort`)는 문서 고정 계약이라
/// 손대지 않고 App 내부 확장 포트로 둔다(Phase 11의 LastTrainChangeAlerting과 같은 방식).
nonisolated protocol LastTrainDepartureEnding: Sendable {
/// 현재 세션을 departed("지금 출발하세요") 최종 상태로 전환하고, 출발+유예 시점에
/// 잠금화면에서 자동 소멸하도록 예약한다 — 앱이 다시 깨지 않아도 시스템이 내린다.
/// 실패·세션 부재는 흡수한다(포트 계약과 동일 — LA 실패가 확인 기록을 막으면 안 된다).
func endAsDeparted() async
}

/// ActivityKit → Domain `LastTrainActivityPort` 어댑터. ActivityKit을 import하는 곳은 App에서 이 파일뿐.
/// 단일 알람 정책과 동일하게 Live Activity도 단일 세션만 유지한다(새 start가 기존 세션을 교체).
/// 포트 계약대로 어떤 실패도 밖으로 던지지 않는다 — LA 실패가 알람 등록·취소를 실패시키면 안 된다.
///
/// actor인 이유: 포트는 nonisolated async 요구사항을 가진 Sendable 프로토콜이라
/// MainActor 클래스의 격리 멤버로는 적합성이 성립하지 않는다(Sendable 경계를 넘는 격리 적합성 불가).
/// ActivityKit의 `Activity`는 Sendable 미표기이나 스레드 안전 설계라 `@preconcurrency`로 완화한다.
actor LastTrainLiveActivityAdapter: LastTrainActivityPort, LastTrainChangeAlerting {
actor LastTrainLiveActivityAdapter: LastTrainActivityPort, LastTrainChangeAlerting,
LastTrainDepartureEnding {
/// 유저 스와이프 dismiss 기록 키 — 앱 재실행 후에도 남아야 Phase 12 폴백 트리거 재료가 된다.
private static let dismissedDefaultsKey = "la.dismissedByUser"

Expand Down Expand Up @@ -129,6 +140,50 @@ actor LastTrainLiveActivityAdapter: LastTrainActivityPort, LastTrainChangeAlerti

var isDismissedByUser: Bool { dismissedByUser }

// MARK: - LastTrainDepartureEnding (Phase 13)

/// 정책: departed 상태는 잠금화면에 남았다가 출발 + 10분에 자동 소멸한다(.after) —
/// "지금 출발" 상태가 잠시 남는 것이 취지. end 이후 남은 시간 창(최대 3분+10분)의
/// 재변경 인지는 알람 재스케줄(기존 경로)이 담당하고, LA 재생성은 하지 않는다(계약).
private var departedDismissalGraceSeconds: TimeInterval {
#if DEV
// 자동 검수 규약: 자동 소멸 대기가 과도할 때 DEV 한정 단축 —
// `simctl spawn booted defaults write com.atcha.iOS.v2 dev.la.departedDismissalGrace -int 60`
let override = userDefaults.double(forKey: "dev.la.departedDismissalGrace")
if override > 0 { return override }
#endif
return 600
}

func endAsDeparted() async {
// 프로그램적 end — 예약 소멸 시점에 도착하는 .dismissed를 유저 스와이프로
// 오인하지 않도록 관찰을 먼저 끊는다(end(final:)과 동일 순서).
stateObservationTask?.cancel()
stateObservationTask = nil
// 세션 부재(강제 종료 후 고아, LA 비활성 등)는 조용히 no-op —
// 확인 기록은 이미 남았고, 고아 재부착은 Phase 14 몫이다.
guard let activity else { return }
self.activity = nil

// departed 상태는 현재 콘텐츠의 세션 사실(출발·알람 시각)을 그대로 잇는다 —
// glance 색은 imminent와 동일 척도(정책), 배지는 접는다.
let current = activity.content.state
let departed = LastTrainActivityAttributes.ContentState(
departureTime: current.departureTime,
alarmTime: current.alarmTime,
urgency: .imminent,
changeBadgeExpiry: nil,
status: .departed
)
await activity.end(
// staleDate nil: departed는 시각 최신성 경고 대상이 아니다 — 소멸은 .after가 맡는다.
ActivityContent(state: departed, staleDate: nil),
dismissalPolicy: .after(
current.departureTime.addingTimeInterval(departedDismissalGraceSeconds)
)
)
}

// MARK: - LastTrainChangeAlerting

func update(state: LastTrainActivityState, alert: (title: String, body: String)?) async {
Expand Down
33 changes: 33 additions & 0 deletions Projects/App/Sources/AlarmSessionLifecycleService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import Foundation
import os

/// 알람 발화 이후의 세션 수명 로직 (Phase 13) — stopIntent("확인" 탭)가 조합 루트를
/// 거쳐 도달하는 유일한 지점. 시간 경과 판정(만료)은 AlarmSyncService의 wake 시점
/// 리컨실 몫이고, 여기는 "확인" 사건의 기록과 표출 전이만 담당한다.
@MainActor
final class AlarmSessionLifecycleService {
/// departed 전환·예약 소멸 경로 — LA 어댑터가 구현한다(실패 전부 흡수, non-throwing).
private let liveActivity: any LastTrainDepartureEnding
private static let logger = Logger(
subsystem: "com.atcha.iOS.v2", category: "SessionLifecycle"
)

/// stopIntent 확인 기록 — Phase 14 스냅샷 `acknowledged` 필드의 인메모리 선행.
/// 프로세스가 죽으면 사라진다(강제 종료 케이스의 영속화는 Phase 14 몫).
private(set) var isAcknowledged = false

init(liveActivity: any LastTrainDepartureEnding) {
self.liveActivity = liveActivity
}

/// 알람 "확인" 탭(stopIntent 실행) — ① 확인 기록 ② LA departed 전환
/// ③ 출발+10분 자동 소멸 예약. ②③은 어댑터의 end 한 번으로 구현된다
/// (final content = departed, dismissalPolicy = .after) — 앱이 다시 깨지
/// 않아도 잠금화면에서 시스템이 내린다.
func alarmAcknowledged() async {
// 로그는 자동 검수 ②(강제 종료 후 인텐트 실행 여부 판정)의 증적 채널이다.
Self.logger.info("알람 확인(stopIntent) 수신 — departed 전환 + 자동 소멸 예약")
isAcknowledged = true
await liveActivity.endAsDeparted()
}
}
75 changes: 66 additions & 9 deletions Projects/App/Sources/AlarmSyncService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ import os
/// 서버 무관여로 가능하고, push-to-start 재생성은 하지 않는다(지운 의사 존중).
/// ④ advanced(actionable: false)는 LA를 missed 상태로, sessionEnded는 serviceEnded 최종
/// 상태로 내리고 로컬 알람을 취소한다. 배너 정리는 changes 스트림을 받은 홈의 몫.
///
/// Phase 13 클라 자체 만료: sync 진입 시 보유 세션이 유예(출발+60초)를 넘겼으면 만료
/// 후보로 잡고, refresh 결과와 무관하게 로컬 sessionEnded 처리한다 — 단 refresh가
/// 성공해 **미래 출발 시각**을 반환하면 서버 우선(만료 취소, 정상 갱신 경로).
/// 만료 확정 세션은 기록해 이후 refresh가 같은 과거 세션으로 배너를 되살리지 못하게
/// 한다(홈의 미래 시각 가드가 1차 방어, 이 기록이 2차).
// Sendable 프로토콜(AlarmSyncEvents 등) 채택이 기본 MainActor 격리를 nonisolated로
// 추론시키므로 명시한다 — 상태(subscribers 등)는 전부 메인 액터에서만 만진다.
@MainActor
Expand All @@ -42,6 +48,9 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents {
/// 구독 전에 끝난 동기화를 놓치지 않기 위한 replay-1. 홈은 앱 시작 동기화와
/// 거의 동시에 구독하므로 순서에 기대지 않는다. 변경 판정의 "이전 값"이기도 하다.
private var lastInfo: AlarmInfo?
/// Phase 13 만료 2차 방어 — 로컬 만료를 확정한 세션. 이후 refresh가 같은 routeId의
/// 과거 세션을 반환해도 무시한다(미래 출발이 오면 서버 우선으로 해제).
private var locallyExpiredSession: AlarmInfo?
/// 진행 중 동기화 — 트리거가 겹치면(예: 앱 시작 직후 포그라운드 노티) 합류한다.
private var inFlight: Task<AlarmInfo?, Never>?
private var foregroundObserver: (any NSObjectProtocol)?
Expand Down Expand Up @@ -89,6 +98,8 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents {
return await inFlight.value
}
Self.logger.info("알람 동기화 시작")
// Phase 13 선행 판정 — 확정은 refresh 결과를 본 뒤(미래 출발이면 서버 우선 취소).
let expiryCandidate = expireLocallyIfNeeded(now: Date())
let task = Task { [refreshAlarmUseCase] () -> AlarmInfo? in
do {
return try await refreshAlarmUseCase.execute()
Expand All @@ -100,20 +111,66 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents {
inFlight = task
let info = await task.value
inFlight = nil
if let info {
Self.logger.info("알람 동기화 성공: route=\(info.lastRouteId, privacy: .public)")
let previous = lastInfo
lastInfo = info
for continuation in subscribers.values {
continuation.yield(info)

guard let info else {
// refresh 실패여도 만료는 확정한다 — "refresh 결과와 무관하게"가 정책이다.
if let expiryCandidate {
await finalizeLocalExpiry(of: expiryCandidate)
}
// Phase 11 피기백 — 이 시점에 알람 재스케줄은 이미 완료돼 있다
// (RefreshAlarmUseCase.execute 반환 = 재스케줄 포함). 표출은 그 뒤에만 덧붙는다.
await propagateChange(previous: previous, latest: info)
return nil
}

if let departure = info.departureTime, departure > Date() {
// 서버 우선 — 미래 출발 시각이 오면 만료 후보·확정 기록 모두 해제하고 정상 경로.
locallyExpiredSession = nil
} else if let expiryCandidate {
// 성공했지만 여전히 과거 세션(또는 출발 시각 없음) — 만료 확정.
// 이 결과는 구독자에게 흘리지 않는다(죽은 세션으로 배너·버튼 복원 금지).
await finalizeLocalExpiry(of: expiryCandidate)
return info
} else if let expired = locallyExpiredSession, expired.lastRouteId == info.lastRouteId {
// 만료 확정 후 같은 과거 세션의 재수신 — 무시(2차 방어).
Self.logger.info("만료 확정 세션 재수신 → 무시: route=\(info.lastRouteId, privacy: .public)")
return info
}

Self.logger.info("알람 동기화 성공: route=\(info.lastRouteId, privacy: .public)")
let previous = lastInfo
lastInfo = info
for continuation in subscribers.values {
continuation.yield(info)
}
// Phase 11 피기백 — 이 시점에 알람 재스케줄은 이미 완료돼 있다
// (RefreshAlarmUseCase.execute 반환 = 재스케줄 포함). 표출은 그 뒤에만 덧붙는다.
await propagateChange(previous: previous, latest: info)
return info
}

// MARK: - Phase 13 클라 자체 만료 (wake 시점 판정)

/// sync 진입 선행 판정 — 보유 세션이 만료 유예(출발+60초, AlarmTiming 단일 기준)를
/// 넘겼으면 만료 후보를 반환한다. 판정 자체는 Domain 순수 함수(시각 주입 테스트 대상).
private func expireLocallyIfNeeded(now: Date) -> AlarmInfo? {
guard let lastInfo,
let departure = lastInfo.departureTime,
AlarmTiming.isSessionExpired(departureTime: departure, now: now)
else { return nil }
return lastInfo
}

/// 만료 확정 = 로컬 sessionEnded 처리: 알람 레코드 정리 → LA 최종 종료 →
/// changes yield(홈 정리는 기존 sessionEnded 소비 경로 재사용). 서버 계약 무관여.
private func finalizeLocalExpiry(of session: AlarmInfo) async {
Self.logger.info(
"클라 자체 만료 확정(로컬 sessionEnded): route=\(session.lastRouteId, privacy: .public)"
)
locallyExpiredSession = session
// replay-1이 죽은 세션을 재구독자에게 되살리지 않도록 비운다.
lastInfo = nil
await presentSessionEnded(previous: session, now: Date())
yieldChange(.sessionEnded)
}

// MARK: - Phase 11·12 변경 표출 (판정 → LA/로컬 노티/인앱 채널)

/// 판정 → 채널 분기. LA 호출은 전부 실패 무해(포트가 non-throwing) — 알람에 영향 없음.
Expand Down
15 changes: 12 additions & 3 deletions Projects/App/Sources/AppDIContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ final class AppDIContainer {
// 같은 요청 이력을 봐야 한다. UNUserNotificationCenter를 아는 곳은 이 어댑터뿐.
private let localNotificationPort: any LocalNotificationPort
let alarmSyncService: AlarmSyncService
/// Phase 13 발화 이후 세션 수명 — stopIntent(AlarmAcknowledgeIntent)가 조합 루트를
/// 거쳐 도달하는 지점. AppDelegate 경유로 인텐트 perform()이 접근한다.
let alarmSessionLifecycle: AlarmSessionLifecycleService
#if DEV
/// DEV 플로팅 디버그 메뉴가 dismiss 기록 강제 토글에 접근하는 유일한 통로 (Phase 12 검수).
let devLiveActivityAdapter: LastTrainLiveActivityAdapter
Expand Down Expand Up @@ -84,12 +87,18 @@ final class AppDIContainer {
#endif

// 디바이스 포트 어댑터 — CoreLocation/AlarmKit/ActivityKit을 아는 곳은 App의 어댑터뿐.
let alarmScheduler = CoreAlarmSchedulerAdapter()
// Phase 13: stop 버튼("확인")에 발화 확인 인텐트를 싣는다 — 인텐트 타입은 App 소유,
// CoreAlarm은 인스턴스를 전달만 한다(주입 실패 시 인텐트 없이 스케줄되는 폴백 유지).
let alarmScheduler = CoreAlarmSchedulerAdapter(
scheduling: AlarmKitScheduler(stopIntent: AlarmAcknowledgeIntent())
)
self.alarmScheduler = alarmScheduler
// 구체 어댑터로 들고 있다가 두 얼굴로 나눠 준다 — Domain 포트(등록/해제 UseCase)와
// App 내부 변경 표출 경로(LastTrainChangeAlerting, Phase 11 훅).
// 구체 어댑터로 들고 있다가 세 얼굴로 나눠 준다 — Domain 포트(등록/해제 UseCase),
// App 내부 변경 표출 경로(LastTrainChangeAlerting, Phase 11 훅),
// 발화 확인 경로(LastTrainDepartureEnding, Phase 13).
let liveActivityAdapter = LastTrainLiveActivityAdapter()
self.liveActivityPort = liveActivityAdapter
self.alarmSessionLifecycle = AlarmSessionLifecycleService(liveActivity: liveActivityAdapter)
#if DEV
self.devLiveActivityAdapter = liveActivityAdapter
#endif
Expand Down
26 changes: 26 additions & 0 deletions Projects/App/Sources/Intents/AlarmAcknowledgeIntent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import AppIntents
import UIKit

/// AlarmKit stop 버튼("확인")에 실리는 인텐트 — 알람 발화 확인 감지의 유일한 훅 (Phase 13).
/// LiveActivityIntent는 앱 프로세스에서 실행된다(필요 시 시스템이 백그라운드로 깨운다).
/// 인텐트 타입은 App이 소유하고, CoreAlarm에는 인스턴스만 주입된다(App → CoreAlarm 한 방향).
///
/// 강제 종료 상태에서의 실행 여부는 실기기 잔여 검수로 판명한다 — 판명 전까지는
/// "wake 시점 리컨실(expireLocallyIfNeeded)이 커버한다"는 보수적 가정으로 진행(규약).
nonisolated struct AlarmAcknowledgeIntent: LiveActivityIntent {
static let title: LocalizedStringResource = "막차 알람 확인"
/// 제품 결정: 심야의 "확인"은 앱을 열지 않는 것이 기본값 — 조용한 원상복귀.
static let openAppWhenRun: Bool = false
/// 단축어·스포트라이트 노출 불필요 — 알람 stop 버튼 전용.
static let isDiscoverable: Bool = false

func perform() async throws -> some IntentResult {
// 조합 루트(AppDelegate 소유)의 세션 수명 서비스로 위임한다. 델리게이트 부재
// (이론상 초기화 경합)면 조용히 no-op — 만료 리컨실이 최종 안전망이다.
let lifecycle = await MainActor.run {
(UIApplication.shared.delegate as? AppDelegate)?.container.alarmSessionLifecycle
}
await lifecycle?.alarmAcknowledged()
return .result()
}
}
Loading
Loading