Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions HomeAssistant.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions Sources/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions Sources/App/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down
27 changes: 25 additions & 2 deletions Sources/App/Settings/Focus/FocusNameFocusFilterAppIntent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions Sources/App/Settings/Focus/FocusSettingsViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
57 changes: 30 additions & 27 deletions Sources/Shared/API/Webhook/Sensors/FocusNameSensor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,21 @@ final class FocusNameSensorUpdateSignaler: BaseSensorUpdateSignaler, SensorProvi
super.observe()
guard !isObserving else { return }

// The Focus Filter tells us which Focus started…
focusFilterCancellable = Current.focusFilter.state.observe { [weak self] _ in
// A filter run changes the name.
focusFilterCancellable = Current.focusFilter.runs.observe { [weak self] _ in
self?.signal()
}
// …and the Focus status tells us when every Focus ended, which no filter reports.
focusStatusCancellable = Current.focusStatus.receivedStatus.observe { [weak self] _ in
// A status push changes the "Is focused" attribute. The Intents extension that received
// it reports it itself, so this only keeps the sensor on screen current: in the background
// it would add a full update of every sensor to that extension's short run.
focusStatusCancellable = Current.focusStatus.pushes.observe { [weak self] _ in
#if os(watchOS)
self?.signal()
#else
if Current.isForegroundApp() {
self?.signal()
}
#endif
}
isObserving = true

Expand All @@ -47,14 +55,12 @@ final class FocusNameSensorUpdateSignaler: BaseSensorUpdateSignaler, SensorProvi
}
}

/// Reports _which_ Focus is running, which iOS has no API for — empty while none is.
/// Reports _which_ Focus is on, which iOS has no API for. Empty while none is.
///
/// The user creates the names in the app's Focus settings and pairs each one with a Focus in
/// Settings › Focus › Focus Filters; activating that Focus runs our filter, which stores the paired
/// name for this sensor to send. The name lasts until that Focus ends — its filter's reset run, or
/// iOS confirming every Focus ended — and a Focus that starts without a filter run afterwards is
/// reported as running with no name, never as the Focus before it. Not being able to tell whether
/// the named Focus ended keeps its name rather than inventing a state.
/// Settings › Focus › Focus Filters. iOS performs the filter with that name when the Focus turns
/// on and with none when it ends, and the app also asks iOS for the filter's current values on
/// every read, see `FocusReport`.
///
/// The sensor is always listed, never erroring out while nothing is configured: its detail screen
/// is the only route to the Focus names, so a sensor that dropped out of the list until a name
Expand All @@ -78,24 +84,21 @@ final class FocusNameSensor: SensorProvider {
return .init(error: FocusNameError.unavailable)
}

let report = FocusReport.current()
return FocusReport.current().map { [self] report in
let _: FocusNameSensorUpdateSignaler = request.dependencies.updateSignaler(for: self)

// Empty while no named Focus is running, which is also the state before any name exists —
// what keeps the sensor in the list for a user who has yet to configure it.
let sensor = with(WebhookSensor(
name: "Focus name",
uniqueID: WebhookSensorId.focusName.rawValue,
icon: "mdi:moon-waning-crescent",
state: report.name ?? ""
)) {
if let isFocused = report.isFocused {
$0.Attributes = ["Is focused": isFocused]
}
// Empty while no named Focus is on, which is also the state before any name exists,
// what keeps the sensor in the list for a user who has yet to configure it.
return [with(WebhookSensor(
name: "Focus name",
uniqueID: WebhookSensorId.focusName.rawValue,
icon: "mdi:moon-waning-crescent",
state: report.name ?? ""
)) {
if let isFocused = report.isFocused {
$0.Attributes = ["Is focused": isFocused]
}
}]
}

// Set up our observer
let _: FocusNameSensorUpdateSignaler = request.dependencies.updateSignaler(for: self)

return .value([sensor])
}
}
66 changes: 30 additions & 36 deletions Sources/Shared/API/Webhook/Sensors/FocusSensor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
}
}
17 changes: 0 additions & 17 deletions Sources/Shared/Environment/FocusFilterLiveConfirmation.swift

This file was deleted.

19 changes: 19 additions & 0 deletions Sources/Shared/Environment/FocusFilterState.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading