Skip to content

Commit

Permalink
ZoneManager: Rewrite & Test Region Monitoring (#698)
Browse files Browse the repository at this point in the history
- Rewrites the region monitoring into several smaller, more testable pieces.
- Does a one-shot location update whenever a region's state changes.
- Writes a whole lot of tests around it.
- Still wraps it in the "In-Development Updating" setting flag so I can get beta feedback and iterate.

Replaces #697.
  • Loading branch information
zacwest committed Jun 26, 2020
1 parent 8345b09 commit aad17dc
Show file tree
Hide file tree
Showing 23 changed files with 1,552 additions and 177 deletions.
198 changes: 81 additions & 117 deletions HomeAssistant.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

10 changes: 0 additions & 10 deletions HomeAssistant.xcodeproj/xcshareddata/xcschemes/Debug.xcscheme
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,6 @@
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "B657A9061CA646EB00121384"
BuildableName = "HomeAssistantUITests.xctest"
BlueprintName = "HomeAssistantUITests"
ReferencedContainer = "container:HomeAssistant.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
Expand Down
27 changes: 24 additions & 3 deletions HomeAssistant/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
private var webViewControllerPromise: Guarantee<WebViewController>
private var webViewControllerSeal: (WebViewController) -> Void

private(set) var regionManager: RegionManager!
private var regionManager: RegionManager?
private var zoneManager: ZoneManager?

private var periodicUpdateTimer: Timer? {
willSet {
if periodicUpdateTimer != newValue {
Expand All @@ -61,6 +63,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
_ application: UIApplication,
willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
if NSClassFromString("XCTest") != nil {
return true
}

setDefaults()

UNUserNotificationCenter.current().delegate = self
Expand All @@ -76,9 +82,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate {

self.registerCallbackURLKitHandlers()

self.regionManager = RegionManager()
if Current.settingsStore.useNewOneShotLocation {
self.zoneManager = ZoneManager()
} else {
self.regionManager = RegionManager()
}

Current.syncMonitoredRegions = { self.regionManager.syncMonitoredRegions() }
Current.syncMonitoredRegions = {
self.regionManager?.syncMonitoredRegions()
self.zoneManager?.syncZones().cauterize()
}

UIApplication.shared.setMinimumBackgroundFetchInterval(UIApplication.backgroundFetchIntervalMinimum)

Expand All @@ -98,6 +111,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate {

func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
if NSClassFromString("XCTest") != nil {
return true
}

setupView()

_ = HomeAssistantAPI.authenticatedAPI()?.CreateEvent(eventType: "ios.finished_launching", eventData: [:])
Expand Down Expand Up @@ -222,6 +239,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
return false
}

if NSClassFromString("XCTest") != nil {
return false
}

Current.Log.info("allowing state to be restored")
return true
}
Expand Down
124 changes: 124 additions & 0 deletions HomeAssistant/Classes/ZoneManager/ZoneManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import Foundation
import PromiseKit
import CoreLocation
import Shared
import UIKit

class ZoneManager {
let locationManager: CLLocationManager
let collector: ZoneManagerCollector
let processor: ZoneManagerProcessor

init(
locationManager: CLLocationManager = .init(),
collector: ZoneManagerCollector = ZoneManagerCollectorImpl(),
processor: ZoneManagerProcessor = ZoneManagerProcessorImpl()
) {
self.collector = collector
self.processor = processor

self.locationManager = with(locationManager) {
$0.allowsBackgroundLocationUpdates = true
$0.pausesLocationUpdatesAutomatically = false
}

// we separate out the delegate from ourselves to force a clean separation from location manager
self.collector.delegate = self
self.processor.delegate = self

log(state: .initialize)
syncZones().cauterize()
locationManager.delegate = collector
locationManager.startMonitoringSignificantLocationChanges()
}

private func log(state: ZoneManagerState) {
Current.Log.info(state)
}

private func perform(event: ZoneManagerEvent) {
let logPayload: [String: Any] = [
"start_ssid": Current.connectivity.currentWiFiSSID() ?? "none",
"event": event.description
]

// although technically the processor also does this, it does it after some async processing.
// let's be very cofident that we're not going to miss out on an update due to being suspended
UIApplication.shared.backgroundTask(withName: "zone-manager-perform-event") { _ in
processor.perform(event: event)
}.done {
Current.clientEventStore.addEvent(ClientEvent(
text: "Updated location",
type: .locationUpdate,
payload: logPayload
))
}.catch { error in
Current.Log.error("final error for \(event): \(error)")
Current.clientEventStore.addEvent(ClientEvent(
text: "Failed updating: \(error.localizedDescription)",
type: .locationUpdate,
payload: logPayload
))
}
}

internal func syncZones() -> Promise<Void> {
let expected = Set(
Current.realm()
.objects(RLMZone.self)
.map { $0.region() }
.map(ZoneManagerEquatableRegion.init(region:))
)
let actual = Set(
locationManager
.monitoredRegions
.map(ZoneManagerEquatableRegion.init(region:))
)

let needsRemoval = actual.subtracting(expected)
let needsAddition = expected.subtracting(actual)

// process removals before additions
// this is important because the system is focused on identifier
for region in needsRemoval.map(\.region) {
Current.clientEventStore.addEvent(ClientEvent(
text: "Ending monitoring \(region)",
type: .locationUpdate
))
locationManager.stopMonitoring(for: region)
}

for region in needsAddition.map(\.region) {
Current.clientEventStore.addEvent(ClientEvent(
text: "Initially monitoring \(region)",
type: .locationUpdate
))
locationManager.startMonitoring(for: region)
}

Current.Log.info {
[
"monitoring \(expected.count)",
"started \(needsAddition.count)",
"ended \(needsRemoval.count)"
].joined(separator: ", ")
}
return .value(())
}
}

extension ZoneManager: ZoneManagerCollectorDelegate {
func collector(_ collector: ZoneManagerCollector, didLog state: ZoneManagerState) {
log(state: state)
}

func collector(_ collector: ZoneManagerCollector, didCollect event: ZoneManagerEvent) {
perform(event: event)
}
}

extension ZoneManager: ZoneManagerProcessorDelegate {
func processor(_ processor: ZoneManagerProcessor, didLog state: ZoneManagerState) {
log(state: state)
}
}
67 changes: 67 additions & 0 deletions HomeAssistant/Classes/ZoneManager/ZoneManagerCollector.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import CoreLocation
import PromiseKit
import Shared

protocol ZoneManagerCollectorDelegate: AnyObject {
func collector(_ collector: ZoneManagerCollector, didLog state: ZoneManagerState)
func collector(_ collector: ZoneManagerCollector, didCollect event: ZoneManagerEvent)
}

protocol ZoneManagerCollector: CLLocationManagerDelegate {
var delegate: ZoneManagerCollectorDelegate? { get set }
}

class ZoneManagerCollectorImpl: NSObject, ZoneManagerCollector {
weak var delegate: ZoneManagerCollectorDelegate?

func locationManager(
_ manager: CLLocationManager,
didFailWithError error: Error
) {
delegate?.collector(self, didLog: .didError(error))
}

func locationManager(
_ manager: CLLocationManager,
monitoringDidFailFor region: CLRegion?,
withError error: Error
) {
delegate?.collector(self, didLog: .didFailMonitoring(region, error))
}

func locationManager(
_ manager: CLLocationManager,
didStartMonitoringFor region: CLRegion
) {
delegate?.collector(self, didLog: .didStartMonitoring(region))
manager.requestState(for: region)
}

func locationManager(
_ manager: CLLocationManager,
didDetermineState state: CLRegionState,
for region: CLRegion
) {
let zone = Current.realm()
.objects(RLMZone.self)
.first(where: { $0.ID == region.identifier })

let event = ZoneManagerEvent(
eventType: .region(region, state),
associatedZone: zone
)

delegate?.collector(self, didCollect: event)
}

func locationManager(
_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]
) {
let event = ZoneManagerEvent(
eventType: .locationChange(locations)
)

delegate?.collector(self, didCollect: event)
}
}
38 changes: 38 additions & 0 deletions HomeAssistant/Classes/ZoneManager/ZoneManagerEquatableRegion.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import Foundation
import CoreLocation

/// Wraps CLRegion but does deep inspection of properties
/// so changing e.g. lat/long breaks Equatable rather than relying on identifier
struct ZoneManagerEquatableRegion: Hashable {
let region: CLRegion

private var beaconRegion: CLBeaconRegion? {
region as? CLBeaconRegion
}

private var circularReason: CLCircularRegion? {
region as? CLCircularRegion
}

func hash(into hasher: inout Hasher) {
hasher.combine(region.hash)
}

static func == (lhs: ZoneManagerEquatableRegion, rhs: ZoneManagerEquatableRegion) -> Bool {
guard lhs.region.identifier == rhs.region.identifier else {
return false
}

if let lhs = lhs.beaconRegion, let rhs = rhs.beaconRegion {
return lhs.proximityUUID == rhs.proximityUUID &&
lhs.minor == rhs.minor &&
lhs.major == rhs.major
} else if let lhs = lhs.circularReason, let rhs = rhs.circularReason {
return lhs.center.latitude == rhs.center.latitude &&
lhs.center.longitude == rhs.center.longitude &&
lhs.radius == rhs.radius
} else {
return false
}
}
}

0 comments on commit aad17dc

Please sign in to comment.