Stabilize iOS/watchOS/tvOS apps and add cross-platform audit remediation
This commit is contained in:
@@ -29,6 +29,8 @@ struct MainWatchView: View {
|
||||
.lineLimit(10)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(vm.watchPackageModel.currentExerciseName), \(vm.watchPackageModel.currentTimeLeft) seconds remaining")
|
||||
|
||||
|
||||
HStack {
|
||||
@@ -48,6 +50,8 @@ struct MainWatchView: View {
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("Heart rate \(heartValue) beats per minute")
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
@@ -58,10 +62,14 @@ struct MainWatchView: View {
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
})
|
||||
.buttonStyle(BorderedButtonStyle(tint: .green))
|
||||
.accessibilityLabel("Next exercise")
|
||||
}
|
||||
} else {
|
||||
Text("No Werkout")
|
||||
Text("🍑")
|
||||
Text("No active workout")
|
||||
.font(.headline)
|
||||
Image(systemName: "figure.strengthtraining.traditional")
|
||||
.foregroundColor(.secondary)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ struct WatchControlView: View {
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
})
|
||||
.buttonStyle(BorderedButtonStyle(tint: .red))
|
||||
.accessibilityLabel("Stop workout")
|
||||
|
||||
Button(action: {
|
||||
vm.restartExercise()
|
||||
@@ -31,6 +32,7 @@ struct WatchControlView: View {
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
})
|
||||
.buttonStyle(BorderedButtonStyle(tint: .yellow))
|
||||
.accessibilityLabel("Restart exercise")
|
||||
|
||||
Button(action: {
|
||||
vm.previousExercise()
|
||||
@@ -40,6 +42,7 @@ struct WatchControlView: View {
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
})
|
||||
.buttonStyle(BorderedButtonStyle(tint: .blue))
|
||||
.accessibilityLabel("Previous exercise")
|
||||
}
|
||||
VStack {
|
||||
Button(action: {
|
||||
@@ -56,6 +59,7 @@ struct WatchControlView: View {
|
||||
}
|
||||
})
|
||||
.buttonStyle(BorderedButtonStyle(tint: .blue))
|
||||
.accessibilityLabel(watchWorkout.isPaused ? "Resume workout" : "Pause workout")
|
||||
|
||||
Button(action: {
|
||||
vm.nextExercise()
|
||||
@@ -65,6 +69,7 @@ struct WatchControlView: View {
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
})
|
||||
.buttonStyle(BorderedButtonStyle(tint: .green))
|
||||
.accessibilityLabel("Next exercise")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,12 @@
|
||||
|
||||
import WatchKit
|
||||
import HealthKit
|
||||
import os
|
||||
|
||||
class WatchDelegate: NSObject, WKApplicationDelegate {
|
||||
private let logger = Logger(subsystem: "com.werkout.watch", category: "lifecycle")
|
||||
func applicationDidFinishLaunching() {
|
||||
autorizeHealthKit()
|
||||
authorizeHealthKit()
|
||||
}
|
||||
|
||||
func applicationDidBecomeActive() {
|
||||
@@ -25,17 +27,24 @@ class WatchDelegate: NSObject, WKApplicationDelegate {
|
||||
// WatchWorkout.shared.startWorkout()
|
||||
}
|
||||
|
||||
func autorizeHealthKit() {
|
||||
let healthKitTypes: Set = [
|
||||
HKObjectType.quantityType(forIdentifier: .heartRate)!,
|
||||
HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)!,
|
||||
HKObjectType.quantityType(forIdentifier: .oxygenSaturation)!,
|
||||
func authorizeHealthKit() {
|
||||
guard let heartRateType = HKObjectType.quantityType(forIdentifier: .heartRate),
|
||||
let activeEnergyType = HKObjectType.quantityType(forIdentifier: .activeEnergyBurned),
|
||||
let oxygenSaturationType = HKObjectType.quantityType(forIdentifier: .oxygenSaturation) else {
|
||||
logger.error("Missing required HealthKit quantity types during authorization")
|
||||
return
|
||||
}
|
||||
|
||||
let healthKitTypes: Set<HKObjectType> = [
|
||||
heartRateType,
|
||||
activeEnergyType,
|
||||
oxygenSaturationType,
|
||||
HKObjectType.activitySummaryType(),
|
||||
HKQuantityType.workoutType()
|
||||
]
|
||||
HKHealthStore().requestAuthorization(toShare: nil, read: healthKitTypes) { (succ, error) in
|
||||
if !succ {
|
||||
fatalError("Error requesting authorization from health store: \(String(describing: error)))")
|
||||
self.logger.error("HealthKit authorization failed: \(String(describing: error), privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import Foundation
|
||||
import WatchConnectivity
|
||||
import SwiftUI
|
||||
import HealthKit
|
||||
import os
|
||||
import SharedCore
|
||||
|
||||
extension WatchMainViewModel: WCSessionDelegate {
|
||||
func session(_ session: WCSession, didReceiveMessageData messageData: Data) {
|
||||
@@ -16,27 +18,87 @@ extension WatchMainViewModel: WCSessionDelegate {
|
||||
}
|
||||
|
||||
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
|
||||
print("activation did complete")
|
||||
if let error {
|
||||
logger.error("Watch session activation failed: \(error.localizedDescription, privacy: .public)")
|
||||
} else {
|
||||
logger.info("Watch session activation state: \(activationState.rawValue, privacy: .public)")
|
||||
if activationState == .activated {
|
||||
DataSender.flushQueuedPayloadsIfPossible(session: session)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DataSender {
|
||||
private static let logger = Logger(subsystem: "com.werkout.watch", category: "session")
|
||||
private static let queue = DispatchQueue(label: "com.werkout.watch.sender")
|
||||
private static var queuedPayloads = BoundedFIFOQueue<Data>(maxCount: 100)
|
||||
|
||||
static func send(_ data: Data) {
|
||||
guard WCSession.default.activationState == .activated else {
|
||||
if let validationError = WatchPayloadValidation.validate(data) {
|
||||
logger.error("Dropped invalid watch payload: \(String(describing: validationError), privacy: .public)")
|
||||
return
|
||||
}
|
||||
|
||||
queue.async {
|
||||
let session = WCSession.default
|
||||
guard session.activationState == .activated else {
|
||||
enqueue(data)
|
||||
session.activate()
|
||||
return
|
||||
}
|
||||
|
||||
deliver(data, using: session)
|
||||
flushQueuedPayloads(using: session)
|
||||
}
|
||||
}
|
||||
|
||||
static func flushQueuedPayloadsIfPossible(session: WCSession = .default) {
|
||||
queue.async {
|
||||
flushQueuedPayloads(using: session)
|
||||
}
|
||||
}
|
||||
|
||||
private static func flushQueuedPayloads(using session: WCSession) {
|
||||
guard session.activationState == .activated else {
|
||||
return
|
||||
}
|
||||
|
||||
guard queuedPayloads.isEmpty == false else {
|
||||
return
|
||||
}
|
||||
|
||||
let payloads = queuedPayloads.dequeueAll()
|
||||
payloads.forEach { deliver($0, using: session) }
|
||||
}
|
||||
|
||||
private static func deliver(_ data: Data, using session: WCSession) {
|
||||
#if os(iOS)
|
||||
guard WCSession.default.isWatchAppInstalled else {
|
||||
guard session.isWatchAppInstalled else {
|
||||
return
|
||||
}
|
||||
#else
|
||||
guard WCSession.default.isCompanionAppInstalled else {
|
||||
guard session.isCompanionAppInstalled else {
|
||||
return
|
||||
}
|
||||
#endif
|
||||
WCSession.default.sendMessageData(data, replyHandler: nil)
|
||||
{ error in
|
||||
print("Cannot send message: \(String(describing: error))")
|
||||
|
||||
if session.isReachable {
|
||||
session.sendMessageData(data, replyHandler: nil) { error in
|
||||
logger.error("Cannot send watch message: \(error.localizedDescription, privacy: .public)")
|
||||
queue.async {
|
||||
enqueue(data)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
session.transferUserInfo(["package": data])
|
||||
}
|
||||
}
|
||||
|
||||
private static func enqueue(_ data: Data) {
|
||||
let droppedCount = queuedPayloads.enqueue(data)
|
||||
if droppedCount > 0 {
|
||||
logger.warning("Dropping oldest queued watch payload to enforce queue cap")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,12 @@ import Foundation
|
||||
import WatchConnectivity
|
||||
import SwiftUI
|
||||
import HealthKit
|
||||
import os
|
||||
import SharedCore
|
||||
|
||||
class WatchMainViewModel: NSObject, ObservableObject {
|
||||
static let shared = WatchMainViewModel()
|
||||
let logger = Logger(subsystem: "com.werkout.watch", category: "session")
|
||||
|
||||
var session: WCSession
|
||||
|
||||
@@ -29,46 +32,46 @@ class WatchMainViewModel: NSObject, ObservableObject {
|
||||
session.activate()
|
||||
|
||||
}
|
||||
|
||||
private func send(_ action: WatchActions) {
|
||||
do {
|
||||
let data = try JSONEncoder().encode(action)
|
||||
DataSender.send(data)
|
||||
} catch {
|
||||
logger.error("Failed to encode watch action: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
// actions from view
|
||||
func nextExercise() {
|
||||
let nextExerciseAction = WatchActions.nextExercise
|
||||
let data = try! JSONEncoder().encode(nextExerciseAction)
|
||||
DataSender.send(data)
|
||||
send(.nextExercise)
|
||||
WKInterfaceDevice.current().play(.start)
|
||||
}
|
||||
|
||||
func restartExercise() {
|
||||
let nextExerciseAction = WatchActions.restartExercise
|
||||
let data = try! JSONEncoder().encode(nextExerciseAction)
|
||||
DataSender.send(data)
|
||||
send(.restartExercise)
|
||||
WKInterfaceDevice.current().play(.start)
|
||||
}
|
||||
|
||||
func previousExercise() {
|
||||
let nextExerciseAction = WatchActions.previousExercise
|
||||
let data = try! JSONEncoder().encode(nextExerciseAction)
|
||||
DataSender.send(data)
|
||||
send(.previousExercise)
|
||||
WKInterfaceDevice.current().play(.start)
|
||||
}
|
||||
|
||||
func completeWorkout() {
|
||||
let nextExerciseAction = WatchActions.stopWorkout
|
||||
let data = try! JSONEncoder().encode(nextExerciseAction)
|
||||
DataSender.send(data)
|
||||
send(.stopWorkout)
|
||||
WKInterfaceDevice.current().play(.start)
|
||||
}
|
||||
|
||||
func pauseWorkout() {
|
||||
let nextExerciseAction = WatchActions.pauseWorkout
|
||||
let data = try! JSONEncoder().encode(nextExerciseAction)
|
||||
DataSender.send(data)
|
||||
send(.pauseWorkout)
|
||||
WKInterfaceDevice.current().play(.start)
|
||||
WatchWorkout.shared.togglePaused()
|
||||
}
|
||||
|
||||
func dataToAction(messageData: Data) {
|
||||
if let model = try? JSONDecoder().decode(PhoneToWatchActions.self, from: messageData) {
|
||||
do {
|
||||
let model = try WatchPayloadValidation.decode(PhoneToWatchActions.self, from: messageData)
|
||||
DispatchQueue.main.async {
|
||||
switch model {
|
||||
case .inExercise(let newWatchPackageModel):
|
||||
@@ -87,6 +90,8 @@ class WatchMainViewModel: NSObject, ObservableObject {
|
||||
WatchWorkout.shared.startWorkout()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
logger.error("Rejected PhoneToWatchActions payload: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,16 @@
|
||||
|
||||
import Foundation
|
||||
import HealthKit
|
||||
import os
|
||||
|
||||
class WatchWorkout: NSObject, ObservableObject, HKWorkoutSessionDelegate, HKLiveWorkoutBuilderDelegate {
|
||||
static let shared = WatchWorkout()
|
||||
let healthStore = HKHealthStore()
|
||||
var hkWorkoutSession: HKWorkoutSession!
|
||||
var hkBuilder: HKLiveWorkoutBuilder!
|
||||
private let logger = Logger(subsystem: "com.werkout.watch", category: "workout")
|
||||
var hkWorkoutSession: HKWorkoutSession?
|
||||
var hkBuilder: HKLiveWorkoutBuilder?
|
||||
var heartRates = [Int]()
|
||||
private var shouldSendWorkoutDetails = true
|
||||
|
||||
@Published var heartValue: Int?
|
||||
@Published var isInWorkout = false
|
||||
@@ -21,39 +24,51 @@ class WatchWorkout: NSObject, ObservableObject, HKWorkoutSessionDelegate, HKLive
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
setupCore()
|
||||
_ = setupCore()
|
||||
}
|
||||
|
||||
func setupCore() {
|
||||
@discardableResult
|
||||
func setupCore() -> Bool {
|
||||
do {
|
||||
let configuration = HKWorkoutConfiguration()
|
||||
configuration.activityType = .functionalStrengthTraining
|
||||
configuration.locationType = .indoor
|
||||
|
||||
hkWorkoutSession = try HKWorkoutSession(healthStore: healthStore, configuration: configuration)
|
||||
hkBuilder = hkWorkoutSession.associatedWorkoutBuilder()
|
||||
hkWorkoutSession.delegate = self
|
||||
hkBuilder.delegate = self
|
||||
hkBuilder = hkWorkoutSession?.associatedWorkoutBuilder()
|
||||
hkWorkoutSession?.delegate = self
|
||||
hkBuilder?.delegate = self
|
||||
|
||||
/// Set the workout builder's data source.
|
||||
hkBuilder.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore,
|
||||
workoutConfiguration: configuration)
|
||||
hkBuilder?.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore,
|
||||
workoutConfiguration: configuration)
|
||||
return true
|
||||
} catch {
|
||||
fatalError()
|
||||
logger.error("Failed to configure workout session: \(error.localizedDescription, privacy: .public)")
|
||||
hkWorkoutSession = nil
|
||||
hkBuilder = nil
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func startWorkout() {
|
||||
if isInWorkout { return }
|
||||
guard setupCore(),
|
||||
let hkWorkoutSession = hkWorkoutSession else {
|
||||
isInWorkout = false
|
||||
return
|
||||
}
|
||||
|
||||
isInWorkout = true
|
||||
setupCore()
|
||||
shouldSendWorkoutDetails = true
|
||||
hkWorkoutSession.startActivity(with: Date())
|
||||
//WKInterfaceDevice.current().play(.start)
|
||||
}
|
||||
|
||||
func stopWorkout(sendDetails: Bool) {
|
||||
shouldSendWorkoutDetails = sendDetails
|
||||
// hkWorkoutSession.endCurrentActivity(on: Date())
|
||||
hkWorkoutSession.end()
|
||||
hkWorkoutSession?.end()
|
||||
}
|
||||
|
||||
func togglePaused() {
|
||||
@@ -61,9 +76,19 @@ class WatchWorkout: NSObject, ObservableObject, HKWorkoutSessionDelegate, HKLive
|
||||
}
|
||||
|
||||
func beginDataCollection() {
|
||||
guard let hkBuilder = hkBuilder else {
|
||||
DispatchQueue.main.async {
|
||||
self.isInWorkout = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
hkBuilder.beginCollection(withStart: Date()) { (succ, error) in
|
||||
if !succ {
|
||||
fatalError("Error beginning collection from builder: \(String(describing: error)))")
|
||||
self.logger.error("Error beginning workout collection: \(String(describing: error), privacy: .public)")
|
||||
DispatchQueue.main.async {
|
||||
self.isInWorkout = false
|
||||
}
|
||||
}
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
@@ -72,6 +97,15 @@ class WatchWorkout: NSObject, ObservableObject, HKWorkoutSessionDelegate, HKLive
|
||||
}
|
||||
|
||||
func getWorkoutBuilderDetails(completion: @escaping (() -> Void)) {
|
||||
guard let hkBuilder = hkBuilder else {
|
||||
DispatchQueue.main.async {
|
||||
self.heartRates.removeAll()
|
||||
self.isInWorkout = false
|
||||
}
|
||||
completion()
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.heartRates.removeAll()
|
||||
self.isInWorkout = false
|
||||
@@ -83,19 +117,24 @@ class WatchWorkout: NSObject, ObservableObject, HKWorkoutSessionDelegate, HKLive
|
||||
return
|
||||
}
|
||||
|
||||
self.hkBuilder.finishWorkout { (workout, error) in
|
||||
hkBuilder.finishWorkout { (workout, error) in
|
||||
guard let workout = workout else {
|
||||
completion()
|
||||
return
|
||||
}
|
||||
// if !sendDetails { return }
|
||||
|
||||
DispatchQueue.main.async() {
|
||||
let watchFinishWorkoutModel = WatchFinishWorkoutModel(healthKitUUID: workout.uuid)
|
||||
let data = try! JSONEncoder().encode(watchFinishWorkoutModel)
|
||||
let watchAction = WatchActions.workoutComplete(data)
|
||||
let watchActionData = try! JSONEncoder().encode(watchAction)
|
||||
DataSender.send(watchActionData)
|
||||
if self.shouldSendWorkoutDetails {
|
||||
do {
|
||||
let watchFinishWorkoutModel = WatchFinishWorkoutModel(healthKitUUID: workout.uuid)
|
||||
let data = try JSONEncoder().encode(watchFinishWorkoutModel)
|
||||
let watchAction = WatchActions.workoutComplete(data)
|
||||
let watchActionData = try JSONEncoder().encode(watchAction)
|
||||
DataSender.send(watchActionData)
|
||||
} catch {
|
||||
self.logger.error("Failed to send watch completion payload: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
completion()
|
||||
}
|
||||
}
|
||||
@@ -105,30 +144,30 @@ class WatchWorkout: NSObject, ObservableObject, HKWorkoutSessionDelegate, HKLive
|
||||
func workoutSession(_ workoutSession: HKWorkoutSession, didChangeTo toState: HKWorkoutSessionState, from fromState: HKWorkoutSessionState, date: Date) {
|
||||
switch toState {
|
||||
case .notStarted:
|
||||
print("not started")
|
||||
logger.info("Workout state notStarted")
|
||||
case .running:
|
||||
print("running")
|
||||
logger.info("Workout state running")
|
||||
startWorkout()
|
||||
beginDataCollection()
|
||||
case .ended:
|
||||
print("ended")
|
||||
logger.info("Workout state ended")
|
||||
getWorkoutBuilderDetails(completion: {
|
||||
self.setupCore()
|
||||
})
|
||||
case .paused:
|
||||
print("paused")
|
||||
logger.info("Workout state paused")
|
||||
case .prepared:
|
||||
print("prepared")
|
||||
logger.info("Workout state prepared")
|
||||
case .stopped:
|
||||
print("stopped")
|
||||
logger.info("Workout state stopped")
|
||||
@unknown default:
|
||||
fatalError()
|
||||
logger.error("Unknown workout state: \(toState.rawValue, privacy: .public)")
|
||||
}
|
||||
print("[workoutSession] Changed State: \(toState.rawValue)")
|
||||
logger.info("Workout session changed state: \(toState.rawValue, privacy: .public)")
|
||||
}
|
||||
|
||||
func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) {
|
||||
print("[didFailWithError] Workout Builder changed event: \(error.localizedDescription)")
|
||||
logger.error("Workout session failed: \(error.localizedDescription, privacy: .public)")
|
||||
// trying to go from ended to something so just end it all
|
||||
if workoutSession.state == .ended {
|
||||
getWorkoutBuilderDetails(completion: {
|
||||
@@ -140,26 +179,30 @@ class WatchWorkout: NSObject, ObservableObject, HKWorkoutSessionDelegate, HKLive
|
||||
func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf collectedTypes: Set<HKSampleType>) {
|
||||
for type in collectedTypes {
|
||||
guard let quantityType = type as? HKQuantityType else {
|
||||
return
|
||||
continue
|
||||
}
|
||||
switch quantityType {
|
||||
case HKQuantityType.quantityType(forIdentifier: .heartRate):
|
||||
DispatchQueue.main.async() {
|
||||
let statistics = workoutBuilder.statistics(for: quantityType)
|
||||
guard let statistics = workoutBuilder.statistics(for: quantityType),
|
||||
let quantity = statistics.mostRecentQuantity() else {
|
||||
return
|
||||
}
|
||||
let heartRateUnit = HKUnit.count().unitDivided(by: HKUnit.minute())
|
||||
let value = statistics!.mostRecentQuantity()?.doubleValue(for: heartRateUnit)
|
||||
self.heartValue = Int(Double(round(1 * value!) / 1))
|
||||
self.heartRates.append(Int(Double(round(1 * value!) / 1)))
|
||||
print("[workoutBuilder] Heart Rate: \(String(describing: self.heartValue))")
|
||||
let value = quantity.doubleValue(for: heartRateUnit)
|
||||
let roundedHeartRate = Int(Double(round(1 * value) / 1))
|
||||
self.heartValue = roundedHeartRate
|
||||
self.heartRates.append(roundedHeartRate)
|
||||
self.logger.debug("Collected heart rate sample: \(roundedHeartRate, privacy: .public)")
|
||||
}
|
||||
default:
|
||||
return
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {
|
||||
guard let workoutEventType = workoutBuilder.workoutEvents.last?.type else { return }
|
||||
print("[workoutBuilderDidCollectEvent] Workout Builder changed event: \(workoutEventType.rawValue)")
|
||||
logger.info("Workout builder event: \(workoutEventType.rawValue, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user