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
2 changes: 2 additions & 0 deletions Projects/App/Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ let appTarget = Target.target(
// 배포 서명 시 프로비저닝이 production으로 치환한다.
entitlements: .dictionary([
"aps-environment": "development",
// 폴백 노티의 .timeSensitive interruptionLevel용(Phase 15) — 집중 모드 관통.
"com.apple.developer.usernotifications.time-sensitive": true,
]),
dependencies: [
.target(name: "AtchaWidget"),
Expand Down
16 changes: 16 additions & 0 deletions Projects/App/Sources/Adapters/LastTrainLiveActivityAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ nonisolated protocol LastTrainChangeAlerting: Sendable {
/// Phase 12 dismiss 폴백 트리거 — 유저가 잠금화면에서 LA를 스와이프로 지운 기록.
/// true면 LA alert는 도달 불가(update가 no-op)라 호출자가 로컬 노티로 갈아탄다.
var isDismissedByUser: Bool { get async }
/// Phase 15 — LA alert 도달 가능성 단일 판정:
/// 보유 activity 있음 ∧ areActivitiesEnabled ∧ ¬dismissedByUser.
/// false면 update(alert:)가 no-op이거나 잠금화면에 표면이 없다 — 호출자는 로컬 노티로
/// 갈아탄다(채널 갈아타기이지 LA 재생성이 아니다 — push-to-start 금지 정책 불변).
var isAlertReachable: Bool { get async }
/// Phase 12 종료 표출 — missed/serviceEnded 최종 상태로 LA를 내린다(Domain 포트와 동일 구현).
func end(final state: LastTrainActivityState) async
}
Expand Down Expand Up @@ -233,6 +238,17 @@ actor LastTrainLiveActivityAdapter: LastTrainActivityPort, LastTrainChangeAlerti

var isDismissedByUser: Bool { dismissedByUser }

/// Phase 15 폴백 조건 확대 — dismiss 기록 하나로는 "LA를 설정에서 꺼둔 유저"(start가
/// no-op이라 activity 자체가 없다)와 "시작 실패·시스템 종료 후 재시작도 막힌 세션"이
/// 전부 인지 채널 0으로 남는다. 세 조건을 어댑터가 한 번에 판정한다 — 호출자
/// (AlarmSyncService)는 조건을 조립하지 않는다. 죽은 세션 재시작(restartIfNeeded)이
/// 표출(propagateChange)보다 선행하므로, 재시작에 성공한 세션은 자연히 도달 가능이다.
var isAlertReachable: Bool {
activity != nil
&& ActivityAuthorizationInfo().areActivitiesEnabled
&& !dismissedByUser
}

// MARK: - LastTrainDepartureEnding (Phase 13)

/// 정책: departed 상태는 잠금화면에 남았다가 출발 + 10분에 자동 소멸한다(.after) —
Expand Down
33 changes: 24 additions & 9 deletions Projects/App/Sources/Adapters/LocalNotificationAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,19 @@ import Foundation
@preconcurrency import UserNotifications

/// UNUserNotificationCenter → Domain `LocalNotificationPort` 어댑터.
/// UserNotifications를 import하는 곳은 App에서 이 파일뿐(디바이스 프레임워크 App 한정 규칙).
/// UserNotifications import는 App 한정(이 어댑터 + 노티 탭 라우팅 델리게이트) —
/// Feature·Domain 유입 금지 규칙은 불변.
///
/// 역할은 Phase 12의 두 가지뿐:
/// 역할은 두 가지뿐:
/// ① 권한 요청 — 명시된 한 시점(알람 등록 성공 직후, `DefaultRegisterAlarmUseCase` 훅)에서만
/// 불린다. 사일런트 푸시 경로에는 requestAuthorization이 절대 없다 — `post()`는 권한을
/// 묻지 않고 현재 상태만 조회해 authorized가 아니면 조용히 no-op한다.
/// ② dismiss 폴백 발송 — 유저가 LA를 스와이프로 지운 뒤의 변경 표출을 로컬 노티로 대신한다.
/// 반환값(Phase 15): 이번 호출로 최초 요청이 이뤄졌고 거부됐을 때만 `deniedNow` —
/// 홈이 1회 안내 토스트를 띄울 유일한 트리거다(이력이 있으면 요청도 안내도 없다).
/// ② 폴백 발송 — LA alert가 도달할 수 없는 상태(dismissed ∨ 활성 activity 없음 ∨ LA 비활성,
/// Phase 15 확대)의 변경 표출을 로컬 노티로 대신한다. 집중 모드(심야에 흔함)에서 억제되지
/// 않도록 `.timeSensitive` interruptionLevel을 싣는다(time-sensitive entitlement 필요 —
/// 앱 타겟 매니페스트에 선언, 유저가 설정에서 앱별로 끄는 것은 수용).
///
/// actor인 이유: LastTrainLiveActivityAdapter와 동일 — 포트가 nonisolated async 요구사항을
/// 가진 Sendable 프로토콜이라 MainActor 클래스의 격리 멤버로는 적합성이 성립하지 않는다.
Expand All @@ -27,17 +33,24 @@ actor LocalNotificationAdapter: LocalNotificationPort {

// MARK: - LocalNotificationPort

func requestAuthorizationIfNeeded() async {
guard !userDefaults.bool(forKey: Self.authRequestedDefaultsKey) else { return }
@discardableResult
func requestAuthorizationIfNeeded() async -> LocalNotificationAuthorizationOutcome {
guard !userDefaults.bool(forKey: Self.authRequestedDefaultsKey) else {
return .alreadySettled
}
// 허용·거부·에러 무관하게 "요청했음"을 기록한다 — 발송 가능 여부는 post()가 매번
// notificationSettings()로 실시간 조회하므로 결과까지 저장할 필요가 없고,
// 안내 토스트도 이 1회 요청의 결과(deniedNow)에만 매달리므로 구조적으로 1회다.
userDefaults.set(true, forKey: Self.authRequestedDefaultsKey)
do {
_ = try await UNUserNotificationCenter.current()
let granted = try await UNUserNotificationCenter.current()
.requestAuthorization(options: [.alert, .sound])
return granted ? .granted : .deniedNow
} catch {
// 실패 흡수(포트 계약) — 권한 요청 실패가 알람 등록 결과에 영향을 줄 수 없다.
// 거부 "확정"이 아니므로 안내 토스트 대상도 아니다.
return .alreadySettled
}
// 허용·거부·에러 무관하게 "요청했음"만 기록한다 — 발송 가능 여부는 post()가 매번
// notificationSettings()로 실시간 조회하므로 결과까지 저장할 필요가 없다.
userDefaults.set(true, forKey: Self.authRequestedDefaultsKey)
}

func post(title: String, body: String) async {
Expand All @@ -50,6 +63,8 @@ actor LocalNotificationAdapter: LocalNotificationPort {
content.title = title
content.body = body
content.sound = .default
// 막차 변경은 즉시 행동이 필요한 정보다 — 집중 모드를 관통한다(Phase 15).
content.interruptionLevel = .timeSensitive
let request = UNNotificationRequest(
// 폴백 노티는 변경 이벤트당 1건으로 드물다 — 교체(고정 id) 없이 개별 발송.
identifier: UUID().uuidString,
Expand Down
41 changes: 41 additions & 0 deletions Projects/App/Sources/Adapters/NotificationTapRoutingDelegate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import Foundation
@preconcurrency import UserNotifications

/// UNUserNotificationCenter 델리게이트 (Phase 15) — 포그라운드 표시 정책과 노티 탭 라우팅.
/// UserNotifications import는 App 한정(LocalNotificationAdapter + 이 파일) — Feature·Domain
/// 유입 금지 규칙은 불변. 이 앱의 노티는 폴백 노티 하나뿐이라 payload 파싱·identifier 분기가 없다.
///
/// 클래스는 App 기본 격리(MainActor)를 그대로 쓴다 — 시스템이 부르는 델리게이트 메서드만
/// nonisolated witness로 두고 내부에서 메인 액터로 복귀한다.
final class NotificationTapRoutingDelegate: NSObject, UNUserNotificationCenterDelegate {
/// 노티 탭 → 홈 랜딩 훅 — SceneDelegate가 AppCoordinator.returnToHome()으로 배선한다.
/// 앱 종료 상태에서의 탭(훅 미배선 시점)은 버린다 — 스플래시 → 홈이 곧 랜딩이라 의미가 같다.
var onTap: (() -> Void)?

/// AppDelegate launch 완료 전에 호출해야 한다 — 탭이 앱을 cold start시키는 경우의
/// didReceive까지 시스템이 이 델리게이트로 전달한다. UN 타입을 노출하지 않는 등록
/// 메서드라 AppDelegate에 UserNotifications import가 생기지 않는다.
func attachToNotificationCenter() {
UNUserNotificationCenter.current().delegate = self
}

/// 포그라운드 표시 정책 명시(Phase 15 결정): 표출 분기가 applicationState를 선행 검사해
/// 포그라운드에서는 애초에 노티를 발송하지 않는 것이 1차 방어라 이중 알림은 구조적으로
/// 없다. 이 정책은 "백그라운드 발송 → 활성화 직후 도달" 경합에서 인앱 토스트가 이미
/// 지나갔을 때 유일한 가시 채널을 살리는 안전망이다.
nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification
) async -> UNNotificationPresentationOptions {
[.banner, .sound]
}

nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse
) async {
// 기본 탭만 라우팅한다 — 커스텀 액션·dismiss 액션은 없다(만들지도 않았다).
guard response.actionIdentifier == UNNotificationDefaultActionIdentifier else { return }
await MainActor.run { self.onTap?() }
}
}
56 changes: 33 additions & 23 deletions Projects/App/Sources/AlarmSyncService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ import os
/// 홈 배너 강조·토스트)을 덧붙인다. 알람 재스케줄은 `RefreshAlarmUseCase.execute()` 안에서
/// 이미 끝난 뒤라(반환 = 재스케줄 완료) LA·인앱 표출 실패가 알람을 막을 구조 자체가 없다.
///
/// Phase 12 폴백·종료: ③ 유저가 LA를 스와이프로 지운 기록(`isDismissedByUser`)이 있으면
/// 백그라운드 alert 채널을 로컬 노티(같은 문구)로 갈아탄다 — 피기백 시점엔 앱이 깨어 있으므로
/// Phase 12 폴백·종료(15에서 조건 확대): ③ LA alert가 도달 불가한 상태(dismissed ∨ 활성
/// activity 없음 ∨ LA 비활성 — 어댑터의 `isAlertReachable` 단일 판정)면 백그라운드 alert
/// 채널을 로컬 노티(같은 문구, time-sensitive)로 갈아탄다 — 피기백 시점엔 앱이 깨어 있으므로
/// 서버 무관여로 가능하고, push-to-start 재생성은 하지 않는다(지운 의사 존중).
/// ④ advanced(actionable: false)는 LA를 missed 상태로, sessionEnded는 serviceEnded 최종
/// 상태로 내리고 로컬 알람을 취소한다. 배너 정리는 changes 스트림을 받은 홈의 몫.
Expand Down Expand Up @@ -329,24 +330,32 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents {

guard alarmTime > now else {
// 새 알람 시각이 이미 과거(출발은 미래) — 마지노선 침범. 원래 울렸어야 할 알람
// 시점이 지나 있으므로 조용한 채널로는 늦다: 포그라운드 여부와 무관하게 즉시 최후통첩.
// 시점이 지나 있으므로 조용한 채널로는 늦다: 백그라운드면 즉시 최후통첩.
let ultimatumState = LastTrainActivityState(
departureTime: departure,
alarmTime: alarmTime,
urgency: .imminent,
changeBadgeExpiry: badgeExpiry,
phase: .active
)
if UIApplication.shared.applicationState == .active {
// 포그라운드 — LA alert 소리·로컬 노티 없이 조용한 상태 갱신만(Phase 15
// 이중 알림 제거). 사용자 주의는 인앱 채널(changes 스트림 → 배너 강조 +
// 토스트)이 단독으로 맡는다 — 일반 advanced·missed 분기와 동일 구조.
await liveActivity.update(state: ultimatumState, alert: nil)
return
}
let alert = (
title: LastTrainChangeMessages.ultimatumTitle,
body: LastTrainChangeMessages.ultimatumBody(latestDeparture: departure)
)
if await liveActivity.isDismissedByUser {
// dismiss 폴백(Phase 12) — LA는 유저가 지웠다: alert를 실을 update는 no-op이고
// push-to-start 재생성은 하지 않는다(어차피 세션도 없고, 지운 의사 존중이 정책).
if await liveActivity.isAlertReachable {
await liveActivity.update(state: ultimatumState, alert: alert)
} else {
// 도달 불가 폴백(Phase 12→15 확대) — dismissed·activity 없음·LA 비활성
// 전부 로컬 노티로 갈아탄다. push-to-start 재생성은 하지 않는다(정책 불변).
// 피기백 시점엔 앱이 깨어 있으므로 서버 무관여 로컬 노티로 같은 문구를 보낸다.
await localNotification.post(title: alert.title, body: alert.body)
} else {
await liveActivity.update(state: LastTrainActivityState(
departureTime: departure,
alarmTime: alarmTime,
urgency: .imminent,
changeBadgeExpiry: badgeExpiry,
phase: .active
), alert: alert)
}
return
}
Expand All @@ -373,13 +382,13 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents {
title: LastTrainChangeMessages.advancedAlertTitle(minutesEarlier: minutesEarlier),
body: LastTrainChangeMessages.advancedAlertBody(from: previousDeparture, to: departure)
)
if await liveActivity.isDismissedByUser {
// dismiss 폴백(Phase 12) — LA alert 대신 같은 행동 중심 문구의 로컬 노티.
// push-to-start 재생성 금지(정책) — 지워진 LA를 되살리지 않는다.
await localNotification.post(title: alert.title, body: alert.body)
} else {
if await liveActivity.isAlertReachable {
// 잠금화면 alert + "당겨짐" 배지(만료 now+10분).
await liveActivity.update(state: state, alert: alert)
} else {
// 도달 불가 폴백(Phase 12→15 확대) — LA alert 대신 같은 행동 중심 문구의
// 로컬 노티. push-to-start 재생성 금지(정책) — 지워진 LA를 되살리지 않는다.
await localNotification.post(title: alert.title, body: alert.body)
}
}

Expand Down Expand Up @@ -408,11 +417,12 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents {
if UIApplication.shared.applicationState == .active {
// 포그라운드 — 상태 전환만 조용히. 사용자 주의는 인앱 채널이 맡는다(이중 알림 방지).
await liveActivity.update(state: state, alert: nil)
} else if await liveActivity.isDismissedByUser {
// dismiss 폴백(Phase 12) — push-to-start 재생성 금지, 같은 문구의 로컬 노티로 대신한다.
await localNotification.post(title: alert.title, body: alert.body)
} else {
} else if await liveActivity.isAlertReachable {
await liveActivity.update(state: state, alert: alert)
} else {
// 도달 불가 폴백(Phase 12→15 확대) — push-to-start 재생성 금지, 같은 문구의
// 로컬 노티로 대신한다.
await localNotification.post(title: alert.title, body: alert.body)
}
}

Expand Down
11 changes: 11 additions & 0 deletions Projects/App/Sources/AppCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,17 @@ final class AppCoordinator: Coordinator, CoordinatorFinishDelegate {
}
}

/// 노티 탭 랜딩(Phase 15) — presented를 접고 내비게이션을 홈 루트로 되돌린다.
/// 이 앱의 노티는 폴백 노티뿐이고 목적지는 항상 홈(배너·카드가 최신 상태를 말한다).
/// 스플래시 단계의 탭이면 popToRoot가 스플래시에 머무를 뿐 — 부트스트랩 후 홈 자연 랜딩.
func returnToHome() {
navigationController.presentedViewController?.dismiss(animated: false)
// 프로그램적 pop은 SearchCoordinator.closeFlow()를 타지 않아 자식 코디네이터가
// 잔존할 수 있다 — 스와이프 백 누수와 같은 계열이라 Phase 17
// (UINavigationControllerDelegate 정리)이 일괄 해소한다. 여기서 선취하지 않는다.
navigationController.popToRootViewController(animated: false)
}

func coordinatorDidFinish(_ coordinator: any Coordinator) {
removeChild(coordinator)
}
Expand Down
3 changes: 3 additions & 0 deletions Projects/App/Sources/AppDIContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ final class AppDIContainer {
/// Phase 13 발화 이후 세션 수명 — stopIntent(AlarmAcknowledgeIntent)가 조합 루트를
/// 거쳐 도달하는 지점. AppDelegate 경유로 인텐트 perform()이 접근한다.
let alarmSessionLifecycle: AlarmSessionLifecycleService
/// 노티 탭 라우팅 델리게이트(Phase 15) — AppDelegate가 launch 시 등록하고,
/// SceneDelegate가 홈 랜딩 훅(onTap)을 배선한다.
let notificationTapDelegate = NotificationTapRoutingDelegate()
#if DEV
/// DEV 플로팅 디버그 메뉴가 dismiss 기록 강제 토글에 접근하는 유일한 통로 (Phase 12 검수).
var devLiveActivityAdapter: LastTrainLiveActivityAdapter { liveActivityAdapter }
Expand Down
3 changes: 3 additions & 0 deletions Projects/App/Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
// 어댑터(actor)가 직렬화하므로 안전하다.
let container = container
Task { await container.reattachOrphanLiveActivities() }
// 노티 탭 라우팅(Phase 15) — launch 완료 전에 등록해야 탭이 앱을 cold start시키는
// 경우의 didReceive까지 잡는다. 홈 랜딩 훅 배선은 SceneDelegate 몫.
container.notificationTapDelegate.attachToNotificationCenter()
configureFirebaseIfAvailable()
if isFirebaseEnabled {
Messaging.messaging().delegate = self
Expand Down
4 changes: 4 additions & 0 deletions Projects/App/Sources/SceneDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
self.window = window
appCoordinator = coordinator
coordinator.start()
// 노티 탭 → 홈 랜딩 배선(Phase 15). 델리게이트 등록은 AppDelegate(launch 시점) 몫.
container.notificationTapDelegate.onTap = { [weak coordinator] in
coordinator?.returnToHome()
}

#if DEV
installDevChangeButton(in: window, container: container)
Expand Down
17 changes: 15 additions & 2 deletions Projects/Domain/Sources/Interfaces/LocalNotificationPort.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
/// 알림 권한 요청의 결과 — "이번 호출로 무엇이 일어났는가"를 담는다 (Phase 15).
/// 홈이 1회 안내 토스트를 띄울 유일한 트리거는 `deniedNow`다 — 요청 이력이 있으면
/// 시스템 요청도 안내도 다시 일어나지 않는다(재요청·재안내 스팸 금지).
public enum LocalNotificationAuthorizationOutcome: Sendable, Equatable {
/// 이번 호출로 요청이 이뤄졌고 허용됨.
case granted
/// 이번 호출로 최초 요청이 이뤄졌고 거부됨 — 1회 안내의 유일한 트리거.
case deniedNow
/// 요청 이력 있음(결과 무관) 또는 요청 불가 — 요청도 안내도 없다.
case alreadySettled
}

/// 로컬 노티 포트 — 구현은 App 어댑터(UNUserNotificationCenter는 App 한정 규칙).
/// 권한 요청은 명시된 한 시점(알람 등록 성공 직후)에서만 호출된다.
/// 거부 상태 기록·재요청 금지는 구현(어댑터) 책임. 실패는 밖으로 던지지 않는다.
public protocol LocalNotificationPort: Sendable {
func requestAuthorizationIfNeeded() async
/// LA dismiss 폴백용 즉시 발송. 권한 없으면 조용히 no-op.
@discardableResult
func requestAuthorizationIfNeeded() async -> LocalNotificationAuthorizationOutcome
/// LA alert 도달 불가 폴백용 즉시 발송. 권한 없으면 조용히 no-op.
func post(title: String, body: String) async
}
Loading