diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index d6e40adc25..2b739f50b9 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -1917,8 +1917,10 @@ EntityFuzzySearch/FuzzySearcher.swift, EntityFuzzySearch/FuzzyTextNormalizer.swift, Environment.swift, + FocusFilterState.swift, FocusFilterWrapper.swift, FocusReport.swift, + FocusStatusObservation.swift, FocusStatusWrapper.swift, ForceCloseWarningManager.swift, FrontendThemeProvider.swift, diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index 660bac9b51..8095d5803a 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -70,6 +70,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { MaterialDesignIcons.register() + // Before anything reads the Focus sensors, which the launch below already does. + FocusNameFocusFilterAppIntent.registerCurrentValuesReader() guard !Current.isRunningTests else { return true diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index 03b5a7365b..3e22bcd65f 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -811,14 +811,14 @@ This server requires a client certificate (mTLS) but the operation was cancelled "focus.how_it_works.intro" = "iOS tells apps that a Focus is on, but never which one. A Focus Filter is the one place iOS lets you hand that detail to an app, so you tell it the name once and it reports it from then on."; "focus.how_it_works.naming.body" = "Create a name here for each Focus you want reported. The name is sent exactly as you type it, so it is what you will see in Home Assistant."; "focus.how_it_works.naming.title" = "Name your Focuses"; -"focus.how_it_works.no_focus.body" = "The sensor reports Not focused once every Focus has ended. This needs the Focus permission, which is what tells the app a Focus is running at all."; +"focus.how_it_works.no_focus.body" = "The sensor is empty once every Focus has ended. Its Is focused attribute also turns off, which needs the Focus permission."; "focus.how_it_works.no_focus.title" = "When no Focus is on"; "focus.how_it_works.privacy.body" = "The names are yours, kept on this device, and only leave it as the state of the sensor you enable. The app never reads your Focus settings."; "focus.how_it_works.privacy.title" = "Your privacy"; "focus.how_it_works.reporting.body" = "Turning that Focus on runs the filter, and the app sends the name to Home Assistant as the Focus name sensor."; "focus.how_it_works.reporting.title" = "It reports itself"; "focus.how_it_works.title" = "How it works?"; -"focus.how_it_works.unpaired.body" = "iOS only runs the filter of the Focus that starts, so add one to every Focus you want reported. A Focus without a filter leaves the previous name in place until a paired Focus starts or all of them end."; +"focus.how_it_works.unpaired.body" = "iOS only runs the filter of a Focus it is added to, so add one to every Focus you want reported. While a Focus without a filter is on, the sensor is empty."; "focus.how_it_works.unpaired.title" = "Focuses without a filter"; "focus.names.active" = "Reporting now"; "focus.names.add" = "Add Focus name"; @@ -828,7 +828,7 @@ This server requires a client certificate (mTLS) but the operation was cancelled "focus.names.header" = "Focus names"; "focus.names.last_reported" = "Last reported"; "focus.names.placeholder" = "Name"; -"focus.permission_missing.body" = "Without it the app cannot tell when every Focus has ended, so the last reported name keeps being sent."; +"focus.permission_missing.body" = "Without it the app only sees the Focuses you added the filter to."; "focus.permission_missing.title" = "Focus permission is off"; "focus.reported.current_name" = "Focus name"; "focus.reported.header" = "Reported to Home Assistant"; diff --git a/Sources/App/Settings/Focus/FocusNameFocusFilterAppIntent.swift b/Sources/App/Settings/Focus/FocusNameFocusFilterAppIntent.swift index c28a05cb18..3780f618e4 100644 --- a/Sources/App/Settings/Focus/FocusNameFocusFilterAppIntent.swift +++ b/Sources/App/Settings/Focus/FocusNameFocusFilterAppIntent.swift @@ -4,8 +4,9 @@ import PromiseKit import Shared /// The Focus Filter the user adds to a Focus in Settings › Focus, picking one of the names they -/// created in the app. iOS runs this when that Focus activates, which is the only moment it tells -/// us anything about _which_ Focus is running. +/// created in the app. iOS performs it with that name when the Focus turns on, and with none when +/// it ends. That, and asking iOS for its current values, are the only ways iOS tells an app +/// _which_ Focus is on. struct FocusNameFocusFilterAppIntent: SetFocusFilterIntent { static let title: LocalizedStringResource = .init( "app_intents.focus_filter.title", @@ -38,6 +39,28 @@ struct FocusNameFocusFilterAppIntent: SetFocusFilterIntent { return .init(title: .init(stringLiteral: focusName.name)) } + /// Lets the Focus sensors, which live in Shared and can't see this type, ask iOS which name the + /// filter has right now. Called once at launch. + static func registerCurrentValuesReader() { + Current.focusFilter.setCurrentValuesReader { + try await currentFocusName() + } + } + + /// The name of the Focus that is on, or `nil` when no Focus with a name picked in this filter + /// is on. Apple documents `notFound` only as a failure to find the current Focus + /// configuration, which is read here as there being no Focus with this filter on. Any other + /// error leaves the name to the filter's runs. + static func currentFocusName( + reading current: () async throws -> FocusNameFocusFilterAppIntent = { try await Self.current } + ) async throws -> String? { + do { + return try await current().focusName?.name + } catch SetFocusFilterIntentError.notFound { + return nil + } + } + func perform() async throws -> some IntentResult { // Logged before anything acts on it, and with the entity's id: this line is the only // record that iOS ran the filter at all, and a run carrying no name is indistinguishable diff --git a/Sources/App/Settings/Focus/FocusSettingsViewModel.swift b/Sources/App/Settings/Focus/FocusSettingsViewModel.swift index ff975c3b7a..63235e00c0 100644 --- a/Sources/App/Settings/Focus/FocusSettingsViewModel.swift +++ b/Sources/App/Settings/Focus/FocusSettingsViewModel.swift @@ -7,8 +7,7 @@ final class FocusSettingsViewModel: ObservableObject { /// The name the Focus Filter last reported, shown so the user can tell the pairing worked. @Published var activeFocusName: String? @Published var isFocusSensorEnabled = false - /// Without the Focus status permission nothing tells us a Focus ended, so the last name the - /// filter reported would stay put. + /// Without the Focus status permission the app only sees the Focuses paired with the filter. @Published var isFocusPermissionGranted = false func load() { diff --git a/Sources/App/Settings/Sensors/Permissions/SensorPermission.swift b/Sources/App/Settings/Sensors/Permissions/SensorPermission.swift index 22a56746f8..cc56467471 100644 --- a/Sources/App/Settings/Sensors/Permissions/SensorPermission.swift +++ b/Sources/App/Settings/Sensors/Permissions/SensorPermission.swift @@ -28,8 +28,8 @@ enum SensorPermission: String, CaseIterable, Identifiable { WebhookSensorId.activity.rawValue: .motion, WebhookSensorId.pressure.rawValue: .motion, WebhookSensorId.focus.rawValue: .focus, - // The Focus name sensor stands on it too: without it nothing tells the app a Focus - // ended, so the name the filter last reported would stay put. + // The Focus name sensor's "Is focused" attribute stands on it too: without it only a + // Focus paired with the filter can be seen. WebhookSensorId.focusName.rawValue: .focus, WebhookSensorId.geocodedLocation.rawValue: .location, WebhookSensorId.connectivitySSID.rawValue: .location, diff --git a/Sources/Shared/API/Webhook/Sensors/FocusNameSensor.swift b/Sources/Shared/API/Webhook/Sensors/FocusNameSensor.swift index edd87fca12..613c9edb33 100644 --- a/Sources/Shared/API/Webhook/Sensors/FocusNameSensor.swift +++ b/Sources/Shared/API/Webhook/Sensors/FocusNameSensor.swift @@ -23,13 +23,21 @@ final class FocusNameSensorUpdateSignaler: BaseSensorUpdateSignaler, SensorProvi super.observe() guard !isObserving else { return } - // The Focus Filter tells us which Focus started… - focusFilterCancellable = Current.focusFilter.state.observe { [weak self] _ in + // A filter run changes the name. + focusFilterCancellable = Current.focusFilter.runs.observe { [weak self] _ in self?.signal() } - // …and the Focus status tells us when every Focus ended, which no filter reports. - focusStatusCancellable = Current.focusStatus.receivedStatus.observe { [weak self] _ in + // A status push changes the "Is focused" attribute. The Intents extension that received + // it reports it itself, so this only keeps the sensor on screen current: in the background + // it would add a full update of every sensor to that extension's short run. + focusStatusCancellable = Current.focusStatus.pushes.observe { [weak self] _ in + #if os(watchOS) self?.signal() + #else + if Current.isForegroundApp() { + self?.signal() + } + #endif } isObserving = true @@ -47,14 +55,12 @@ final class FocusNameSensorUpdateSignaler: BaseSensorUpdateSignaler, SensorProvi } } -/// Reports _which_ Focus is running, which iOS has no API for — empty while none is. +/// Reports _which_ Focus is on, which iOS has no API for. Empty while none is. /// /// The user creates the names in the app's Focus settings and pairs each one with a Focus in -/// Settings › Focus › Focus Filters; activating that Focus runs our filter, which stores the paired -/// name for this sensor to send. The name lasts until that Focus ends — its filter's reset run, or -/// iOS confirming every Focus ended — and a Focus that starts without a filter run afterwards is -/// reported as running with no name, never as the Focus before it. Not being able to tell whether -/// the named Focus ended keeps its name rather than inventing a state. +/// Settings › Focus › Focus Filters. iOS performs the filter with that name when the Focus turns +/// on and with none when it ends, and the app also asks iOS for the filter's current values on +/// every read, see `FocusReport`. /// /// The sensor is always listed, never erroring out while nothing is configured: its detail screen /// is the only route to the Focus names, so a sensor that dropped out of the list until a name @@ -78,24 +84,21 @@ final class FocusNameSensor: SensorProvider { return .init(error: FocusNameError.unavailable) } - let report = FocusReport.current() + return FocusReport.current().map { [self] report in + let _: FocusNameSensorUpdateSignaler = request.dependencies.updateSignaler(for: self) - // Empty while no named Focus is running, which is also the state before any name exists — - // what keeps the sensor in the list for a user who has yet to configure it. - let sensor = with(WebhookSensor( - name: "Focus name", - uniqueID: WebhookSensorId.focusName.rawValue, - icon: "mdi:moon-waning-crescent", - state: report.name ?? "" - )) { - if let isFocused = report.isFocused { - $0.Attributes = ["Is focused": isFocused] - } + // Empty while no named Focus is on, which is also the state before any name exists, + // what keeps the sensor in the list for a user who has yet to configure it. + return [with(WebhookSensor( + name: "Focus name", + uniqueID: WebhookSensorId.focusName.rawValue, + icon: "mdi:moon-waning-crescent", + state: report.name ?? "" + )) { + if let isFocused = report.isFocused { + $0.Attributes = ["Is focused": isFocused] + } + }] } - - // Set up our observer - let _: FocusNameSensorUpdateSignaler = request.dependencies.updateSignaler(for: self) - - return .value([sensor]) } } diff --git a/Sources/Shared/API/Webhook/Sensors/FocusSensor.swift b/Sources/Shared/API/Webhook/Sensors/FocusSensor.swift index dca4e9592b..ca7a9a367a 100644 --- a/Sources/Shared/API/Webhook/Sensors/FocusSensor.swift +++ b/Sources/Shared/API/Webhook/Sensors/FocusSensor.swift @@ -22,20 +22,20 @@ final class FocusSensorUpdateSignaler: BaseSensorUpdateSignaler, SensorProviderU override func observe() { super.observe() guard !isObserving else { return } - cancellable = Current.focusStatus.receivedStatus.observe { [weak self] _ in + cancellable = Current.focusStatus.pushes.observe { [weak self] _ in #if os(watchOS) self?.signal() #else - // this means that we will double-update the focus sensor if the app is running - // this feels less likely to happen, but allows us to keep the in-app visual state right + // The Intents extension that received the push reports it itself, so this only keeps + // the sensor on screen current. In the background it would be a whole extra update. if Current.isForegroundApp() { self?.signal() } #endif } - // The sensor also stands on Focus Filter runs, whose own explicit sensor update can fail - // (unreachable server) — and they are the only signal when Focus status isn't shared. - focusFilterCancellable = Current.focusFilter.state.observe { [weak self] _ in + // A Focus Filter run turns the sensor on for a Focus whose status iOS doesn't report, and + // the run's own update can fail to reach the server. + focusFilterCancellable = Current.focusFilter.runs.observe { [weak self] _ in self?.signal() } isObserving = true @@ -54,6 +54,7 @@ final class FocusSensorUpdateSignaler: BaseSensorUpdateSignaler, SensorProviderU } } +/// Whether any Focus is on, see `FocusReport`. final class FocusSensor: SensorProvider { public enum FocusError: Error, Equatable { case unavailable @@ -65,40 +66,33 @@ final class FocusSensor: SensorProvider { } func sensors() -> Promise<[WebhookSensor]> { - // Everything we know, not just the live status: a Focus Filter run proves a Focus is on - // even while the live query still describes the one that just ended (or can't answer at - // all because the user doesn't share Focus status), and the status iOS pushed us is what - // says it ended. This is what flips the sensor when iOS wakes us for a Focus change. - let report = FocusReport.current() + // Only a device without Focus has no sensor to offer. Anything else stays listed, since + // the list row is the only switch there is. + guard Current.focusStatus.isAvailable() else { + return .init(error: FocusError.unavailable) + } + + return FocusReport.current().map { [self] report in + let _: FocusSensorUpdateSignaler = request.dependencies.updateSignaler(for: self) - guard let isFocused = report.isFocused else { - // Only when nothing can answer does why it can't matter: a device without Focus has no - // sensor to offer. Anything else stays listed — reporting `unavailable` rather than - // dropping out, since the list row is the only switch there is — whether the permission - // is missing, which switching the sensor on asks for, or iOS simply gave no answer. - guard Current.focusStatus.isAvailable() else { - return .init(error: FocusError.unavailable) + guard let isFocused = report.isFocused else { + // Nothing says, which is where a missing permission leaves it: switching the + // sensor on is what asks for it. + return [WebhookSensor( + awaitingPermissionNamed: "Focus", + uniqueID: WebhookSensorId.focus.rawValue, + type: "binary_sensor" + )] } - return .value([WebhookSensor( - awaitingPermissionNamed: "Focus", + return [with(WebhookSensor( + name: "Focus", uniqueID: WebhookSensorId.focus.rawValue, - type: "binary_sensor" - )]) + icon: "mdi:moon-waning-crescent", + state: isFocused + )) { + $0.Type = "binary_sensor" + }] } - - let sensors = [with(WebhookSensor( - name: "Focus", - uniqueID: WebhookSensorId.focus.rawValue, - icon: "mdi:moon-waning-crescent", - state: isFocused - )) { - $0.Type = "binary_sensor" - }] - - // Set up our observer - let _: FocusSensorUpdateSignaler = request.dependencies.updateSignaler(for: self) - - return .value(sensors) } } diff --git a/Sources/Shared/Environment/FocusFilterLiveConfirmation.swift b/Sources/Shared/Environment/FocusFilterLiveConfirmation.swift deleted file mode 100644 index 206eef75cd..0000000000 --- a/Sources/Shared/Environment/FocusFilterLiveConfirmation.swift +++ /dev/null @@ -1,17 +0,0 @@ -import Foundation - -/// The live confirmation of one filter run, keyed by that run's date. -/// -/// Kept apart from `FocusFilterState`: the sensors observe that key and treat every write to it as -/// a Focus change, so recording an observation there would start a whole sensor run of its own. -public struct FocusFilterLiveConfirmation: Codable, Equatable { - /// The `FocusFilterState.date` of the run this confirms. - public var filterDate: Date - /// When iOS answered that a Focus was on. - public var date: Date - - public init(filterDate: Date, date: Date) { - self.filterDate = filterDate - self.date = date - } -} diff --git a/Sources/Shared/Environment/FocusFilterState.swift b/Sources/Shared/Environment/FocusFilterState.swift new file mode 100644 index 0000000000..f978cf37e9 --- /dev/null +++ b/Sources/Shared/Environment/FocusFilterState.swift @@ -0,0 +1,19 @@ +import Foundation + +/// One thing the Focus Filter said about which Focus is on, with when it said it, so what the +/// filter's runs and the app's readings of it said can be ordered and the newest one used. +/// +/// Stored in the app group under a key that predates this type. JSON decoding ignores the field +/// an older build stored alongside these two, so the last run an older build recorded still reads. +public struct FocusFilterState: Codable, Equatable { + /// The `FocusName` the user paired with the Focus that is on, or `nil` when no Focus with a + /// name picked in the filter is on, which includes the run iOS makes when a Focus ends. + public var name: String? + /// When the filter said it. + public var date: Date + + public init(name: String?, date: Date) { + self.name = name + self.date = date + } +} diff --git a/Sources/Shared/Environment/FocusFilterWrapper.swift b/Sources/Shared/Environment/FocusFilterWrapper.swift index 90d3304352..c2ec904a58 100644 --- a/Sources/Shared/Environment/FocusFilterWrapper.swift +++ b/Sources/Shared/Environment/FocusFilterWrapper.swift @@ -1,102 +1,199 @@ import Foundation +import HAKit -/// What the iOS Focus Filter last told us, stored in the app group so the sensor can read it from -/// whichever process happens to be running. -public struct FocusFilterState: Codable, Equatable { - /// The `FocusName` the user paired with the Focus that activated, or `nil` when the filter ran - /// without one selected — including the reset run iOS makes when a Focus deactivates. - public var name: String? - /// When the filter last ran, so writing the same name twice still notifies observers. - public var date: Date - /// When iOS itself answered that a Focus was on while this name stood, or `nil` while it never - /// has. A Focus whose status the user doesn't share reads back as "not focused" for as long as it - /// runs, so this is what says the live answer is about this Focus — and so can end it. - public var liveConfirmedDate: Date? - - public init(name: String?, date: Date, liveConfirmedDate: Date? = nil) { - self.name = name - self.date = date - self.liveConfirmedDate = liveConfirmedDate - } -} - -public class FocusFilterStateSync: UserDefaultsValueSync { - init() { - super.init(settingsKey: "FocusFilterStateKey") - } -} - -/// The bridge between the iOS Focus Filter — which is the only way to learn _which_ Focus is -/// running, since iOS offers no API for it — and the `focus_name` sensor. +/// The bridge between the iOS Focus Filter, the only way iOS lets an app learn _which_ Focus is +/// on, and the `focus_name` sensor. /// -/// The filter's App Intent stores the name the user paired with the activating Focus here; the -/// sensor reads it back, and pairs it with `Current.focusStatus` to tell "no Focus is running" from -/// "a Focus is running that we have no name for". +/// The name comes from two things the filter says, both stored in the app group so every process +/// reports the same one, and the newest of the two is the name: +/// - Each time iOS performs the filter. It does so with the paired name when that Focus turns on, +/// and with no name once "the previously delivered values are no longer relevant" (WWDC22, Meet +/// Focus filters), which is how a Focus ending reaches the filter. +/// - Each time the app asks iOS for the filter's current values, `SetFocusFilterIntent.current`. +/// That corrects a run that never landed, such as the one for a Focus that ended while iOS +/// didn't launch the app. Only the app can ask, because the filter is declared there. public class FocusFilterWrapper { - /// How long a reported name is protected from the reset run that follows it. + /// How long a reported name is protected from a run with no name that follows it. /// - /// Switching Focus runs two filter passes — the starting Focus' with its name, the ending - /// Focus' with none — around the same moment and in no guaranteed order, and both are timed by - /// when the app got round to running them rather than by when iOS decided them. Without this, - /// a reset arriving second erases the name the switch just reported, and the sensor blanks - /// while a Focus is running. + /// Switching Focus can perform the filter twice around the same moment, in no guaranteed + /// order: once with the starting Focus' name and once with none for the Focus that ended. + /// Without this, the run with no name landing second erases the name the switch just reported. + /// The app's next reading of the current values corrects the rare Focus that really did end + /// this quickly. static let resetGracePeriod: TimeInterval = 10 - private(set) lazy var state = FocusFilterStateSync() + /// How long to wait for iOS to answer with the filter's current values before reporting + /// without them. A sensor read must not hang on a system call that never returns. + static var currentValuesTimeout: TimeInterval = 5 - /// Unobserved on purpose — see `FocusFilterLiveConfirmation`. - private(set) lazy var liveConfirmation = UserDefaultsValueSync( - settingsKey: "FocusFilterLiveConfirmationKey" - ) + /// Every time iOS performed the filter. Observed by the Focus sensors, because a run is a + /// Focus change. + private(set) lazy var runs = UserDefaultsValueSync(settingsKey: "FocusFilterStateKey") - /// The last Focus Filter run, with the moment it happened so it can be ordered against what - /// the Focus status pushed us, and whether iOS has confirmed it since. - public lazy var activeFocusState: () -> FocusFilterState? = { [weak self] in - guard let self, var state = state.value else { return nil } - if let confirmation = liveConfirmation.value, confirmation.filterDate == state.date { - state.liveConfirmedDate = confirmation.date + /// The app's last reading of the filter's current values. Not observed: every sensor read + /// writes it, so observing it would make each read start the next one. + private(set) lazy var readings = UserDefaultsValueSync(settingsKey: "FocusFilterReadingKey") + + /// How the app asks iOS for the name the filter currently has, `nil` when no Focus with a name + /// picked is on. + public typealias CurrentValuesReader = () async throws -> String? + + /// The app's reader. Unset in every other process. + private let currentValuesReader = HAProtected(value: nil) + + /// Called by the app at launch with how to ask iOS for the filter's current values. + public func setCurrentValuesReader(_ reader: @escaping CurrentValuesReader) { + currentValuesReader.mutate { $0 = reader } + } + + /// One question to iOS about the filter's current values. + struct Request { + let id: UUID + /// When it was asked, which is when its answer is dated: iOS can answer after a filter run + /// that came later, and that run is the newer of the two. + let askedAt: Date + let answer: Task, Never> + /// Whether a read gave up waiting on it. iOS hasn't answered within the timeout, so no new + /// question is asked until it does, and nothing waits on it again. + var timedOut = false + } + + /// The question in flight, shared by every read that arrives while it is, so both Focus + /// sensors reading at once ask iOS once and get the same answer. + private let request = HAProtected(value: nil) + + /// Asks iOS for the filter's current values and stores the answer, when this process can ask + /// and iOS answers in time. + func readFromIOS() async { + guard let reader = currentValuesReader.read({ $0 }) else { return } + + guard let request = currentRequest(asking: reader) else { + Current.Log.info("focus filter current values still unanswered from an earlier read, skipped") + return } - return state + + guard let result = await Self.withTimeout(Self.currentValuesTimeout, { await request.answer.value }) else { + self.request.mutate { current in + if current?.id == request.id { + current?.timedOut = true + } + } + request.answer.cancel() + Current.Log.error("focus filter current values took over \(Self.currentValuesTimeout)s, skipped") + return + } + + switch result { + case let .success(name): + readings.value = FocusFilterState(name: name, date: request.askedAt) + case let .failure(error): + Current.Log.error("focus filter current values unavailable: \(error)") + } + } + + /// The question in flight, or a new one. `nil` while one a read gave up on is still waiting + /// for iOS, so a call iOS never answers can't gain a sibling on every read. + func currentRequest(asking reader: @escaping CurrentValuesReader) -> Request? { + request.mutate { current -> Request? in + if let current { + return current.timedOut ? nil : current + } + let id = UUID() + // Taken before the question can reach iOS, so no answer predates what it was dated. + let askedAt = Current.date() + let answer = Task { [weak self] () -> Result in + let result: Result + do { + let name = try await reader() + result = .success(name) + } catch { + result = .failure(error) + } + self?.request.mutate { current in + if current?.id == id { + current = nil + } + } + return result + } + let new = Request(id: id, askedAt: askedAt, answer: answer) + current = new + return new + } + } + + /// The newest of what the filter's runs and the app's readings of it said. + public lazy var activeFocusState: () -> FocusFilterState? = { [weak self] in + guard let self else { return nil } + guard let run = runs.value else { return readings.value } + guard let reading = readings.value else { return run } + return reading.date >= run.date ? reading : run } - /// The name reported by the last Focus Filter run, if any. + /// The name of the Focus that is on, if the filter said one. public lazy var activeFocusName: () -> String? = { [weak self] in self?.activeFocusState()?.name } - /// Called by the Focus Filter's App Intent when a Focus activates — and, with `nil`, when one - /// deactivates. + /// Called by the Focus Filter's App Intent each time iOS performs it: with the paired name when + /// a Focus turns on, and with `nil` when one ends. public lazy var setActiveFocusName: (String?) -> Void = { [weak self] name in guard let self else { return } - let previous = state.value let now = Current.date() - let isReset = name?.isEmpty != false + let name = name?.isEmpty == false ? name : nil - if isReset, let previous, let previousName = previous.name, !previousName.isEmpty, + if name == nil, let previous = runs.value, let previousName = previous.name, now.timeIntervalSince(previous.date) < Self.resetGracePeriod { - // The Focus that just ended in a switch, reported after the one that started it. - Current.Log.info("focus filter ignoring reset run right after \(previousName) was reported") + Current.Log.info("focus filter ignoring run with no name right after \(previousName) was reported") return } - state.value = FocusFilterState(name: name, date: now) - } - - /// Records that iOS answered a Focus was on while the named run `filterState` stood. Only the - /// run still current can be confirmed: a later run is a different Focus. - public lazy var confirmLive: (FocusFilterState) -> Void = { [weak self] filterState in - guard let self, let current = state.value, current.date == filterState.date, - current.name?.isEmpty == false else { return } - if let existing = liveConfirmation.value, existing.filterDate == current.date { return } - Current.Log.info("focus filter run for \(current.name ?? "") confirmed by the live focus status") - liveConfirmation.value = FocusFilterLiveConfirmation(filterDate: current.date, date: Current.date()) + runs.value = FocusFilterState(name: name, date: now) } /// Stops reporting a name the user deleted in settings. A filter still paired with it reports /// it again the next time it runs, which is when the name exists again as far as the app is /// concerned. + /// + /// Each answer keeps its date, so clearing an older one can't outrank a newer answer about a + /// different Focus. public lazy var forgetFocusName: (String) -> Void = { [weak self] name in - guard let self, let previous = state.value, previous.name == name else { return } - state.value = FocusFilterState(name: nil, date: Current.date()) + guard let self else { return } + if let run = runs.value, run.name == name { + runs.value = FocusFilterState(name: nil, date: run.date) + } + if let reading = readings.value, reading.name == name { + readings.value = FocusFilterState(name: nil, date: reading.date) + } + } + + /// Waits for `operation` for up to `timeout`, without waiting for it to finish once that has + /// passed, which a task group would do even once cancelled. + private static func withTimeout( + _ timeout: TimeInterval, + _ operation: @escaping () async -> T + ) async -> T? { + await withCheckedContinuation { continuation in + let resumed = HAProtected(value: false) + let resume: (T?) -> Void = { result in + let isFirst = resumed.mutate { resumed -> Bool in + defer { resumed = true } + return !resumed + } + if isFirst { + continuation.resume(returning: result) + } + } + let timer = Task { + try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + guard !Task.isCancelled else { return } + resume(nil) + } + Task { + let value = await operation() + resume(value) + timer.cancel() + } + } } } diff --git a/Sources/Shared/Environment/FocusReport.swift b/Sources/Shared/Environment/FocusReport.swift index 6c71132060..f83b1e7490 100644 --- a/Sources/Shared/Environment/FocusReport.swift +++ b/Sources/Shared/Environment/FocusReport.swift @@ -1,26 +1,24 @@ import Foundation +import PromiseKit -/// What we currently know about Focus: which named Focus is running, and whether any Focus is -/// running right now. +/// What both Focus sensors report: which named Focus is on, and whether any Focus is on. /// -/// Two sources each know half of it and neither is complete on its own. The Focus Filter runs when -/// a Focus *starts* and is the only thing that ever tells us its name. `INShareFocusStatusIntent` -/// pushes whether any Focus is running, which is what tells us one ended — but it says `false` -/// for a Focus whose status the user doesn't share, whether pushed to us or asked for, during a -/// switch it still describes the Focus that just ended, and iOS doesn't reliably push it at all -/// when every Focus ends, so for a Focus the app can ask iOS about, asking is what settles it in -/// either direction. +/// Each half has its own source, and the newest answer each source gave is the one that counts: +/// - The name is only ever told by the Focus Filter, see `FocusFilterWrapper`. +/// - Whether a Focus is on is told by iOS's Focus status, see `FocusStatusWrapper`. /// -/// The name only ever belongs to the Focus whose filter run reported it. Once that Focus ended, a -/// later "focused" answer is a different Focus, or the same one iOS reactivated without re-running -/// the filter: neither carries a name, and reporting the old one there put a Focus that ended -/// hours earlier on the sensor when a Focus with no filter paired to it started. +/// A named Focus is on whatever the status says. Focus status is shared per Focus, and iOS +/// reports "not focused" for one that doesn't share it or that allows this app's notifications, +/// so the filter is the only way such a Focus is seen at all. +/// +/// Every process resolves from the same stored answers, so the app and its extensions report the +/// same thing. Only the app can ask iOS, and it stores each answer it gets. The extensions don't +/// rebuild a Focus state from a push the app has since seen past. public struct FocusReport: Equatable { - /// The name the Focus Filter reported for the Focus that is running, or `nil` while no named - /// filter run is current: none ever ran, the last run was a reset, or iOS confirmed every Focus - /// ended since it ran. + /// The name the Focus Filter gave for the Focus that is on, or `nil` while no Focus with a + /// name picked in the filter is on. public let name: String? - /// Whether any Focus is running, or `nil` when nothing we have access to can say. + /// Whether any Focus is on, or `nil` when nothing this process can read says. public let isFocused: Bool? public init(name: String?, isFocused: Bool?) { @@ -28,132 +26,45 @@ public struct FocusReport: Equatable { self.isFocused = isFocused } - /// How long after a filter run a status saying nothing is running is read as the tail end of a - /// switch rather than as that Focus ending. - /// - /// iOS starts the new Focus — running its filter — before it reports the previous one ending, - /// and the two land in different processes: the status in the Intents extension, the filter in - /// the app, which iOS often has to launch in the background first. The window has to cover - /// that launch, or a Focus started while the app isn't running blanks the sensor it just - /// reported to. Nothing hangs on the window being tight: the filter's own reset run is what - /// normally ends a Focus, and it clears the name whenever it lands. - static let switchGracePeriod: TimeInterval = 30 + static func resolve(filter: FocusFilterState?, status: FocusStatusObservation?) -> FocusReport { + let name = filter?.name.flatMap { $0.isEmpty ? nil : $0 } + return FocusReport(name: name, isFocused: name != nil ? true : status?.isFocused) + } - public static func current() -> FocusReport { - let filterState = Current.focusFilter.activeFocusState() - let receivedStatus = Current.focusStatus.lastReceived() - let now = Current.date() + /// Asks iOS for fresh answers where this process can, stores them, and resolves the report + /// from the newest answers every process stored. + public static func current() -> Promise { + Promise { seal in + Task { + await Current.focusFilter.readFromIOS() + Current.focusStatus.readFromIOS() - let liveStatus = liveIsFocused() + let report = resolve( + filter: Current.focusFilter.activeFocusState(), + status: Current.focusStatus.latest() + ) - // A filter only runs with a name when a Focus starts — the nil-name run iOS makes on - // deactivation must not count — and nothing has told us it ended since. - let name: String? - if let filterState, let reportedName = filterState.name, !reportedName.isEmpty, - !hasEnded(filterState: filterState, receivedStatus: receivedStatus, liveStatus: liveStatus, now: now) { - name = reportedName - } else { - name = nil - } + // Both Focus sensors stand on this, and each answer comes from a different process + // at a different moment, so all of them are logged next to the result to make a + // wrong report readable after the fact. + Current.Log.info { + "focus report: \(report) from " + + "filter[run(\(describe(Current.focusFilter.runs.value))) " + + "reading(\(describe(Current.focusFilter.readings.value)))] " + + "status[push(\(describe(Current.focusStatus.pushes.value))) " + + "reading(\(describe(Current.focusStatus.readings.value)))]" + } - let isFocused: Bool? - if name != nil { - isFocused = true - } else if let receivedStatus, let pushSaysFocused = receivedStatus.isFocused, pushSaysFocused, - let liveSaysFocused = liveStatus, !liveSaysFocused, - now.timeIntervalSince(receivedStatus.date) > switchGracePeriod { - // Rely on iOS's live Focus state, not the Focus name: whatever iOS answers is the - // source of truth, so a live "not focused" ends a pushed "running" once that push has - // had the switch window to settle. - isFocused = false - } else if let receivedStatus, receivedStatus.isFocused == false, liveStatus == true, - now.timeIntervalSince(receivedStatus.date) > switchGracePeriod { - // The same rule the other way round, and the reason a Focus could read as off while - // iOS said one was running. A pushed "nothing is running" is the weakest thing we - // hold: it is also what iOS says about a Focus the user doesn't share, iOS only - // pushes it when it feels like it, and nothing ages it out — one arriving days ago - // otherwise outranked every later answer. Asking iOS is not ambiguous in this - // direction, because it never answers "focused" while no Focus is on, so once that - // push has had the switch window to settle the live answer replaces it. - isFocused = true - } else { - isFocused = receivedStatus?.isFocused ?? liveStatus + seal.fulfill(report) + } } - - let report = FocusReport(name: name, isFocused: isFocused) - - if name != nil, let filterState, filterState.liveConfirmedDate == nil, liveStatus == true, - now.timeIntervalSince(filterState.date) > switchGracePeriod { - // iOS only answers "focused" for a Focus whose status the user shares, so an answer - // that lands while this name stands says the live status is about this Focus, and a - // later "not focused" from it is this Focus ending. Not taken inside the switch - // window, where the answer can still be about the Focus that just ended. - Current.focusFilter.confirmLive(filterState) - } - - // Both Focus sensors stand on this, and every input comes from a different process at a - // different moment, so the inputs are logged alongside the answer to make a wrong report - // readable after the fact. - Current.Log.info { - let filter = filterState.map { - "name(\($0.name ?? "")) at(\($0.date)) " + - "liveConfirmed(\(String(describing: $0.liveConfirmedDate)))" - } ?? "" - let status = receivedStatus.map { - "isFocused(\(String(describing: $0.isFocused))) at(\($0.date)) " + - "lastEnded(\(String(describing: $0.lastEndedDate))) " + - "lastStarted(\(String(describing: $0.lastStartedDate)))" - } ?? "" - return "focus report: \(report) from filter[\(filter)] status[\(status)] " + - "live(\(String(describing: liveStatus)))" - } - - return report } - /// Whether the Focus whose filter run reported the name has ended, which is what makes that - /// name stale. - /// - /// Only a Focus the Focus status is known to be about can be ended by it. Focus status is - /// shared per Focus, and the ones the user doesn't share read back as "not focused" for as long - /// as they run — repeatedly, since iOS re-shares that on its own — so without a confirmation to - /// pair it with, a status saying nothing is running says nothing about this Focus. The filter's - /// own reset run still ends those. - /// - /// Two things confirm it: iOS pushing "running" after the filter ran, and iOS answering - /// "focused" while the name stood. The push alone was not enough — it comes to the Intents - /// extension, which iOS has running before it has finished launching the app to run the - /// filter, so for a Focus that starts with the app closed it usually lands *before* the filter - /// run it confirms and never counted. Once confirmed, the Focus has ended when iOS said every - /// Focus had ended after that run, or answers "not focused" now; both only after the switch - /// window, inside which they can still describe the Focus that just ended. - private static func hasEnded( - filterState: FocusFilterState, - receivedStatus: FocusStatusState?, - liveStatus: Bool?, - now: Date - ) -> Bool { - let settledAfterFilter = filterState.date.addingTimeInterval(switchGracePeriod) - - let confirmedByPush = receivedStatus?.lastStartedDate.map { $0 >= filterState.date } ?? false - guard filterState.liveConfirmedDate != nil || confirmedByPush else { return false } - - if let lastEndedDate = receivedStatus?.lastEndedDate, lastEndedDate > settledAfterFilter { - return true - } - return liveStatus == false && now > settledAfterFilter + private static func describe(_ state: FocusFilterState?) -> String { + state.map { "\($0.name ?? "") at \($0.date)" } ?? "" } - /// Asking iOS directly, which only the app can do, and which only answers for Focuses whose - /// status the user shares. The fallback for when no status was ever pushed to us, what ends a - /// pushed "running" whose "ended" push never came, and what starts a Focus the last push — - /// which can be days old — still calls not running. In an app extension this is the status - /// iOS pushed, read back together with the filter run it has to be weighed against. - private static func liveIsFocused() -> Bool? { - guard Current.focusStatus.isAvailable(), - Current.focusStatus.authorizationStatus() == .authorized else { - return nil - } - return Current.focusStatus.status().isFocused + private static func describe(_ observation: FocusStatusObservation?) -> String { + observation.map { "\(String(describing: $0.isFocused)) at \($0.date)" } ?? "" } } diff --git a/Sources/Shared/Environment/FocusStatusObservation.swift b/Sources/Shared/Environment/FocusStatusObservation.swift new file mode 100644 index 0000000000..87f209d078 --- /dev/null +++ b/Sources/Shared/Environment/FocusStatusObservation.swift @@ -0,0 +1,19 @@ +import Foundation + +/// One answer iOS gave to "is a Focus on", with when it was given, so answers taken by different +/// processes at different moments can be ordered and the newest one used. +/// +/// Stored in the app group under keys that predate this type. JSON decoding ignores the fields an +/// older build stored alongside these two, so the last status an older build received still reads. +public struct FocusStatusObservation: Codable, Equatable { + /// `true` while a Focus that silences this app is on, `false` otherwise, and `nil` when iOS + /// gave no answer, which is what it does for an app without the Focus status permission. + public var isFocused: Bool? + /// When iOS gave the answer. + public var date: Date + + public init(isFocused: Bool?, date: Date) { + self.isFocused = isFocused + self.date = date + } +} diff --git a/Sources/Shared/Environment/FocusStatusWrapper.swift b/Sources/Shared/Environment/FocusStatusWrapper.swift index deec9b4286..1989626133 100644 --- a/Sources/Shared/Environment/FocusStatusWrapper.swift +++ b/Sources/Shared/Environment/FocusStatusWrapper.swift @@ -1,42 +1,24 @@ import Intents import PromiseKit -/// The Focus status iOS last pushed to us, kept in the app group so every process reads the same -/// thing instead of asking iOS itself, along with when a Focus last ended — which is what decides -/// whether the name the Focus Filter reported is still the Focus that is running. -public struct FocusStatusState: Codable, Equatable { - /// Whether any Focus was running, or `nil` when iOS sent a status without saying. - public var isFocused: Bool? - /// When this status arrived, so pushing the same status twice still notifies observers. - public var date: Date - /// When iOS last said every Focus had ended, kept across later updates. A name the filter - /// reported before that moment belongs to a Focus that is over; one reported after it doesn't. - public var lastEndedDate: Date? - /// When iOS last said a Focus was running, kept across later updates. iOS reports "not - /// focused" for a Focus whose status the user doesn't share, so this is what says whether it - /// can see the running Focus at all: without it, a status saying nothing is running is - /// silence rather than an ending. - public var lastStartedDate: Date? - - public init(isFocused: Bool?, date: Date, lastEndedDate: Date?, lastStartedDate: Date? = nil) { - self.isFocused = isFocused - self.date = date - self.lastEndedDate = lastEndedDate - self.lastStartedDate = lastStartedDate - } -} - -public class FocusStatusStateSync: UserDefaultsValueSync { - init() { - super.init(settingsKey: "FocusStatusStateKey") - } -} - +/// iOS's answer to whether a Focus is on, from the two places it gives one. +/// +/// - The `INShareFocusStatusIntent` iOS hands the Intents extension when the status changes. +/// Apple's sample reads the value from the intent there, not from `INFocusStatusCenter`, which +/// in an extension answers "not focused" whatever is on. +/// - `INFocusStatusCenter`, which the app asks itself every time it reads the Focus sensors. +/// +/// Both answers are stored in the app group with when they were given, and the newest one is the +/// status. A push can be hours old by the time something reads it, since iOS doesn't reliably +/// deliver the one for a Focus ending, while the app's reading is as fresh as its last sensor +/// read. The other extensions, such as the push provider, can't ask iOS at all, so they report +/// what the newest answer said instead of an older push that the app has since seen past. +/// +/// `isFocused` is `true` while a Focus that silences this app is on. It is `false` otherwise, +/// including while the Focus that is on allows this app's notifications or doesn't share its +/// status. Which Focus is on, and whether one iOS reports as not focused is on, is the Focus +/// Filter's to say. public class FocusStatusWrapper { - /// Every Focus status iOS pushed us, in the app group. Observed to signal the Focus sensors, - /// and read back by `FocusReport` to decide whether a reported name is still current. - private(set) lazy var receivedStatus = FocusStatusStateSync() - public enum AuthorizationStatus: Equatable { case notDetermined case restricted @@ -59,19 +41,18 @@ public class FocusStatusWrapper { } } - private var lastStatus: Status? { - willSet { - precondition(Current.isAppExtension) - } - } + /// Every status iOS pushed to the Intents extension. Observed by the Focus sensors, because a + /// push is iOS saying the status changed. + private(set) lazy var pushes = UserDefaultsValueSync(settingsKey: "FocusStatusStateKey") - public lazy var isAvailable: () -> Bool = { [weak self] in - if Current.isAppExtension { - return self?.lastStatus != nil - } else { - return true - } - } + /// The app's own last reading. Not observed: every sensor read writes it, so observing it + /// would make each read start the next one. + private(set) lazy var readings = UserDefaultsValueSync( + settingsKey: "FocusStatusReadingKey" + ) + + /// Whether this device has Focus at all. Every OS version the app supports does. + public var isAvailable: () -> Bool = { true } public var authorizationStatus: () -> AuthorizationStatus = { .init(authorizationStatus: INFocusStatusCenter.default.authorizationStatus) @@ -87,54 +68,49 @@ public class FocusStatusWrapper { return promise } - public struct Status: Equatable { - public var isFocused: Bool? + /// Whether this process can ask iOS for the status. Only the app can: in an extension + /// `INFocusStatusCenter` answers "not focused" whatever is on. + public var canAskIOS: () -> Bool = { !Current.isAppExtension } - public init(focusStatus: INFocusStatus) { - self.init( - isFocused: focusStatus.isFocused - ) - } + /// iOS's live answer, `nil` without the Focus status permission. + public var liveIsFocused: () -> Bool? = { + INFocusStatusCenter.default.focusStatus.isFocused + } - public init(isFocused: Bool?) { - self.isFocused = isFocused + /// Stores what iOS pushed to the Intents extension. + /// + /// A push without a value is skipped, as Apple's sample does: iOS only leaves the value out + /// for an app without the permission, and the app's own reading already says that. + public func record(pushed focusStatus: INFocusStatus?) { + guard let isFocused = focusStatus?.isFocused else { + Current.Log.info("focus status push carried no value, keeping the previous status") + return } + pushes.value = FocusStatusObservation(isFocused: isFocused, date: Current.date()) } - public func update(fromReceived status: INFocusStatus?) { - precondition(Current.isAppExtension) - lastStatus = status.flatMap { Status(focusStatus: $0) } - - let now = Current.date() - let isFocused = lastStatus?.isFocused - let previous = receivedStatus.value - - // State persisted before `lastStartedDate` existed still carries the same evidence in its - // own `isFocused`: a status that said a Focus was running is the confirmation, so the - // first status after an upgrade doesn't have to be the one that establishes it. - let previousStartedDate = previous?.lastStartedDate ?? (previous?.isFocused == true ? previous?.date : nil) - - // Recorded rather than acted on: iOS runs the next Focus' filter before it tells us the - // previous one ended, so which of the two is current is decided when they are read back - // together, not by letting whichever arrives last overwrite the other. - receivedStatus.value = FocusStatusState( - isFocused: isFocused, - date: now, - lastEndedDate: isFocused == false ? now : previous?.lastEndedDate, - lastStartedDate: isFocused == true ? now : previousStartedDate - ) + /// Asks iOS for the status and stores the answer, when this process can ask. + /// + /// Without the permission iOS doesn't answer, and that is stored too: a push from before the + /// permission was revoked would otherwise keep reporting a status nothing updates anymore. + func readFromIOS() { + guard canAskIOS() else { return } + let isFocused = authorizationStatus() == .authorized ? liveIsFocused() : nil + readings.value = FocusStatusObservation(isFocused: isFocused, date: Current.date()) } - public lazy var status: () -> Status = { [weak self] in - if Current.isAppExtension, let lastStatus = self?.lastStatus { - return lastStatus - } else { - return .init(focusStatus: INFocusStatusCenter.default.focusStatus) - } + /// The newest answer iOS gave, from whichever process received it. + public lazy var latest: () -> FocusStatusObservation? = { [weak self] in + guard let self else { return nil } + return Self.newest(pushes.value, readings.value) } - /// The last status iOS pushed us, from whichever process received it. - public lazy var lastReceived: () -> FocusStatusState? = { [weak self] in - self?.receivedStatus.value + private static func newest( + _ lhs: FocusStatusObservation?, + _ rhs: FocusStatusObservation? + ) -> FocusStatusObservation? { + guard let lhs else { return rhs } + guard let rhs else { return lhs } + return rhs.date >= lhs.date ? rhs : lhs } } diff --git a/Sources/Shared/Intents/FocusStatusIntentHandler.swift b/Sources/Shared/Intents/FocusStatusIntentHandler.swift index 71e6fd6e7e..428d74c5a9 100644 --- a/Sources/Shared/Intents/FocusStatusIntentHandler.swift +++ b/Sources/Shared/Intents/FocusStatusIntentHandler.swift @@ -5,17 +5,18 @@ import PromiseKit class FocusStatusIntentHandler: NSObject, INShareFocusStatusIntentHandling { /// How long to let a status change settle before reporting it. /// - /// Switching Focus pushes "nothing is running" for the Focus that ended and runs the starting - /// Focus' filter separately — the filter in the app, which iOS may have to launch first. - /// Sending the moment the status arrives publishes that gap as a real state, so Home Assistant - /// sees the Focus sensors blank mid-switch and stays that way if the filter's own update never - /// lands. Waiting lets the filter run first, so one settled state goes out instead of two. + /// Switching Focus pushes "not focused" for the Focus that ended and performs the starting + /// Focus' filter separately, in the app, which iOS may have to launch first. Sending the moment + /// the status arrives publishes that gap as a real state. Waiting lets the filter run first, so + /// one settled state goes out instead of two. static var settleDelay: TimeInterval = 5 func handle(intent: INShareFocusStatusIntent, completion: @escaping (INShareFocusStatusIntentResponse) -> Void) { - let currentState = intent.focusStatus - Current.focusStatus.update(fromReceived: currentState) - Current.Log.info("starting, status from intent is \(String(describing: currentState)) from \(intent)") + // Read from the intent rather than from `INFocusStatusCenter`, as Apple's sample does: in + // an extension the center answers "not focused" whatever is on. + let pushedStatus = intent.focusStatus + Current.focusStatus.record(pushed: pushedStatus) + Current.Log.info("starting, status from intent is \(String(describing: pushedStatus)) from \(intent)") // `Current.apis` picks each server's URL from the cached network information, which this // extension — launched by iOS to hand us the status — doesn't have yet. Without it a server diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index 9002a3284c..9bd4d26d7b 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -2884,7 +2884,7 @@ public enum L10n { public static var title: String { return L10n.tr("Localizable", "focus.how_it_works.naming.title") } } public enum NoFocus { - /// The sensor reports Not focused once every Focus has ended. This needs the Focus permission, which is what tells the app a Focus is running at all. + /// The sensor is empty once every Focus has ended. Its Is focused attribute also turns off, which needs the Focus permission. public static var body: String { return L10n.tr("Localizable", "focus.how_it_works.no_focus.body") } /// When no Focus is on public static var title: String { return L10n.tr("Localizable", "focus.how_it_works.no_focus.title") } @@ -2902,7 +2902,7 @@ public enum L10n { public static var title: String { return L10n.tr("Localizable", "focus.how_it_works.reporting.title") } } public enum Unpaired { - /// iOS only runs the filter of the Focus that starts, so add one to every Focus you want reported. A Focus without a filter leaves the previous name in place until a paired Focus starts or all of them end. + /// iOS only runs the filter of a Focus it is added to, so add one to every Focus you want reported. While a Focus without a filter is on, the sensor is empty. public static var body: String { return L10n.tr("Localizable", "focus.how_it_works.unpaired.body") } /// Focuses without a filter public static var title: String { return L10n.tr("Localizable", "focus.how_it_works.unpaired.title") } @@ -2927,7 +2927,7 @@ public enum L10n { public static var placeholder: String { return L10n.tr("Localizable", "focus.names.placeholder") } } public enum PermissionMissing { - /// Without it the app cannot tell when every Focus has ended, so the last reported name keeps being sent. + /// Without it the app only sees the Focuses you added the filter to. public static var body: String { return L10n.tr("Localizable", "focus.permission_missing.body") } /// Focus permission is off public static var title: String { return L10n.tr("Localizable", "focus.permission_missing.title") } diff --git a/Tests/App/Settings/FocusNameFocusFilterAppIntent.test.swift b/Tests/App/Settings/FocusNameFocusFilterAppIntent.test.swift index ae04402a68..9d78853f98 100644 --- a/Tests/App/Settings/FocusNameFocusFilterAppIntent.test.swift +++ b/Tests/App/Settings/FocusNameFocusFilterAppIntent.test.swift @@ -1,3 +1,4 @@ +import AppIntents @testable import HomeAssistant @testable import Shared import Testing @@ -57,4 +58,55 @@ struct FocusNameFocusFilterAppIntentTests { #expect(stored == .some(nil)) } } + + @Test func currentValuesAnswerWithThePickedName() async throws { + var intent = FocusNameFocusFilterAppIntent() + intent.focusName = .init(id: "3F9B0A1C-6C0E-4C0B-9D2E-7A1B2C3D4E5F", name: "Work") + + let name = try await FocusNameFocusFilterAppIntent.currentFocusName(reading: { intent }) + + #expect(name == "Work") + } + + /// iOS not finding the current configuration is read as no Focus with the filter being on. + @Test func currentValuesNotFoundAnswerWithNoName() async throws { + let name = try await FocusNameFocusFilterAppIntent.currentFocusName(reading: { + throw SetFocusFilterIntentError.notFound + }) + + #expect(name == nil) + } + + /// Any other failure is not an answer, so the sensors keep what the filter's runs said. + @Test func currentValuesFailingOtherwiseIsPassedOn() async throws { + await #expect(throws: SetFocusFilterIntentError.missingParameterValue) { + try await FocusNameFocusFilterAppIntent.currentFocusName(reading: { + throw SetFocusFilterIntentError.missingParameterValue + }) + } + } + + /// The reader the app registers asks iOS itself, which a unit test can't steer, so this only + /// pins down what the sensors rely on: asking never holds a sensor read past the timeout, and + /// whatever iOS answers, the stored state stays readable. + @Test func registeredReaderAnswersWithinTheTimeout() async throws { + let previousFilter = Current.focusFilter + let previousTimeout = FocusFilterWrapper.currentValuesTimeout + defer { + Current.focusFilter.readings.value = nil + Current.focusFilter = previousFilter + FocusFilterWrapper.currentValuesTimeout = previousTimeout + } + Current.focusFilter = FocusFilterWrapper() + Current.focusFilter.readings.value = nil + FocusFilterWrapper.currentValuesTimeout = 2 + + FocusNameFocusFilterAppIntent.registerCurrentValuesReader() + let started = Date() + await Current.focusFilter.readFromIOS() + + #expect(Date().timeIntervalSince(started) < 10) + // No Focus with the filter is on in a test run, so any answer iOS gives names none. + #expect(Current.focusFilter.activeFocusName() == nil) + } } diff --git a/Tests/Shared/Sensors/FocusNameSensor.test.swift b/Tests/Shared/Sensors/FocusNameSensor.test.swift index 2edff804d7..29db9c610e 100644 --- a/Tests/Shared/Sensors/FocusNameSensor.test.swift +++ b/Tests/Shared/Sensors/FocusNameSensor.test.swift @@ -14,28 +14,72 @@ class FocusNameSensorTests: XCTestCase { private let now = Date(timeIntervalSince1970: 1_700_000_000) + /// Holds a reader until the test lets it answer, whether or not its task is cancelled. + private final class Gate { + private let lock = NSLock() + private var isOpen = false + private var waiters = [CheckedContinuation]() + + func wait() async { + await withCheckedContinuation { continuation in + lock.lock() + if isOpen { + lock.unlock() + continuation.resume() + } else { + waiters.append(continuation) + lock.unlock() + } + } + } + + func open() { + lock.lock() + isOpen = true + let waiting = waiters + waiters = [] + lock.unlock() + waiting.forEach { $0.resume() } + } + } + + /// Counts how many times iOS was asked, from whichever thread asked. + private final class Calls { + private let lock = NSLock() + private var value = 0 + + var count: Int { + lock.lock() + defer { lock.unlock() } + return value + } + + func record() { + lock.lock() + value += 1 + lock.unlock() + } + } + override func setUpWithError() throws { try super.setUpWithError() try clearFocusNames() - Current.focusFilter = FocusFilterWrapper() - Current.focusStatus = FocusStatusWrapper() - Current.focusFilter.state.value = nil - Current.focusFilter.liveConfirmation.value = nil - Current.focusStatus.receivedStatus.value = nil + resetFocusState() Current.date = { [now] in now } let previousIsTestFlight = Current.isTestFlight - addTeardownBlock { Current.isTestFlight = previousIsTestFlight } + let previousTimeout = FocusFilterWrapper.currentValuesTimeout + addTeardownBlock { + Current.isTestFlight = previousIsTestFlight + FocusFilterWrapper.currentValuesTimeout = previousTimeout + } Current.isTestFlight = true + setUpIOS(liveIsFocused: nil) } override func tearDownWithError() throws { try clearFocusNames() - Current.focusFilter.state.value = nil - Current.focusFilter.liveConfirmation.value = nil - Current.focusStatus.receivedStatus.value = nil - Current.focusFilter = FocusFilterWrapper() - Current.focusStatus = FocusStatusWrapper() + resetFocusState() Current.isAppExtension = false Current.date = Date.init try super.tearDownWithError() @@ -47,472 +91,538 @@ class FocusNameSensorTests: XCTestCase { } } - /// - Parameters: - /// - filterRan: how long before now the Focus Filter reported `activeFocusName`, so a test - /// can place it before or after what the Focus status pushed. - /// - receivedStatus: what iOS last pushed us, which is what tells us a Focus ended. - private func setUpDependencies( - activeFocusName: String?, - filterRan: TimeInterval = -60, - receivedStatus: FocusStatusState? = nil, - liveIsFocused: Bool? = nil, - focusAuthorization: FocusStatusWrapper.AuthorizationStatus = .authorized, - focusAvailable: Bool = true + private func resetFocusState() { + Current.focusFilter = FocusFilterWrapper() + Current.focusStatus = FocusStatusWrapper() + Current.focusFilter.runs.value = nil + Current.focusFilter.readings.value = nil + Current.focusStatus.pushes.value = nil + Current.focusStatus.readings.value = nil + } + + private func setUpIOS( + canAskIOS: Bool = true, + authorization: FocusStatusWrapper.AuthorizationStatus = .authorized, + liveIsFocused: Bool? ) { - Current.focusFilter.activeFocusState = { [now] in - activeFocusName.map { FocusFilterState(name: $0, date: now.addingTimeInterval(filterRan)) } - } - Current.focusStatus.lastReceived = { receivedStatus } - Current.focusStatus.isAvailable = { focusAvailable } - Current.focusStatus.authorizationStatus = { focusAuthorization } - Current.focusStatus.status = { .init(isFocused: liveIsFocused) } - } - - /// Only the live answer, leaving `activeFocusState` reading what was stored so the confirmation - /// it records can be read back. - private func setUpLiveStatus(isFocused: Bool?) { - Current.focusStatus.isAvailable = { true } - Current.focusStatus.authorizationStatus = { .authorized } - Current.focusStatus.status = { .init(isFocused: isFocused) } - } - - private func received( - isFocused: Bool?, - at offset: TimeInterval, - lastEnded: TimeInterval? = nil, - lastStarted: TimeInterval? = nil - ) -> FocusStatusState { - let date = now.addingTimeInterval(offset) - return FocusStatusState( - isFocused: isFocused, - date: date, - lastEndedDate: lastEnded.map { now.addingTimeInterval($0) } ?? (isFocused == false ? date : nil), - lastStartedDate: lastStarted.map { now.addingTimeInterval($0) } ?? (isFocused == true ? date : nil) - ) + Current.focusStatus.canAskIOS = { canAskIOS } + Current.focusStatus.authorizationStatus = { authorization } + Current.focusStatus.liveIsFocused = { liveIsFocused } } - /// The sensor's detail screen is the only route to the Focus names, so it has to be in the - /// list before the first one is created or a new user can never get there. - func testReportsEmptyStateWithoutNamesOrActiveName() throws { - setUpDependencies(activeFocusName: nil, liveIsFocused: true) + private func run(_ name: String?, at offset: TimeInterval) { + Current.focusFilter.runs.value = FocusFilterState(name: name, date: now.addingTimeInterval(offset)) + } + private func report() throws -> (state: String?, isFocused: Bool?) { let sensors = try hang(FocusNameSensor(request: request).sensors()) XCTAssertEqual(sensors.count, 1) - XCTAssertEqual(sensors[0].UniqueID, "focus_name") - XCTAssertEqual(sensors[0].State as? String, "") - XCTAssertEqual(sensors[0].Attributes?["Is focused"] as? Bool, true) + XCTAssertEqual(sensors.first?.UniqueID, WebhookSensorId.focusName.rawValue) + return (sensors.first?.State as? String, sensors.first?.Attributes?["Is focused"] as? Bool) + } + + // MARK: - The sensor + + /// The sensor's detail screen is the only route to the Focus names, so it has to be in the + /// list before the first one is created or a new user can never get there. + func testReportsEmptyStateWithoutNamesOrRuns() throws { + setUpIOS(liveIsFocused: true) + + let report = try report() + XCTAssertEqual(report.state, "") + XCTAssertEqual(report.isFocused, true) } func testUnavailableOutsideTestFlight() throws { Current.isTestFlight = false - FocusName(name: "Work").save() - setUpDependencies(activeFocusName: "Work", receivedStatus: received(isFocused: true, at: -59)) + run("Work", at: -60) - let promise = FocusNameSensor(request: request).sensors() - XCTAssertThrowsError(try hang(promise)) { error in + XCTAssertThrowsError(try hang(FocusNameSensor(request: request).sensors())) { error in XCTAssertEqual(error as? FocusNameSensor.FocusNameError, .unavailable) } } - func testReportsActiveNameWhileFocused() throws { - FocusName(name: "Work").save() - setUpDependencies(activeFocusName: "Work", receivedStatus: received(isFocused: true, at: -59)) - - let sensors = try hang(FocusNameSensor(request: request).sensors()) - XCTAssertEqual(sensors.count, 1) - XCTAssertEqual(sensors[0].UniqueID, "focus_name") - XCTAssertEqual(sensors[0].State as? String, "Work") - XCTAssertEqual(sensors[0].Attributes?["Is focused"] as? Bool, true) - } - - /// The bug behind #5467: iOS runs the new Focus' filter before it reports the status of the - /// switch, and reading the status back at that moment still says nothing is running — which - /// used to throw away the name the filter had just reported. - func testReportsNameReportedRightBeforeAStatusSayingNothingIsRunning() throws { - FocusName(name: "Personal").save() - setUpDependencies( - activeFocusName: "Personal", - filterRan: -1, - receivedStatus: received(isFocused: false, at: 0), - liveIsFocused: false - ) + func testReportsTheNameOfTheRunningFocus() throws { + run("Work", at: -60) + setUpIOS(liveIsFocused: true) - let sensors = try hang(FocusNameSensor(request: request).sensors()) - XCTAssertEqual(sensors[0].State as? String, "Personal") - XCTAssertEqual(sensors[0].Attributes?["Is focused"] as? Bool, true) + let report = try report() + XCTAssertEqual(report.state, "Work") + XCTAssertEqual(report.isFocused, true) } /// A Focus whose status the user doesn't share reads back as "not focused" for as long as it - /// runs, so the name has to stand on the filter's word alone. - func testReportsNameWhileTheLiveStatusKeepsSayingNotFocused() throws { - FocusName(name: "Personal").save() - setUpDependencies(activeFocusName: "Personal", liveIsFocused: false) + /// runs, so the name stands on the filter's word alone. + func testReportsTheNameWhileTheStatusSaysNotFocused() throws { + run("Personal", at: -3600) + Current.focusStatus.pushes.value = FocusStatusObservation(isFocused: false, date: now.addingTimeInterval(-60)) + setUpIOS(liveIsFocused: false) - let sensors = try hang(FocusNameSensor(request: request).sensors()) - XCTAssertEqual(sensors[0].State as? String, "Personal") - } - - /// The filter runs in an app iOS often has to launch in the background first, so the status of - /// the Focus that ended can be processed well after it: inside the switch window the name the - /// filter just reported still stands. - func testKeepsTheNameWhenTheEndedStatusIsProcessedLongAfterTheFilterRun() throws { - FocusName(name: "Sleep").save() - setUpDependencies( - activeFocusName: "Sleep", - filterRan: -20, - receivedStatus: received(isFocused: false, at: 0), - liveIsFocused: false - ) - - let sensors = try hang(FocusNameSensor(request: request).sensors()) - XCTAssertEqual(sensors[0].State as? String, "Sleep") - XCTAssertEqual(sensors[0].Attributes?["Is focused"] as? Bool, true) + let report = try report() + XCTAssertEqual(report.state, "Personal") + XCTAssertEqual(report.isFocused, true) } - /// Knowing every Focus ended blanks the name rather than reporting a made-up state. - func testReportsEmptyOnceEveryFocusEnded() throws { - FocusName(name: "Work").save() - setUpDependencies( - activeFocusName: "Work", - receivedStatus: received(isFocused: false, at: -10, lastStarted: -50) - ) + /// The bug behind #5467: switching Focus performs the new Focus' filter around when iOS pushes + /// the status of the one that ended. Neither can take the other's half away. + func testReportsTheNameRunRightAroundAPushSayingNotFocused() throws { + run("Sleep", at: -1) + Current.focusStatus.pushes.value = FocusStatusObservation(isFocused: false, date: now) + setUpIOS(canAskIOS: false, liveIsFocused: nil) - let sensors = try hang(FocusNameSensor(request: request).sensors()) - XCTAssertEqual(sensors[0].State as? String, "") - XCTAssertEqual(sensors[0].Attributes?["Is focused"] as? Bool, false) - } - - /// The bug behind #5592: Focus status is shared per Focus, and the ones the user doesn't share - /// read back as "not focused" the whole time they run. Nothing ever confirmed this Focus, so - /// those statuses can't be what ended it — only the filter's own reset run can. - func testKeepsTheNameWhenTheEndedStatusNeverSawTheFocusStart() throws { - FocusName(name: "Work").save() - setUpDependencies( - activeFocusName: "Work", - filterRan: -3600, - receivedStatus: received(isFocused: false, at: -60), - liveIsFocused: false - ) + let report = try report() + XCTAssertEqual(report.state, "Sleep") + XCTAssertEqual(report.isFocused, true) + } - let sensors = try hang(FocusNameSensor(request: request).sensors()) - XCTAssertEqual(sensors[0].State as? String, "Work") - XCTAssertEqual(sensors[0].Attributes?["Is focused"] as? Bool, true) + /// When a Focus ends, iOS performs the filter with no name and the status says not focused. + func testReportsEmptyOnceTheFocusEnded() throws { + run("Work", at: -3600) + run(nil, at: -10) + setUpIOS(liveIsFocused: false) + + let report = try report() + XCTAssertEqual(report.state, "") + XCTAssertEqual(report.isFocused, false) } - /// A confirmation stamped at the same instant as the filter run belongs to that run: the two - /// come from different processes reading the same clock, so the boundary counts as seen. - func testReportsEmptyWhenTheFocusWasConfirmedAtTheFilterRunInstant() throws { - FocusName(name: "Work").save() - setUpDependencies( - activeFocusName: "Work", - receivedStatus: received(isFocused: false, at: -10, lastStarted: -60) - ) + /// The bug behind #5921: a Focus without a filter starting after a named one ended is on, but + /// isn't the Focus that reported the name. + func testReportsEmptyWhileAFocusWithoutAFilterIsOn() throws { + run(nil, at: -10) + setUpIOS(liveIsFocused: true) - let sensors = try hang(FocusNameSensor(request: request).sensors()) - XCTAssertEqual(sensors[0].State as? String, "") - XCTAssertEqual(sensors[0].Attributes?["Is focused"] as? Bool, false) - } - - /// Switching from a Focus iOS could see to one it can't: the confirmation belongs to the Focus - /// that ended, so it must not hand the statuses that follow the right to end the new one. - func testKeepsTheNameWhenOnlyThePreviousFocusWasEverConfirmed() throws { - FocusName(name: "Work").save() - setUpDependencies( - activeFocusName: "Work", - filterRan: -3600, - receivedStatus: received(isFocused: false, at: -60, lastStarted: -3610), - liveIsFocused: false - ) + let report = try report() + XCTAssertEqual(report.state, "") + XCTAssertEqual(report.isFocused, true) + } - let sensors = try hang(FocusNameSensor(request: request).sensors()) - XCTAssertEqual(sensors[0].State as? String, "Work") - XCTAssertEqual(sensors[0].Attributes?["Is focused"] as? Bool, true) + /// The log behind this rewrite: Personal was on all evening, but a pushed "not focused" from + /// hours earlier, once the app read "focused" from iOS, was taken as Personal having ended, + /// and the name blanked while the Focus was still on. Nothing but the filter ends a name. + func testTheStatusNeverEndsANamedFocus() throws { + run("Personal", at: -9 * 3600) + Current.focusStatus.pushes.value = FocusStatusObservation(isFocused: false, date: now.addingTimeInterval(-3600)) + setUpIOS(liveIsFocused: true) + + let report = try report() + XCTAssertEqual(report.state, "Personal") + XCTAssertEqual(report.isFocused, true) } - /// A Focus without a filter starting after the named one ended is running, but it is not the - /// Focus that reported the name: "Is focused" carries what's known and the name stays out. - func testReportsEmptyWhenAnUnpairedFocusStartsAfterTheNamedOneEnded() throws { - FocusName(name: "Work").save() - setUpDependencies( - activeFocusName: "Work", - receivedStatus: received(isFocused: true, at: -5, lastEnded: -10) - ) + func testReportsTheNameWithoutTheFocusStatusPermission() throws { + run("Sleep", at: -60) + setUpIOS(authorization: .denied, liveIsFocused: nil) - let sensors = try hang(FocusNameSensor(request: request).sensors()) - XCTAssertEqual(sensors[0].State as? String, "") - XCTAssertEqual(sensors[0].Attributes?["Is focused"] as? Bool, true) + let report = try report() + XCTAssertEqual(report.state, "Sleep") + XCTAssertEqual(report.isFocused, true) } - /// When a Focus deactivates, iOS re-runs the filter with no name picked and pushes that no - /// Focus is running: the sensor blanks. - func testReportsEmptyWhenTheFilterResetsOnDeactivation() throws { - FocusName(name: "Personal").save() - setUpDependencies(activeFocusName: nil, receivedStatus: received(isFocused: false, at: 0)) + func testOmitsIsFocusedWhenNothingSays() throws { + run(nil, at: -60) + setUpIOS(authorization: .denied, liveIsFocused: nil) - let sensors = try hang(FocusNameSensor(request: request).sensors()) - XCTAssertEqual(sensors[0].State as? String, "") - XCTAssertEqual(sensors[0].Attributes?["Is focused"] as? Bool, false) - } - - /// The shape of a log where Sleep started at midnight and the sensor reported the Personal - /// Focus that had ended hours earlier: the filter's last run carried no name, iOS confirmed - /// every Focus ended after it, and only the live status says a Focus is on again. That Focus - /// is running with no name, not the one before it. - func testReportsEmptyWhenAFocusStartsWithoutAFilterRunAfterTheLastOneEnded() throws { - FocusName(name: "Personal").save() - setUpDependencies( - activeFocusName: nil, - receivedStatus: received(isFocused: false, at: -6 * 60 * 60, lastStarted: -12 * 60 * 60), - liveIsFocused: true - ) - Current.focusFilter.activeFocusState = { [now] in - FocusFilterState(name: nil, date: now.addingTimeInterval(-13 * 60 * 60)) - } + let report = try report() + XCTAssertEqual(report.state, "") + XCTAssertNil(report.isFocused) + } - let sensors = try hang(FocusNameSensor(request: request).sensors()) - XCTAssertEqual(sensors[0].State as? String, "") - XCTAssertEqual(sensors[0].Attributes?["Is focused"] as? Bool, true) - } - - /// iOS skips re-running the filter when the same Focus quickly reactivates, which looks - /// exactly like a different Focus starting without one: the pushed status says a Focus is on, - /// and nothing says which, so no name is reported rather than a guess. - func testReportsEmptyWhenFocusReactivatesWithoutAFilterRun() throws { - FocusName(name: "Personal").save() - setUpDependencies( - activeFocusName: nil, - receivedStatus: received(isFocused: true, at: -1, lastEnded: -10) - ) - Current.focusFilter.activeFocusState = { [now] in - FocusFilterState(name: nil, date: now.addingTimeInterval(-11)) + /// A filter run is what changes the name, so it signals the sensor. Only enabled sensors are + /// observed, and enablement is per server. + @MainActor + func testSignalerFiresOnFilterRun() async throws { + let previousServers = Current.servers + addTeardownBlock { + SensorEnablementStore.resetForTesting() + Current.servers = previousServers + } + let servers = FakeServerManager() + servers.addFake() + Current.servers = servers + SensorEnablementStore.resetForTesting() + Current.sensors.setEnabledForAllServers(true, forUniqueIDs: [WebhookSensorId.focusName.rawValue]) + _ = Current.sensors.sensors(reason: .registration, server: ServerFixture.standard) + + let observationExpectation = expectation(description: "Observation") + let signalExpectation = expectation(description: "Signal") + let signaler = FocusNameSensorUpdateSignaler(signal: { + signalExpectation.fulfill() + }) + signaler.notifyObservation = { + observationExpectation.fulfill() } - let sensors = try hang(FocusNameSensor(request: request).sensors()) - XCTAssertEqual(sensors[0].State as? String, "") - XCTAssertEqual(sensors[0].Attributes?["Is focused"] as? Bool, true) - } + await fulfillment(of: [observationExpectation], timeout: 30) - /// Names created but no filter has ever reported one: the sensor must still register — an - /// error would drop it from the sensors list — reporting empty, never a made-up state. - func testReportsEmptyWhenNoFilterEverReported() throws { - FocusName(name: "Work").save() - setUpDependencies(activeFocusName: nil, receivedStatus: received(isFocused: true, at: -5)) + Current.focusFilter.runs.value = FocusFilterState(name: "Personal", date: Date()) - let sensors = try hang(FocusNameSensor(request: request).sensors()) - XCTAssertEqual(sensors[0].State as? String, "") - XCTAssertEqual(sensors[0].Attributes?["Is focused"] as? Bool, true) + await fulfillment(of: [signalExpectation], timeout: 30) + signaler.stopObserving() } - /// iOS sending a status without saying whether a Focus is running is not the same as saying - /// none is, so the reported name has to survive it. - func testKeepsTheNameWhenTheReceivedStatusIsUnknown() throws { - FocusName(name: "Work").save() - setUpDependencies(activeFocusName: "Work", receivedStatus: received(isFocused: nil, at: -5)) + /// A push changes "Is focused", which keeps the sensor on screen current when the binary + /// sensor is off, but only in the foreground: in the background the Intents extension that + /// received it reports it. + @MainActor + func testSignalerFiresOnAPushOnlyInTheForeground() async throws { + let previousServers = Current.servers + let previousIsForegroundApp = Current.isForegroundApp + addTeardownBlock { + SensorEnablementStore.resetForTesting() + Current.servers = previousServers + Current.isForegroundApp = previousIsForegroundApp + } + let servers = FakeServerManager() + servers.addFake() + Current.servers = servers + SensorEnablementStore.resetForTesting() + Current.sensors.setEnabledForAllServers(true, forUniqueIDs: [WebhookSensorId.focusName.rawValue]) + _ = Current.sensors.sensors(reason: .registration, server: ServerFixture.standard) + + let observationExpectation = expectation(description: "Observation") + let signalExpectation = expectation(description: "Signal") + let signaler = FocusNameSensorUpdateSignaler(signal: { + signalExpectation.fulfill() + }) + signaler.notifyObservation = { + observationExpectation.fulfill() + } - let sensors = try hang(FocusNameSensor(request: request).sensors()) - XCTAssertEqual(sensors[0].State as? String, "Work") - } + await fulfillment(of: [observationExpectation], timeout: 30) - /// Without the Focus status permission nothing can tell us a Focus ended, so the filter's own - /// report is all there is — and it only ever runs when a Focus starts. - func testReportsNameWithoutFocusAuthorization() throws { - setUpDependencies(activeFocusName: "Sleep", focusAuthorization: .denied) + Current.isForegroundApp = { false } + Current.focusStatus.pushes.value = FocusStatusObservation(isFocused: true, date: Date()) + Current.isForegroundApp = { true } + Current.focusStatus.pushes.value = FocusStatusObservation(isFocused: false, date: Date()) - let sensors = try hang(FocusNameSensor(request: request).sensors()) - XCTAssertEqual(sensors[0].State as? String, "Sleep") - XCTAssertEqual(sensors[0].Attributes?["Is focused"] as? Bool, true) + await fulfillment(of: [signalExpectation], timeout: 30) + signaler.stopObserving() } - func testReportsEmptyWithoutAuthorizationAndWithoutAnyReport() throws { - FocusName(name: "Sleep").save() - setUpDependencies(activeFocusName: nil, focusAuthorization: .denied) + // MARK: - The filter's current values - let sensors = try hang(FocusNameSensor(request: request).sensors()) - XCTAssertEqual(sensors[0].State as? String, "") - XCTAssertNil(sensors[0].Attributes?["Is focused"]) + /// The run with no name for a Focus that ended while iOS didn't launch the app never lands. + /// Asking iOS for the filter's current values ends the name anyway. + func testCurrentValuesEndANameWhoseRunWithNoNameNeverLanded() throws { + run("Personal", at: -6 * 3600) + Current.focusFilter.setCurrentValuesReader { nil } + setUpIOS(liveIsFocused: false) + + let report = try report() + XCTAssertEqual(report.state, "") + XCTAssertEqual(report.isFocused, false) + XCTAssertEqual(Current.focusFilter.readings.value, FocusFilterState(name: nil, date: now)) } - func testReportsNameWhenFocusStatusUnavailable() throws { - setUpDependencies(activeFocusName: "Sleep", focusAvailable: false) + /// A Focus that turned on while iOS didn't launch the app to perform the filter is named once + /// the app asks. + func testCurrentValuesNameAFocusWhoseRunNeverLanded() throws { + Current.focusFilter.setCurrentValuesReader { "Cinema" } + setUpIOS(liveIsFocused: false) - let sensors = try hang(FocusNameSensor(request: request).sensors()) - XCTAssertEqual(sensors[0].State as? String, "Sleep") + let report = try report() + XCTAssertEqual(report.state, "Cinema") + XCTAssertEqual(report.isFocused, true) } - /// The received status is what every process reads back, so it has to survive the one that - /// received it going away — and it has to remember when a Focus last started and last ended, - /// which is what pairs a status with the Focus it is talking about. - func testReceivedStatusIsStoredWithWhenAFocusLastStartedAndEnded() throws { - Current.isAppExtension = true + /// The app's reading is stored, so an extension, which can't ask, reports the same name. + func testAnExtensionReportsTheNameTheAppLastRead() throws { + run("Personal", at: -6 * 3600) + Current.focusFilter.readings.value = FocusFilterState(name: nil, date: now.addingTimeInterval(-60)) + setUpIOS(canAskIOS: false, liveIsFocused: nil) - Current.focusStatus.update(fromReceived: INFocusStatus(isFocused: false)) - XCTAssertEqual(Current.focusStatus.lastReceived()?.isFocused, false) - XCTAssertEqual(Current.focusStatus.lastReceived()?.lastEndedDate, now) + XCTAssertEqual(try report().state, "") + } - Current.date = { [now] in now.addingTimeInterval(60) } - Current.focusStatus.update(fromReceived: INFocusStatus(isFocused: true)) - XCTAssertEqual(Current.focusStatus.lastReceived()?.isFocused, true) - XCTAssertEqual(Current.focusStatus.lastReceived()?.lastEndedDate, now) - XCTAssertEqual(Current.focusStatus.lastReceived()?.lastStartedDate, now.addingTimeInterval(60)) + /// A run newer than the app's last reading is the newer answer. + func testARunNewerThanTheLastReadingIsTheName() throws { + Current.focusFilter.readings.value = FocusFilterState(name: "Work", date: now.addingTimeInterval(-60)) + run("Sleep", at: -1) - Current.date = { [now] in now.addingTimeInterval(120) } - Current.focusStatus.update(fromReceived: INFocusStatus(isFocused: false)) - XCTAssertEqual(Current.focusStatus.lastReceived()?.lastEndedDate, now.addingTimeInterval(120)) - XCTAssertEqual(Current.focusStatus.lastReceived()?.lastStartedDate, now.addingTimeInterval(60)) + XCTAssertEqual(try report().state, "Sleep") } - /// Status persisted before it recorded when a Focus started still says one was running, and - /// that is the confirmation — otherwise the first status after an upgrade could never be the - /// one that ends the Focus it was talking about. - func testReceivedStatusBackfillsTheStartFromAPersistedFocusedStatus() throws { - Current.isAppExtension = true - Current.focusStatus.receivedStatus.value = FocusStatusState( - isFocused: true, - date: now.addingTimeInterval(-60), - lastEndedDate: nil - ) - - Current.focusStatus.update(fromReceived: INFocusStatus(isFocused: false)) + /// iOS failing to answer is not an answer: the runs keep the name, and nothing is stored. + func testAFailedReadingLeavesTheNameToTheRuns() throws { + struct ReadFailed: Error {} + run("Work", at: -60) + Current.focusFilter.setCurrentValuesReader { throw ReadFailed() } - XCTAssertEqual(Current.focusStatus.lastReceived()?.lastEndedDate, now) - XCTAssertEqual(Current.focusStatus.lastReceived()?.lastStartedDate, now.addingTimeInterval(-60)) + XCTAssertEqual(try report().state, "Work") + XCTAssertNil(Current.focusFilter.readings.value) } - /// A received status must not destroy the reported name: whether it is still current depends on - /// when the filter ran, which is only known when the two are read back together. - func testReceivedStatusKeepsTheReportedName() throws { - Current.isAppExtension = true - Current.focusFilter.setActiveFocusName("Work") - - Current.focusStatus.update(fromReceived: INFocusStatus(isFocused: false)) + /// A reading that never comes back must not hold the sensor update hostage. + func testASlowReadingIsGivenUpOn() throws { + FocusFilterWrapper.currentValuesTimeout = 0.05 + run("Work", at: -60) + Current.focusFilter.setCurrentValuesReader { + try await Task.sleep(nanoseconds: 2_000_000_000) + return nil + } - XCTAssertEqual(Current.focusFilter.activeFocusName(), "Work") + XCTAssertEqual(try report().state, "Work") + XCTAssertNil(Current.focusFilter.readings.value) } - /// The filter's nil-name reset run on deactivation clears the name, and the next named run + // MARK: - Runs + + /// The run with no name iOS makes when a Focus ends clears the name, and the next named run /// replaces it. - func testFilterResetClearsTheName() throws { + func testRunWithNoNameClearsTheName() throws { Current.focusFilter.setActiveFocusName("Work") let resetDate = now.addingTimeInterval(FocusFilterWrapper.resetGracePeriod + 1) Current.date = { resetDate } Current.focusFilter.setActiveFocusName(nil) - XCTAssertNil(Current.focusFilter.state.value?.name) - XCTAssertEqual(Current.focusFilter.state.value?.date, resetDate) + XCTAssertEqual(Current.focusFilter.runs.value, FocusFilterState(name: nil, date: resetDate)) Current.focusFilter.setActiveFocusName("Sleep") - XCTAssertEqual(Current.focusFilter.state.value?.name, "Sleep") + XCTAssertEqual(Current.focusFilter.activeFocusName(), "Sleep") + } + + /// An empty name is no name. + func testRunWithAnEmptyNameIsARunWithNoName() throws { + Current.focusFilter.setActiveFocusName("") + + XCTAssertEqual(Current.focusFilter.runs.value, FocusFilterState(name: nil, date: now)) } - /// Switching Focus runs the ending Focus' reset pass and the starting Focus' named pass around - /// the same moment and in no guaranteed order, so a reset landing second must not erase the - /// name the switch just reported. - func testFilterResetRightAfterANamedRunIsIgnored() throws { + /// Switching Focus can perform the filter for the starting Focus and the ending one around the + /// same moment and in no guaranteed order, so a run with no name landing second must not + /// erase the name the switch just reported. + func testRunWithNoNameRightAfterANamedRunIsIgnored() throws { Current.focusFilter.setActiveFocusName("Sleep") Current.date = { [now] in now.addingTimeInterval(FocusFilterWrapper.resetGracePeriod - 1) } Current.focusFilter.setActiveFocusName(nil) - XCTAssertEqual(Current.focusFilter.state.value?.name, "Sleep") - XCTAssertEqual(Current.focusFilter.state.value?.date, now) + XCTAssertEqual(Current.focusFilter.runs.value, FocusFilterState(name: "Sleep", date: now)) } - /// A name the user deleted has to stop being reported. + /// A name the user deleted has to stop being reported, whichever answer carried it. func testForgetFocusNameClearsTheName() throws { Current.focusFilter.setActiveFocusName("Work") + Current.focusFilter.readings.value = FocusFilterState(name: "Work", date: now) Current.focusFilter.forgetFocusName("Work") - XCTAssertNil(Current.focusFilter.state.value?.name) + XCTAssertNil(Current.focusFilter.runs.value?.name) + XCTAssertNil(Current.focusFilter.readings.value?.name) + XCTAssertNil(Current.focusFilter.activeFocusName()) } func testForgetFocusNameKeepsAnUnrelatedName() throws { Current.focusFilter.setActiveFocusName("Work") + Current.focusFilter.readings.value = FocusFilterState(name: "Work", date: now) Current.focusFilter.forgetFocusName("Sleep") - XCTAssertEqual(Current.focusFilter.state.value?.name, "Work") + XCTAssertEqual(Current.focusFilter.runs.value?.name, "Work") + XCTAssertEqual(Current.focusFilter.readings.value?.name, "Work") } - /// The filter run lands in the app, which iOS launches in the background after the Intents - /// extension has already stored the push saying the Focus started — so the push that should - /// confirm the Focus usually predates the run and can't. The live answer confirms it instead, - /// and then a live "not focused" is this Focus ending, with or without the push that said so. - func testEndsTheNameOnceTheLiveStatusSaysNotFocusedAfterItConfirmedTheFocus() throws { - FocusName(name: "Work").save() - let filterRan = now.addingTimeInterval(-3600) - Current.focusFilter.state.value = FocusFilterState(name: "Work", date: filterRan) - Current.focusFilter.liveConfirmation.value = FocusFilterLiveConfirmation( - filterDate: filterRan, - date: now.addingTimeInterval(-1800) - ) - Current.focusStatus.lastReceived = { [now] in - FocusStatusState(isFocused: true, date: now.addingTimeInterval(-3602), lastEndedDate: nil) - } - setUpLiveStatus(isFocused: false) + /// Deleting the name an older answer carried must not outrank a newer answer about another + /// Focus, in either direction. + func testForgettingAnOlderNameKeepsTheNewerAnswer() throws { + run("Work", at: -60) + Current.focusFilter.readings.value = FocusFilterState(name: "Sleep", date: now.addingTimeInterval(-10)) + Current.focusFilter.forgetFocusName("Work") - let sensors = try hang(FocusNameSensor(request: request).sensors()) - XCTAssertEqual(sensors[0].State as? String, "") - XCTAssertEqual(sensors[0].Attributes?["Is focused"] as? Bool, false) + XCTAssertEqual(Current.focusFilter.runs.value, FocusFilterState(name: nil, date: now.addingTimeInterval(-60))) + XCTAssertEqual(Current.focusFilter.activeFocusName(), "Sleep") + + run("Sleep", at: -10) + Current.focusFilter.readings.value = FocusFilterState(name: "Work", date: now.addingTimeInterval(-60)) + Current.focusFilter.forgetFocusName("Work") + + XCTAssertEqual( + Current.focusFilter.readings.value, + FocusFilterState(name: nil, date: now.addingTimeInterval(-60)) + ) + XCTAssertEqual(Current.focusFilter.activeFocusName(), "Sleep") } - /// The confirmation is taken from the live answer only once the switch window has passed: - /// inside it the answer can still be about the Focus that just ended. - func testLiveFocusedConfirmsTheNameOnceTheSwitchWindowPassed() throws { - FocusName(name: "Work").save() - let filterRan = now.addingTimeInterval(-FocusReport.switchGracePeriod) - Current.focusFilter.state.value = FocusFilterState(name: "Work", date: filterRan) - setUpLiveStatus(isFocused: true) + // MARK: - Asking iOS - _ = try hang(FocusNameSensor(request: request).sensors()) - XCTAssertNil(Current.focusFilter.activeFocusState()?.liveConfirmedDate, "still inside the switch window") + /// iOS can answer a question asked before a filter run after that run landed. The answer is + /// dated when it was asked, so the run stays the newer of the two. + func testAnAnswerAskedBeforeARunDoesNotOutrankIt() async throws { + let gate = Gate() + let asked = expectation(description: "iOS asked") + Current.focusFilter.setCurrentValuesReader { + asked.fulfill() + await gate.wait() + return nil + } + let reading = Task { await Current.focusFilter.readFromIOS() } + await fulfillment(of: [asked], timeout: 30) Current.date = { [now] in now.addingTimeInterval(1) } - let sensors = try hang(FocusNameSensor(request: request).sensors()) - XCTAssertEqual(sensors[0].State as? String, "Work") - XCTAssertEqual(Current.focusFilter.activeFocusState()?.liveConfirmedDate, now.addingTimeInterval(1)) + Current.focusFilter.setActiveFocusName("Work") + gate.open() + await reading.value + + XCTAssertEqual(Current.focusFilter.readings.value, FocusFilterState(name: nil, date: now)) + XCTAssertEqual(Current.focusFilter.activeFocusName(), "Work") } - /// A Focus whose status the user doesn't share never reads back as "focused", so it is never - /// confirmed by the live answer, and a live "not focused" keeps meaning nothing about it. - func testLiveNotFocusedNeverConfirmsNorEndsAnUnsharedFocus() throws { - FocusName(name: "Work").save() - Current.focusFilter.state.value = FocusFilterState(name: "Work", date: now.addingTimeInterval(-3600)) - setUpLiveStatus(isFocused: false) + /// Both Focus sensors read at once. They share one question, so neither can store an older + /// answer over the other's. + func testReadsWhileAQuestionIsInFlightShareIt() async throws { + let gate = Gate() + let calls = Calls() + let reader: FocusFilterWrapper.CurrentValuesReader = { + calls.record() + await gate.wait() + return "Work" + } - let sensors = try hang(FocusNameSensor(request: request).sensors()) - XCTAssertEqual(sensors[0].State as? String, "Work") - XCTAssertNil(Current.focusFilter.activeFocusState()?.liveConfirmedDate) + let first = try XCTUnwrap(Current.focusFilter.currentRequest(asking: reader)) + let second = try XCTUnwrap(Current.focusFilter.currentRequest(asking: reader)) + XCTAssertEqual(first.id, second.id) + + gate.open() + _ = await first.answer.value + XCTAssertEqual(calls.count, 1) + + // Answered, so the next read asks again. + let third = try XCTUnwrap(Current.focusFilter.currentRequest(asking: reader)) + XCTAssertNotEqual(third.id, first.id) + _ = await third.answer.value + XCTAssertEqual(calls.count, 2) + } + + /// A question iOS doesn't answer in time is cancelled, and the next read asks again once the + /// cancelled one has stopped. + func testATimedOutQuestionIsCancelled() async throws { + FocusFilterWrapper.currentValuesTimeout = 0.05 + let calls = Calls() + let cancelled = expectation(description: "question cancelled") + Current.focusFilter.setCurrentValuesReader { + calls.record() + do { + try await Task.sleep(nanoseconds: 30_000_000_000) + } catch { + cancelled.fulfill() + throw error + } + return "Work" + } + + await Current.focusFilter.readFromIOS() + await fulfillment(of: [cancelled], timeout: 30) + XCTAssertEqual(calls.count, 1) + + FocusFilterWrapper.currentValuesTimeout = 30 + Current.focusFilter.setCurrentValuesReader { "Sleep" } + let askedAgain = expectation(for: NSPredicate { _, _ in + Task { await Current.focusFilter.readFromIOS() } + return Current.focusFilter.readings.value?.name == "Sleep" + }, evaluatedWith: nil) + await fulfillment(of: [askedAgain], timeout: 30) + } + + /// A question that ignores being cancelled and never comes back must not gain a sibling on + /// every read: until iOS answers it, reads go without asking. + func testNoNewQuestionWhileATimedOutOneIsUnanswered() async throws { + FocusFilterWrapper.currentValuesTimeout = 0.05 + let gate = Gate() + let calls = Calls() + let answered = expectation(description: "the stuck question answered") + answered.assertForOverFulfill = false + Current.focusFilter.setCurrentValuesReader { + calls.record() + await gate.wait() + answered.fulfill() + return nil + } + + await Current.focusFilter.readFromIOS() + await Current.focusFilter.readFromIOS() + XCTAssertEqual(calls.count, 1) + XCTAssertNil(Current.focusFilter.readings.value, "the stuck answer comes too late to be stored") + + gate.open() + await fulfillment(of: [answered], timeout: 30) + let cleared = expectation(for: NSPredicate { _, _ in + Task { await Current.focusFilter.readFromIOS() } + return calls.count > 1 + }, evaluatedWith: nil) + await fulfillment(of: [cleared], timeout: 30) } - /// A confirmation belongs to the run it confirmed: the next named run is a different Focus. - func testLiveConfirmationDoesNotCarryOverToTheNextFilterRun() throws { - Current.focusFilter.setActiveFocusName("Work") + // MARK: - Pushes + + /// The push is stored with when it arrived, which is what orders it against the app's reading. + func testAPushIsStoredWithWhenItArrived() throws { + Current.isAppExtension = true + + Current.focusStatus.record(pushed: INFocusStatus(isFocused: false)) + XCTAssertEqual(Current.focusStatus.pushes.value, FocusStatusObservation(isFocused: false, date: now)) + Current.date = { [now] in now.addingTimeInterval(60) } - try Current.focusFilter.confirmLive(XCTUnwrap(Current.focusFilter.activeFocusState())) - XCTAssertNotNil(Current.focusFilter.activeFocusState()?.liveConfirmedDate) + Current.focusStatus.record(pushed: INFocusStatus(isFocused: true)) + XCTAssertEqual( + Current.focusStatus.pushes.value, + FocusStatusObservation(isFocused: true, date: now.addingTimeInterval(60)) + ) + } - Current.date = { [now] in now.addingTimeInterval(120) } - Current.focusFilter.setActiveFocusName("Sleep") - XCTAssertNil(Current.focusFilter.activeFocusState()?.liveConfirmedDate) + /// A push without a value is skipped, as Apple's sample does. + func testAPushWithoutAValueIsSkipped() throws { + Current.focusStatus.pushes.value = FocusStatusObservation(isFocused: true, date: now.addingTimeInterval(-60)) + + Current.focusStatus.record(pushed: INFocusStatus(isFocused: nil)) + Current.focusStatus.record(pushed: nil) + + XCTAssertEqual( + Current.focusStatus.pushes.value, + FocusStatusObservation(isFocused: true, date: now.addingTimeInterval(-60)) + ) } - /// Only the run still current can be confirmed, and only once. - func testConfirmLiveIgnoresAnOutdatedRunAndRepeats() throws { + /// A push only ever changes the status half. + func testAPushKeepsTheName() throws { Current.focusFilter.setActiveFocusName("Work") - Current.focusFilter.confirmLive(FocusFilterState(name: "Work", date: now.addingTimeInterval(-1))) - XCTAssertNil(Current.focusFilter.activeFocusState()?.liveConfirmedDate) - Current.date = { [now] in now.addingTimeInterval(60) } - try Current.focusFilter.confirmLive(XCTUnwrap(Current.focusFilter.activeFocusState())) - Current.date = { [now] in now.addingTimeInterval(120) } - try Current.focusFilter.confirmLive(XCTUnwrap(Current.focusFilter.activeFocusState())) - XCTAssertEqual(Current.focusFilter.activeFocusState()?.liveConfirmedDate, now.addingTimeInterval(60)) + Current.focusStatus.record(pushed: INFocusStatus(isFocused: false)) + + XCTAssertEqual(Current.focusFilter.activeFocusName(), "Work") } + // MARK: - Stored by an older build + + /// The keys predate these types and carried more fields. What an older build stored still + /// reads, so the first report after an upgrade starts from the last thing iOS said. + func testReadsWhatAnOlderBuildStored() throws { + let prefs = Current.settingsStore.prefs + prefs.set( + Data(#"{"name":"Personal","date":721485839,"liveConfirmedDate":721513035}"#.utf8), + forKey: "FocusFilterStateKey" + ) + prefs.set( + Data(#"{"isFocused":false,"date":721511049,"lastEndedDate":721511049,"lastStartedDate":719850873}"#.utf8), + forKey: "FocusStatusStateKey" + ) + + XCTAssertEqual( + Current.focusFilter.runs.value, + FocusFilterState(name: "Personal", date: Date(timeIntervalSinceReferenceDate: 721_485_839)) + ) + XCTAssertEqual( + Current.focusStatus.pushes.value, + FocusStatusObservation(isFocused: false, date: Date(timeIntervalSinceReferenceDate: 721_511_049)) + ) + } + + // MARK: - The Intents extension + /// The Intents extension is launched by iOS to receive the status, with no network information /// cached: without refreshing it first a server reachable only at home has no usable URL and /// the report goes nowhere while claiming success. So the refresh has to have happened by the /// time the Focus sensors go out. func testStatusIntentRefreshesNetworkInformationBeforeReporting() throws { Current.isAppExtension = true + setUpIOS(canAskIOS: false, liveIsFocused: nil) let previousServers = Current.servers let previousApis = Current.cachedApis let previousWebhooks = Current.webhooks @@ -554,9 +664,9 @@ class FocusNameSensorTests: XCTestCase { completed.fulfill() } - wait(for: [sent, completed], timeout: 10) + wait(for: [sent, completed], timeout: 30) XCTAssertEqual(refreshedBeforeReporting, true) - XCTAssertEqual(Current.focusStatus.lastReceived()?.isFocused, true) + XCTAssertEqual(Current.focusStatus.latest()?.isFocused, true) } /// With no server to report to, the status is still stored and the intent still succeeds: the @@ -586,7 +696,7 @@ class FocusNameSensorTests: XCTestCase { completed.fulfill() } - wait(for: [completed], timeout: 10) - XCTAssertEqual(Current.focusStatus.lastReceived()?.isFocused, false) + wait(for: [completed], timeout: 30) + XCTAssertEqual(Current.focusStatus.pushes.value?.isFocused, false) } } diff --git a/Tests/Shared/Sensors/FocusSensor.test.swift b/Tests/Shared/Sensors/FocusSensor.test.swift index ebdd5cd450..dde3136b02 100644 --- a/Tests/Shared/Sensors/FocusSensor.test.swift +++ b/Tests/Shared/Sensors/FocusSensor.test.swift @@ -29,232 +29,213 @@ class FocusSensorTests: XCTestCase { Current.sensors.setEnabledForAllServers(true, forUniqueIDs: [ WebhookSensorId.focus.rawValue, ]) - Current.focusFilter = FocusFilterWrapper() - Current.focusStatus = FocusStatusWrapper() - Current.focusFilter.state.value = nil - Current.focusFilter.liveConfirmation.value = nil - Current.focusStatus.receivedStatus.value = nil + resetFocusState() Current.date = { [now] in now } } override func tearDown() { SensorEnablementStore.resetForTesting() Current.servers = previousServers - Current.focusFilter.state.value = nil - Current.focusFilter.liveConfirmation.value = nil - Current.focusStatus.receivedStatus.value = nil - Current.focusFilter = FocusFilterWrapper() - Current.focusStatus = FocusStatusWrapper() + resetFocusState() Current.date = Date.init super.tearDown() } - private func setUpDependencies( + private func resetFocusState() { + Current.focusFilter = FocusFilterWrapper() + Current.focusStatus = FocusStatusWrapper() + Current.focusFilter.runs.value = nil + Current.focusFilter.readings.value = nil + Current.focusStatus.pushes.value = nil + Current.focusStatus.readings.value = nil + } + + /// - Parameters: + /// - canAskIOS: `false` for an extension, which can't read `INFocusStatusCenter`. + /// - liveIsFocused: what iOS answers when the app asks it. + private func setUpIOS( + canAskIOS: Bool = true, authorization: FocusStatusWrapper.AuthorizationStatus = .authorized, - isAvailable: Bool = true, - status: FocusStatusWrapper.Status = .init(isFocused: nil), - filterState: FocusFilterState? = nil, - receivedStatus: FocusStatusState? = nil + liveIsFocused: Bool? ) { + Current.focusStatus.canAskIOS = { canAskIOS } Current.focusStatus.authorizationStatus = { authorization } - Current.focusStatus.isAvailable = { isAvailable } - Current.focusStatus.status = { status } - Current.focusStatus.lastReceived = { receivedStatus } - Current.focusFilter.activeFocusState = { filterState } + Current.focusStatus.liveIsFocused = { liveIsFocused } + } + + private func focusState() throws -> Any? { + let sensors = try hang(FocusSensor(request: request).sensors()) + XCTAssertEqual(sensors.count, 1) + let sensor = try XCTUnwrap(sensors.first(where: { $0.UniqueID == WebhookSensorId.focus.rawValue })) + XCTAssertEqual(sensor.Type, "binary_sensor") + return sensor.State } func testNotAvailable() throws { - setUpDependencies(isAvailable: false) + Current.focusStatus.isAvailable = { false } - let promise = FocusSensor(request: request).sensors() - XCTAssertThrowsError(try hang(promise)) { error in + XCTAssertThrowsError(try hang(FocusSensor(request: request).sensors())) { error in XCTAssertEqual(error as? FocusSensor.FocusError, .unavailable) } } + func testIsFocusedYes() throws { + setUpIOS(liveIsFocused: true) + + let sensors = try hang(FocusSensor(request: request).sensors()) + let focusSensor = try XCTUnwrap(sensors.first(where: { $0.UniqueID == "focus" })) + XCTAssertEqual(focusSensor.Name, "Focus") + XCTAssertEqual(focusSensor.Icon, "mdi:moon-waning-crescent") + XCTAssertEqual(focusSensor.Type, "binary_sensor") + XCTAssertEqual(focusSensor.State as? Bool, true) + } + + func testIsFocusedNo() throws { + setUpIOS(liveIsFocused: false) + + XCTAssertEqual(try focusState() as? Bool, false) + } + /// Listed rather than dropped: switching it on is what asks for Focus access, so a sensor that /// vanished until it was granted would have no row left to switch on. func testNotAuthorizedReportsUnavailable() throws { - for state: FocusStatusWrapper.AuthorizationStatus in [ - .restricted, .denied, .notDetermined, - ] { - setUpDependencies(authorization: state) - - let sensors = try hang(FocusSensor(request: request).sensors()) - XCTAssertEqual(sensors.count, 1, "\(state)") - XCTAssertEqual(sensors[0].UniqueID, WebhookSensorId.focus.rawValue) - XCTAssertEqual(sensors[0].State as? String, "unavailable") - XCTAssertEqual(sensors[0].Type, "binary_sensor") + for state: FocusStatusWrapper.AuthorizationStatus in [.restricted, .denied, .notDetermined] { + setUpIOS(authorization: state, liveIsFocused: true) + + XCTAssertEqual(try focusState() as? String, "unavailable", "\(state)") } } /// iOS giving no answer is not a reason to vanish from the list: the row is the only switch. func testIsFocusedNilStaysListedAsUnavailable() throws { - setUpDependencies(status: .init(isFocused: nil)) + setUpIOS(liveIsFocused: nil) - let sensors = try hang(FocusSensor(request: request).sensors()) - XCTAssertEqual(sensors.count, 1) - XCTAssertEqual(sensors[0].UniqueID, WebhookSensorId.focus.rawValue) - XCTAssertEqual(sensors[0].State as? String, "unavailable") - XCTAssertEqual(sensors[0].Type, "binary_sensor") + XCTAssertEqual(try focusState() as? String, "unavailable") } - /// A named filter run keeps the sensor on until that Focus ends — and for a Focus the live - /// status confirmed, a live "not focused" is it ending, even when the "ended" push never came - /// and the filter's reset run never landed. - func testConfirmedNamedFocusEndsWhenTheLiveStatusSaysNotFocused() throws { - let filterRan = now.addingTimeInterval(-3600) - setUpDependencies( - status: .init(isFocused: false), - filterState: .init(name: "Work", date: filterRan, liveConfirmedDate: now.addingTimeInterval(-1800)) - ) + /// Every read stores what iOS answered, so the extensions that can't ask report it too. + func testTheAppStoresWhatIOSAnswered() throws { + setUpIOS(liveIsFocused: true) - let sensors = try hang(FocusSensor(request: request).sensors()) - let focusSensor = try XCTUnwrap(sensors.first(where: { $0.UniqueID == "focus" })) - XCTAssertEqual(focusSensor.State as? Bool, false) + _ = try focusState() + + XCTAssertEqual(Current.focusStatus.readings.value, FocusStatusObservation(isFocused: true, date: now)) } - /// The same run before anything confirmed it: the live "not focused" is also what iOS says - /// about a Focus whose status the user doesn't share, so the name — and the sensor — stand. - func testUnconfirmedNamedFocusStandsDespiteTheLiveStatusSayingNotFocused() throws { - setUpDependencies( - status: .init(isFocused: false), - filterState: .init(name: "Work", date: now.addingTimeInterval(-3600)) - ) + /// A Focus paired with the filter is on even while iOS reports "not focused", which it does for + /// a Focus that doesn't share its status or that allows this app's notifications. + func testNamedFocusIsOnWhateverTheStatusSays() throws { + Current.focusFilter.runs.value = FocusFilterState(name: "Personal", date: now.addingTimeInterval(-3600)) + Current.focusStatus.pushes.value = FocusStatusObservation(isFocused: false, date: now.addingTimeInterval(-60)) + setUpIOS(liveIsFocused: false) - let sensors = try hang(FocusSensor(request: request).sensors()) - let focusSensor = try XCTUnwrap(sensors.first(where: { $0.UniqueID == "focus" })) - XCTAssertEqual(focusSensor.State as? Bool, true) + XCTAssertEqual(try focusState() as? Bool, true) } - func testIsFocusedYes() throws { - setUpDependencies(status: .init(isFocused: true)) - - let promise = FocusSensor(request: request).sensors() - let sensors = try hang(promise) - let focusSensor = try XCTUnwrap(sensors.first(where: { $0.UniqueID == "focus" })) + /// The filter path doesn't need the Focus status permission. + func testNamedFocusIsOnWithoutAuthorization() throws { + Current.focusFilter.runs.value = FocusFilterState(name: "Personal", date: now.addingTimeInterval(-60)) + setUpIOS(authorization: .denied, liveIsFocused: nil) - XCTAssertEqual(focusSensor.Name, "Focus") - XCTAssertEqual(focusSensor.Icon, "mdi:moon-waning-crescent") - XCTAssertEqual(focusSensor.Type, "binary_sensor") - XCTAssertEqual(focusSensor.State as? Bool, true) + XCTAssertEqual(try focusState() as? Bool, true) } - func testIsFocusedNo() throws { - setUpDependencies(status: .init(isFocused: false)) + /// The run iOS makes with no name when a Focus ends says nothing about other Focuses: the + /// status decides, both ways. + func testRunWithNoNameDefersToTheStatus() throws { + Current.focusFilter.runs.value = FocusFilterState(name: nil, date: now.addingTimeInterval(-1)) - let promise = FocusSensor(request: request).sensors() - let sensors = try hang(promise) - let focusSensor = try XCTUnwrap(sensors.first(where: { $0.UniqueID == "focus" })) + setUpIOS(liveIsFocused: false) + XCTAssertEqual(try focusState() as? Bool, false) - XCTAssertEqual(focusSensor.Name, "Focus") - XCTAssertEqual(focusSensor.Icon, "mdi:moon-waning-crescent") - XCTAssertEqual(focusSensor.Type, "binary_sensor") - XCTAssertEqual(focusSensor.State as? Bool, false) + setUpIOS(liveIsFocused: true) + XCTAssertEqual(try focusState() as? Bool, true, "a Focus without a filter is on") } - /// A named Focus Filter run is proof a Focus started, standing even while the live status - /// still describes the Focus that just ended — this is what flips the sensor to true when iOS - /// wakes us for a Focus change. - func testIsFocusedYesWhileNamedFilterRunStandsDespiteLiveNo() throws { - setUpDependencies( - status: .init(isFocused: false), - filterState: .init(name: "Personal", date: Date()) - ) + /// The bug behind #5711: iOS doesn't reliably push the status of a Focus ending, so a pushed + /// "focused" stood until the next Focus started. The app's own reading is newer. + func testFocusEndingWithoutAPushIsReadFromIOS() throws { + Current.focusStatus.pushes.value = FocusStatusObservation(isFocused: true, date: now.addingTimeInterval(-36000)) + setUpIOS(liveIsFocused: false) - let sensors = try hang(FocusSensor(request: request).sensors()) - let focusSensor = try XCTUnwrap(sensors.first(where: { $0.UniqueID == "focus" })) - XCTAssertEqual(focusSensor.State as? Bool, true) + XCTAssertEqual(try focusState() as? Bool, false) } - /// The filter path doesn't need the Focus status permission, so a named run answers even when - /// the live status can't. - func testIsFocusedYesFromNamedFilterRunWithoutAuthorization() throws { - setUpDependencies( - authorization: .denied, - filterState: .init(name: "Personal", date: Date()) + /// The bug behind #5850: a pushed "not focused" from days ago outranked iOS answering that a + /// Focus is on. + func testStalePushedNotFocusedYieldsToIOS() throws { + Current.focusStatus.pushes.value = FocusStatusObservation( + isFocused: false, + date: now.addingTimeInterval(-48 * 3600) ) + setUpIOS(liveIsFocused: true) - let sensors = try hang(FocusSensor(request: request).sensors()) - let focusSensor = try XCTUnwrap(sensors.first(where: { $0.UniqueID == "focus" })) - XCTAssertEqual(focusSensor.State as? Bool, true) + XCTAssertEqual(try focusState() as? Bool, true) } - /// The nil-name run iOS makes when a Focus deactivates is not proof one is on: the pushed - /// status decides. - func testFilterResetRunDefersToTheReceivedStatus() throws { - let now = Date() - setUpDependencies( - filterState: .init(name: nil, date: now), - receivedStatus: .init(isFocused: false, date: now, lastEndedDate: now) + /// The log behind this rewrite. The push provider extension, which can't ask iOS, rebuilt the + /// sensor from a three-hour-old pushed "not focused" and reported it off, while the app had + /// read "focused" from iOS half an hour earlier and the paired Personal Focus was still on. + /// Every process now reports the newest answer any of them stored. + func testAnExtensionReportsTheNewestStoredAnswerRatherThanAnOlderPush() throws { + Current.focusStatus.pushes.value = FocusStatusObservation( + isFocused: false, + date: now.addingTimeInterval(-3 * 3600) ) + Current.focusStatus.readings.value = FocusStatusObservation( + isFocused: true, + date: now.addingTimeInterval(-1800) + ) + setUpIOS(canAskIOS: false, liveIsFocused: false) - let sensors = try hang(FocusSensor(request: request).sensors()) - let focusSensor = try XCTUnwrap(sensors.first(where: { $0.UniqueID == "focus" })) - XCTAssertEqual(focusSensor.State as? Bool, false) + XCTAssertEqual(try focusState() as? Bool, true) + XCTAssertEqual( + Current.focusStatus.readings.value?.date, + now.addingTimeInterval(-1800), + "an extension must not store an answer it can't get" + ) } - /// The bug behind #5711: iOS wakes us when a Focus starts but not reliably when one ends, so - /// a pushed "running" whose "ended" push never came stood until the next Focus started. iOS - /// only pushes "running" for a Focus whose status the user shares, so the live status is - /// truthful for it — but inside the switch window it can still describe the Focus that just - /// ended, so the push has to be older than that window before a live "not focused" ends it. - func testStaleReceivedRunningEndsOnceTheLiveStatusSaysNotFocused() throws { - setUpDependencies( - status: .init(isFocused: false), - receivedStatus: .init(isFocused: true, date: now, lastEndedDate: nil) + /// The other way round: a push newer than the app's last reading is the newer answer. + func testAnExtensionReportsAPushNewerThanTheAppsReading() throws { + Current.focusStatus.readings.value = FocusStatusObservation( + isFocused: true, + date: now.addingTimeInterval(-1800) ) + Current.focusStatus.pushes.value = FocusStatusObservation(isFocused: false, date: now.addingTimeInterval(-60)) + setUpIOS(canAskIOS: false, liveIsFocused: nil) - Current.date = { [now] in now.addingTimeInterval(FocusReport.switchGracePeriod) } - var sensors = try hang(FocusSensor(request: request).sensors()) - var focusSensor = try XCTUnwrap(sensors.first(where: { $0.UniqueID == "focus" })) - XCTAssertEqual(focusSensor.State as? Bool, true, "still inside the switch window") - - Current.date = { [now] in now.addingTimeInterval(FocusReport.switchGracePeriod + 1) } - sensors = try hang(FocusSensor(request: request).sensors()) - focusSensor = try XCTUnwrap(sensors.first(where: { $0.UniqueID == "focus" })) - XCTAssertEqual(focusSensor.State as? Bool, false, "the push has settled and iOS says it ended") + XCTAssertEqual(try focusState() as? Bool, false) } - /// The mirror of the case above, from a log where the sensor published "off" in the same - /// breath as iOS answered that a Focus was running: the last pushed status was two days old, - /// and being the only thing consulted it outranked the live answer indefinitely. - func testStaleReceivedNotRunningYieldsToTheLiveStatusSayingFocused() throws { - setUpDependencies( - status: .init(isFocused: true), - receivedStatus: .init(isFocused: false, date: now, lastEndedDate: now) - ) - - Current.date = { [now] in now.addingTimeInterval(FocusReport.switchGracePeriod) } - var sensors = try hang(FocusSensor(request: request).sensors()) - var focusSensor = try XCTUnwrap(sensors.first(where: { $0.UniqueID == "focus" })) - XCTAssertEqual(focusSensor.State as? Bool, false, "still inside the switch window") + /// Without the permission iOS stops answering and pushing, so a push from before it was + /// revoked must not keep being reported. + func testRevokedPermissionOutranksAStalePush() throws { + Current.focusStatus.pushes.value = FocusStatusObservation(isFocused: true, date: now.addingTimeInterval(-3600)) + setUpIOS(authorization: .denied, liveIsFocused: nil) - Current.date = { [now] in now.addingTimeInterval(FocusReport.switchGracePeriod + 1) } - sensors = try hang(FocusSensor(request: request).sensors()) - focusSensor = try XCTUnwrap(sensors.first(where: { $0.UniqueID == "focus" })) - XCTAssertEqual(focusSensor.State as? Bool, true, "the push has settled and iOS says one is on") + XCTAssertEqual(try focusState() as? String, "unavailable") } - /// The whole shape of that log: the Focus carrying the name ended — its filter's reset run - /// landing just now — while iOS says a Focus is still on, which is what switching to a Focus - /// with no filter paired to it looks like. The ended name must not take the sensor down with - /// it. - func testFilterResetRunDoesNotEndAFocusTheLiveStatusStillReports() throws { - let twoDaysAgo = now.addingTimeInterval(-48 * 60 * 60) - setUpDependencies( - status: .init(isFocused: true), - filterState: .init(name: nil, date: now), - receivedStatus: .init(isFocused: false, date: twoDaysAgo, lastEndedDate: twoDaysAgo) - ) + func testUpdateSignalerCreated() throws { + setUpIOS(liveIsFocused: false) - let sensors = try hang(FocusSensor(request: request).sensors()) - let focusSensor = try XCTUnwrap(sensors.first(where: { $0.UniqueID == "focus" })) - XCTAssertEqual(focusSensor.State as? Bool, true) + let dependencies = SensorProviderDependencies() + let provider = FocusSensor(request: .init( + reason: .trigger("unit-test"), + dependencies: dependencies, + location: nil, + serverVersion: Version() + )) + _ = try hang(provider.sensors()) + + let signaler: FocusSensorUpdateSignaler? = dependencies.existingSignaler(for: provider) + XCTAssertNotNil(signaler) } - func testUpdateSignalerCreated() throws { - setUpDependencies(status: .init(isFocused: false)) + /// Created while nothing can be read yet too: a filter run is still what turns it on. + func testUpdateSignalerCreatedWhileUnavailable() throws { + setUpIOS(authorization: .denied, liveIsFocused: nil) let dependencies = SensorProviderDependencies() let provider = FocusSensor(request: .init( @@ -263,54 +244,49 @@ class FocusSensorTests: XCTestCase { location: nil, serverVersion: Version() )) - let promise = provider.sensors() - _ = try hang(promise) + _ = try hang(provider.sensors()) let signaler: FocusSensorUpdateSignaler? = dependencies.existingSignaler(for: provider) XCTAssertNotNil(signaler) } + /// A push only signals in the foreground: in the background the Intents extension that + /// received it reports it itself. @MainActor func testSignaler() async throws { - setUpDependencies( - authorization: .authorized, - isAvailable: true, - status: .init(focusStatus: .init(isFocused: true)) - ) + setUpIOS(liveIsFocused: true) _ = Current.sensors.sensors(reason: .registration, server: ServerFixture.standard) - let expectation1 = expectation(description: "Observation") - let expectation2 = expectation(description: "Signal") + let observationExpectation = expectation(description: "Observation") + let signalExpectation = expectation(description: "Signal") let signaler = FocusSensorUpdateSignaler(signal: { - expectation2.fulfill() + signalExpectation.fulfill() }) signaler.notifyObservation = { - expectation1.fulfill() + observationExpectation.fulfill() } - await fulfillment(of: [expectation1], timeout: 10) + await fulfillment(of: [observationExpectation], timeout: 10) + let previousIsForegroundApp = Current.isForegroundApp + addTeardownBlock { Current.isForegroundApp = previousIsForegroundApp } let date = Date() Current.isForegroundApp = { false } - Current.focusStatus.receivedStatus.value = .init(isFocused: true, date: date, lastEndedDate: nil) + Current.focusStatus.pushes.value = FocusStatusObservation(isFocused: true, date: date) Current.isForegroundApp = { true } - Current.focusStatus.receivedStatus.value = .init( - isFocused: true, - date: date.addingTimeInterval(1.0), - lastEndedDate: nil - ) + Current.focusStatus.pushes.value = FocusStatusObservation(isFocused: true, date: date.addingTimeInterval(1)) - // so it sticks around, but we don't need to access it directly - await fulfillment(of: [expectation2], timeout: 10) + await fulfillment(of: [signalExpectation], timeout: 10) + withExtendedLifetime(signaler) {} } /// A Focus Filter run must signal the sensor: its own explicit update can fail, and it is the - /// only signal when Focus status isn't shared. + /// only signal for a Focus whose status iOS doesn't report. @MainActor func testSignalerFiresOnFilterRun() async throws { - setUpDependencies(status: .init(isFocused: true)) + setUpIOS(liveIsFocused: true) _ = Current.sensors.sensors(reason: .registration, server: ServerFixture.standard) let observationExpectation = expectation(description: "Observation") @@ -325,8 +301,9 @@ class FocusSensorTests: XCTestCase { await fulfillment(of: [observationExpectation], timeout: 10) - Current.focusFilter.state.value = FocusFilterState(name: "Personal", date: Date()) + Current.focusFilter.runs.value = FocusFilterState(name: "Personal", date: Date()) await fulfillment(of: [signalExpectation], timeout: 10) + withExtendedLifetime(signaler) {} } }