diff --git a/Packages/BiscottiKit/Sources/AppCore/AppCore.swift b/Packages/BiscottiKit/Sources/AppCore/AppCore.swift index c01f8cf3..75cb0bc0 100644 --- a/Packages/BiscottiKit/Sources/AppCore/AppCore.swift +++ b/Packages/BiscottiKit/Sources/AppCore/AppCore.swift @@ -326,6 +326,11 @@ public final class AppCore { /// Calendar-start notification timer tasks, keyed by event composite key. private var calendarTimerTasks: [String: Task] = [:] + /// Removal timers for posted calendar notifications, keyed by event key. + /// Separate from `calendarTimerTasks` so `scheduleCalendarTimers()` (which + /// runs on every calendar refresh) never cancels them (C1). + private var calendarNotificationExpiryTasks: [String: Task] = [:] + /// Background tasks for consuming detector events and notification actions. private var detectorConsumerTask: Task? private var notificationConsumerTask: Task? @@ -468,6 +473,10 @@ public final class AppCore { upcoming = calendar.upcoming logger.info("startBackgroundServices: calendar done") + // L1: clean up stale notifications from previous process before + // detection starts (so a fresh ad-hoc can't race the sweep). + await cleanUpStaleNotificationsOnLaunch() + logger.info("startBackgroundServices: detector start") detector.start() @@ -520,6 +529,10 @@ public final class AppCore { // persist on screen during an active recording. await notifications.cancelAdHocDetected() + // C2: remove all calendar notifications on recording start. + cancelAllCalendarNotificationExpiryTasks() + await notifications.cancelAllMeetingStarting() + // Stash the eventKey/title so retry can re-use them. pendingStartupEventKey = eventKey pendingStartupTitle = title @@ -1215,7 +1228,7 @@ extension AppCore { case let .started(app): await handleDetectionStarted(app: app) case let .stopped(app): - handleDetectionStopped(app: app) + await handleDetectionStopped(app: app) case .allMicUsersStopped: handleAllMicUsersStopped() } @@ -1274,7 +1287,12 @@ extension AppCore { runState = .detectedPending } - private func handleDetectionStopped(app: DetectedApp) { + private func handleDetectionStopped(app: DetectedApp) async { + // D1: remove the stopped app's ad-hoc notification regardless of + // run state. This is a no-op when the notification was already + // dismissed or removed by a recording start. + await notifications.cancelAdHocDetected(bundleID: app.bundleID) + // If pending detection and the stopped app matches, revert to idle if runState == .detectedPending, activeDetectedBundleID == app.bundleID @@ -1378,6 +1396,9 @@ extension AppCore { // MARK: - Calendar-start timers extension AppCore { + /// How long a calendar notification stays after the meeting starts (C1). + nonisolated static let calendarNotificationLifetime: TimeInterval = 300 + /// Filters upcoming events by the calendar notification mode. /// Pure and testable. nonisolated static func eventsToNotify( @@ -1432,15 +1453,70 @@ extension AppCore { guard runState == .idle || runState == .detectedPending else { return } + // Skip if the Mac slept through start + lifetime (notification + // would already be expired). + guard Self.shouldPostCalendarNotification( + eventStart: event.start, now: Date() + ) else { + logger.info( + "handleCalendarTimerFired: skipped expired notification for \(event.id)" + ) + return + } + lastCalendarNotificationDate = Date() await notifications.present( .meetingStarting( eventKey: event.id, title: event.title, - joinURL: event.conferenceURL + joinURL: event.conferenceURL, + start: event.start ) ) + + let expiry = event.start.addingTimeInterval( + Self.calendarNotificationLifetime + ) + scheduleCalendarNotificationExpiry( + eventKey: event.id, at: expiry + ) + } + + /// False once the notification would already be expired + /// (now >= start + lifetime). + nonisolated static func shouldPostCalendarNotification( + eventStart: Date, now: Date + ) -> Bool { + let expiry = eventStart.addingTimeInterval( + calendarNotificationLifetime + ) + return now < expiry + } + + private func scheduleCalendarNotificationExpiry( + eventKey: String, at expiry: Date + ) { + calendarNotificationExpiryTasks[eventKey]?.cancel() + let delay = expiry.timeIntervalSinceNow + let sched = scheduler + calendarNotificationExpiryTasks[eventKey] = Task { [weak self] in + if delay > 0 { + do { + try await sched.sleep(for: .seconds(delay)) + } catch { return } + } + guard let self, !Task.isCancelled else { return } + calendarNotificationExpiryTasks[eventKey] = nil + await notifications.cancelMeetingStarting(eventKey: eventKey) + } + } + + private func cancelAllCalendarNotificationExpiryTasks() { + for (_, task) in calendarNotificationExpiryTasks { + task.cancel() + } + calendarNotificationExpiryTasks.removeAll() } /// Mirrors `calendar.upcoming` into `self.upcoming` and reschedules @@ -1516,6 +1592,73 @@ extension AppCore { } } +// MARK: - Notification cleanup (L1) + +extension AppCore { + struct ScheduledExpiry: Equatable { + let eventKey: String + let expiry: Date + } + + struct LaunchNotificationCleanupPlan: Equatable { + var adHocBundleIDsToRemove: [String] = [] + var meetingEventKeysToRemove: [String] = [] + var meetingExpiriesToSchedule: [ScheduledExpiry] = [] + } + + nonisolated static func launchNotificationCleanupPlan( + _ delivered: [DeliveredOfferNotification], now: Date + ) -> LaunchNotificationCleanupPlan { + var plan = LaunchNotificationCleanupPlan() + for item in delivered { + switch item { + case let .adHocDetected(bundleID): + plan.adHocBundleIDsToRemove.append(bundleID) + case let .meetingStarting(eventKey, meetingStart): + let expiry = meetingStart.addingTimeInterval( + calendarNotificationLifetime + ) + if expiry <= now { + plan.meetingEventKeysToRemove.append(eventKey) + } else { + plan.meetingExpiriesToSchedule.append( + ScheduledExpiry( + eventKey: eventKey, expiry: expiry + ) + ) + } + } + } + return plan + } + + private func cleanUpStaleNotificationsOnLaunch() async { + let delivered = await notifications + .deliveredOfferNotifications() + let plan = Self.launchNotificationCleanupPlan( + delivered, now: Date() + ) + logger.info( + "L1 cleanup: \(plan.adHocBundleIDsToRemove.count) ad-hoc, \(plan.meetingEventKeysToRemove.count) expired cal, \(plan.meetingExpiriesToSchedule.count) deferred cal" + ) + for bundleID in plan.adHocBundleIDsToRemove { + await notifications.cancelAdHocDetected( + bundleID: bundleID + ) + } + for eventKey in plan.meetingEventKeysToRemove { + await notifications.cancelMeetingStarting( + eventKey: eventKey + ) + } + for item in plan.meetingExpiriesToSchedule { + scheduleCalendarNotificationExpiry( + eventKey: item.eventKey, at: item.expiry + ) + } + } +} + // MARK: - Association extension AppCore { diff --git a/Packages/BiscottiKit/Sources/AppCore/PreviewAppCore.swift b/Packages/BiscottiKit/Sources/AppCore/PreviewAppCore.swift index 8581576e..75b7db81 100644 --- a/Packages/BiscottiKit/Sources/AppCore/PreviewAppCore.swift +++ b/Packages/BiscottiKit/Sources/AppCore/PreviewAppCore.swift @@ -197,6 +197,10 @@ func alertStyle() async -> UNAlertStyle { .banner } + + func deliveredNotifications() async -> [DeliveredNotification] { + [] + } } /// No-op LLM runner for previews. diff --git a/Packages/BiscottiKit/Sources/Notifications/LiveNotificationCenter.swift b/Packages/BiscottiKit/Sources/Notifications/LiveNotificationCenter.swift index c9b8e7f9..da4633b8 100644 --- a/Packages/BiscottiKit/Sources/Notifications/LiveNotificationCenter.swift +++ b/Packages/BiscottiKit/Sources/Notifications/LiveNotificationCenter.swift @@ -53,4 +53,23 @@ public struct LiveNotificationCenter: NotificationCenterProviding, Sendable { public func alertStyle() async -> UNAlertStyle { await UNUserNotificationCenter.current().notificationSettings().alertStyle } + + public func deliveredNotifications() async -> [DeliveredNotification] { + let notifications = await UNUserNotificationCenter.current() + .deliveredNotifications() + return notifications.map { notification in + let content = notification.request.content + var stringInfo: [String: String] = [:] + for (key, value) in content.userInfo { + if let strKey = key as? String, let strVal = value as? String { + stringInfo[strKey] = strVal + } + } + return DeliveredNotification( + identifier: notification.request.identifier, + date: notification.date, + userInfo: stringInfo + ) + } + } } diff --git a/Packages/BiscottiKit/Sources/Notifications/NotificationCenterProviding.swift b/Packages/BiscottiKit/Sources/Notifications/NotificationCenterProviding.swift index 51389dce..5bf3e13d 100644 --- a/Packages/BiscottiKit/Sources/Notifications/NotificationCenterProviding.swift +++ b/Packages/BiscottiKit/Sources/Notifications/NotificationCenterProviding.swift @@ -25,4 +25,25 @@ public protocol NotificationCenterProviding: Sendable { /// Current on-screen alert style (banner vs. alert vs. none). func alertStyle() async -> UNAlertStyle + + /// Notifications currently in Notification Center for this app. + func deliveredNotifications() async -> [DeliveredNotification] +} + +/// A delivered notification snapshot, safe to send across isolation boundaries. +/// +/// Maps from `UNNotification` (a non-Sendable class) so nothing non-Sendable +/// leaves `LiveNotificationCenter`. +public struct DeliveredNotification: Sendable, Equatable { + public let identifier: String + public let date: Date + public let userInfo: [String: String] + + public init( + identifier: String, date: Date, userInfo: [String: String] + ) { + self.identifier = identifier + self.date = date + self.userInfo = userInfo + } } diff --git a/Packages/BiscottiKit/Sources/Notifications/NotificationIdentifiers.swift b/Packages/BiscottiKit/Sources/Notifications/NotificationIdentifiers.swift index a8eb2324..989b32a0 100644 --- a/Packages/BiscottiKit/Sources/Notifications/NotificationIdentifiers.swift +++ b/Packages/BiscottiKit/Sources/Notifications/NotificationIdentifiers.swift @@ -28,6 +28,7 @@ enum UserInfoKey { static let bundleID = "biscotti.bundleID" static let joinURL = "biscotti.joinURL" static let meetingID = "biscotti.meetingID" + static let eventStart = "biscotti.eventStart" } // MARK: - Kind string values stored in userInfo @@ -46,15 +47,27 @@ enum KindValue { /// notification in-place (UNNotificationRequest semantics). func requestIdentifier(for kind: NotificationKind) -> String { switch kind { - case let .meetingStarting(eventKey, _, _): - "biscotti.notif.meeting-start.\(eventKey)" + case let .meetingStarting(eventKey, _, _, _): + meetingStartRequestIdentifier(eventKey: eventKey) case let .adHocDetected(bundleID, _): - "biscotti.notif.adhoc.\(bundleID)" + adHocRequestIdentifier(bundleID: bundleID) case let .stopCountdown(meetingID, _): countdownRequestIdentifier(meetingID: meetingID) } } +/// Standalone meeting-start ID builder for cancel methods that +/// don't receive a full `NotificationKind`. +func meetingStartRequestIdentifier(eventKey: String) -> String { + "biscotti.notif.meeting-start.\(eventKey)" +} + +/// Standalone ad-hoc ID builder for cancel methods that +/// don't receive a full `NotificationKind`. +func adHocRequestIdentifier(bundleID: String) -> String { + "biscotti.notif.adhoc.\(bundleID)" +} + /// Standalone countdown ID builder for `cancelCountdown` which /// doesn't receive a full `NotificationKind`. func countdownRequestIdentifier(meetingID: UUID) -> String { diff --git a/Packages/BiscottiKit/Sources/Notifications/NotificationKind.swift b/Packages/BiscottiKit/Sources/Notifications/NotificationKind.swift index f2653a15..19fd46d3 100644 --- a/Packages/BiscottiKit/Sources/Notifications/NotificationKind.swift +++ b/Packages/BiscottiKit/Sources/Notifications/NotificationKind.swift @@ -3,7 +3,7 @@ import Foundation /// The three notification kinds the app can present. public enum NotificationKind: Sendable, Equatable { /// A calendar-driven meeting is starting (or imminent). - case meetingStarting(eventKey: String, title: String, joinURL: URL?) + case meetingStarting(eventKey: String, title: String, joinURL: URL?, start: Date) /// An ad-hoc meeting was detected in a meeting app. case adHocDetected(bundleID: String, appName: String) diff --git a/Packages/BiscottiKit/Sources/Notifications/NotificationService.swift b/Packages/BiscottiKit/Sources/Notifications/NotificationService.swift index 1a9df278..e88d774a 100644 --- a/Packages/BiscottiKit/Sources/Notifications/NotificationService.swift +++ b/Packages/BiscottiKit/Sources/Notifications/NotificationService.swift @@ -2,6 +2,12 @@ import Foundation import os import UserNotifications +/// A delivered "offer to record" notification, parsed. Other kinds are not reported. +public enum DeliveredOfferNotification: Equatable, Sendable { + case meetingStarting(eventKey: String, meetingStart: Date) + case adHocDetected(bundleID: String) +} + /// Manages notification lifecycle: authorization, presentation, countdown cancellation, /// and the typed action stream consumed by AppCore. /// @@ -137,6 +143,67 @@ public final class NotificationService { presentedAdHocIDs.removeAll() } + /// C1: removes one calendar notification (pending + delivered). + public func cancelMeetingStarting(eventKey: String) async { + let identifier = meetingStartRequestIdentifier(eventKey: eventKey) + logger.info("cancelMeetingStarting: removing \(identifier)") + provider.removePendingRequests(withIdentifiers: [identifier]) + provider.removeDeliveredNotifications(withIdentifiers: [identifier]) + } + + /// C2: removes every delivered/pending calendar notification. + public func cancelAllMeetingStarting() async { + let delivered = await provider.deliveredNotifications() + let ids = delivered + .filter { $0.userInfo[UserInfoKey.kind] == KindValue.meetingStarting } + .map(\.identifier) + guard !ids.isEmpty else { return } + logger.info("cancelAllMeetingStarting: removing \(ids)") + provider.removePendingRequests(withIdentifiers: ids) + provider.removeDeliveredNotifications(withIdentifiers: ids) + } + + /// D1: removes one app's "Meeting detected" notification and drops it + /// from `presentedAdHocIDs`. + public func cancelAdHocDetected(bundleID: String) async { + let identifier = adHocRequestIdentifier(bundleID: bundleID) + logger.info("cancelAdHocDetected(bundleID:): removing \(identifier)") + provider.removePendingRequests(withIdentifiers: [identifier]) + provider.removeDeliveredNotifications(withIdentifiers: [identifier]) + presentedAdHocIDs.remove(identifier) + } + + /// L1: parsed list of delivered offer notifications. + public func deliveredOfferNotifications() async -> [DeliveredOfferNotification] { + let delivered = await provider.deliveredNotifications() + return delivered.compactMap { notification in + let info = notification.userInfo + switch info[UserInfoKey.kind] { + case KindValue.meetingStarting: + guard let eventKey = info[UserInfoKey.eventKey] else { + return nil + } + let meetingStart: Date = if let raw = info[UserInfoKey.eventStart], + let epoch = Double(raw) + { + Date(timeIntervalSince1970: epoch) + } else { + notification.date + } + return .meetingStarting( + eventKey: eventKey, meetingStart: meetingStart + ) + case KindValue.adHoc: + guard let bundleID = info[UserInfoKey.bundleID] else { + return nil + } + return .adHocDetected(bundleID: bundleID) + default: + return nil + } + } + } + // MARK: - Action stream /// An unbounded `AsyncStream` of typed actions from user interactions with @@ -279,9 +346,10 @@ private func makeRequest(for kind: NotificationKind) -> UNNotificationRequest { let identifier = requestIdentifier(for: kind) switch kind { - case let .meetingStarting(eventKey, title, joinURL): + case let .meetingStarting(eventKey, title, joinURL, start): fillMeetingStartContent( - content, eventKey: eventKey, title: title, joinURL: joinURL + content, eventKey: eventKey, title: title, joinURL: joinURL, + start: start ) case let .adHocDetected(bundleID, appName): @@ -310,7 +378,8 @@ private func fillMeetingStartContent( _ content: UNMutableNotificationContent, eventKey: String, title: String, - joinURL: URL? + joinURL: URL?, + start: Date ) { content.title = title content.body = "" @@ -321,7 +390,8 @@ private func fillMeetingStartContent( var info: [String: String] = [ UserInfoKey.kind: KindValue.meetingStarting, - UserInfoKey.eventKey: eventKey + UserInfoKey.eventKey: eventKey, + UserInfoKey.eventStart: String(start.timeIntervalSince1970) ] if let joinURL { info[UserInfoKey.joinURL] = joinURL.absoluteString diff --git a/Packages/BiscottiKit/Tests/AppCoreTests/NotificationCleanupTests.swift b/Packages/BiscottiKit/Tests/AppCoreTests/NotificationCleanupTests.swift new file mode 100644 index 00000000..833ed9fb --- /dev/null +++ b/Packages/BiscottiKit/Tests/AppCoreTests/NotificationCleanupTests.swift @@ -0,0 +1,634 @@ +import AudioCapture +import BiscottiTestSupport +import Calendar +import DataStore +import Foundation +import MeetingDetection +import Notifications +import Testing +@testable import AppCore + +// MARK: - C1: Calendar notification expiry + +@Suite("AppCore -- C1 calendar notification expiry") +struct CalendarNotificationExpiryTests { + @Test("calendar notification removed after 300s") + @MainActor + func calendarNotificationRemovedAfterExpiry() async throws { + let now = Date() + let dto = makeMeetingDTO( + title: "Standup", + start: now.addingTimeInterval(60), + end: now.addingTimeInterval(3600) + ) + + let fix = try makeCoreFixture( + calendarEventDTOs: [dto], + useFakeScheduler: true, + testName: "C1Expiry" + ) + defer { fix.cleanup() } + let fakeScheduler = try #require(fix.fakeScheduler) + + try await fix.store.updateSettings { + $0.onboardingComplete = true + } + await fix.core.onLaunch() + + // Fire the calendar-start timer. + fakeScheduler.advance(by: .seconds(65)) + try await pollUntil { + !fix.fakeNotificationCenter.addedRequests.isEmpty + } + + let postedID = try #require( + fix.fakeNotificationCenter.addedRequests + .first { + $0.content.categoryIdentifier + .contains("meeting-starting") + }?.identifier + ) + + // Notification should NOT be removed yet. + #expect( + !fix.fakeNotificationCenter.backing.removedDeliveredIDs + .contains(postedID) + ) + + // The expiry delay is computed from wall-clock Date(), so the + // fake scheduler needs enough total advance to cover the full + // ~360s (start + 60 + lifetime 300) from the wall-clock + // perspective. Advance 365s more to be safe. + fakeScheduler.advance(by: .seconds(365)) + try await pollUntil { + fix.fakeNotificationCenter.backing.removedDeliveredIDs + .contains(postedID) + } + + #expect( + fix.fakeNotificationCenter.backing.removedDeliveredIDs + .contains(postedID) + ) + } + + @Test("C1 expiry survives calendar refresh (not cancelled by scheduleCalendarTimers)") + @MainActor + func expiryNotCancelledByCalendarRefresh() async throws { + let now = Date() + let dto = makeMeetingDTO( + title: "Standup", + start: now.addingTimeInterval(60), + end: now.addingTimeInterval(3600) + ) + + let fix = try makeCoreFixture( + calendarEventDTOs: [dto], + useFakeScheduler: true, + testName: "C1Refresh" + ) + defer { fix.cleanup() } + let fakeScheduler = try #require(fix.fakeScheduler) + + try await fix.store.updateSettings { + $0.onboardingComplete = true + } + await fix.core.onLaunch() + + // Fire calendar-start timer. + fakeScheduler.advance(by: .seconds(65)) + try await pollUntil { + !fix.fakeNotificationCenter.addedRequests.isEmpty + } + let postedID = try #require( + fix.fakeNotificationCenter.addedRequests + .first { + $0.content.categoryIdentifier + .contains("meeting-starting") + }?.identifier + ) + + // Simulate a calendar refresh by clearing upcoming events. + // This triggers scheduleCalendarTimers() via the mirror task. + fix.fakeEventStore.eventDTOs = [] + let refreshNow = Date() + await fix.calendarService.refreshUpcoming( + window: DateInterval( + start: refreshNow, + end: refreshNow.addingTimeInterval(86400) + ) + ) + try await Task.sleep(for: .milliseconds(100)) + + // Advance past the expiry -- the notification should still be + // removed because calendarNotificationExpiryTasks is separate + // from calendarTimerTasks. + fakeScheduler.advance(by: .seconds(365)) + try await pollUntil { + fix.fakeNotificationCenter.backing.removedDeliveredIDs + .contains(postedID) + } + #expect( + fix.fakeNotificationCenter.backing.removedDeliveredIDs + .contains(postedID) + ) + } +} + +// MARK: - C1: shouldPostCalendarNotification (pure) + +@Suite("AppCore -- shouldPostCalendarNotification") +struct ShouldPostCalendarNotificationTests { + @Test("true at event start") + func trueAtStart() { + let start = Date() + #expect( + AppCore.shouldPostCalendarNotification( + eventStart: start, now: start + ) + ) + } + + @Test("true at start + 299s") + func trueBeforeExpiry() { + let start = Date() + #expect( + AppCore.shouldPostCalendarNotification( + eventStart: start, + now: start.addingTimeInterval(299) + ) + ) + } + + @Test("false at start + 300s") + func falseAtExpiry() { + let start = Date() + #expect( + !AppCore.shouldPostCalendarNotification( + eventStart: start, + now: start.addingTimeInterval(300) + ) + ) + } + + @Test("false well after expiry") + func falseWellAfter() { + let start = Date() + #expect( + !AppCore.shouldPostCalendarNotification( + eventStart: start, + now: start.addingTimeInterval(600) + ) + ) + } +} + +// MARK: - C2: Recording start removes calendar notifications + +@Suite("AppCore -- C2 recording start removes calendar notifications") +struct RecordingStartCancelCalendarTests { + @Test("startRecording removes calendar notification and cancels expiry") + @MainActor + func startRecordingRemovesCalendar() async throws { + let now = Date() + let dto = makeMeetingDTO( + title: "Standup", + start: now.addingTimeInterval(60), + end: now.addingTimeInterval(3600) + ) + + let fix = try makeCoreFixture( + calendarEventDTOs: [dto], + useFakeScheduler: true, + testName: "C2" + ) + defer { fix.cleanup() } + let fakeScheduler = try #require(fix.fakeScheduler) + + try await fix.store.updateSettings { + $0.onboardingComplete = true + } + await fix.core.onLaunch() + + // Fire calendar-start timer. + fakeScheduler.advance(by: .seconds(65)) + try await pollUntil { + !fix.fakeNotificationCenter.addedRequests.isEmpty + } + + let postedID = try #require( + fix.fakeNotificationCenter.addedRequests + .first { + $0.content.categoryIdentifier + .contains("meeting-starting") + }?.identifier + ) + + // Start recording. + await fix.core.startRecording() + + // The calendar notification should be removed via + // cancelAllMeetingStarting(). + #expect( + fix.fakeNotificationCenter.backing.removedDeliveredIDs + .contains(postedID) + ) + + // The expiry task was cancelled. Advancing past the expiry + // should NOT produce another removal for the same ID. + let removedCountAfterStart = fix.fakeNotificationCenter + .backing.removedDeliveredIDs.count + fakeScheduler.advance(by: .seconds(400)) + try await Task.sleep(for: .milliseconds(100)) + + // Count should not increase (expiry task was cancelled). + #expect( + fix.fakeNotificationCenter.backing.removedDeliveredIDs + .count == removedCountAfterStart + ) + + _ = await fix.core.stopRecording() + } +} + +// MARK: - D1: Detection stopped removes ad-hoc notification + +@Suite("AppCore -- D1 detection stopped removes ad-hoc") +struct DetectionStoppedCancelAdHocTests { + @Test("detector .stopped removes that app's ad-hoc notification") + @MainActor + func stoppedRemovesAdHoc() async throws { + let fix = try makeCoreFixture( + useFakeScheduler: true, + useImmediateDetectorClock: true, + testName: "D1" + ) + defer { fix.cleanup() } + + try await fix.store.updateSettings { + $0.onboardingComplete = true + } + await fix.core.onLaunch() + + // Emit Zoom in-call -> detectedPending. + fix.fakeActivitySource.emit([ + makeAudioProcess( + bundleID: "us.zoom.xos", + input: true, output: true + ) + ]) + try await pollUntil { fix.core.runState == .detectedPending } + + let adHocID = "biscotti.notif.adhoc.us.zoom.xos" + + // Zoom stops. + fix.fakeActivitySource.emit([ + makeAudioProcess( + bundleID: "us.zoom.xos", + input: false, output: false + ) + ]) + try await pollUntil { fix.core.runState == .idle } + + #expect( + fix.fakeNotificationCenter.backing.removedDeliveredIDs + .contains(adHocID) + ) + } + + @Test("only the stopped app's notification is removed") + @MainActor + func onlyStoppedAppRemoved() async throws { + let fix = try makeCoreFixture( + useFakeScheduler: true, + useImmediateDetectorClock: true, + testName: "D1Multi" + ) + defer { fix.cleanup() } + + try await fix.store.updateSettings { + $0.onboardingComplete = true + } + await fix.core.onLaunch() + + // Emit two apps. + fix.fakeActivitySource.emit([ + makeAudioProcess( + bundleID: "us.zoom.xos", + input: true, output: true + ), + makeAudioProcess( + bundleID: "com.microsoft.teams2", + input: true, output: true, + pid: 2 + ) + ]) + try await pollUntil { fix.core.runState == .detectedPending } + + // Stop only Zoom. + fix.fakeActivitySource.emit([ + makeAudioProcess( + bundleID: "com.microsoft.teams2", + input: true, output: true, + pid: 2 + ) + ]) + try await pollUntil { + fix.fakeNotificationCenter.backing.removedDeliveredIDs + .contains("biscotti.notif.adhoc.us.zoom.xos") + } + + #expect( + fix.fakeNotificationCenter.backing.removedDeliveredIDs + .contains("biscotti.notif.adhoc.us.zoom.xos") + ) + // Teams' notification should NOT be removed. + #expect( + !fix.fakeNotificationCenter.backing.removedDeliveredIDs + .contains("biscotti.notif.adhoc.com.microsoft.teams2") + ) + } + + @Test("D1 while recording still calls remove for the stopped app") + @MainActor + func d1WhileRecording() async throws { + let fix = try makeCoreFixture( + useFakeScheduler: true, + useImmediateDetectorClock: true, + testName: "D1Recording" + ) + defer { fix.cleanup() } + + try await fix.store.updateSettings { + $0.onboardingComplete = true + } + await fix.core.onLaunch() + + // Emit Zoom in-call -> detectedPending -> start recording. + fix.fakeActivitySource.emit([ + makeAudioProcess( + bundleID: "us.zoom.xos", + input: true, output: true + ) + ]) + try await pollUntil { fix.core.runState == .detectedPending } + await fix.core.recordDetectedEvent(eventKey: nil) + guard case .recording = fix.core.runState else { + Issue.record("Expected recording") + return + } + + let adHocID = "biscotti.notif.adhoc.us.zoom.xos" + + // Count how many times the ad-hoc ID appears BEFORE Zoom stops. + // The bulk cancelAdHocDetected() in startRecording already added + // one occurrence. + let countBefore = fix.fakeNotificationCenter.backing + .removedDeliveredIDs.count(where: { $0 == adHocID }) + + // Zoom stops while recording. + fix.fakeActivitySource.emit([ + makeAudioProcess( + bundleID: "us.zoom.xos", + input: false, output: false + ) + ]) + + // D1 fires cancelAdHocDetected(bundleID:) which adds another + // occurrence of the ad-hoc ID to removedDeliveredIDs. + try await pollUntil { + fix.fakeNotificationCenter.backing.removedDeliveredIDs + .count(where: { $0 == adHocID }) > countBefore + } + + let countAfter = fix.fakeNotificationCenter.backing + .removedDeliveredIDs.count(where: { $0 == adHocID }) + #expect(countAfter > countBefore) + + _ = await fix.core.stopRecording() + } +} + +// MARK: - L1: Launch cleanup plan (pure) + +@Suite("AppCore -- L1 launch cleanup plan") +struct LaunchCleanupPlanTests { + @Test("ad-hoc always in remove list") + func adHocAlwaysRemoved() { + let plan = AppCore.launchNotificationCleanupPlan( + [.adHocDetected(bundleID: "us.zoom.xos")], + now: Date() + ) + #expect(plan.adHocBundleIDsToRemove == ["us.zoom.xos"]) + #expect(plan.meetingEventKeysToRemove.isEmpty) + #expect(plan.meetingExpiriesToSchedule.isEmpty) + } + + @Test("expired meeting-start in remove list") + func expiredMeetingStartRemoved() { + let now = Date() + let oldStart = now.addingTimeInterval(-600) // 10 min ago + let plan = AppCore.launchNotificationCleanupPlan( + [.meetingStarting(eventKey: "ev-1", meetingStart: oldStart)], + now: now + ) + #expect(plan.meetingEventKeysToRemove == ["ev-1"]) + #expect(plan.meetingExpiriesToSchedule.isEmpty) + } + + @Test("fresh meeting-start in schedule list") + func freshMeetingStartScheduled() { + let now = Date() + let recentStart = now.addingTimeInterval(-120) // 2 min ago + let plan = AppCore.launchNotificationCleanupPlan( + [.meetingStarting( + eventKey: "ev-1", meetingStart: recentStart + )], + now: now + ) + #expect(plan.meetingEventKeysToRemove.isEmpty) + #expect(plan.meetingExpiriesToSchedule.count == 1) + let scheduled = plan.meetingExpiriesToSchedule[0] + #expect(scheduled.eventKey == "ev-1") + // expiry = recentStart + 300 = now + 180 + let expectedExpiry = recentStart.addingTimeInterval(300) + #expect( + abs( + scheduled.expiry.timeIntervalSince1970 + - expectedExpiry.timeIntervalSince1970 + ) < 0.001 + ) + } + + @Test("boundary: expiry == now means remove") + func boundaryExpiryEqualsNow() { + let now = Date() + let startAtBoundary = now.addingTimeInterval(-300) + let plan = AppCore.launchNotificationCleanupPlan( + [.meetingStarting( + eventKey: "ev-1", meetingStart: startAtBoundary + )], + now: now + ) + #expect(plan.meetingEventKeysToRemove == ["ev-1"]) + #expect(plan.meetingExpiriesToSchedule.isEmpty) + } + + @Test("mixed input produces correct three lists") + func mixedInput() { + let now = Date() + let plan = AppCore.launchNotificationCleanupPlan( + [ + .adHocDetected(bundleID: "us.zoom.xos"), + .meetingStarting( + eventKey: "old", + meetingStart: now.addingTimeInterval(-600) + ), + .meetingStarting( + eventKey: "fresh", + meetingStart: now.addingTimeInterval(-60) + ) + ], + now: now + ) + #expect(plan.adHocBundleIDsToRemove == ["us.zoom.xos"]) + #expect(plan.meetingEventKeysToRemove == ["old"]) + #expect(plan.meetingExpiriesToSchedule.count == 1) + #expect(plan.meetingExpiriesToSchedule[0].eventKey == "fresh") + } +} + +// MARK: - L1: Launch cleanup integration + +@Suite("AppCore -- L1 launch cleanup integration") +struct LaunchCleanupIntegrationTests { + @Test("onLaunch removes stale notifications and schedules fresh expiry") + @MainActor + func launchCleansUp() async throws { + let fix = try makeCoreFixture( + useFakeScheduler: true, + testName: "L1Integration" + ) + defer { fix.cleanup() } + let fakeScheduler = try #require(fix.fakeScheduler) + + let now = Date() + fix.fakeNotificationCenter.backing.delivered = + makeLaunchCleanupSeed(now: now) + + try await fix.store.updateSettings { + $0.onboardingComplete = true + } + await fix.core.onLaunch() + + let removed = fix.fakeNotificationCenter.backing + .removedDeliveredIDs + + // Ad-hoc and expired meeting-start should be removed. + #expect(removed.contains("biscotti.notif.adhoc.us.zoom.xos")) + #expect( + removed.contains("biscotti.notif.meeting-start.old-ev") + ) + + // Countdown should NOT be removed. + #expect(!removed.contains("biscotti.notif.countdown.xxx")) + + // Fresh meeting-start should NOT be removed yet. + #expect( + !removed.contains("biscotti.notif.meeting-start.fresh-ev") + ) + + // Advance past the remaining expiry (~180s from wall clock). + fakeScheduler.advance(by: .seconds(185)) + try await pollUntil { + fix.fakeNotificationCenter.backing.removedDeliveredIDs + .contains("biscotti.notif.meeting-start.fresh-ev") + } + + #expect( + fix.fakeNotificationCenter.backing.removedDeliveredIDs + .contains("biscotti.notif.meeting-start.fresh-ev") + ) + } +} + +/// Build the seed array for the L1 launch-cleanup integration test. +private func makeLaunchCleanupSeed( + now: Date +) -> [DeliveredNotification] { + [ + DeliveredNotification( + identifier: "biscotti.notif.adhoc.us.zoom.xos", + date: now, + userInfo: [ + "biscotti.kind": "ad-hoc", + "biscotti.bundleID": "us.zoom.xos" + ] + ), + DeliveredNotification( + identifier: "biscotti.notif.meeting-start.old-ev", + date: now.addingTimeInterval(-600), + userInfo: [ + "biscotti.kind": "meeting-starting", + "biscotti.eventKey": "old-ev", + "biscotti.eventStart": String( + now.addingTimeInterval(-600).timeIntervalSince1970 + ) + ] + ), + DeliveredNotification( + identifier: "biscotti.notif.meeting-start.fresh-ev", + date: now.addingTimeInterval(-120), + userInfo: [ + "biscotti.kind": "meeting-starting", + "biscotti.eventKey": "fresh-ev", + "biscotti.eventStart": String( + now.addingTimeInterval(-120).timeIntervalSince1970 + ) + ] + ), + DeliveredNotification( + identifier: "biscotti.notif.countdown.xxx", + date: now, + userInfo: [ + "biscotti.kind": "countdown", + "biscotti.meetingID": UUID().uuidString + ] + ) + ] +} + +// MARK: - Helpers + +private func makeMeetingDTO( + eventIdentifier: String = "ev-1", + title: String = "Standup", + start: Date, + end: Date, + attendeeCount: Int = 3, + location: String? = "https://zoom.us/j/123" +) -> EKEventDTO { + EKEventDTO( + eventIdentifier: eventIdentifier, + calendarItemIdentifier: "ci-\(eventIdentifier)", + calendarItemExternalIdentifier: "ext-\(eventIdentifier)", + occurrenceDate: start, + title: title, + startDate: start, + endDate: end, + isAllDay: false, + location: location, + url: nil, + timeZone: nil, + notes: nil, + status: nil, + availability: nil, + calendarIdentifier: "cal-1", + calendarTitle: "Work", + calendarColorHex: "#0066CC", + calendarSourceTitle: "iCloud", + birthdayContactIdentifier: nil, + attendeeCount: attendeeCount, + attendees: [], + organizer: nil + ) +} diff --git a/Packages/BiscottiKit/Tests/BiscottiTestSupport/CoreFixture.swift b/Packages/BiscottiKit/Tests/BiscottiTestSupport/CoreFixture.swift index 57e07cd2..7690a977 100644 --- a/Packages/BiscottiKit/Tests/BiscottiTestSupport/CoreFixture.swift +++ b/Packages/BiscottiKit/Tests/BiscottiTestSupport/CoreFixture.swift @@ -212,6 +212,7 @@ public final class FakeTestNotificationCenter: NotificationCenterProviding, public var addedRequests: [UNNotificationRequest] = [] public var removedPendingIDs: [String] = [] public var removedDeliveredIDs: [String] = [] + public var delivered: [DeliveredNotification] = [] public var registeredCategories: Set = [] public var scriptedAlertStyle: UNAlertStyle = .banner } @@ -230,6 +231,19 @@ public final class FakeTestNotificationCenter: NotificationCenterProviding, public func add(_ request: UNNotificationRequest) async throws { backing.addedRequests.append(request) + // Model delivery: replace any existing entry with the same identifier. + backing.delivered.removeAll { $0.identifier == request.identifier } + var stringInfo: [String: String] = [:] + for (key, value) in request.content.userInfo { + if let strKey = key as? String, let strVal = value as? String { + stringInfo[strKey] = strVal + } + } + backing.delivered.append(DeliveredNotification( + identifier: request.identifier, + date: Date(), + userInfo: stringInfo + )) } public func removePendingRequests(withIdentifiers ids: [String]) { @@ -240,6 +254,12 @@ public final class FakeTestNotificationCenter: NotificationCenterProviding, withIdentifiers ids: [String] ) { backing.removedDeliveredIDs.append(contentsOf: ids) + let idSet = Set(ids) + backing.delivered.removeAll { idSet.contains($0.identifier) } + } + + public func deliveredNotifications() async -> [DeliveredNotification] { + backing.delivered } public func setCategories( diff --git a/Packages/BiscottiKit/Tests/NotificationsTests/CancelAdHocTests.swift b/Packages/BiscottiKit/Tests/NotificationsTests/CancelAdHocTests.swift index 5f881aec..f58a5017 100644 --- a/Packages/BiscottiKit/Tests/NotificationsTests/CancelAdHocTests.swift +++ b/Packages/BiscottiKit/Tests/NotificationsTests/CancelAdHocTests.swift @@ -94,7 +94,7 @@ struct CancelAdHocTests { // Present a meeting-starting notification (not ad-hoc). await service.present( - .meetingStarting(eventKey: "ev-1", title: "Standup", joinURL: nil) + .meetingStarting(eventKey: "ev-1", title: "Standup", joinURL: nil, start: Date()) ) await service.cancelAdHocDetected() diff --git a/Packages/BiscottiKit/Tests/NotificationsTests/ContentConstructionTests.swift b/Packages/BiscottiKit/Tests/NotificationsTests/ContentConstructionTests.swift index 2b700112..594ee011 100644 --- a/Packages/BiscottiKit/Tests/NotificationsTests/ContentConstructionTests.swift +++ b/Packages/BiscottiKit/Tests/NotificationsTests/ContentConstructionTests.swift @@ -12,7 +12,7 @@ struct ContentConstructionTests { _ = await service.requestAuthorization() await service.present( - .meetingStarting(eventKey: "k", title: "Standup", joinURL: nil) + .meetingStarting(eventKey: "k", title: "Standup", joinURL: nil, start: Date()) ) #expect(fake.addedRequests.count == 1) @@ -34,7 +34,7 @@ struct ContentConstructionTests { let url = try #require(URL(string: "https://zoom.us/j/123")) await service.present( - .meetingStarting(eventKey: "k2", title: "1:1", joinURL: url) + .meetingStarting(eventKey: "k2", title: "1:1", joinURL: url, start: Date()) ) #expect(fake.addedRequests.count == 1) diff --git a/Packages/BiscottiKit/Tests/NotificationsTests/FakeNotificationCenter.swift b/Packages/BiscottiKit/Tests/NotificationsTests/FakeNotificationCenter.swift index b71722f9..806e16fb 100644 --- a/Packages/BiscottiKit/Tests/NotificationsTests/FakeNotificationCenter.swift +++ b/Packages/BiscottiKit/Tests/NotificationsTests/FakeNotificationCenter.swift @@ -17,6 +17,7 @@ final class FakeNotificationCenter: NotificationCenterProviding, @unchecked Send var addedRequests: [UNNotificationRequest] = [] var removedPendingIDs: [[String]] = [] var removedDeliveredIDs: [[String]] = [] + var delivered: [DeliveredNotification] = [] var authRequestCount = 0 var authorizationGranted = true var currentStatus: UNAuthorizationStatus = .authorized @@ -38,6 +39,19 @@ final class FakeNotificationCenter: NotificationCenterProviding, @unchecked Send func add(_ request: UNNotificationRequest) async throws { backing.addedRequests.append(request) + // Model delivery: replace any existing entry with the same identifier. + backing.delivered.removeAll { $0.identifier == request.identifier } + var stringInfo: [String: String] = [:] + for (key, value) in request.content.userInfo { + if let strKey = key as? String, let strVal = value as? String { + stringInfo[strKey] = strVal + } + } + backing.delivered.append(DeliveredNotification( + identifier: request.identifier, + date: Date(), + userInfo: stringInfo + )) } func removePendingRequests(withIdentifiers ids: [String]) { @@ -46,6 +60,12 @@ final class FakeNotificationCenter: NotificationCenterProviding, @unchecked Send func removeDeliveredNotifications(withIdentifiers ids: [String]) { backing.removedDeliveredIDs.append(ids) + let idSet = Set(ids) + backing.delivered.removeAll { idSet.contains($0.identifier) } + } + + func deliveredNotifications() async -> [DeliveredNotification] { + backing.delivered } func authorizationStatus() async -> UNAuthorizationStatus { diff --git a/Packages/BiscottiKit/Tests/NotificationsTests/ForegroundPresentationTests.swift b/Packages/BiscottiKit/Tests/NotificationsTests/ForegroundPresentationTests.swift index 42f775bc..1f680740 100644 --- a/Packages/BiscottiKit/Tests/NotificationsTests/ForegroundPresentationTests.swift +++ b/Packages/BiscottiKit/Tests/NotificationsTests/ForegroundPresentationTests.swift @@ -13,7 +13,7 @@ struct ForegroundPresentationTests { _ = await service.requestAuthorization() await service.present( - .meetingStarting(eventKey: "k", title: "Standup", joinURL: nil) + .meetingStarting(eventKey: "k", title: "Standup", joinURL: nil, start: Date()) ) let request = fake.addedRequests[0] diff --git a/Packages/BiscottiKit/Tests/NotificationsTests/NotificationCleanupTests.swift b/Packages/BiscottiKit/Tests/NotificationsTests/NotificationCleanupTests.swift new file mode 100644 index 00000000..cc68b99a --- /dev/null +++ b/Packages/BiscottiKit/Tests/NotificationsTests/NotificationCleanupTests.swift @@ -0,0 +1,307 @@ +import Foundation +import Notifications +import Testing + +// MARK: - cancelMeetingStarting / cancelAdHocDetected(bundleID:) + +@Suite("Notification cleanup -- single cancel") +struct NotificationSingleCancelTests { + @Test("cancelMeetingStarting removes the correct ID from pending and delivered") + @MainActor + func cancelMeetingStartingRemovesCorrectID() async { + let fake = FakeNotificationCenter() + let service = NotificationService(provider: fake) + _ = await service.requestAuthorization() + + await service.present( + .meetingStarting( + eventKey: "ev-1", title: "Standup", joinURL: nil, + start: Date() + ) + ) + #expect(fake.addedRequests.count == 1) + let expectedID = "biscotti.notif.meeting-start.ev-1" + + await service.cancelMeetingStarting(eventKey: "ev-1") + + #expect(fake.removedPendingIDs.last == [expectedID]) + #expect(fake.removedDeliveredIDs.last == [expectedID]) + } + + @Test("cancelAdHocDetected(bundleID:) removes single-app ID and drops from tracking") + @MainActor + func cancelAdHocBundleIDRemovesAndDrops() async { + let fake = FakeNotificationCenter() + let service = NotificationService(provider: fake) + _ = await service.requestAuthorization() + + await service.present( + .adHocDetected(bundleID: "us.zoom.xos", appName: "Zoom") + ) + let expectedID = "biscotti.notif.adhoc.us.zoom.xos" + + await service.cancelAdHocDetected(bundleID: "us.zoom.xos") + + #expect(fake.removedPendingIDs.last == [expectedID]) + #expect(fake.removedDeliveredIDs.last == [expectedID]) + + // The old bulk cancelAdHocDetected() should not include this ID + // again (it was dropped from presentedAdHocIDs). + let pendingCountBefore = fake.removedPendingIDs.count + await service.cancelAdHocDetected() + #expect(fake.removedPendingIDs.count == pendingCountBefore) + } +} + +// MARK: - cancelAllMeetingStarting + +@Suite("Notification cleanup -- cancelAllMeetingStarting") +struct CancelAllMeetingStartingTests { + @Test("removes all meeting-start delivered entries, leaves ad-hoc and countdown") + @MainActor + func removesOnlyMeetingStart() async { + let fake = FakeNotificationCenter() + let service = NotificationService(provider: fake) + _ = await service.requestAuthorization() + + // Post a mix of notification kinds. + await service.present( + .meetingStarting( + eventKey: "ev-1", title: "A", joinURL: nil, + start: Date() + ) + ) + await service.present( + .meetingStarting( + eventKey: "ev-2", title: "B", joinURL: nil, + start: Date() + ) + ) + await service.present( + .adHocDetected(bundleID: "us.zoom.xos", appName: "Zoom") + ) + await service.present( + .stopCountdown(meetingID: UUID(), secondsRemaining: 10) + ) + + let removedBefore = fake.removedDeliveredIDs.count + + await service.cancelAllMeetingStarting() + + // Exactly one removal call with the two meeting-start IDs. + #expect(fake.removedDeliveredIDs.count == removedBefore + 1) + let removedIDs = Set(fake.removedDeliveredIDs.last ?? []) + #expect(removedIDs.count == 2) + #expect(removedIDs.contains("biscotti.notif.meeting-start.ev-1")) + #expect(removedIDs.contains("biscotti.notif.meeting-start.ev-2")) + } + + @Test("no remove calls when no meeting-start entries exist") + @MainActor + func noRemoveCallsWhenEmpty() async { + let fake = FakeNotificationCenter() + let service = NotificationService(provider: fake) + _ = await service.requestAuthorization() + + // Only post an ad-hoc notification. + await service.present( + .adHocDetected(bundleID: "us.zoom.xos", appName: "Zoom") + ) + + let pendingBefore = fake.removedPendingIDs.count + let deliveredBefore = fake.removedDeliveredIDs.count + + await service.cancelAllMeetingStarting() + + // No new removal calls. + #expect(fake.removedPendingIDs.count == pendingBefore) + #expect(fake.removedDeliveredIDs.count == deliveredBefore) + } +} + +// MARK: - Cancel runs regardless of authorization + +@Suite("Notification cleanup -- authorization") +struct NotificationCleanupAuthorizationTests { + @Test("cancel methods run when authorization is denied") + @MainActor + func cancelRunsWhenDenied() async { + let fake = FakeNotificationCenter() + fake.authorizationGranted = false + fake.currentStatus = .denied + let service = NotificationService(provider: fake) + + // Seed a delivered notification directly (present would skip due + // to denied auth, so seed the fake's delivered list). + fake.backing.delivered = [ + DeliveredNotification( + identifier: "biscotti.notif.meeting-start.ev-1", + date: Date(), + userInfo: [ + "biscotti.kind": "meeting-starting", + "biscotti.eventKey": "ev-1" + ] + ) + ] + + await service.cancelMeetingStarting(eventKey: "ev-1") + #expect(!fake.removedPendingIDs.isEmpty) + + await service.cancelAdHocDetected(bundleID: "us.zoom.xos") + #expect(fake.removedPendingIDs.count == 2) + + await service.cancelAllMeetingStarting() + // The meeting-start was already removed from delivered by the + // first cancel, so cancelAllMeetingStarting finds nothing. This + // verifies it still executes (no early return on auth status). + } +} + +// MARK: - Content: eventStart in userInfo + +@Suite("Notification cleanup -- eventStart userInfo") +struct EventStartUserInfoTests { + @Test("meetingStarting request has eventStart in userInfo") + @MainActor + func eventStartInUserInfo() async { + let fake = FakeNotificationCenter() + let service = NotificationService(provider: fake) + _ = await service.requestAuthorization() + + let start = Date(timeIntervalSince1970: 1_700_000_000) + await service.present( + .meetingStarting( + eventKey: "k", title: "Test", joinURL: nil, + start: start + ) + ) + + let content = fake.addedRequests[0].content + let storedStart = content.userInfo["biscotti.eventStart"] as? String + #expect(storedStart == String(start.timeIntervalSince1970)) + } +} + +// MARK: - deliveredOfferNotifications parsing + +@Suite("Notification cleanup -- deliveredOfferNotifications") +struct DeliveredOfferParsingTests { + @Test("parses meeting-start with eventStart") + @MainActor + func parsesMeetingStartWithEventStart() async { + let fake = FakeNotificationCenter() + let service = NotificationService(provider: fake) + + let start = Date(timeIntervalSince1970: 1_700_000_000) + fake.backing.delivered = [ + DeliveredNotification( + identifier: "biscotti.notif.meeting-start.ev-1", + date: Date(), + userInfo: [ + "biscotti.kind": "meeting-starting", + "biscotti.eventKey": "ev-1", + "biscotti.eventStart": String( + start.timeIntervalSince1970 + ) + ] + ) + ] + + let offers = await service.deliveredOfferNotifications() + #expect(offers.count == 1) + #expect( + offers[0] + == .meetingStarting(eventKey: "ev-1", meetingStart: start) + ) + } + + @Test("parses meeting-start without eventStart (falls back to date)") + @MainActor + func parsesMeetingStartFallback() async { + let fake = FakeNotificationCenter() + let service = NotificationService(provider: fake) + + let deliveryDate = Date(timeIntervalSince1970: 1_700_000_100) + fake.backing.delivered = [ + DeliveredNotification( + identifier: "biscotti.notif.meeting-start.ev-old", + date: deliveryDate, + userInfo: [ + "biscotti.kind": "meeting-starting", + "biscotti.eventKey": "ev-old" + ] + ) + ] + + let offers = await service.deliveredOfferNotifications() + #expect(offers.count == 1) + #expect( + offers[0] + == .meetingStarting( + eventKey: "ev-old", meetingStart: deliveryDate + ) + ) + } + + @Test("parses ad-hoc") + @MainActor + func parsesAdHoc() async { + let fake = FakeNotificationCenter() + let service = NotificationService(provider: fake) + + fake.backing.delivered = [ + DeliveredNotification( + identifier: "biscotti.notif.adhoc.us.zoom.xos", + date: Date(), + userInfo: [ + "biscotti.kind": "ad-hoc", + "biscotti.bundleID": "us.zoom.xos" + ] + ) + ] + + let offers = await service.deliveredOfferNotifications() + #expect(offers.count == 1) + #expect(offers[0] == .adHocDetected(bundleID: "us.zoom.xos")) + } + + @Test("skips countdown, unknown kind, and missing keys") + @MainActor + func skipsNonOffer() async { + let fake = FakeNotificationCenter() + let service = NotificationService(provider: fake) + + fake.backing.delivered = [ + // Countdown -- skipped + DeliveredNotification( + identifier: "biscotti.notif.countdown.xxx", + date: Date(), + userInfo: [ + "biscotti.kind": "countdown", + "biscotti.meetingID": UUID().uuidString + ] + ), + // Unknown kind -- skipped + DeliveredNotification( + identifier: "something", + date: Date(), + userInfo: ["biscotti.kind": "future-kind"] + ), + // Missing eventKey -- skipped + DeliveredNotification( + identifier: "biscotti.notif.meeting-start.broken", + date: Date(), + userInfo: ["biscotti.kind": "meeting-starting"] + ), + // Missing bundleID -- skipped + DeliveredNotification( + identifier: "biscotti.notif.adhoc.broken", + date: Date(), + userInfo: ["biscotti.kind": "ad-hoc"] + ) + ] + + let offers = await service.deliveredOfferNotifications() + #expect(offers.isEmpty) + } +} diff --git a/Packages/BiscottiKit/Tests/NotificationsTests/RequestIdentifierTests.swift b/Packages/BiscottiKit/Tests/NotificationsTests/RequestIdentifierTests.swift index 7d1e495d..a1c1f18c 100644 --- a/Packages/BiscottiKit/Tests/NotificationsTests/RequestIdentifierTests.swift +++ b/Packages/BiscottiKit/Tests/NotificationsTests/RequestIdentifierTests.swift @@ -12,10 +12,10 @@ struct RequestIdentifierTests { _ = await service.requestAuthorization() await service.present( - .meetingStarting(eventKey: "abc", title: "A", joinURL: nil) + .meetingStarting(eventKey: "abc", title: "A", joinURL: nil, start: Date()) ) await service.present( - .meetingStarting(eventKey: "xyz", title: "B", joinURL: nil) + .meetingStarting(eventKey: "xyz", title: "B", joinURL: nil, start: Date()) ) #expect(fake.addedRequests.count == 2) diff --git a/specs/projects/notification_cleanup/architecture.md b/specs/projects/notification_cleanup/architecture.md new file mode 100644 index 00000000..bd94866f --- /dev/null +++ b/specs/projects/notification_cleanup/architecture.md @@ -0,0 +1,278 @@ +--- +status: complete +--- + +# Architecture: Notification Cleanup + +Implements `functional_spec.md` (C1, C2, D1, L1, L2). Small project: one architecture doc, no component docs. + +Paths are relative to `Packages/BiscottiKit/`. + +## Overview and Ownership + +The existing split stays: + +- **`Notifications` module (`NotificationService`)**: owns notification identifiers, `userInfo` encoding/decoding, and the calls to `NotificationCenterProviding`. It gets new *removal* and *query* methods. It has no timers and no policy. +- **`AppCore`**: owns all timers (convention from `stage_c/components/notifications.md`: AppCore runs its own timers via the `AppScheduler` seam) and all policy (the 5-minute lifetime, when to remove what). + +No new module. No new dependency. + +## 1. `Notifications` module changes + +### 1.1 `NotificationKind.meetingStarting` gets the event start + +`Sources/Notifications/NotificationKind.swift`: + +```swift +case meetingStarting(eventKey: String, title: String, joinURL: URL?, start: Date) +``` + +Update all pattern matches (`requestIdentifier(for:)`, `makeRequest(for:)`) and test call sites (`ContentConstructionTests`, `ForegroundPresentationTests`, `RequestIdentifierTests`, `CancelAdHocTests`). The request identifier does **not** change (`biscotti.notif.meeting-start.`). + +### 1.2 Event start in `userInfo` + +`NotificationIdentifiers.swift`: add `UserInfoKey.eventStart = "biscotti.eventStart"`. + +`fillMeetingStartContent` writes `info[UserInfoKey.eventStart] = String(start.timeIntervalSince1970)`. (All `userInfo` values stay `String`.) The launch cleanup (L1) reads it back. `ResponseMapper` ignores unknown keys, so it needs no change. + +Add a free function next to `countdownRequestIdentifier`: + +```swift +func meetingStartRequestIdentifier(eventKey: String) -> String // "biscotti.notif.meeting-start.\(eventKey)" +func adHocRequestIdentifier(bundleID: String) -> String // "biscotti.notif.adhoc.\(bundleID)" +``` + +`requestIdentifier(for:)` calls these, so there is one source of truth for each ID format. + +### 1.3 Provider seam: list delivered notifications + +`NotificationCenterProviding` gets one new method: + +```swift +/// Notifications currently in Notification Center for this app. +func deliveredNotifications() async -> [DeliveredNotification] +``` + +New public Sendable value type (in `NotificationCenterProviding.swift`), so `UNNotification` (a non-Sendable class) never crosses an isolation boundary: + +```swift +public struct DeliveredNotification: Sendable, Equatable { + public let identifier: String + public let date: Date // UNNotification.date (delivery time) + public let userInfo: [String: String] // only String keys/values kept + public init(identifier: String, date: Date, userInfo: [String: String]) +} +``` + +`LiveNotificationCenter`: `await UNUserNotificationCenter.current().deliveredNotifications()`, mapped to `DeliveredNotification` in the same function (keep entries of `request.content.userInfo` where key and value are both `String`). + +`PreviewNotificationCenter` (`Sources/AppCore/PreviewAppCore.swift`): returns `[]`. + +### 1.4 `NotificationService` new API + +```swift +/// A delivered "offer to record" notification, parsed. Other kinds are not reported. +public enum DeliveredOfferNotification: Equatable, Sendable { + case meetingStarting(eventKey: String, meetingStart: Date) + case adHocDetected(bundleID: String) +} + +/// C1: removes one calendar notification (pending + delivered). +public func cancelMeetingStarting(eventKey: String) async + +/// C2: removes every delivered/pending calendar notification. +public func cancelAllMeetingStarting() async + +/// D1: removes one app's "Meeting detected" notification and drops it from +/// `presentedAdHocIDs`. +public func cancelAdHocDetected(bundleID: String) async + +/// L1: parsed list of delivered offer notifications. +public func deliveredOfferNotifications() async -> [DeliveredOfferNotification] +``` + +Behavior: + +- All `cancel…` methods run regardless of authorization (same rule as `cancelCountdown`) and call both `removePendingRequests` and `removeDeliveredNotifications` with the same ID list. Each logs one `info` line with the IDs. +- `cancelAllMeetingStarting()`: calls `provider.deliveredNotifications()`, keeps entries with `userInfo[kind] == KindValue.meetingStarting`, and removes their identifiers. If the list is empty, it makes no remove calls. It uses the delivered list (not an in-memory set) so it also removes notifications from a previous process that L1 kept alive. Pending requests are not a concern: all requests use `trigger: nil`. +- `deliveredOfferNotifications()`: parses each `DeliveredNotification` by `userInfo[kind]`: + - `meeting-starting` + `eventKey` present → `.meetingStarting(eventKey:, meetingStart:)`, where `meetingStart = Double(userInfo[eventStart]).map(Date.init(timeIntervalSince1970:)) ?? notification.date`. (The fallback covers notifications posted by older builds; those are posted at the event start, so the delivery date is a good estimate.) + - `ad-hoc` + `bundleID` present → `.adHocDetected(bundleID:)`. + - Anything else (countdown, missing keys, unknown kind) → skipped. +- The existing `cancelAdHocDetected()` (remove all tracked) is unchanged. + +## 2. `AppCore` changes (`Sources/AppCore/AppCore.swift`) + +### 2.1 New state and constant + +```swift +/// How long a calendar notification stays after the meeting starts (C1). +nonisolated static let calendarNotificationLifetime: TimeInterval = 300 // 5 minutes + +/// Removal timers for posted calendar notifications, keyed by event key. +/// Separate from `calendarTimerTasks` so `scheduleCalendarTimers()` (which +/// runs on every calendar refresh) never cancels them (C1). +private var calendarNotificationExpiryTasks: [String: Task] = [:] +``` + +### 2.2 Removal timer + +```swift +private func scheduleCalendarNotificationExpiry(eventKey: String, at expiry: Date) { + calendarNotificationExpiryTasks[eventKey]?.cancel() + let delay = expiry.timeIntervalSinceNow + let sched = scheduler + calendarNotificationExpiryTasks[eventKey] = Task { [weak self] in + if delay > 0 { + do { try await sched.sleep(for: .seconds(delay)) } catch { return } + } + guard let self, !Task.isCancelled else { return } + calendarNotificationExpiryTasks[eventKey] = nil + await notifications.cancelMeetingStarting(eventKey: eventKey) + } +} + +private func cancelAllCalendarNotificationExpiryTasks() { + for (_, task) in calendarNotificationExpiryTasks { task.cancel() } + calendarNotificationExpiryTasks.removeAll() +} +``` + +**L2 (sleep/wake):** `LiveAppScheduler` sleeps on `ContinuousClock`, which keeps counting while the Mac is asleep. A deadline that passes during sleep fires at wake. No added code. (The existing calendar-start timers already depend on this.) + +### 2.3 C1 — post, then schedule removal + +`handleCalendarTimerFired(event:)`: + +1. Existing checks (mode, recording suppression) stay. +2. **New:** `guard Self.shouldPostCalendarNotification(eventStart: event.start, now: Date()) else { log; return }`, where + + ```swift + /// False once the notification would already be expired (now >= start + lifetime). + nonisolated static func shouldPostCalendarNotification(eventStart: Date, now: Date) -> Bool + ``` + + Then `let expiry = event.start.addingTimeInterval(Self.calendarNotificationLifetime)`. (This occurs when the Mac sleeps through the start and `start + 5 min`, and the timer fires at wake. Without this check, we post a notification that is already expired.) +3. Present `.meetingStarting(eventKey:, title:, joinURL:, start: event.start)`. +4. `scheduleCalendarNotificationExpiry(eventKey: event.id, at: expiry)`. + +`scheduleCalendarTimers()` is **not** changed and does not touch `calendarNotificationExpiryTasks`. + +### 2.4 C2 — recording start + +In `startRecording(eventKey:title:)`, after the existing `await notifications.cancelAdHocDetected()`: + +```swift +cancelAllCalendarNotificationExpiryTasks() +await notifications.cancelAllMeetingStarting() +``` + +This is inside the one-recording-at-a-time guard, so it runs once per real start. Every start path already goes through `startRecording`. + +### 2.5 D1 — call ended + +`handleDetectionStopped(app:)` becomes `async`; `consumeDetectorEvents` awaits it. Add, before the existing `detectedPending` check, with no run-state condition: + +```swift +await notifications.cancelAdHocDetected(bundleID: app.bundleID) +``` + +The existing `runState` / `activeDetectedBundleID` reset logic stays unchanged. + +### 2.6 L1 — launch cleanup + +New method, called from `startBackgroundServices()` **before** `detector.start()` (so a new "Meeting detected" notification cannot be posted and then removed by the sweep) and before `scheduleCalendarTimers()`: + +```swift +private func cleanUpStaleNotificationsOnLaunch() async { + let delivered = await notifications.deliveredOfferNotifications() + let plan = Self.launchNotificationCleanupPlan(delivered, now: Date()) + for bundleID in plan.adHocBundleIDsToRemove { + await notifications.cancelAdHocDetected(bundleID: bundleID) + } + for eventKey in plan.meetingEventKeysToRemove { + await notifications.cancelMeetingStarting(eventKey: eventKey) + } + for item in plan.meetingExpiriesToSchedule { + scheduleCalendarNotificationExpiry(eventKey: item.eventKey, at: item.expiry) + } +} +``` + +The decision is a pure, `nonisolated static` function for direct unit tests: + +```swift +struct ScheduledExpiry: Equatable { let eventKey: String; let expiry: Date } + +struct LaunchNotificationCleanupPlan: Equatable { + var adHocBundleIDsToRemove: [String] = [] + var meetingEventKeysToRemove: [String] = [] + var meetingExpiriesToSchedule: [ScheduledExpiry] = [] +} + +nonisolated static func launchNotificationCleanupPlan( + _ delivered: [DeliveredOfferNotification], now: Date +) -> LaunchNotificationCleanupPlan +``` + +Rules: +- `.adHocDetected(bundleID)` → always in `adHocBundleIDsToRemove`. +- `.meetingStarting(eventKey, meetingStart)`: `expiry = meetingStart + calendarNotificationLifetime`. If `expiry <= now` → `meetingEventKeysToRemove`; else → `meetingExpiriesToSchedule`. + +Mark the plan types `package` (or `internal` + `@testable import`, matching what `AppCoreTests` already does). + +`startBackgroundServices()` is near the lint function-body limit; the single added call (`await cleanUpStaleNotificationsOnLaunch()`) keeps it short. + +## 3. Error Handling and Logging + +- The `UserNotifications` remove calls do not throw. `deliveredNotifications()` does not throw. There is no error path to handle. +- If the system has no delivered notifications, or authorization is denied, all queries return empty lists and the removals are no-ops. +- Logging: `NotificationService` logs each removal (`info`, the IDs) on its existing logger. `AppCore` logs on the `Detection` logger for D1, and on the `AppCore` logger for the launch cleanup summary (counts) and for a skipped late calendar post. + +## 4. Testing Strategy + +Swift Testing, as in the existing suites. All tests run via `make test`. + +### 4.1 Test fakes + +Both `FakeNotificationCenter` (`Tests/NotificationsTests/`) and `FakeTestNotificationCenter` (`Tests/BiscottiTestSupport/CoreFixture.swift`) model delivery: + +- New backing field `delivered: [DeliveredNotification]`. +- `add(_:)` also appends a `DeliveredNotification` built from the request (`identifier`, `date: Date()`, String-only `userInfo`), replacing any entry with the same identifier (real `UNUserNotificationCenter` semantics). +- `removeDeliveredNotifications(withIdentifiers:)` also removes matching entries from `delivered` (the existing recording arrays stay, so current assertions keep working). +- `deliveredNotifications()` returns `delivered`. +- Tests seed `delivered` directly for launch-cleanup cases. + +### 4.2 `NotificationsTests` (new file `NotificationCleanupTests.swift`, plus updated call sites) + +1. `cancelMeetingStarting(eventKey:)` removes `biscotti.notif.meeting-start.` from pending and delivered. +2. `cancelAdHocDetected(bundleID:)` removes `biscotti.notif.adhoc.`; a following `cancelAdHocDetected()` does not include that ID again. +3. `cancelAllMeetingStarting()` removes all meeting-start delivered entries and leaves ad-hoc and countdown entries. +4. `cancelAllMeetingStarting()` with no meeting-start entries makes no remove calls. +5. Cancel methods run when authorization is denied. +6. Content: a `.meetingStarting` request has `userInfo[eventStart]` equal to `String(start.timeIntervalSince1970)`. +7. `deliveredOfferNotifications()`: parses meeting-start (with `eventStart`), meeting-start without `eventStart` (falls back to `date`), ad-hoc; skips countdown, unknown kind, missing `eventKey`/`bundleID`. + +### 4.3 `AppCoreTests` (new file `NotificationCleanupTests.swift`, using `CoreFixture` + `FakeScheduler`) + +Timers compute delays from `Date()` while `FakeScheduler` controls only the sleep, as the existing calendar-timer tests do. Set event starts relative to `Date()` and advance the scheduler by the delay plus a small margin. + +1. **C1:** event starts in 60s → advance 60s → calendar notification posted, not removed → advance 300s → the meeting-start ID is in `removedDeliveredIDs`. +2. **C1 survives refresh:** after the post, change `upcoming` (remove the event) → advance 300s → still removed exactly at expiry (the expiry task was not cancelled by `scheduleCalendarTimers()`). +3. **C1 late fire (pure):** `shouldPostCalendarNotification` is true at `start`, true at `start + 299s`, false at `start + 300s` and later. +4. **C2:** post a calendar notification, then `startRecording()` → meeting-start ID removed and no pending expiry sleep remains for it (`FakeScheduler.pendingCount` drops). +5. **D1 pending:** ad-hoc posted for app A → detector `.stopped(A)` → `biscotti.notif.adhoc.A` removed. +6. **D1 other app:** ad-hoc for A and B → `.stopped(A)` → only A removed. +7. **D1 while recording:** `.stopped(A)` while recording still calls remove for A (no-op in practice). +8. **L1 plan (pure):** `launchNotificationCleanupPlan` with ad-hoc, expired meeting-start, fresh meeting-start → correct three lists; boundary `expiry == now` → remove. +9. **L1 integration:** seed fake `delivered` with ad-hoc, expired meeting-start, fresh meeting-start (start 2 min ago), countdown → `onLaunch()` → ad-hoc and expired removed, countdown untouched → advance 180s → fresh one removed. + +### 4.4 Manual check (not gated) + +No library in the manual-test staleness list is touched (`Notifications`/`AppCore` only; `MeetingDetection` is unchanged), so `manual_test_results.json` does not change. After merge, a human check on hardware is recommended: calendar notification goes away 5 minutes after the start; "Meeting detected" goes away about 8 seconds after leaving the call; stale entries are gone after relaunch. + +## 5. Risks + +- **`deliveredNotifications()` isolation:** `UNNotification` is not `Sendable`. Map to `DeliveredNotification` inside `LiveNotificationCenter` before returning, so nothing non-Sendable leaves the call. +- **Delivered list freshness:** `cancelAllMeetingStarting()` depends on the system list. If the user already dismissed a notification, it is not in the list, and that is correct. +- **Sleep/wake on `ContinuousClock`:** expected to fire promptly at wake (the calendar-start timers rely on the same behavior). Covered by the recommended manual check, not by an automated test. diff --git a/specs/projects/notification_cleanup/functional_spec.md b/specs/projects/notification_cleanup/functional_spec.md new file mode 100644 index 00000000..84fd0833 --- /dev/null +++ b/specs/projects/notification_cleanup/functional_spec.md @@ -0,0 +1,74 @@ +--- +status: complete +--- + +# Functional Spec: Notification Cleanup + +## Problem + +Biscotti posts two kinds of "offer to record" notifications at `.timeSensitive` priority: + +- **Calendar notification** ("Record & Join" / "Record"), posted at the event start time. +- **"Meeting detected" notification**, posted when an app has held the microphone for 3 seconds. + +Today, Biscotti removes "Meeting detected" notifications only when a recording starts. It never removes calendar notifications. Both kinds stay in Notification Center (and, with the "Alerts" style, on screen) long after they stop being useful. + +## Goal + +Remove each notification (from the screen **and** from Notification Center) when it no longer applies. The priority level does not change; removal is the fix. + +"Remove" in this spec means: remove the delivered notification and any pending request with the same identifier. Removing a notification that the user has already dismissed is a silent no-op. + +## Behaviors + +### C1 — Calendar notification expires 5 minutes after the meeting starts + +- When Biscotti posts a calendar notification for an event, it removes that notification at `event.start + 5 minutes`. +- The removal time is fixed when the notification is posted. It does not use the event end time, also for meetings shorter than 5 minutes. +- Calendar changes after the post have no effect on the removal: if the event is deleted, moved, or its notification setting changes, the notification is still removed at the original `start + 5 minutes`. A calendar refresh must not cancel or change a scheduled removal. + +### C2 — A recording start removes calendar notifications + +- When a recording starts, from any path (calendar notification action, "Meeting detected" action, menu bar, home, event preview, hotkey, etc.), Biscotti removes all of its calendar notifications at once, as it already does for "Meeting detected" notifications. +- After this removal, a scheduled C1 removal for the same notification is a no-op. + +### D1 — "Meeting detected" notification is removed when the call ends + +- When the meeting detector reports that a detected app stopped using the microphone (the existing per-app "stopped" signal: mic released for 8 seconds), Biscotti removes that app's "Meeting detected" notification. +- This applies in all run states, not only while the detection is pending. +- No added grace period. If an app releases the mic when the user mutes, the notification can go away during the muted period. This is acceptable. +- The existing behavior stays: a recording start removes all "Meeting detected" notifications. + +### L1 — Cleanup at app launch + +Delivered notifications stay in Notification Center after Biscotti quits or crashes. At launch, Biscotti examines its delivered notifications: + +- **"Meeting detected" notifications:** remove all. Detection state starts fresh at launch, so none of them can still be tracked. +- **Calendar notifications:** + - If the meeting started more than 5 minutes ago, remove the notification at once. + - If the meeting started less than 5 minutes ago, schedule the removal at `start + 5 minutes` (as C1). +- Other notification kinds (for example, the auto-stop countdown) are not changed by the launch cleanup. + +### L2 — Sleep and wake + +Removal times are wall-clock times. If the Mac is asleep at a removal time, the removal occurs immediately after wake. + +## Edge Cases + +| Case | Behavior | +|---|---| +| User dismisses or clicks the notification before the removal time | The later removal is a silent no-op. | +| Calendar notification is not posted (suppressed because a recording runs, or mode is "Never") | No removal is scheduled. | +| The Mac sleeps through `start + 5 minutes`, so the calendar timer fires at wake | Do not post the calendar notification (it would already be expired). | +| Two calendar events start at the same time | Each notification has its own removal at its own `start + 5 minutes`. | +| Two apps detected (two "Meeting detected" notifications) | Each app's notification is removed when that app's "stopped" signal arrives. | +| Notification authorization is denied | Removal still runs (cleanup is always valid), as `cancelCountdown` does today. | +| Recording start fails after the notifications are removed | The notifications stay removed. | + +## Out of Scope + +- Changing notification priority (`.timeSensitive`) or presentation. +- The auto-stop countdown notification (already removed when the countdown is cancelled or the recording stops). +- Removing notifications when settings change (for example, "Monitor for Meetings" turned off, or the calendar mode set to "Never"). Existing notifications expire through C1 / D1 / L1. +- Any new user setting. The 5-minute window is a constant in code. +- UI changes. This project has no user-facing UI apart from notifications that go away. diff --git a/specs/projects/notification_cleanup/implementation_plan.md b/specs/projects/notification_cleanup/implementation_plan.md new file mode 100644 index 00000000..280bc3e4 --- /dev/null +++ b/specs/projects/notification_cleanup/implementation_plan.md @@ -0,0 +1,9 @@ +--- +status: complete +--- + +# Implementation Plan: Notification Cleanup + +## Phases + +- [x] Phase 1: All of `architecture.md` — `Notifications` module (event start in `userInfo`, `deliveredNotifications()` seam + `DeliveredNotification`, new `cancel…`/query methods on `NotificationService`, fakes model delivery) and `AppCore` wiring (C1 expiry timers + late-post skip, C2 on recording start, D1 on detection stopped, L1 launch cleanup), with the tests in architecture §4. diff --git a/specs/projects/notification_cleanup/phase_plans/phase_1.md b/specs/projects/notification_cleanup/phase_plans/phase_1.md new file mode 100644 index 00000000..7b14eb57 --- /dev/null +++ b/specs/projects/notification_cleanup/phase_plans/phase_1.md @@ -0,0 +1,43 @@ +--- +status: complete +--- + +# Phase 1: Notification Cleanup + +## Overview + +Implements all notification cleanup behaviors (C1, C2, D1, L1, L2) in one phase: the `Notifications` module gets new removal/query methods and the `start` field on `meetingStarting`, while `AppCore` gets expiry timers, recording-start removal, detection-stop removal, and launch cleanup. Both test fakes model delivery so the new code is testable without hardware. + +## Steps + +1. **NotificationKind.swift** -- add `start: Date` to `.meetingStarting(eventKey:title:joinURL:start:)`. +2. **NotificationIdentifiers.swift** -- add `UserInfoKey.eventStart`, add `meetingStartRequestIdentifier(eventKey:)` and `adHocRequestIdentifier(bundleID:)` free functions; refactor `requestIdentifier(for:)` to call them. +3. **NotificationCenterProviding.swift** -- add `deliveredNotifications() async -> [DeliveredNotification]` and the `DeliveredNotification` value type. +4. **LiveNotificationCenter.swift** -- implement `deliveredNotifications()` by mapping `UNNotification` to `DeliveredNotification`. +5. **PreviewAppCore.swift** -- add `deliveredNotifications()` returning `[]` to `PreviewNotificationCenter`. +6. **NotificationService.swift** -- write `eventStart` into `userInfo` in `fillMeetingStartContent`; add `DeliveredOfferNotification`, `cancelMeetingStarting(eventKey:)`, `cancelAllMeetingStarting()`, `cancelAdHocDetected(bundleID:)`, `deliveredOfferNotifications()`; update `makeRequest` switch for the new `start` parameter. +7. **FakeNotificationCenter** (NotificationsTests) -- add `delivered` backing, update `add`/`removeDeliveredNotifications`, add `deliveredNotifications()`. +8. **FakeTestNotificationCenter** (CoreFixture.swift) -- same delivery-modelling changes. +9. **AppCore.swift** -- add `calendarNotificationLifetime`, `calendarNotificationExpiryTasks`, `scheduleCalendarNotificationExpiry`, `cancelAllCalendarNotificationExpiryTasks`; update `handleCalendarTimerFired` with `shouldPostCalendarNotification` guard + expiry scheduling + `start:` argument; update `startRecording` with `cancelAllCalendarNotificationExpiryTasks` + `cancelAllMeetingStarting`; make `handleDetectionStopped` async and add `cancelAdHocDetected(bundleID:)`; add `cleanUpStaleNotificationsOnLaunch`, `launchNotificationCleanupPlan`, `shouldPostCalendarNotification`, `ScheduledExpiry`, `LaunchNotificationCleanupPlan`; call cleanup from `startBackgroundServices`. +10. Update all existing test call sites that construct `.meetingStarting` to include `start:`. +11. **NotificationsTests/NotificationCleanupTests.swift** -- new file with tests per architecture section 4.2. +12. **AppCoreTests/NotificationCleanupTests.swift** -- new file with tests per architecture section 4.3. + +## Tests + +- `cancelMeetingStarting(eventKey:)` removes the correct ID from pending and delivered +- `cancelAdHocDetected(bundleID:)` removes the single-app ID and drops from tracking +- `cancelAllMeetingStarting()` removes all meeting-start entries, leaves others +- `cancelAllMeetingStarting()` with no entries makes no remove calls +- Cancel methods run when authorization is denied +- `meetingStarting` request has `userInfo[eventStart]` equal to `String(start.timeIntervalSince1970)` +- `deliveredOfferNotifications()` parses meeting-start, meeting-start fallback, ad-hoc, skips others +- C1: calendar notification posted then removed after 300s +- C1 survives calendar refresh (expiry not cancelled by `scheduleCalendarTimers`) +- `shouldPostCalendarNotification` boundary test (true < 300s, false >= 300s) +- C2: recording start removes calendar notification and drops expiry task +- D1 pending: `.stopped(app)` removes that app's ad-hoc notification +- D1 other app: only the stopped app's notification is removed +- D1 while recording: still calls remove +- L1 plan (pure): correct three lists, boundary `expiry == now` -> remove +- L1 integration: seed delivered, launch, verify removals and deferred expiry diff --git a/specs/projects/notification_cleanup/project_overview.md b/specs/projects/notification_cleanup/project_overview.md new file mode 100644 index 00000000..3021672b --- /dev/null +++ b/specs/projects/notification_cleanup/project_overview.md @@ -0,0 +1,12 @@ +--- +status: complete +--- + +# Notification Cleanup + +Biscotti needs to close notifications when they no longer apply. + +- **Meeting/time based ones** (calendar event notifications): remove them 5 minutes after the meeting starts. +- **"Meeting detected" based ones**: remove them when the call ends. + +Today we show notifications with high priority, and never remove them.