From 5cb2e690f35e682f50342842aafafa578778e40a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantalea=CC=83o?= <5808343+bgoncal@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:59:41 +0200 Subject: [PATCH 1/3] Fix Focus sensors reporting a stale state The push provider extension could not ask iOS for the Focus status and rebuilt the sensors from an old push, reporting off while a Focus was on. The Focus name could also be cleared while the paired Focus was still on. Each source now keeps its newest reading in the app group, and every process reports from those. The app asks iOS for the status and for the Focus filter's current values on every read. --- HomeAssistant.xcodeproj/project.pbxproj | 2 + Sources/App/AppDelegate.swift | 2 + .../Resources/en.lproj/Localizable.strings | 6 +- .../Focus/FocusNameFocusFilterAppIntent.swift | 27 +- .../Focus/FocusSettingsViewModel.swift | 3 +- .../Permissions/SensorPermission.swift | 4 +- .../API/Webhook/Sensors/FocusNameSensor.swift | 55 +- .../API/Webhook/Sensors/FocusSensor.swift | 66 +- .../FocusFilterLiveConfirmation.swift | 17 - .../Shared/Environment/FocusFilterState.swift | 19 + .../Environment/FocusFilterWrapper.swift | 178 +++-- Sources/Shared/Environment/FocusReport.swift | 181 ++--- .../Environment/FocusStatusObservation.swift | 19 + .../Environment/FocusStatusWrapper.swift | 150 ++--- .../Intents/FocusStatusIntentHandler.swift | 17 +- .../Shared/Resources/Swiftgen/Strings.swift | 6 +- .../FocusNameFocusFilterAppIntent.test.swift | 52 ++ .../Shared/Sensors/FocusNameSensor.test.swift | 636 ++++++++---------- Tests/Shared/Sensors/FocusSensor.test.swift | 343 +++++----- 19 files changed, 832 insertions(+), 951 deletions(-) delete mode 100644 Sources/Shared/Environment/FocusFilterLiveConfirmation.swift create mode 100644 Sources/Shared/Environment/FocusFilterState.swift create mode 100644 Sources/Shared/Environment/FocusStatusObservation.swift 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..5a30ce4ec2 100644 --- a/Sources/Shared/API/Webhook/Sensors/FocusNameSensor.swift +++ b/Sources/Shared/API/Webhook/Sensors/FocusNameSensor.swift @@ -4,7 +4,6 @@ import PromiseKit final class FocusNameSensorUpdateSignaler: BaseSensorUpdateSignaler, SensorProviderUpdateSignaler { private var focusFilterCancellable: HACancellable? - private var focusStatusCancellable: HACancellable? private let signal: () -> Void init(signal: @escaping () -> Void) { @@ -16,19 +15,17 @@ final class FocusNameSensorUpdateSignaler: BaseSensorUpdateSignaler, SensorProvi deinit { focusFilterCancellable?.cancel() - focusStatusCancellable?.cancel() } override func observe() { super.observe() guard !isObserving else { return } - // The Focus Filter tells us which Focus started… - focusFilterCancellable = Current.focusFilter.state.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 + // Only the filter's runs change the name. A status push changes the "Is focused" + // attribute too, but the Intents extension that received it reports both Focus sensors + // itself, and signalling here would add a full update of every sensor to that extension's + // short run. + focusFilterCancellable = Current.focusFilter.runs.observe { [weak self] _ in self?.signal() } isObserving = true @@ -42,19 +39,16 @@ final class FocusNameSensorUpdateSignaler: BaseSensorUpdateSignaler, SensorProvi super.stopObserving() guard isObserving else { return } focusFilterCancellable?.cancel() - focusStatusCancellable?.cancel() isObserving = false } } -/// 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 +72,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..0b21cdd8d7 100644 --- a/Sources/Shared/Environment/FocusFilterWrapper.swift +++ b/Sources/Shared/Environment/FocusFilterWrapper.swift @@ -1,102 +1,140 @@ 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 } + } + + /// 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 } + + switch await Self.withTimeout(Self.currentValuesTimeout, reader) { + case let .some(.success(name)): + readings.value = FocusFilterState(name: name, date: Current.date()) + case let .some(.failure(error)): + Current.Log.error("focus filter current values unavailable: \(error)") + case .none: + Current.Log.error("focus filter current values took over \(Self.currentValuesTimeout)s, skipped") } - return state } - /// The name reported by the last Focus Filter run, if any. + /// 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 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. 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 } + let now = Current.date() + if runs.value?.name == name { + runs.value = FocusFilterState(name: nil, date: now) + } + if readings.value?.name == name { + readings.value = FocusFilterState(name: nil, date: now) + } + } + + /// Runs `operation`, giving up on it after `timeout` without waiting for it to finish, which a + /// task group would do even once cancelled. + private static func withTimeout( + _ timeout: TimeInterval, + _ operation: @escaping () async throws -> T + ) async -> Result? { + await withCheckedContinuation { continuation in + let resumed = HAProtected(value: false) + let resume: (Result?) -> 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 { + do { + let value = try await operation() + resume(.success(value)) + } catch { + resume(.failure(error)) + } + 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..6c1ab47f90 100644 --- a/Tests/Shared/Sensors/FocusNameSensor.test.swift +++ b/Tests/Shared/Sensors/FocusNameSensor.test.swift @@ -17,25 +17,22 @@ class FocusNameSensorTests: XCTestCase { 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 +44,369 @@ 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 sensors = try hang(FocusNameSensor(request: request).sensors()) - XCTAssertEqual(sensors[0].State as? String, "Work") - XCTAssertEqual(sensors[0].Attributes?["Is focused"] as? Bool, true) + let report = try report() + XCTAssertEqual(report.state, "Sleep") + XCTAssertEqual(report.isFocused, true) } - /// 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) - ) - - 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 - ) + /// 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 sensors = try hang(FocusNameSensor(request: request).sensors()) - XCTAssertEqual(sensors[0].State as? String, "Work") - XCTAssertEqual(sensors[0].Attributes?["Is focused"] as? Bool, true) + let report = try report() + XCTAssertEqual(report.state, "") + XCTAssertEqual(report.isFocused, false) } - /// 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) - ) + /// 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, true) + let report = try report() + XCTAssertEqual(report.state, "") + 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)) + /// 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 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, "Personal") + XCTAssertEqual(report.isFocused, true) + } - 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)) - } + 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) } - /// 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)) + 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, true) + let report = try report() + XCTAssertEqual(report.state, "") + XCTAssertNil(report.isFocused) } - /// 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 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, "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.focusFilter.runs.value = FocusFilterState(name: "Personal", 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) - - let sensors = try hang(FocusNameSensor(request: request).sensors()) - XCTAssertEqual(sensors[0].State as? String, "") - XCTAssertEqual(sensors[0].Attributes?["Is focused"] as? Bool, false) - } + // MARK: - Pushes - /// 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) + /// The push is stored with when it arrived, which is what orders it against the app's reading. + func testAPushIsStoredWithWhenItArrived() throws { + Current.isAppExtension = true - _ = try hang(FocusNameSensor(request: request).sensors()) - XCTAssertNil(Current.focusFilter.activeFocusState()?.liveConfirmedDate, "still inside the switch window") + Current.focusStatus.record(pushed: INFocusStatus(isFocused: false)) + XCTAssertEqual(Current.focusStatus.pushes.value, FocusStatusObservation(isFocused: false, date: now)) - 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.date = { [now] in now.addingTimeInterval(60) } + Current.focusStatus.record(pushed: INFocusStatus(isFocused: true)) + XCTAssertEqual( + Current.focusStatus.pushes.value, + FocusStatusObservation(isFocused: true, date: now.addingTimeInterval(60)) + ) } - /// 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) + /// 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)) - let sensors = try hang(FocusNameSensor(request: request).sensors()) - XCTAssertEqual(sensors[0].State as? String, "Work") - XCTAssertNil(Current.focusFilter.activeFocusState()?.liveConfirmedDate) + Current.focusStatus.record(pushed: INFocusStatus(isFocused: nil)) + Current.focusStatus.record(pushed: nil) + + XCTAssertEqual( + Current.focusStatus.pushes.value, + FocusStatusObservation(isFocused: true, date: now.addingTimeInterval(-60)) + ) } - /// A confirmation belongs to the run it confirmed: the next named run is a different Focus. - func testLiveConfirmationDoesNotCarryOverToTheNextFilterRun() throws { + /// A push only ever changes the status half. + func testAPushKeepsTheName() throws { Current.focusFilter.setActiveFocusName("Work") - Current.date = { [now] in now.addingTimeInterval(60) } - try Current.focusFilter.confirmLive(XCTUnwrap(Current.focusFilter.activeFocusState())) - XCTAssertNotNil(Current.focusFilter.activeFocusState()?.liveConfirmedDate) - Current.date = { [now] in now.addingTimeInterval(120) } - Current.focusFilter.setActiveFocusName("Sleep") - XCTAssertNil(Current.focusFilter.activeFocusState()?.liveConfirmedDate) + Current.focusStatus.record(pushed: INFocusStatus(isFocused: false)) + + XCTAssertEqual(Current.focusFilter.activeFocusName(), "Work") } - /// Only the run still current can be confirmed, and only once. - func testConfirmLiveIgnoresAnOutdatedRunAndRepeats() throws { - Current.focusFilter.setActiveFocusName("Work") - Current.focusFilter.confirmLive(FocusFilterState(name: "Work", date: now.addingTimeInterval(-1))) - XCTAssertNil(Current.focusFilter.activeFocusState()?.liveConfirmedDate) + // MARK: - Stored by an older build - 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)) + /// 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 +448,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 +480,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) {} } } From 880830b9647d03afae4c127b47cecf26c7e5c8b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantalea=CC=83o?= <5808343+bgoncal@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:36:02 +0200 Subject: [PATCH 2/3] Keep Focus filter readings ordered and bounded Date each reading of the filter's current values by when it was asked, share one request between concurrent reads, and stop asking again while a timed-out request is still unanswered. Deleting a Focus name keeps the date of the answer it clears. The Focus name sensor refreshes again on a status push while the app is in the foreground. --- .../API/Webhook/Sensors/FocusNameSensor.swift | 20 +- .../Environment/FocusFilterWrapper.swift | 103 +++++++-- .../Shared/Sensors/FocusNameSensor.test.swift | 215 ++++++++++++++++++ 3 files changed, 312 insertions(+), 26 deletions(-) diff --git a/Sources/Shared/API/Webhook/Sensors/FocusNameSensor.swift b/Sources/Shared/API/Webhook/Sensors/FocusNameSensor.swift index 5a30ce4ec2..613c9edb33 100644 --- a/Sources/Shared/API/Webhook/Sensors/FocusNameSensor.swift +++ b/Sources/Shared/API/Webhook/Sensors/FocusNameSensor.swift @@ -4,6 +4,7 @@ import PromiseKit final class FocusNameSensorUpdateSignaler: BaseSensorUpdateSignaler, SensorProviderUpdateSignaler { private var focusFilterCancellable: HACancellable? + private var focusStatusCancellable: HACancellable? private let signal: () -> Void init(signal: @escaping () -> Void) { @@ -15,19 +16,29 @@ final class FocusNameSensorUpdateSignaler: BaseSensorUpdateSignaler, SensorProvi deinit { focusFilterCancellable?.cancel() + focusStatusCancellable?.cancel() } override func observe() { super.observe() guard !isObserving else { return } - // Only the filter's runs change the name. A status push changes the "Is focused" - // attribute too, but the Intents extension that received it reports both Focus sensors - // itself, and signalling here would add a full update of every sensor to that extension's - // short run. + // A filter run changes the name. focusFilterCancellable = Current.focusFilter.runs.observe { [weak self] _ in self?.signal() } + // 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 #if DEBUG @@ -39,6 +50,7 @@ final class FocusNameSensorUpdateSignaler: BaseSensorUpdateSignaler, SensorProvi super.stopObserving() guard isObserving else { return } focusFilterCancellable?.cancel() + focusStatusCancellable?.cancel() isObserving = false } } diff --git a/Sources/Shared/Environment/FocusFilterWrapper.swift b/Sources/Shared/Environment/FocusFilterWrapper.swift index 0b21cdd8d7..d65b2edd14 100644 --- a/Sources/Shared/Environment/FocusFilterWrapper.swift +++ b/Sources/Shared/Environment/FocusFilterWrapper.swift @@ -46,18 +46,79 @@ public class FocusFilterWrapper { currentValuesReader.mutate { $0 = reader } } + /// One question to iOS about the filter's current values. + private 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 } - switch await Self.withTimeout(Self.currentValuesTimeout, reader) { - case let .some(.success(name)): - readings.value = FocusFilterState(name: name, date: Current.date()) - case let .some(.failure(error)): - Current.Log.error("focus filter current values unavailable: \(error)") - case .none: + guard let request = currentRequest(asking: reader) else { + Current.Log.info("focus filter current values still unanswered from an earlier read, skipped") + return + } + + 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. + private 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 } } @@ -93,26 +154,28 @@ public class FocusFilterWrapper { /// 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 else { return } - let now = Current.date() - if runs.value?.name == name { - runs.value = FocusFilterState(name: nil, date: now) + if let run = runs.value, run.name == name { + runs.value = FocusFilterState(name: nil, date: run.date) } - if readings.value?.name == name { - readings.value = FocusFilterState(name: nil, date: now) + if let reading = readings.value, reading.name == name { + readings.value = FocusFilterState(name: nil, date: reading.date) } } - /// Runs `operation`, giving up on it after `timeout` without waiting for it to finish, which a - /// task group would do even once cancelled. + /// 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 throws -> T - ) async -> Result? { + _ operation: @escaping () async -> T + ) async -> T? { await withCheckedContinuation { continuation in let resumed = HAProtected(value: false) - let resume: (Result?) -> Void = { result in + let resume: (T?) -> Void = { result in let isFirst = resumed.mutate { resumed -> Bool in defer { resumed = true } return !resumed @@ -127,12 +190,8 @@ public class FocusFilterWrapper { resume(nil) } Task { - do { - let value = try await operation() - resume(.success(value)) - } catch { - resume(.failure(error)) - } + let value = await operation() + resume(value) timer.cancel() } } diff --git a/Tests/Shared/Sensors/FocusNameSensor.test.swift b/Tests/Shared/Sensors/FocusNameSensor.test.swift index 6c1ab47f90..aea327d565 100644 --- a/Tests/Shared/Sensors/FocusNameSensor.test.swift +++ b/Tests/Shared/Sensors/FocusNameSensor.test.swift @@ -14,6 +14,53 @@ 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() @@ -214,6 +261,45 @@ class FocusNameSensorTests: XCTestCase { signaler.stopObserving() } + /// 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() + } + + await fulfillment(of: [observationExpectation], timeout: 30) + + Current.isForegroundApp = { false } + Current.focusStatus.pushes.value = FocusStatusObservation(isFocused: true, date: Date()) + Current.isForegroundApp = { true } + Current.focusStatus.pushes.value = FocusStatusObservation(isFocused: false, date: Date()) + + await fulfillment(of: [signalExpectation], timeout: 30) + signaler.stopObserving() + } + // MARK: - The filter's current values /// The run with no name for a Focus that ended while iOS didn't launch the app never lands. @@ -334,6 +420,135 @@ class FocusNameSensorTests: XCTestCase { XCTAssertEqual(Current.focusFilter.readings.value?.name, "Work") } + /// 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") + + 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") + } + + // MARK: - Asking iOS + + /// 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) } + Current.focusFilter.setActiveFocusName("Work") + gate.open() + await reading.value + + XCTAssertEqual(Current.focusFilter.readings.value, FocusFilterState(name: nil, date: now)) + XCTAssertEqual(Current.focusFilter.activeFocusName(), "Work") + } + + /// 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 asked = expectation(description: "iOS asked") + asked.assertForOverFulfill = false + Current.focusFilter.setCurrentValuesReader { + calls.record() + asked.fulfill() + await gate.wait() + return "Work" + } + + let first = Task { await Current.focusFilter.readFromIOS() } + await fulfillment(of: [asked], timeout: 30) + let second = Task { await Current.focusFilter.readFromIOS() } + gate.open() + await first.value + await second.value + + XCTAssertEqual(calls.count, 1) + XCTAssertEqual(Current.focusFilter.readings.value, FocusFilterState(name: "Work", date: now)) + } + + /// 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) + } + // MARK: - Pushes /// The push is stored with when it arrived, which is what orders it against the app's reading. From 97277cb0a3512f041a484611adb9055c3373d586 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantalea=CC=83o?= <5808343+bgoncal@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:16:06 +0100 Subject: [PATCH 3/3] Make the shared Focus filter request test deterministic --- .../Environment/FocusFilterWrapper.swift | 4 ++-- .../Shared/Sensors/FocusNameSensor.test.swift | 23 ++++++++++--------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/Sources/Shared/Environment/FocusFilterWrapper.swift b/Sources/Shared/Environment/FocusFilterWrapper.swift index d65b2edd14..c2ec904a58 100644 --- a/Sources/Shared/Environment/FocusFilterWrapper.swift +++ b/Sources/Shared/Environment/FocusFilterWrapper.swift @@ -47,7 +47,7 @@ public class FocusFilterWrapper { } /// One question to iOS about the filter's current values. - private struct Request { + 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. @@ -93,7 +93,7 @@ public class FocusFilterWrapper { /// 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. - private func currentRequest(asking reader: @escaping CurrentValuesReader) -> Request? { + func currentRequest(asking reader: @escaping CurrentValuesReader) -> Request? { request.mutate { current -> Request? in if let current { return current.timedOut ? nil : current diff --git a/Tests/Shared/Sensors/FocusNameSensor.test.swift b/Tests/Shared/Sensors/FocusNameSensor.test.swift index aea327d565..29db9c610e 100644 --- a/Tests/Shared/Sensors/FocusNameSensor.test.swift +++ b/Tests/Shared/Sensors/FocusNameSensor.test.swift @@ -470,24 +470,25 @@ class FocusNameSensorTests: XCTestCase { func testReadsWhileAQuestionIsInFlightShareIt() async throws { let gate = Gate() let calls = Calls() - let asked = expectation(description: "iOS asked") - asked.assertForOverFulfill = false - Current.focusFilter.setCurrentValuesReader { + let reader: FocusFilterWrapper.CurrentValuesReader = { calls.record() - asked.fulfill() await gate.wait() return "Work" } - let first = Task { await Current.focusFilter.readFromIOS() } - await fulfillment(of: [asked], timeout: 30) - let second = Task { await Current.focusFilter.readFromIOS() } - gate.open() - await first.value - await second.value + 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) - XCTAssertEqual(Current.focusFilter.readings.value, FocusFilterState(name: "Work", date: now)) + + // 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