MusicMotion/MotionManager.swift
/* |
Copyright (C) 2016 Apple Inc. All Rights Reserved. |
See LICENSE.txt for this sample’s licensing information |
Abstract: |
This class manages the CoreMotion interactions and provides a delegate to indicate changes in context. |
*/ |
import Foundation |
import CoreMotion |
/** |
`MotionContext` describes the user's current motion activity level. The higher |
the intensity, the more active the user is. Driving is handled seperately because |
the user's activity level is not directly not applicable while driving. |
*/ |
enum MotionContext: String, CustomStringConvertible { |
case LowIntensity = "Low Intensity" |
case MediumIntensity = "Medium Intensity" |
case HighIntensity = "High Intensity" |
case Driving = "Driving" |
// MARK: CustomStringConvertible |
var description: String { |
return self.rawValue |
} |
} |
/** |
`MotionContextDelegate` exists to inform delegates of motion context changes. |
These contexts can be used to enable motion aware application specific behavior. |
*/ |
protocol MotionContextDelegate: class { |
func lowIntensityContextStarted(_ manager: MotionManager) |
func mediumIntensityContextStarted(_ manager: MotionManager) |
func highIntensityContextStarted(_ manager: MotionManager) |
func drivingContextStarted(_ manager: MotionManager) |
func didEncounterAuthorizationError(_ manager: MotionManager) |
} |
/// These constants are application specific and should be tuned for your specific needs. |
class MotionManager { |
// MARK: Static Properties |
static let maxActivitySamples = 2 |
// 18 minutes per mile in meters per second. |
static let mediumPace = 0.671080 |
// 12 minutes per mile in meters per second. |
static let highPace = 0.447387 |
static let maxAltitudeSamples = 10 |
static let metersForSignificantAltitudeChange = 5.0 |
static let maxPedometerSamples = 1 |
// MARK: Properties |
weak var delegate: MotionContextDelegate? |
var currentContext = MotionContext.LowIntensity |
let motionQueue: OperationQueue = { |
let motionQueue = OperationQueue() |
motionQueue.name = "com.example.apple-samplecode.MusicMotion" |
return motionQueue |
}() |
var recentActivities = [Activity]() |
let activityManager = CMMotionActivityManager() |
var recentMotionActivities = [CMMotionActivity]() |
let pedometer = CMPedometer() |
var recentPedometerData = [CMPedometerData]() |
let altimeter = CMAltimeter() |
var recentAlitudeData = [CMAltitudeData]() |
var isInHighIntensityContext: Bool { |
return ( isUserPaceHigh && isUserRunning ) || hasAltitudeChangedRecently |
} |
var isInMediumIntensityContext: Bool { |
return isUserPaceMedium && isUserWalking |
} |
var isInLowIntensityContext: Bool { |
return isUserStationary |
} |
var isInDrivingContext: Bool { |
return isUserDriving |
} |
// MARK: Live Activity Updates |
/** |
This function is the entry point for the motion context logic. This function |
fuses together activity updates and pedometer data to infer the user's |
activity level. |
*/ |
func startMonitoring() { |
// If activity updates are supported, start updates on the motionQueue. |
if CMMotionActivityManager.isActivityAvailable() { |
activityManager.startActivityUpdates(to: motionQueue) { activity in |
// Ignore unclassified activites. |
guard let activity = activity , activity.hasActivitySignature else { return } |
self.saveMotionActivity(activity) |
self.updateUserContext() |
} |
} |
else { |
print("Activity updates are not available.") |
} |
// If step counting is available, start pedometer updates from now forward. |
if CMPedometer.isStepCountingAvailable() { |
let now = Date() |
pedometer.startUpdates(from: now) { pedometerData, error in |
if let pedometerData = pedometerData { |
self.savePedometerData(pedometerData) |
self.updateUserContext() |
} |
else if let error = error { |
self.handleError(error as NSError) |
} |
} |
} |
else { |
print("Step counting is not available.") |
} |
} |
// MARK: Context Decision Logic |
func updateUserContext() { |
// If the user is running or walking, enable altitude updates to record elevation changes. |
if isUserRunning || isUserWalking { |
if CMAltimeter.isRelativeAltitudeAvailable() { |
altimeter.startRelativeAltitudeUpdates(to: motionQueue) { altitudeData, error in |
if let altitudeData = altitudeData { |
self.saveAltitudeData(altitudeData) |
self.updateUserContext() |
} |
else if let error = error { |
self.handleError(error as NSError) |
} |
} |
} |
else { |
print("Relative altitude is not available.") |
} |
} |
else if CMAltimeter.isRelativeAltitudeAvailable() { |
altimeter.stopRelativeAltitudeUpdates() |
} |
updateContextAndNotifyDelegate() |
} |
func updateContextAndNotifyDelegate() { |
// Only invoke the delegate if we see a change in intensity. |
if currentContext != .Driving && isInDrivingContext { |
currentContext = .Driving |
delegate?.drivingContextStarted(self) |
} |
else if currentContext != .LowIntensity && isInLowIntensityContext { |
currentContext = .LowIntensity |
delegate?.lowIntensityContextStarted(self) |
} |
else if currentContext != .MediumIntensity && isInMediumIntensityContext { |
self.currentContext = .MediumIntensity |
delegate?.mediumIntensityContextStarted(self) |
} |
else if currentContext != .HighIntensity && isInHighIntensityContext { |
currentContext = .HighIntensity |
delegate?.highIntensityContextStarted(self) |
} |
} |
// MARK: Context Decision Functions |
func activitesMatch(_ test: (CMMotionActivity) -> Bool) -> Bool { |
if recentMotionActivities.isEmpty { return false } |
// Only return true if every activity passes the test closure. |
return !recentMotionActivities.contains { !test($0) } |
} |
// Confidence could be incorporated in the isUser functions to trade accuracy with responsiveness. |
var isUserRunning: Bool { |
return activitesMatch { $0.running } |
} |
var isUserStationary: Bool { |
return activitesMatch { $0.stationary } |
} |
var isUserWalking: Bool { |
return activitesMatch { $0.walking } |
} |
var isUserDriving: Bool { |
return activitesMatch { $0.automotive } |
} |
var hasAltitudeChangedRecently: Bool { |
guard let lastAltitude = recentAlitudeData.last?.relativeAltitude, |
let firstAltitude = recentAlitudeData.first?.relativeAltitude else { |
return false |
} |
return fabs(firstAltitude.doubleValue - lastAltitude.doubleValue) > MotionManager.metersForSignificantAltitudeChange |
} |
var isUserPaceHigh: Bool { |
let pace = currentPace |
/* |
Avoid pace comparision if pace is unavailable. This will making running |
the only determination for high intensity. |
*/ |
if pace == 0 { return true } |
// Return true if we are faster than the high pace. |
return pace < MotionManager.highPace |
} |
var isUserPaceMedium: Bool { |
let pace = currentPace |
/* |
Avoid pace comparision if pace is unavailable. This will making walking |
the only determination for medium intensity. |
*/ |
if pace == 0 { return true } |
/* |
Return true if we are faster than the medium pace and but slower than |
the high pace. |
*/ |
return pace < MotionManager.mediumPace && pace > MotionManager.highPace |
} |
// The faster the user is moving the lower the pace value. |
var currentPace: Double { |
// If pace is not available then return. |
guard let pace = recentPedometerData.first?.currentPace?.doubleValue else { return 0 } |
return pace |
} |
//MARK: Recent Activity Processing |
func filterActivites(_ activities: [CMMotionActivity]) -> [CMMotionActivity] { |
// Filter out unknown activity, stationary activity, and low confidence activity. |
return activities.filter { activity in |
return activity.hasActivitySignature && |
!activity.stationary && |
activity.confidence.rawValue > CMMotionActivityConfidence.low.rawValue |
} |
} |
/// A convenience type to use as the return value to `findActivitySegments(_:)`. |
typealias ActivitySegment = (activity: CMMotionActivity, endDate: Date) |
func findActivitySegments(_ activities: [CMMotionActivity]) -> [ActivitySegment] { |
var segments = [ActivitySegment]() |
var i = 0 |
while i < activities.count - 1 { |
let activity = activities[i] |
let startDate = activity.startDate |
/* |
If the next nearest activity is the same and was within 60 minutes, |
consolidate the events together. |
*/ |
i += 1 |
var nextActivity = activities[i] |
var endDate = nextActivity.startDate |
while i < activities.count - 1 { |
/* |
Once both activities are not the same, we have reached the end |
of our current activity. |
*/ |
if !activity.isSimilarToActivity(nextActivity) { |
break |
} |
/* |
Make sure the previous matching activity was within 60 minutes. |
After 60 minutes we will call that a separate activity. Ex: Walking, |
Stationary (60 mins), Walking will become two seperate Walking |
activities. |
*/ |
let previousActivityEnd = activities[i - 1].startDate |
let secondsBetweenActivites = endDate.timeIntervalSince(previousActivityEnd) |
if secondsBetweenActivites >= 60 * 60 { |
break |
} |
i += 1 |
nextActivity = activities[i] |
endDate = nextActivity.startDate |
} |
/* |
Since we exit the loop we longer match activities, move back one |
position to the last match. |
*/ |
if i != activities.count - 1 { |
i -= 1 |
nextActivity = activities[i] |
} |
else { |
/* |
If we are at the end of the activities, treat the user as if |
they are in the same activity still. |
*/ |
nextActivity = activities[i] |
} |
endDate = nextActivity.startDate |
/* |
If the total activity duration was longer than a minute, create an |
`ActivitySegment`. |
*/ |
if endDate.timeIntervalSince(startDate) > 60 { |
let activitySegment = ActivitySegment(activity, endDate) |
segments.append(activitySegment) |
} |
// Incrememnt the counter for the next loop. |
i += 1 |
} |
return segments |
} |
func createActivityDataWithActivities(_ activities: [CMMotionActivity], completionHandler: @escaping (Void) -> Void) -> [Activity] { |
var results = [Activity]() |
/* |
This group is used to ensure all of the queries finish before we invoke |
our `completionHandler`. |
*/ |
let group = DispatchGroup() |
// Serialization queue for result array. |
let queue = DispatchQueue(label: "com.example.apple-samplecode.com.resultQueue", attributes: []) |
/* |
First, filter activity data that does not have a signature, is low |
confidence, or is stationary. |
*/ |
let filteredActivities = filterActivites(activities) |
/* |
Next, find the periods of time between each signifcant activity segment |
to query for pedometer data. |
*/ |
let activitySegments = findActivitySegments(filteredActivities) |
for (activity, endDate) in activitySegments { |
group.enter() |
pedometer.queryPedometerData(from: activity.startDate, to: endDate) { pedometerData, error in |
queue.async { |
let activity = Activity(activity: activity, startDate: activity.startDate, endDate: endDate, pedometerData: pedometerData) |
results += [activity] |
} |
if let error = error { |
self.handleError(error as NSError) |
} |
group.leave() |
} |
} |
group.notify(queue: DispatchQueue.main) { |
queue.sync { |
self.recentActivities = results.reversed() |
completionHandler() |
} |
} |
return results |
} |
// MARK: Historical Queries |
func queryForRecentActivityData(_ completionHandler: @escaping (Void) -> Void) { |
let now = Date() |
let dateComponents: DateComponents = { |
var dateComponents = DateComponents() |
dateComponents.setValue(-7, for: .day) |
return dateComponents |
}() |
guard let startDate = NSCalendar.current.date(byAdding: dateComponents, to: now) else { return } |
activityManager.queryActivityStarting(from: startDate, to: now, to: motionQueue) { activities, error in |
if let activities = activities { |
_ = self.createActivityDataWithActivities(activities, completionHandler:completionHandler) |
} |
else if let error = error { |
self.handleError(error as NSError) |
} |
} |
} |
// MARK: Data Management |
func savePedometerData(_ pedometerData: CMPedometerData) { |
recentPedometerData.insert(pedometerData, at: 0) |
if recentPedometerData.count > MotionManager.maxPedometerSamples { |
recentPedometerData.removeLast() |
} |
} |
func saveAltitudeData(_ altitude: CMAltitudeData) { |
recentAlitudeData.insert(altitude, at: 0) |
if recentAlitudeData.count > MotionManager.maxAltitudeSamples { |
recentAlitudeData.removeLast() |
} |
} |
func saveMotionActivity(_ activity: CMMotionActivity) { |
recentMotionActivities.insert(activity, at: 0) |
// Expire samples older than 60 seconds. |
let secondsToExpireActivity: TimeInterval = 1 * 60 |
recentMotionActivities = recentMotionActivities.filter { activity in |
let secondsElapsed = Date().timeIntervalSince(activity.startDate) |
return secondsElapsed <= secondsToExpireActivity |
} |
// Remove the oldest element once we exceed our maximum sample count. |
if recentMotionActivities.count > MotionManager.maxActivitySamples { |
recentMotionActivities.removeLast() |
} |
} |
// MARK: Handling Authorization and Errors |
func handleError(_ error: NSError) { |
if error.code == Int(CMErrorMotionActivityNotAuthorized.rawValue) { |
delegate?.didEncounterAuthorizationError(self) |
} |
else { |
print(error) |
} |
} |
} |
extension CMMotionActivity { |
func isSimilarToActivity(_ activity: CMMotionActivity) -> Bool { |
// If we have multiple states set in an activity this will indicate a match on the first one. |
return walking && activity.walking || |
running && activity.running || |
automotive && activity.automotive || |
cycling && activity.cycling || |
stationary && activity.stationary |
} |
var hasActivitySignature: Bool { |
return walking || running || automotive || cycling || stationary |
} |
} |
Copyright © 2016 Apple Inc. All Rights Reserved. Terms of Use | Privacy Policy | Updated: 2016-09-28