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
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import CoreStorage
import Domain
import Foundation

/// Domain `AlarmSessionSnapshotStore` → CoreStorage(UserDefaults 백엔드) 어댑터 (Phase 14).
/// 스냅샷은 재실행 브리지이지 정본이 아니다 — 저장·삭제 실패는 조용히 흡수하고(다음
/// 등록/sync가 자가치유), 필드 추가 등으로 인한 디코딩 실패도 nil로 무해화한다
/// (최근 검색 저장소의 자가치유 패턴 재사용).
struct AlarmSessionSnapshotStoreAdapter: AlarmSessionSnapshotStore {
private static let storageKey = "alarm.sessionSnapshot"

private let store: any KeyValueStore

init(store: any KeyValueStore = UserDefaultsKeyValueStore()) {
self.store = store
}

func load() async -> AlarmSessionSnapshot? {
(try? store.value(AlarmSessionSnapshot.self, forKey: Self.storageKey)) ?? nil
}

func save(_ snapshot: AlarmSessionSnapshot) async {
try? store.setValue(snapshot, forKey: Self.storageKey)
}

func clear() async {
try? store.removeValue(forKey: Self.storageKey)
}
}
60 changes: 53 additions & 7 deletions Projects/App/Sources/Adapters/DevDemoFallbacks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,13 @@ struct DevDemoFallbackLastRouteRepository: LastRouteRepository {
do { return try await base.lastRoute(id: id) }
catch {
print("⚠️ [DEV 우회] 경로 상세 실패 → 데모 경로 반환: \(error)")
// 카드 복원(Phase 14) 검수 정합: 알려진 세션(재실행 후에도 영속)의 출발 시각을
// 재사용해야 배너·LA와 카드의 시각이 어긋나지 않는다. 모르면 새 데모 시각.
let knownDeparture = await DevChangeSimulator.shared.knownSessionDeparture(routeId: id)
let route = Self.demoRoute(
start: Coordinate(latitude: 37.4979, longitude: 127.0276),
end: Coordinate(latitude: 37.4853, longitude: 126.9015)
end: Coordinate(latitude: 37.4853, longitude: 126.9015),
departure: knownDeparture
)
await DevChangeSimulator.shared.noteKnownSession(
routeId: route.id, departureTime: route.departureTime
Expand All @@ -72,10 +76,13 @@ struct DevDemoFallbackLastRouteRepository: LastRouteRepository {
}
}

/// 출발 시각은 8분 뒤 — 알람이 버퍼(−3분) 반영으로 +5분 시점에 걸린다.
/// (3분이면 알람 시각 ≈ now라 등록 탭 시점에 이미 과거가 되어 AlarmKit이 거부한다.)
private static func demoRoute(start: Coordinate, end: Coordinate) -> LastRoute {
let departure = Date().addingTimeInterval(8 * 60)
/// 출발 시각은 8분 뒤 — 알람이 도보(첫 walk leg 120초)+버퍼(180초) 반영으로 +3분
/// 시점에 걸린다(Phase 14 검수 ③: 배너·LA·발화가 전부 "출발 − 도보 − 3분" 기준).
/// 더 이르면 등록 탭 시점에 이미 과거가 되어 tooLate 가드·AlarmKit 거부에 걸린다.
private static func demoRoute(
start: Coordinate, end: Coordinate, departure: Date? = nil
) -> LastRoute {
let departure = departure ?? Date().addingTimeInterval(8 * 60)
return LastRoute(
id: "dev-demo-route",
departureTime: departure,
Expand All @@ -85,6 +92,20 @@ struct DevDemoFallbackLastRouteRepository: LastRouteRepository {
totalDistance: 14200,
totalWalkDistance: 700,
legs: [
TransportLeg(
mode: .walk,
sectionTime: 120,
distance: 150,
departureTime: nil,
routeName: nil,
lineType: nil,
start: nil,
end: RoutePoint(name: "강남역", coordinate: start),
subwayFinalStation: nil,
subwayDirection: nil,
isExpressSubway: false,
isLastSubway: false
),
TransportLeg(
mode: .subway,
sectionTime: 1500,
Expand Down Expand Up @@ -168,12 +189,21 @@ final class DevChangeSimulator {
case end
}

/// 알려진 세션의 UserDefaults 키 (Phase 14 검수) — 강제 종료·재실행 검수에서
/// 기준 출발 시각을 잃으면 주입 diff·카드 복원 시각이 어긋나므로 DEV 한정 영속화한다.
private static let knownRouteIdKey = "dev.sim.knownRouteId"
private static let knownDepartureKey = "dev.sim.knownDeparture"

private var pending: Injection?
/// 마지막으로 알려진 세션 — 데모 경로 생성·등록·refresh 성공·주입 적용 시 갱신된다.
private var knownRouteId: String?
private var knownDepartureTime: Date?

private init() {}
private init() {
knownRouteId = UserDefaults.standard.string(forKey: Self.knownRouteIdKey)
let epoch = UserDefaults.standard.double(forKey: Self.knownDepartureKey)
knownDepartureTime = epoch > 0 ? Date(timeIntervalSince1970: epoch) : nil
}

/// 주입 예약 — 다음 refresh() 1회가 소비한다.
func inject(_ injection: Injection) {
Expand All @@ -184,7 +214,18 @@ final class DevChangeSimulator {
/// departureTime이 nil이면 routeId만 갱신한다(등록 경로는 출발 시각을 모른다).
func noteKnownSession(routeId: String, departureTime: Date?) {
knownRouteId = routeId
if let departureTime { knownDepartureTime = departureTime }
UserDefaults.standard.set(routeId, forKey: Self.knownRouteIdKey)
if let departureTime {
knownDepartureTime = departureTime
UserDefaults.standard.set(
departureTime.timeIntervalSince1970, forKey: Self.knownDepartureKey
)
}
}

/// 알려진 세션의 출발 시각 — routeId가 일치할 때만 (데모 상세의 시각 정합용).
func knownSessionDeparture(routeId: String) -> Date? {
knownRouteId == routeId ? knownDepartureTime : nil
}

/// 보류 중 주입을 소비해 변형된 AlarmInfo를 만든다. 주입이 없으면 nil.
Expand All @@ -204,6 +245,11 @@ final class DevChangeSimulator {
case .end: departure = nil
}
knownDepartureTime = departure
if let departure {
UserDefaults.standard.set(
departure.timeIntervalSince1970, forKey: Self.knownDepartureKey
)
}
return AlarmInfo(
lastRouteId: routeId,
departureTime: departure,
Expand Down
132 changes: 107 additions & 25 deletions Projects/App/Sources/Adapters/LastTrainLiveActivityAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,21 @@ nonisolated protocol LastTrainDepartureEnding: Sendable {
func endAsDeparted() async
}

/// Phase 14 재실행 정합성 경로 — 고아 LA 재부착과 죽은 세션 재시작. Domain 포트는
/// 시그니처 고정 계약이라 App 내부 확장 포트로 둔다(재부착은 어댑터 내부 동작).
nonisolated protocol LastTrainSessionRestoring: Sendable {
/// 부트스트랩 직후 1회 — 프로세스가 죽는 사이 잠금화면에 남은
/// `Activity.activities`를 스캔해, 스냅샷과 routeId가 일치하고 미만료인 세션은
/// **adopt**(보관 + 상태 관찰 재개 — 이후 update/end가 정상 동작)하고,
/// 불일치·만료·스냅샷 없음은 즉시 정리한다.
func reattachOrphans(snapshot: AlarmSessionSnapshot?, now: Date) async
/// sync 성공 후 — 스냅샷은 살아 있는데(미만료·미확인) 활성 activity가 없고 dismiss
/// 기록도 없으면 LA를 로컬 재시작한다. 8시간 한도로 시스템이 내린 세션·시작 실패
/// 세션 커버 — push-to-start 금지 정책과 무관(그 정책은 유저가 지운 LA의 재생성 금지,
/// dismiss 기록이 있으면 여기서도 재시작하지 않는다).
func restartIfNeeded(snapshot: AlarmSessionSnapshot, now: Date) async
}

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

Expand Down Expand Up @@ -79,12 +94,18 @@ actor LastTrainLiveActivityAdapter: LastTrainActivityPort, LastTrainChangeAlerti
}

let departureTime = session.departureTime ?? route.departureTime
// 로컬 알람 발화 시각 — register/refresh와 동일 기준(출발 − 도보 − 버퍼, Phase 14).
let alarmTime = AlarmTiming.alarmFireDate(
departureTime: departureTime,
firstWalkSeconds: route.firstWalkSectionSeconds
)
let initialState = LastTrainActivityState(
departureTime: departureTime,
// 로컬 알람 발화 시각 — register/refresh와 동일한 버퍼 반영값(출발 − 3분).
alarmTime: AlarmTiming.alarmFireDate(departureTime: departureTime),
alarmTime: alarmTime,
// 긴급도는 이후 갱신과 같은 척도인 **알람 시각** 기준(Phase 14 정합 — 최초
// start만 출발 시각 기준이던 불일치 제거).
urgency: Domain.LastTrainUrgency.forTimeRemaining(
departureTime.timeIntervalSinceNow
alarmTime.timeIntervalSinceNow
),
changeBadgeExpiry: nil,
phase: .active
Expand All @@ -94,7 +115,9 @@ actor LastTrainLiveActivityAdapter: LastTrainActivityPort, LastTrainChangeAlerti
let requested = try Activity.request(
attributes: LastTrainActivityAttributes(
routeId: route.id,
routeName: Self.displayRouteName(for: route)
routeName: route.sessionDisplayName,
transportKind: Self.transportKind(from: route.boardingLeg?.mode),
firstWalkSeconds: route.firstWalkSectionSeconds
),
// staleDate = 출발 시각: 갱신이 끊긴 LA가 출발 시각이 지난 뒤에도
// 오래된 정보를 신선한 것처럼 보이지 않게 시스템이 stale 처리하도록 방어.
Expand All @@ -110,6 +133,76 @@ actor LastTrainLiveActivityAdapter: LastTrainActivityPort, LastTrainChangeAlerti
}
}

// MARK: - LastTrainSessionRestoring (Phase 14)

func reattachOrphans(snapshot: AlarmSessionSnapshot?, now: Date) async {
// 살아 있는 세션을 이미 보관 중이면(이론상 재부착 전 start 경합) 손대지 않는다.
var adopted = activity != nil
for orphan in Activity<LastTrainActivityAttributes>.activities {
if !adopted,
// dismiss 기록이 있는 세션은 재부착도 하지 않는다 — 유저가 지운 LA는
// 어떤 경로로도 되살리지 않는 정책과 한 몸(정상 흐름에선 지워진 LA가
// 목록에 없지만, 기록·상태가 어긋난 경우에도 지운 의사가 이긴다).
!dismissedByUser,
let snapshot, !snapshot.expired,
orphan.attributes.routeId == snapshot.info.lastRouteId,
let departure = snapshot.info.departureTime,
!AlarmTiming.isSessionExpired(departureTime: departure, now: now),
orphan.activityState == .active || orphan.activityState == .stale {
// adopt — 보관 + 상태 관찰 재개. 이후 update/end가 정상 동작한다.
activity = orphan
observeActivityState(orphan)
adopted = true
} else {
// 불일치·만료·스냅샷 없음(고아) — 잠금화면에서 즉시 정리한다.
await orphan.end(nil, dismissalPolicy: .immediate)
}
}
}

func restartIfNeeded(snapshot: AlarmSessionSnapshot, now: Date) async {
// dismiss 존중(유저가 지운 LA 재생성 금지)·확인된 세션(departed 소멸 예약 완료)
// 재시작 금지. 활성 activity가 있으면 당연히 재시작하지 않는다 — adopt된 세션 포함.
guard activity == nil,
!dismissedByUser,
!snapshot.expired,
!snapshot.acknowledged,
let departure = snapshot.info.departureTime,
!AlarmTiming.isSessionExpired(departureTime: departure, now: now),
ActivityAuthorizationInfo().areActivitiesEnabled
else { return }

let alarmTime = AlarmTiming.alarmFireDate(
departureTime: departure,
firstWalkSeconds: snapshot.firstWalkSeconds
)
let state = LastTrainActivityState(
departureTime: departure,
alarmTime: alarmTime,
urgency: Domain.LastTrainUrgency.forTimeRemaining(alarmTime.timeIntervalSince(now)),
changeBadgeExpiry: nil,
phase: .active
)
do {
let requested = try Activity.request(
attributes: LastTrainActivityAttributes(
routeId: snapshot.info.lastRouteId,
routeName: snapshot.routeDisplayName.isEmpty ? "막차" : snapshot.routeDisplayName,
transportKind: Self.transportKind(from: snapshot.transportMode),
firstWalkSeconds: snapshot.firstWalkSeconds
),
content: ActivityContent(
state: Self.contentState(from: state),
staleDate: departure
)
)
activity = requested
observeActivityState(requested)
} catch {
// 재시작 실패도 흡수한다 — 알람·홈 배너만으로 동작(포트 계약과 동일 태도).
}
}

func update(state: LastTrainActivityState, alert: Bool) async {
// 문구 없는 Bool 경로(Domain 포트) — alert=true면 범용 폴백 문구로 위임한다.
// Phase 11 훅은 이 경로 대신 LastTrainChangeAlerting으로 변경 유형별 문구를 싣는다.
Expand Down Expand Up @@ -260,26 +353,15 @@ actor LastTrainLiveActivityAdapter: LastTrainActivityPort, LastTrainChangeAlerti
)
}

/// 노선 표시명 — 막차 탑승 구간(departureTime이 있는 첫 대중교통 구간, 없으면 첫 대중교통 구간) 기준.
/// 버스 routeName은 "타입:번호"(예: "간선:472") → "472번 버스", 지하철은 노선명 그대로(급행이면 " 급행").
private nonisolated static func displayRouteName(for route: LastRoute) -> String {
let transitLegs = route.legs.filter { $0.mode == .bus || $0.mode == .subway }
guard let leg = transitLegs.first(where: { $0.departureTime != nil }) ?? transitLegs.first
else { return "막차" }

switch leg.mode {
case .subway:
guard let name = leg.routeName else { return "지하철" }
return leg.isExpressSubway ? "\(name) 급행" : name
case .bus:
guard let routeName = leg.routeName else { return "버스" }
guard let colonIndex = routeName.firstIndex(of: ":") else {
return "\(routeName)번 버스"
}
let number = String(routeName[routeName.index(after: colonIndex)...])
return "\(number)번 버스"
case .walk, .unknown:
return "막차"
/// Domain 수단 → 위젯 계약 수단 키 (Phase 14 DI 아이콘 분기).
private nonisolated static func transportKind(
from mode: TransportMode?
) -> LastTrainTransportKind? {
switch mode {
case .bus: .bus
case .subway: .subway
case .walk, .unknown: .other
case nil: nil
}
}
}
20 changes: 16 additions & 4 deletions Projects/App/Sources/AlarmSessionLifecycleService.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Domain
import Foundation
import os

Expand All @@ -8,26 +9,37 @@ import os
final class AlarmSessionLifecycleService {
/// departed 전환·예약 소멸 경로 — LA 어댑터가 구현한다(실패 전부 흡수, non-throwing).
private let liveActivity: any LastTrainDepartureEnding
/// 확인 기록 영속화 (Phase 14) — 강제 종료·재실행 후에도 남아 재시작 판정
/// (확인된 세션 재시작 금지)의 재료가 된다.
private let snapshotStore: any AlarmSessionSnapshotStore
private static let logger = Logger(
subsystem: "com.atcha.iOS.v2", category: "SessionLifecycle"
)

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

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

/// 알람 "확인" 탭(stopIntent 실행) — ① 확인 기록 ② LA departed 전환
/// 알람 "확인" 탭(stopIntent 실행) — ① 확인 기록(스냅샷 영속화) ② LA departed 전환
/// ③ 출발+10분 자동 소멸 예약. ②③은 어댑터의 end 한 번으로 구현된다
/// (final content = departed, dismissalPolicy = .after) — 앱이 다시 깨지
/// 않아도 잠금화면에서 시스템이 내린다.
func alarmAcknowledged() async {
// 로그는 자동 검수 ②(강제 종료 후 인텐트 실행 여부 판정)의 증적 채널이다.
Self.logger.info("알람 확인(stopIntent) 수신 — departed 전환 + 자동 소멸 예약")
isAcknowledged = true
// 세션 스냅샷이 있을 때만 기록한다 — 스냅샷 없는 확인(이론상 경합)은 남길 곳이 없고,
// 그 경우의 정리는 wake 시점 리컨실이 맡는다.
if let snapshot = await snapshotStore.load() {
await snapshotStore.save(snapshot.updating(acknowledged: true))
}
await liveActivity.endAsDeparted()
}
}
Loading