From acce71226197ce16dbb8f3d0dbc38e92246057fd Mon Sep 17 00:00:00 2001 From: Trey t Date: Wed, 11 Feb 2026 12:54:40 -0600 Subject: [PATCH] Stabilize iOS/watchOS/tvOS apps and add cross-platform audit remediation --- .github/workflows/apple-platform-ci.yml | 21 ++ SharedCore/.gitignore | 2 + SharedCore/Package.swift | 35 +++ .../Sources/SharedCore/AppNotifications.swift | 6 + .../Sources/SharedCore/BoundedFIFOQueue.swift | 40 +++ .../Sources/SharedCore/RuntimeReporting.swift | 76 +++++ .../Sources/SharedCore/TokenSecurity.swift | 103 +++++++ .../SharedCore/WatchPayloadValidation.swift | 40 +++ .../SharedCore/WorkoutValidation.swift | 50 ++++ .../RuntimeReporterTests.swift | 20 ++ .../BoundedFIFOQueueTests.swift | 36 +++ .../WatchPayloadValidationTests.swift | 47 +++ .../WorkoutValidationTests.swift | 39 +++ .../TokenSecurityTests.swift | 65 +++++ .../project.pbxproj | 25 ++ .../WekoutThotViewer/ContentView.swift | 78 +++-- docs/stabilization_steps_1_5.md | 35 +++ docs/step10_reliability_round2.md | 39 +++ ...tep11_watch_regression_and_architecture.md | 40 +++ docs/step12_hardware_disconnect_pass.md | 53 ++++ docs/step6_audit_round1.md | 55 ++++ docs/step7_ui_accessibility_round1.md | 62 ++++ docs/step8_performance_state_round1.md | 48 +++ docs/step9_memory_lifecycle_round1.md | 39 +++ iphone/Werkout-ios-Info.plist | 28 ++ iphone/Werkout_ios.xcodeproj/project.pbxproj | 54 +++- iphone/Werkout_ios/APIModels/Exercise.swift | 5 +- .../APIModels/PlannedWorkout.swift | 12 +- iphone/Werkout_ios/APIModels/Workout.swift | 41 ++- iphone/Werkout_ios/AudioEngine.swift | 11 +- iphone/Werkout_ios/BridgeModule+Timer.swift | 5 +- iphone/Werkout_ios/BridgeModule+Watch.swift | 165 +++++++---- .../BridgeModule+WorkoutActions.swift | 12 +- iphone/Werkout_ios/BridgeModule.swift | 3 + iphone/Werkout_ios/CurrentWorkoutInfo.swift | 18 +- iphone/Werkout_ios/DataStore.swift | 37 ++- iphone/Werkout_ios/Extensions.swift | 114 ++++++-- iphone/Werkout_ios/HealthKitHelper.swift | 213 ++++++++------ iphone/Werkout_ios/JSON/PreviewData.swift | 108 ++----- iphone/Werkout_ios/JSON/RegisteredUser.json | 2 +- iphone/Werkout_ios/Network/Network.swift | 221 ++++++++++---- iphone/Werkout_ios/Persistence.swift | 33 ++- .../Resources/Werkout-ios-Info.plist | 15 +- iphone/Werkout_ios/UserStore.swift | 275 +++++++++++++++--- .../AllWorkouts/AllWorkoutsListView.swift | 23 +- .../Views/AllWorkouts/AllWorkoutsView.swift | 53 ++-- .../CompletedWorkoutView.swift | 25 +- .../CreateExerciseActionsView.swift | 32 +- .../CreateWorkout/CreateViewModels.swift | 87 ++++-- .../CreateWorkout/CreateWorkoutMainView.swift | 38 ++- .../Views/ExternalWorkoutDetailView.swift | 44 ++- .../Werkout_ios/Views/Login/LoginView.swift | 25 +- .../Werkout_ios/Views/PlanWorkoutView.swift | 26 +- .../WorkoutDetail/ExerciseListView.swift | 163 +++++++---- .../WorkoutDetail/WorkoutDetailView.swift | 68 +++-- .../WorkoutDetailViewModel.swift | 5 +- .../Views/WorkoutHistoryView.swift | 2 +- iphone/Werkout_ios/Werkout_iosApp.swift | 19 +- iphone/Werkout_ios/subview/ActionsView.swift | 6 + .../Werkout_ios/subview/AddSupersetView.swift | 7 +- .../Werkout_ios/subview/AllExerciseView.swift | 31 +- .../subview/CompletedWorkoutsView.swift | 16 +- .../subview/CreateWorkoutSupersetView.swift | 28 +- .../subview/PlannedWorkoutView.swift | 54 ++-- iphone/Werkout_ios/subview/PlayerUIView.swift | 12 +- .../MainWatchView.swift | 12 +- .../WatchControlView.swift | 5 + .../WatchDelegate.swift | 23 +- ...WatchMainViewModel+WCSessionDelegate.swift | 76 ++++- .../WatchMainViewModel.swift | 37 ++- .../WatchWorkout.swift | 117 +++++--- scripts/ci/scan_tokens.sh | 23 ++ .../watch_disconnect_hardware_pass.sh | 132 +++++++++ scripts/smoke/build_ios.sh | 23 ++ scripts/smoke/build_tvos.sh | 23 ++ scripts/smoke/build_watch.sh | 23 ++ scripts/smoke/smoke_all.sh | 21 ++ 77 files changed, 2940 insertions(+), 765 deletions(-) create mode 100644 .github/workflows/apple-platform-ci.yml create mode 100644 SharedCore/.gitignore create mode 100644 SharedCore/Package.swift create mode 100644 SharedCore/Sources/SharedCore/AppNotifications.swift create mode 100644 SharedCore/Sources/SharedCore/BoundedFIFOQueue.swift create mode 100644 SharedCore/Sources/SharedCore/RuntimeReporting.swift create mode 100644 SharedCore/Sources/SharedCore/TokenSecurity.swift create mode 100644 SharedCore/Sources/SharedCore/WatchPayloadValidation.swift create mode 100644 SharedCore/Sources/SharedCore/WorkoutValidation.swift create mode 100644 SharedCore/Tests/SharedCoreTVOSTests/RuntimeReporterTests.swift create mode 100644 SharedCore/Tests/SharedCoreWatchOSTests/BoundedFIFOQueueTests.swift create mode 100644 SharedCore/Tests/SharedCoreWatchOSTests/WatchPayloadValidationTests.swift create mode 100644 SharedCore/Tests/SharedCoreWatchOSTests/WorkoutValidationTests.swift create mode 100644 SharedCore/Tests/SharedCoreiOSTests/TokenSecurityTests.swift create mode 100644 docs/stabilization_steps_1_5.md create mode 100644 docs/step10_reliability_round2.md create mode 100644 docs/step11_watch_regression_and_architecture.md create mode 100644 docs/step12_hardware_disconnect_pass.md create mode 100644 docs/step6_audit_round1.md create mode 100644 docs/step7_ui_accessibility_round1.md create mode 100644 docs/step8_performance_state_round1.md create mode 100644 docs/step9_memory_lifecycle_round1.md create mode 100644 iphone/Werkout-ios-Info.plist create mode 100755 scripts/ci/scan_tokens.sh create mode 100755 scripts/hardware/watch_disconnect_hardware_pass.sh create mode 100755 scripts/smoke/build_ios.sh create mode 100755 scripts/smoke/build_tvos.sh create mode 100755 scripts/smoke/build_watch.sh create mode 100755 scripts/smoke/smoke_all.sh diff --git a/.github/workflows/apple-platform-ci.yml b/.github/workflows/apple-platform-ci.yml new file mode 100644 index 0000000..18a6ce3 --- /dev/null +++ b/.github/workflows/apple-platform-ci.yml @@ -0,0 +1,21 @@ +name: Apple Platform CI + +on: + push: + branches: ["**"] + pull_request: + +jobs: + smoke-and-tests: + runs-on: macos-15 + timeout-minutes: 45 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Xcode Version + run: xcodebuild -version + + - name: Run smoke suite + run: ./scripts/smoke/smoke_all.sh diff --git a/SharedCore/.gitignore b/SharedCore/.gitignore new file mode 100644 index 0000000..2d9f16e --- /dev/null +++ b/SharedCore/.gitignore @@ -0,0 +1,2 @@ +.build/ +.swiftpm/ diff --git a/SharedCore/Package.swift b/SharedCore/Package.swift new file mode 100644 index 0000000..266d78a --- /dev/null +++ b/SharedCore/Package.swift @@ -0,0 +1,35 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "SharedCore", + platforms: [ + .iOS(.v16), + .watchOS(.v9), + .tvOS(.v16), + .macOS(.v13) + ], + products: [ + .library( + name: "SharedCore", + targets: ["SharedCore"] + ) + ], + targets: [ + .target( + name: "SharedCore" + ), + .testTarget( + name: "SharedCoreiOSTests", + dependencies: ["SharedCore"] + ), + .testTarget( + name: "SharedCoreWatchOSTests", + dependencies: ["SharedCore"] + ), + .testTarget( + name: "SharedCoreTVOSTests", + dependencies: ["SharedCore"] + ) + ] +) diff --git a/SharedCore/Sources/SharedCore/AppNotifications.swift b/SharedCore/Sources/SharedCore/AppNotifications.swift new file mode 100644 index 0000000..4f5d617 --- /dev/null +++ b/SharedCore/Sources/SharedCore/AppNotifications.swift @@ -0,0 +1,6 @@ +import Foundation + +public enum AppNotifications { + public static let createdNewWorkout = Notification.Name("CreatedNewWorkout") +} + diff --git a/SharedCore/Sources/SharedCore/BoundedFIFOQueue.swift b/SharedCore/Sources/SharedCore/BoundedFIFOQueue.swift new file mode 100644 index 0000000..786802a --- /dev/null +++ b/SharedCore/Sources/SharedCore/BoundedFIFOQueue.swift @@ -0,0 +1,40 @@ +import Foundation + +public struct BoundedFIFOQueue { + private var storage: [Element] = [] + public let maxCount: Int + + public init(maxCount: Int) { + self.maxCount = max(1, maxCount) + } + + public var count: Int { + storage.count + } + + public var isEmpty: Bool { + storage.isEmpty + } + + @discardableResult + public mutating func enqueue(_ element: Element) -> Int { + var droppedCount = 0 + if storage.count >= maxCount { + droppedCount = storage.count - maxCount + 1 + storage.removeFirst(droppedCount) + } + storage.append(element) + return droppedCount + } + + public mutating func dequeueAll() -> [Element] { + let elements = storage + storage.removeAll() + return elements + } + + public mutating func clear() { + storage.removeAll() + } +} + diff --git a/SharedCore/Sources/SharedCore/RuntimeReporting.swift b/SharedCore/Sources/SharedCore/RuntimeReporting.swift new file mode 100644 index 0000000..298dced --- /dev/null +++ b/SharedCore/Sources/SharedCore/RuntimeReporting.swift @@ -0,0 +1,76 @@ +import Foundation +import os + +public enum RuntimeSeverity: String, Sendable { + case info + case warning + case error +} + +public struct RuntimeEvent: Sendable { + public let severity: RuntimeSeverity + public let message: String + public let metadata: [String: String] + public let timestamp: Date + + public init(severity: RuntimeSeverity, message: String, metadata: [String: String], timestamp: Date = Date()) { + self.severity = severity + self.message = message + self.metadata = metadata + self.timestamp = timestamp + } +} + +public final class RuntimeReporter { + public typealias Sink = @Sendable (RuntimeEvent) -> Void + + public static let shared = RuntimeReporter() + + private let logger = Logger(subsystem: "com.werkout.sharedcore", category: "runtime") + private let lock = NSLock() + private var sink: Sink? + + private init() {} + + public func setSink(_ sink: Sink?) { + lock.lock() + self.sink = sink + lock.unlock() + } + + public func recordError(_ message: String, metadata: [String: String] = [:]) { + record(.error, message: message, metadata: metadata) + } + + public func recordWarning(_ message: String, metadata: [String: String] = [:]) { + record(.warning, message: message, metadata: metadata) + } + + public func recordInfo(_ message: String, metadata: [String: String] = [:]) { + record(.info, message: message, metadata: metadata) + } + + private func record(_ severity: RuntimeSeverity, message: String, metadata: [String: String]) { + let flattenedMetadata = metadata + .map { "\($0.key)=\($0.value)" } + .sorted() + .joined(separator: ",") + + let logMessage = flattenedMetadata.isEmpty ? message : "\(message) | \(flattenedMetadata)" + + switch severity { + case .info: + logger.info("\(logMessage, privacy: .public)") + case .warning: + logger.warning("\(logMessage, privacy: .public)") + case .error: + logger.error("\(logMessage, privacy: .public)") + } + + lock.lock() + let sink = self.sink + lock.unlock() + + sink?(RuntimeEvent(severity: severity, message: message, metadata: metadata)) + } +} diff --git a/SharedCore/Sources/SharedCore/TokenSecurity.swift b/SharedCore/Sources/SharedCore/TokenSecurity.swift new file mode 100644 index 0000000..c90ee5f --- /dev/null +++ b/SharedCore/Sources/SharedCore/TokenSecurity.swift @@ -0,0 +1,103 @@ +import Foundation + +public enum TokenSecurity { + public static let defaultRotationWindow: TimeInterval = 60 * 60 + private static let authPrefixes = ["token", "bearer"] + + // Basic high-entropy hex token detector for accidental commits. + private static let tokenRegex = try? NSRegularExpression(pattern: "\\b[a-fA-F0-9]{32,}\\b") + + public static func containsPotentialHardcodedToken(in text: String) -> Bool { + guard let tokenRegex else { + return false + } + let range = NSRange(location: 0, length: text.utf16.count) + return tokenRegex.firstMatch(in: text, options: [], range: range) != nil + } + + public static func isRedactedToken(_ token: String?) -> Bool { + guard let token else { return false } + let upper = token.uppercased() + return upper.contains("REDACTED") || upper.contains("YOUR_TOKEN") || upper.contains("PLACEHOLDER") + } + + public static func sanitizeToken(_ token: String?) -> String? { + guard let rawToken = token?.trimmingCharacters(in: .whitespacesAndNewlines), + rawToken.isEmpty == false else { + return nil + } + + let normalized = normalizeAuthPrefix(rawToken) + guard normalized.isEmpty == false, + isRedactedToken(normalized) == false else { + return nil + } + return normalized + } + + public static func jwtExpiration(_ token: String) -> Date? { + let segments = token.split(separator: ".") + guard segments.count == 3 else { + return nil + } + + let payloadSegment = String(segments[1]) + guard let payloadData = base64URLDecode(payloadSegment), + let object = try? JSONSerialization.jsonObject(with: payloadData) as? [String: Any], + let exp = object["exp"] as? TimeInterval else { + return nil + } + + return Date(timeIntervalSince1970: exp) + } + + public static func isExpired(_ token: String?, now: Date = Date()) -> Bool { + guard let token = sanitizeToken(token) else { + return true + } + + guard let expiration = jwtExpiration(token) else { + // Non-JWT tokens cannot be locally validated for expiry. + return false + } + + return expiration <= now + } + + public static func shouldRotate(_ token: String?, now: Date = Date(), rotationWindow: TimeInterval = defaultRotationWindow) -> Bool { + guard let token = sanitizeToken(token), + let expiration = jwtExpiration(token) else { + return false + } + + return expiration.timeIntervalSince(now) <= rotationWindow + } + + private static func base64URLDecode(_ input: String) -> Data? { + var value = input + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + + let remainder = value.count % 4 + if remainder > 0 { + value.append(String(repeating: "=", count: 4 - remainder)) + } + + return Data(base64Encoded: value) + } + + private static func normalizeAuthPrefix(_ token: String) -> String { + let lowercased = token.lowercased() + for prefix in authPrefixes { + if lowercased == prefix { + return "" + } + + let prefixed = "\(prefix) " + if lowercased.hasPrefix(prefixed) { + return String(token.dropFirst(prefixed.count)).trimmingCharacters(in: .whitespacesAndNewlines) + } + } + return token + } +} diff --git a/SharedCore/Sources/SharedCore/WatchPayloadValidation.swift b/SharedCore/Sources/SharedCore/WatchPayloadValidation.swift new file mode 100644 index 0000000..df05484 --- /dev/null +++ b/SharedCore/Sources/SharedCore/WatchPayloadValidation.swift @@ -0,0 +1,40 @@ +import Foundation + +public enum WatchPayloadValidationError: Error, Equatable { + case emptyPayload + case payloadTooLarge(actualBytes: Int, maxBytes: Int) + case decodeFailure +} + +public enum WatchPayloadValidation { + public static let defaultMaxPayloadBytes = 256 * 1024 + + public static func validate(_ payload: Data, maxBytes: Int = defaultMaxPayloadBytes) -> WatchPayloadValidationError? { + guard payload.isEmpty == false else { + return .emptyPayload + } + + guard payload.count <= maxBytes else { + return .payloadTooLarge(actualBytes: payload.count, maxBytes: maxBytes) + } + + return nil + } + + public static func decode( + _ type: T.Type, + from payload: Data, + maxBytes: Int = defaultMaxPayloadBytes, + decoder: JSONDecoder = JSONDecoder() + ) throws -> T { + if let validationError = validate(payload, maxBytes: maxBytes) { + throw validationError + } + + do { + return try decoder.decode(T.self, from: payload) + } catch { + throw WatchPayloadValidationError.decodeFailure + } + } +} diff --git a/SharedCore/Sources/SharedCore/WorkoutValidation.swift b/SharedCore/Sources/SharedCore/WorkoutValidation.swift new file mode 100644 index 0000000..714dac0 --- /dev/null +++ b/SharedCore/Sources/SharedCore/WorkoutValidation.swift @@ -0,0 +1,50 @@ +import Foundation + +public struct WorkoutValidationIssue: Equatable { + public let code: String + public let message: String + + public init(code: String, message: String) { + self.code = code + self.message = message + } +} + +public enum WorkoutValidation { + public static func validateSupersets(_ supersets: [[String: Any]]) -> [WorkoutValidationIssue] { + var issues = [WorkoutValidationIssue]() + + if supersets.isEmpty { + issues.append(WorkoutValidationIssue(code: "empty_supersets", message: "Workout requires at least one superset.")) + return issues + } + + for (supersetIndex, superset) in supersets.enumerated() { + let rounds = superset["rounds"] as? Int ?? 0 + if rounds <= 0 { + issues.append(WorkoutValidationIssue(code: "invalid_rounds", message: "Superset \(supersetIndex + 1) must have at least one round.")) + } + + let exercises = superset["exercises"] as? [[String: Any]] ?? [] + if exercises.isEmpty { + issues.append(WorkoutValidationIssue(code: "empty_exercises", message: "Superset \(supersetIndex + 1) must contain at least one exercise.")) + continue + } + + for (exerciseIndex, exercise) in exercises.enumerated() { + let reps = exercise["reps"] as? Int ?? 0 + let duration = exercise["duration"] as? Int ?? 0 + if reps <= 0 && duration <= 0 { + issues.append( + WorkoutValidationIssue( + code: "invalid_exercise_payload", + message: "Exercise \(exerciseIndex + 1) in superset \(supersetIndex + 1) needs reps or duration." + ) + ) + } + } + } + + return issues + } +} diff --git a/SharedCore/Tests/SharedCoreTVOSTests/RuntimeReporterTests.swift b/SharedCore/Tests/SharedCoreTVOSTests/RuntimeReporterTests.swift new file mode 100644 index 0000000..9abe1b7 --- /dev/null +++ b/SharedCore/Tests/SharedCoreTVOSTests/RuntimeReporterTests.swift @@ -0,0 +1,20 @@ +import XCTest +@testable import SharedCore + +final class RuntimeReporterTests: XCTestCase { + func testReporterInvokesSinkWithMetadata() { + let expectation = expectation(description: "sink called") + + RuntimeReporter.shared.setSink { event in + XCTAssertEqual(event.severity, .error) + XCTAssertEqual(event.message, "network failure") + XCTAssertEqual(event.metadata["status"], "500") + expectation.fulfill() + } + + RuntimeReporter.shared.recordError("network failure", metadata: ["status": "500"]) + + waitForExpectations(timeout: 1.0) + RuntimeReporter.shared.setSink(nil) + } +} diff --git a/SharedCore/Tests/SharedCoreWatchOSTests/BoundedFIFOQueueTests.swift b/SharedCore/Tests/SharedCoreWatchOSTests/BoundedFIFOQueueTests.swift new file mode 100644 index 0000000..303ff78 --- /dev/null +++ b/SharedCore/Tests/SharedCoreWatchOSTests/BoundedFIFOQueueTests.swift @@ -0,0 +1,36 @@ +import XCTest +@testable import SharedCore + +final class BoundedFIFOQueueTests: XCTestCase { + func testDisconnectReconnectFlushPreservesOrder() { + var queue = BoundedFIFOQueue(maxCount: 5) + + _ = queue.enqueue(10) + _ = queue.enqueue(20) + _ = queue.enqueue(30) + + XCTAssertEqual(queue.dequeueAll(), [10, 20, 30]) + XCTAssertTrue(queue.isEmpty) + } + + func testOverflowDropsOldestPayloads() { + var queue = BoundedFIFOQueue(maxCount: 3) + + XCTAssertEqual(queue.enqueue(1), 0) + XCTAssertEqual(queue.enqueue(2), 0) + XCTAssertEqual(queue.enqueue(3), 0) + XCTAssertEqual(queue.enqueue(4), 1) + XCTAssertEqual(queue.enqueue(5), 1) + + XCTAssertEqual(queue.dequeueAll(), [3, 4, 5]) + } + + func testMaxCountHasLowerBoundOfOne() { + var queue = BoundedFIFOQueue(maxCount: 0) + + _ = queue.enqueue(1) + XCTAssertEqual(queue.enqueue(2), 1) + XCTAssertEqual(queue.dequeueAll(), [2]) + } +} + diff --git a/SharedCore/Tests/SharedCoreWatchOSTests/WatchPayloadValidationTests.swift b/SharedCore/Tests/SharedCoreWatchOSTests/WatchPayloadValidationTests.swift new file mode 100644 index 0000000..c98de44 --- /dev/null +++ b/SharedCore/Tests/SharedCoreWatchOSTests/WatchPayloadValidationTests.swift @@ -0,0 +1,47 @@ +import XCTest +@testable import SharedCore + +final class WatchPayloadValidationTests: XCTestCase { + private struct MockPayload: Codable, Equatable { + let name: String + let count: Int + } + + func testValidateRejectsEmptyPayload() { + let error = WatchPayloadValidation.validate(Data()) + XCTAssertEqual(error, .emptyPayload) + } + + func testValidateRejectsOversizedPayload() { + let payload = Data(repeating: 0, count: 9) + let error = WatchPayloadValidation.validate(payload, maxBytes: 8) + XCTAssertEqual(error, .payloadTooLarge(actualBytes: 9, maxBytes: 8)) + } + + func testDecodeAcceptsValidPayload() throws { + let payload = try JSONEncoder().encode(MockPayload(name: "set", count: 12)) + let decoded = try WatchPayloadValidation.decode(MockPayload.self, from: payload) + XCTAssertEqual(decoded, MockPayload(name: "set", count: 12)) + } + + func testDecodeRejectsInvalidJSON() throws { + let invalid = Data("not-json".utf8) + + XCTAssertThrowsError(try WatchPayloadValidation.decode(MockPayload.self, from: invalid)) { error in + XCTAssertEqual(error as? WatchPayloadValidationError, .decodeFailure) + } + } + + func testDecodeRejectsOversizedPayloadBeforeDecoding() { + let payload = Data(repeating: 1, count: 32) + + XCTAssertThrowsError( + try WatchPayloadValidation.decode(MockPayload.self, from: payload, maxBytes: 16) + ) { error in + XCTAssertEqual( + error as? WatchPayloadValidationError, + .payloadTooLarge(actualBytes: 32, maxBytes: 16) + ) + } + } +} diff --git a/SharedCore/Tests/SharedCoreWatchOSTests/WorkoutValidationTests.swift b/SharedCore/Tests/SharedCoreWatchOSTests/WorkoutValidationTests.swift new file mode 100644 index 0000000..cf9a9a5 --- /dev/null +++ b/SharedCore/Tests/SharedCoreWatchOSTests/WorkoutValidationTests.swift @@ -0,0 +1,39 @@ +import XCTest +@testable import SharedCore + +final class WorkoutValidationTests: XCTestCase { + func testValidateSupersetsRejectsEmptyPayload() { + let issues = WorkoutValidation.validateSupersets([]) + XCTAssertEqual(issues.first?.code, "empty_supersets") + } + + func testValidateSupersetsRejectsInvalidRoundsAndExercisePayload() { + let supersets: [[String: Any]] = [ + [ + "rounds": 0, + "exercises": [ + ["reps": 0, "duration": 0] + ] + ] + ] + + let issues = WorkoutValidation.validateSupersets(supersets) + XCTAssertTrue(issues.contains(where: { $0.code == "invalid_rounds" })) + XCTAssertTrue(issues.contains(where: { $0.code == "invalid_exercise_payload" })) + } + + func testValidateSupersetsAcceptsValidPayload() { + let supersets: [[String: Any]] = [ + [ + "rounds": 3, + "exercises": [ + ["reps": 12, "duration": 0], + ["reps": 0, "duration": 30] + ] + ] + ] + + let issues = WorkoutValidation.validateSupersets(supersets) + XCTAssertTrue(issues.isEmpty) + } +} diff --git a/SharedCore/Tests/SharedCoreiOSTests/TokenSecurityTests.swift b/SharedCore/Tests/SharedCoreiOSTests/TokenSecurityTests.swift new file mode 100644 index 0000000..51855af --- /dev/null +++ b/SharedCore/Tests/SharedCoreiOSTests/TokenSecurityTests.swift @@ -0,0 +1,65 @@ +import XCTest +@testable import SharedCore + +final class TokenSecurityTests: XCTestCase { + func testSanitizeTokenRejectsEmptyAndRedactedValues() { + XCTAssertNil(TokenSecurity.sanitizeToken(nil)) + XCTAssertNil(TokenSecurity.sanitizeToken(" ")) + XCTAssertNil(TokenSecurity.sanitizeToken("REDACTED_TOKEN")) + XCTAssertEqual(TokenSecurity.sanitizeToken(" abc123 "), "abc123") + } + + func testSanitizeTokenNormalizesAuthorizationPrefixes() { + XCTAssertEqual(TokenSecurity.sanitizeToken("Token abc123"), "abc123") + XCTAssertEqual(TokenSecurity.sanitizeToken("bearer xyz789"), "xyz789") + XCTAssertNil(TokenSecurity.sanitizeToken("Token ")) + } + + func testContainsPotentialHardcodedTokenDetectsLongHexBlob() { + let content = "private let token = \"0123456789abcdef0123456789abcdef\"" + XCTAssertTrue(TokenSecurity.containsPotentialHardcodedToken(in: content)) + } + + func testJWTExpirationAndRotationWindow() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let expiration = now.addingTimeInterval(30 * 60) + + let token = try makeJWT(exp: expiration) + + XCTAssertEqual(TokenSecurity.jwtExpiration(token), expiration) + XCTAssertFalse(TokenSecurity.isExpired(token, now: now)) + XCTAssertTrue(TokenSecurity.shouldRotate(token, now: now, rotationWindow: 60 * 60)) + } + + func testExpiredJWTReturnsExpired() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let expiration = now.addingTimeInterval(-10) + let token = try makeJWT(exp: expiration) + + XCTAssertTrue(TokenSecurity.isExpired(token, now: now)) + } + + func testMalformedTokenDoesNotCrashAndDoesNotTriggerRotation() { + let malformed = "not-a-jwt" + XCTAssertNil(TokenSecurity.jwtExpiration(malformed)) + XCTAssertFalse(TokenSecurity.isExpired(malformed)) + XCTAssertFalse(TokenSecurity.shouldRotate(malformed)) + } + + private func makeJWT(exp: Date) throws -> String { + let header = ["alg": "HS256", "typ": "JWT"] + let payload = ["exp": Int(exp.timeIntervalSince1970)] + + let headerData = try JSONSerialization.data(withJSONObject: header) + let payloadData = try JSONSerialization.data(withJSONObject: payload) + + return "\(base64URL(headerData)).\(base64URL(payloadData)).signature" + } + + private func base64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/WekoutThotViewer/WekoutThotViewer.xcodeproj/project.pbxproj b/WekoutThotViewer/WekoutThotViewer.xcodeproj/project.pbxproj index 0d8d9c5..333b33b 100644 --- a/WekoutThotViewer/WekoutThotViewer.xcodeproj/project.pbxproj +++ b/WekoutThotViewer/WekoutThotViewer.xcodeproj/project.pbxproj @@ -31,6 +31,7 @@ 1CC7CBCF2C21E42C001614B8 /* DataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CC7CBCE2C21E42C001614B8 /* DataStore.swift */; }; 1CC7CBD12C21E5FA001614B8 /* PlayerUIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CC7CBD02C21E5FA001614B8 /* PlayerUIView.swift */; }; 1CC7CBD32C21E678001614B8 /* ThotStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CC7CBD22C21E678001614B8 /* ThotStyle.swift */; }; + D00200012E00000100000001 /* SharedCore in Frameworks */ = {isa = PBXBuildFile; productRef = D00200012E00000100000003 /* SharedCore */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -79,6 +80,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + D00200012E00000100000001 /* SharedCore in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -177,6 +179,9 @@ dependencies = ( ); name = WekoutThotViewer; + packageProductDependencies = ( + D00200012E00000100000003 /* SharedCore */, + ); productName = WekoutThotViewer; productReference = 1CC0930B2C21DE760004E1E6 /* WekoutThotViewer.app */; productType = "com.apple.product-type.application"; @@ -205,6 +210,9 @@ Base, ); mainGroup = 1CC093022C21DE760004E1E6; + packageReferences = ( + D00200012E00000100000002 /* XCLocalSwiftPackageReference "../SharedCore" */, + ); productRefGroup = 1CC0930C2C21DE760004E1E6 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -258,6 +266,13 @@ }; /* End PBXSourcesBuildPhase section */ +/* Begin XCLocalSwiftPackageReference section */ + D00200012E00000100000002 /* XCLocalSwiftPackageReference "../SharedCore" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../SharedCore; + }; +/* End XCLocalSwiftPackageReference section */ + /* Begin XCBuildConfiguration section */ 1CC093172C21DE770004E1E6 /* Debug */ = { isa = XCBuildConfiguration; @@ -387,6 +402,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"WekoutThotViewer/Preview Content\""; DEVELOPMENT_TEAM = V3PF3M6B6U; + ENABLE_APP_INTENTS_METADATA_GENERATION = NO; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = WekoutThotViewer/Info.plist; @@ -414,6 +430,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"WekoutThotViewer/Preview Content\""; DEVELOPMENT_TEAM = V3PF3M6B6U; + ENABLE_APP_INTENTS_METADATA_GENERATION = NO; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = WekoutThotViewer/Info.plist; @@ -434,6 +451,14 @@ }; /* End XCBuildConfiguration section */ +/* Begin XCSwiftPackageProductDependency section */ + D00200012E00000100000003 /* SharedCore */ = { + isa = XCSwiftPackageProductDependency; + package = D00200012E00000100000002 /* XCLocalSwiftPackageReference "../SharedCore" */; + productName = SharedCore; + }; +/* End XCSwiftPackageProductDependency section */ + /* Begin XCConfigurationList section */ 1CC093062C21DE760004E1E6 /* Build configuration list for PBXProject "WekoutThotViewer" */ = { isa = XCConfigurationList; diff --git a/WekoutThotViewer/WekoutThotViewer/ContentView.swift b/WekoutThotViewer/WekoutThotViewer/ContentView.swift index 1a69de1..e87a9e2 100644 --- a/WekoutThotViewer/WekoutThotViewer/ContentView.swift +++ b/WekoutThotViewer/WekoutThotViewer/ContentView.swift @@ -14,17 +14,17 @@ struct ContentView: View { @State var isUpdating = false @ObservedObject var dataStore = DataStore.shared @State var nsfwVideos: [NSFWVideo]? - @State var avPlayer = AVPlayer(url: URL(string: "https://dev.werkout.fitness/media/exercise_videos/2_Dumbbell_Lateral_Lunges.mp4")!) + @State private var showLoginView = false + @State var avPlayer = AVPlayer(url: URL(string: "https://dev.werkout.fitness/media/exercise_videos/2_Dumbbell_Lateral_Lunges.mp4") ?? URL(fileURLWithPath: "/dev/null")) + @State private var currentVideoURL: URL? let videoEnded = NotificationCenter.default.publisher(for: NSNotification.Name.AVPlayerItemDidPlayToEndTime) var body: some View { VStack { if isUpdating { - if isUpdating { - ProgressView() - .progressViewStyle(.circular) - } + ProgressView() + .progressViewStyle(.circular) } else { PlayerView(player: $avPlayer) .onAppear{ @@ -37,8 +37,18 @@ struct ContentView: View { } } .onAppear(perform: { - maybeUpdateShit() + maybeRefreshData() }) + .sheet(isPresented: $showLoginView) { + LoginView(completion: { + needsUpdating = true + maybeRefreshData() + }) + .interactiveDismissDisabled() + } + .onDisappear { + avPlayer.pause() + } } func playRandomVideo() { @@ -48,36 +58,48 @@ struct ContentView: View { } func playVideo(url: String) { - let url = URL(string: BaseURLs.currentBaseURL + url) - avPlayer = AVPlayer(url: url!) + guard let videoURL = URL(string: BaseURLs.currentBaseURL + url) else { + return + } + if currentVideoURL == videoURL { + avPlayer.seek(to: .zero) + avPlayer.isMuted = true + avPlayer.play() + return + } + + currentVideoURL = videoURL + avPlayer = AVPlayer(url: videoURL) avPlayer.isMuted = true avPlayer.play() } - func maybeUpdateShit() { - UserStore.shared.setTreyDevRegisterdUser() + func maybeRefreshData() { + guard UserStore.shared.token != nil else { + isUpdating = false + showLoginView = true + return + } - if UserStore.shared.token != nil{ - if UserStore.shared.plannedWorkouts.isEmpty { - UserStore.shared.fetchPlannedWorkouts() - } - - if needsUpdating { - self.isUpdating = true - dataStore.fetchAllData(completion: { - DispatchQueue.main.async { - guard let allNSFWVideos = dataStore.allNSFWVideos else { - return - } - self.nsfwVideos = allNSFWVideos + if UserStore.shared.plannedWorkouts.isEmpty { + UserStore.shared.fetchPlannedWorkouts() + } + + if needsUpdating { + self.isUpdating = true + dataStore.fetchAllData(completion: { + DispatchQueue.main.async { + guard let allNSFWVideos = dataStore.allNSFWVideos else { self.isUpdating = false - - playRandomVideo() + return } - + self.nsfwVideos = allNSFWVideos self.isUpdating = false - }) - } + self.needsUpdating = false + + playRandomVideo() + } + }) } } } diff --git a/docs/stabilization_steps_1_5.md b/docs/stabilization_steps_1_5.md new file mode 100644 index 0000000..7a8c0de --- /dev/null +++ b/docs/stabilization_steps_1_5.md @@ -0,0 +1,35 @@ +# Steps 1-5 Stabilization Deliverables + +This repo now includes: + +1. `SharedCore` Swift package with cross-platform utilities and dedicated test targets: + - `SharedCoreiOSTests` + - `SharedCoreWatchOSTests` + - `SharedCoreTVOSTests` +2. Auth token lifecycle protections in shared iOS/tvOS user/network code: + - token sanitization + - JWT expiry checks + - proactive refresh trigger when near expiry + - forced logout on `401`/`403` +3. Smoke scripts in `scripts/smoke/` for iOS/watchOS/tvOS plus package tests. +4. Runtime logging hooks (structured `os.Logger`) in network/auth/datastore/watch bridge/workout paths. +5. CI workflow `.github/workflows/apple-platform-ci.yml` that runs the smoke suite. +6. Build warning cleanup: + - disabled AppIntents metadata extraction for iOS/watchOS/tvOS targets that do not link `AppIntents`. + +## SharedCore wiring + +- `SharedCore` is linked as a local Swift package product to: + - `Werkout_ios` (iOS) + - `Werkout_watch Watch App` (watchOS) + - `WekoutThotViewer` (tvOS) +- Shared helpers are actively used in app code: + - `TokenSecurity` now drives token sanitization/expiry/rotation checks in `UserStore`. + - `RuntimeReporter` now handles network/auth/datastore runtime error reporting. + +## Local commands + +```bash +./scripts/ci/scan_tokens.sh +./scripts/smoke/smoke_all.sh +``` diff --git a/docs/step10_reliability_round2.md b/docs/step10_reliability_round2.md new file mode 100644 index 0000000..a139230 --- /dev/null +++ b/docs/step10_reliability_round2.md @@ -0,0 +1,39 @@ +# Step 10 Reliability Round 2 + +## Coverage + +- Reviewed and patched: + - `iphone/Werkout_ios/BridgeModule+Watch.swift` + - `iphone/Werkout_ios/BridgeModule+WorkoutActions.swift` + - `iphone/Werkout_ios/CurrentWorkoutInfo.swift` + - `iphone/Werkout_watch Watch App/WatchDelegate.swift` + - `iphone/Werkout_ios/UserStore.swift` +- Validation: + - `./scripts/smoke/smoke_all.sh` + - iOS/tvOS analyzer passes + +## Fixes + +1. Main-thread state safety for watch session callbacks + - Wrapped `didReceiveMessageData` action handling and `activationDidCompleteWith` state transitions on main. + - Prevents shared bridge state (`@Published` workout/watch properties + queued message mutations) from being changed off-main. + +2. Removed dead closure path that could retain `BridgeModule` + - Removed unused `CurrentWorkoutInfo.complete` closure and its assignment in `BridgeModule+WorkoutActions.start(workout:)`. + - Reduces lifecycle risk and removes dead behavior. + +3. HealthKit authorization crash hardening on watch launch + - Replaced force-unwrapped quantity types with guarded optional binding in `WatchDelegate`. + - Logs and exits cleanly if required HealthKit quantity types are unavailable. + +4. Cross-target notification compile stability + - Updated `UserStore.logout` to post `Notification.Name("CreatedNewWorkout")` directly. + - Avoids reliance on an iOS-only extension file when `UserStore` is compiled in tvOS target. + +## Validation + +- Smoke suite passed: + - token scan + - SharedCore tests + - iOS/watchOS/tvOS builds + diff --git a/docs/step11_watch_regression_and_architecture.md b/docs/step11_watch_regression_and_architecture.md new file mode 100644 index 0000000..9e9a09d --- /dev/null +++ b/docs/step11_watch_regression_and_architecture.md @@ -0,0 +1,40 @@ +# Step 11 Watch Regression + Architecture Cleanup + +## Scope + +Executed in requested order: + +1. `#2` Focused watch/phone disconnect-reconnect regression coverage. +2. `#3` Architecture cleanup to reduce shared cross-target coupling. + +## #2 Regression Work + +- Added shared queue primitive: + - `SharedCore/Sources/SharedCore/BoundedFIFOQueue.swift` +- Added regression tests for disconnect/reconnect replay and overflow behavior: + - `SharedCore/Tests/SharedCoreWatchOSTests/BoundedFIFOQueueTests.swift` +- Wired iOS watch bridge queueing to shared queue: + - `iphone/Werkout_ios/BridgeModule.swift` + - `iphone/Werkout_ios/BridgeModule+Watch.swift` +- Wired watch sender queueing to shared queue: + - `iphone/Werkout_watch Watch App/WatchMainViewModel+WCSessionDelegate.swift` + +## #3 Architecture Cleanup + +- Replaced ad-hoc notification wiring with a shared typed notification constant: + - `SharedCore/Sources/SharedCore/AppNotifications.swift` +- Updated consumers to use shared constant: + - `iphone/Werkout_ios/UserStore.swift` + - `iphone/Werkout_ios/Werkout_iosApp.swift` + - `iphone/Werkout_ios/Views/CreateWorkout/CreateViewModels.swift` + - `iphone/Werkout_ios/Views/AllWorkouts/AllWorkoutsView.swift` +- Removed iOS-only notification extension that created cross-target coupling: + - `iphone/Werkout_ios/Extensions.swift` + +## Validation + +- `./scripts/smoke/smoke_all.sh` passed: + - token scan + - SharedCore tests (including new queue tests) + - iOS/watchOS/tvOS builds + diff --git a/docs/step12_hardware_disconnect_pass.md b/docs/step12_hardware_disconnect_pass.md new file mode 100644 index 0000000..7b0dd70 --- /dev/null +++ b/docs/step12_hardware_disconnect_pass.md @@ -0,0 +1,53 @@ +# Step 12 Hardware Disconnect/Reconnect Pass + +## Date + +- 2026-02-11 (UTC timestamp used during check: `2026-02-11T18:45:42Z`) + +## Coverage Attempted + +- Device inventory: + - `xcrun xcdevice list` +- iOS destination eligibility: + - `xcodebuild -project iphone/Werkout_ios.xcodeproj -scheme 'Werkout_ios' -showdestinations` +- watchOS destination eligibility: + - `xcodebuild -project iphone/Werkout_ios.xcodeproj -scheme 'Werkout_watch Watch App' -showdestinations` + +## Findings + +1. Hardware watch pass could not be executed from this environment. + - Evidence: + - watch scheme showed simulator destinations only under available destinations. + - the only physical watch destination was ineligible: + - `Hollie’s Apple Watch` with error indicating watchOS version mismatch/unknown against deployment target `watchOS 9.4`. +2. Physical iOS devices are present/eligible. + - Evidence: + - available iOS hardware destinations included: + - `Gary Tartt’s iPad` + - `ihollieb` + - `Peeeeeeeeellll` +3. Physical watch pairing/connectivity state is the blocking dependency for true hardware disconnect/reconnect validation. + +## What Was Added For Immediate Re-Run + +- Hardware run script: + - `scripts/hardware/watch_disconnect_hardware_pass.sh` +- Script behavior: + - validates eligible physical iOS + watch destinations + - performs hardware-targeted preflight builds (`CODE_SIGNING_ALLOWED=NO`) + - emits manual disconnect/reconnect checklist and pass criteria artifact + +## Manual Hardware Scenario (Pending Once Watch Is Eligible) + +1. Start iOS + watch apps on physical paired devices. +2. Start workout from iOS. +3. Break transport (Bluetooth off on iPhone or Airplane Mode on watch) for ~30s. +4. While disconnected, trigger multiple state changes on iOS. +5. Reconnect transport and verify watch converges to latest state without crash/replay loop. +6. Repeat for two disconnect/reconnect cycles. + +## Current Status + +- Blocked on eligible physical watch destination. +- Queue/replay behavior is covered by automated tests in `SharedCore/Tests/SharedCoreWatchOSTests/BoundedFIFOQueueTests.swift`, but physical transport behavior remains unverified until watch eligibility is fixed. +- In this Codex shell, scripted destination probing/building is additionally constrained by sandboxed write restrictions to Xcode/SwiftPM cache paths; manual run on your local terminal is expected once watch hardware is eligible. diff --git a/docs/step6_audit_round1.md b/docs/step6_audit_round1.md new file mode 100644 index 0000000..b91c1dd --- /dev/null +++ b/docs/step6_audit_round1.md @@ -0,0 +1,55 @@ +# Step 6 Audit Round 1 (P0/P1) + +## Coverage + +- Reviewed high-risk auth/session/network/watch files: + - `iphone/Werkout_ios/UserStore.swift` + - `iphone/Werkout_ios/Network/Network.swift` + - `iphone/Werkout_ios/BridgeModule+Watch.swift` + - `iphone/Werkout_watch Watch App/WatchMainViewModel.swift` + - `iphone/Werkout_watch Watch App/WatchMainViewModel+WCSessionDelegate.swift` + - `iphone/Werkout_ios/HealthKitHelper.swift` + - `iphone/Werkout_ios/CurrentWorkoutInfo.swift` +- Ran: + - `./scripts/smoke/smoke_all.sh` +- Added/ran regression tests in `SharedCore` for token lifecycle and watch payload validation. + +## Findings And Fixes + +1. `P1` Watch command loss during activation + - Evidence: `iphone/Werkout_watch Watch App/WatchMainViewModel+WCSessionDelegate.swift:40` + - Problem: payloads were dropped when `WCSession` was not activated. + - Fix: added bounded queue (`maxQueuedPayloads`), enqueue on inactive session, flush on activation. + +2. `P1` Silent/unsafe watch payload decode failures + - Evidence: `iphone/Werkout_ios/BridgeModule+Watch.swift:73` + - Evidence: `iphone/Werkout_watch Watch App/WatchMainViewModel.swift:74` + - Problem: `try?` decode silently ignored malformed payloads. + - Fix: added shared `WatchPayloadValidation` with size checks and structured decode failures; both decode paths now reject+log bad payloads. + +3. `P1` Auth token normalization gap for prefixed tokens + - Evidence: `SharedCore/Sources/SharedCore/TokenSecurity.swift:24` + - Problem: `"Token ..."` / `"Bearer ..."` values were not normalized. + - Fix: normalize known auth prefixes and reject bare prefix-only strings. + +4. `P1` Network reliability/threading risk + - Evidence: `iphone/Werkout_ios/Network/Network.swift:12` + - Problem: infinite request timeouts and completion handlers returning on background threads. + - Fix: finite timeout (`30s`) and centralized main-thread completion delivery. + +5. `P1` HealthKit helper shared mutable-state race + - Evidence: `iphone/Werkout_ios/HealthKitHelper.swift:20` + - Problem: mutable cross-request state (`completion`, counters, shared result object) could race and mis-route results. + - Fix: per-request aggregation via `DispatchGroup`, single UUID query (`limit: 1`), thread-safe aggregation queue, structured runtime logging. + +6. `P2` Workout order inconsistency across helpers + - Evidence: `iphone/Werkout_ios/CurrentWorkoutInfo.swift:24` + - Problem: some paths used unsorted `workout.supersets` while others used sorted supersets. + - Fix: unified core navigation/lookup paths on sorted `superset` accessor and corrected bounds check. + +## Validation + +- Smoke suite passed after fixes: + - token scan + - SharedCore tests (including new regression tests) + - iOS/watchOS/tvOS builds diff --git a/docs/step7_ui_accessibility_round1.md b/docs/step7_ui_accessibility_round1.md new file mode 100644 index 0000000..278efbd --- /dev/null +++ b/docs/step7_ui_accessibility_round1.md @@ -0,0 +1,62 @@ +# Step 7 UI/State/Accessibility Round 1 + +## Coverage + +- Reviewed and patched high-traffic iOS/watchOS UI paths: + - workout browsing, planned workouts, workout detail, create-workout flow, login, watch main view +- Ran validation: + - `./scripts/smoke/smoke_all.sh` + +## Fixes Applied + +1. Create workout state consistency and duplicate-submit prevention + - `iphone/Werkout_ios/Views/CreateWorkout/CreateViewModels.swift` + - Added `isUploading` gate, title trimming validation, shared `WorkoutValidation` integration. + +2. Weight stepper logic bug + - `iphone/Werkout_ios/Views/CreateWorkout/CreateViewModels.swift` + - Fixed weight decrement mismatch (`-15` to `-5`) to match increment behavior. + +3. Create-workout UX cleanup and accessibility + - `iphone/Werkout_ios/Views/CreateWorkout/CreateWorkoutMainView.swift` + - Replaced visible sentinel row with hidden spacer, disabled upload button while uploading, added button labels/hints. + +4. Superset editing accessibility/state + - `iphone/Werkout_ios/subview/CreateWorkoutSupersetView.swift` + - Avoided sheet toggle race by setting `showAddExercise = true`; added accessibility labels/hints. + +5. Exercise action controls accessibility + - `iphone/Werkout_ios/Views/CreateWorkout/CreateExerciseActionsView.swift` + - Added accessibility labels to steppers and icon-only controls. + +6. Workout list/planned list row accessibility + - `iphone/Werkout_ios/Views/AllWorkouts/AllWorkoutsListView.swift` + - `iphone/Werkout_ios/subview/PlannedWorkoutView.swift` + - Converted tap-only rows to plain `Button`s for VoiceOver/focus reliability. + +7. Workout detail list ordering/scroll stability + - `iphone/Werkout_ios/Views/WorkoutDetail/ExerciseListView.swift` + - Aligned list ordering with sorted superset order and introduced stable row IDs for consistent scroll targeting. + +8. Workout detail control accessibility + progress text guard + - `iphone/Werkout_ios/Views/WorkoutDetail/WorkoutDetailView.swift` + - Added accessibility labels to icon-only controls and avoided negative progress display. + +9. Login form input/accessibility improvements + - `iphone/Werkout_ios/Views/Login/LoginView.swift` + - Added keyboard/input autocorrection settings and accessibility labels/hints. + +10. HealthKit auth safety/logging in all-workouts screen + - `iphone/Werkout_ios/Views/AllWorkouts/AllWorkoutsView.swift` + - Removed force-unwrapped HK types and added runtime warning on failed authorization. + +11. watchOS no-workout screen and accessibility polish + - `iphone/Werkout_watch Watch App/MainWatchView.swift` + - Replaced emoji placeholder with clear status text/icon and added combined accessibility labels. + +## Validation + +- Smoke suite passed after fixes: + - token scan + - SharedCore tests + - iOS/watchOS/tvOS builds diff --git a/docs/step8_performance_state_round1.md b/docs/step8_performance_state_round1.md new file mode 100644 index 0000000..924668c --- /dev/null +++ b/docs/step8_performance_state_round1.md @@ -0,0 +1,48 @@ +# Step 8 Performance/State Round 1 + +## Coverage + +- Reviewed and patched: + - `iphone/Werkout_ios/DataStore.swift` + - `iphone/Werkout_ios/Views/WorkoutDetail/WorkoutDetailView.swift` + - `iphone/Werkout_ios/Views/WorkoutDetail/ExerciseListView.swift` + - `WekoutThotViewer/WekoutThotViewer/ContentView.swift` + - `iphone/Werkout_ios/BridgeModule.swift` + - `iphone/Werkout_ios/BridgeModule+Watch.swift` + - `iphone/Werkout_ios/BridgeModule+Timer.swift` + - `iphone/Werkout_watch Watch App/WatchWorkout.swift` +- Validation run: + - `./scripts/smoke/smoke_all.sh` + +## Fixes + +1. Coalesced concurrent `fetchAllData` requests + - `DataStore` now queues completion handlers while a fetch is active to prevent overlapping network fan-out and state churn. + +2. Reduced AVPlayer churn in iOS workout detail + - Reuses current player for same URL by seeking to start instead of recreating `AVPlayer` each time exercise/video updates. + +3. Reduced AVPlayer churn in iOS exercise preview sheet + - Added preview URL tracking; same URL now replays without allocating a new player. + +4. Reduced AVPlayer churn in tvOS content loop + - Same URL replay now seeks/replays existing player rather than recreating. + +5. Capped queued watch messages on iOS bridge + - Added queue cap to prevent unbounded growth while watch is disconnected. + +6. Added queue fallback for send failures + - Failed reachable send now re-queues payload for later delivery. + +7. Improved timer power behavior + - Added timer tolerance to workout/exercise timers. + +8. Fixed watch heart-rate loop early-return behavior + - Non-heart sample types now `continue` instead of exiting handler early. + +## Validation + +- Smoke suite passed: + - token scan + - SharedCore tests + - iOS/watchOS/tvOS builds diff --git a/docs/step9_memory_lifecycle_round1.md b/docs/step9_memory_lifecycle_round1.md new file mode 100644 index 0000000..83f1755 --- /dev/null +++ b/docs/step9_memory_lifecycle_round1.md @@ -0,0 +1,39 @@ +# Step 9 Memory/Lifecycle Round 1 + +## Coverage + +- Audited lifecycle cleanup and resource teardown in: + - `iphone/Werkout_ios/subview/PlayerUIView.swift` + - `iphone/Werkout_ios/Views/WorkoutDetail/WorkoutDetailView.swift` + - `iphone/Werkout_ios/Views/WorkoutDetail/ExerciseListView.swift` + - `iphone/Werkout_ios/Views/ExternalWorkoutDetailView.swift` + - `iphone/Werkout_ios/subview/AllExerciseView.swift` + - `iphone/Werkout_ios/Views/CreateWorkout/CreateExerciseActionsView.swift` + - `iphone/Werkout_ios/AudioEngine.swift` + - `WekoutThotViewer/WekoutThotViewer/ContentView.swift` +- Validation: + - `./scripts/smoke/smoke_all.sh` + +## Fixes + +1. Player view teardown safety + - `PlayerView` now pauses previous players when swapping and performs explicit teardown in `dismantleUIView`. + +2. Workout detail closure retention risk + - Clears `BridgeModule.shared.completedWorkout` on `WorkoutDetailView` disappear. + +3. Player pause on dismiss across views + - Added `onDisappear` player pause in workout detail exercise list, create-exercise preview, all-exercise preview, external display, and tvOS content view. + +4. External display player reuse + - Added URL tracking + replay path to avoid reallocating AVPlayer when URL is unchanged. + +5. Audio playback resource churn + - Stops existing players before replacement and logs failures via `RuntimeReporter` instead of `print`. + +## Validation + +- Smoke suite passed: + - token scan + - SharedCore tests + - iOS/watchOS/tvOS builds diff --git a/iphone/Werkout-ios-Info.plist b/iphone/Werkout-ios-Info.plist new file mode 100644 index 0000000..e98ba81 --- /dev/null +++ b/iphone/Werkout-ios-Info.plist @@ -0,0 +1,28 @@ + + + + + ITSAppUsesNonExemptEncryption + + NSAppTransportSecurity + + NSExceptionDomains + + 127.0.0.1 + + NSExceptionAllowsInsecureHTTPLoads + + + localhost + + NSExceptionAllowsInsecureHTTPLoads + + + + + UIBackgroundModes + + audio + + + diff --git a/iphone/Werkout_ios.xcodeproj/project.pbxproj b/iphone/Werkout_ios.xcodeproj/project.pbxproj index ca95336..3a57402 100644 --- a/iphone/Werkout_ios.xcodeproj/project.pbxproj +++ b/iphone/Werkout_ios.xcodeproj/project.pbxproj @@ -19,6 +19,8 @@ 1CF65A9D2A452D290042FFBD /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1CF65A9C2A452D290042FFBD /* Preview Assets.xcassets */; }; 1CF65AA12A452D290042FFBD /* Werkout_watch Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 1CF65A932A452D270042FFBD /* Werkout_watch Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 1CF65AB62A4532940042FFBD /* WatchMainViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65AB52A4532940042FFBD /* WatchMainViewModel.swift */; }; + D00100012E00000100000001 /* SharedCore in Frameworks */ = {isa = PBXBuildFile; productRef = D00100012E00000100000004 /* SharedCore */; }; + D00100012E00000100000002 /* SharedCore in Frameworks */ = {isa = PBXBuildFile; productRef = D00100012E00000100000004 /* SharedCore */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -108,6 +110,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + D00100012E00000100000001 /* SharedCore in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -115,6 +118,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + D00100012E00000100000002 /* SharedCore in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -200,6 +204,7 @@ ); name = Werkout_ios; packageProductDependencies = ( + D00100012E00000100000004 /* SharedCore */, ); productName = Werkout_ios; productReference = 1CF65A222A3972840042FFBD /* Werkout_ios.app */; @@ -218,6 +223,9 @@ dependencies = ( ); name = "Werkout_watch Watch App"; + packageProductDependencies = ( + D00100012E00000100000004 /* SharedCore */, + ); productName = "Werkout_watch Watch App"; productReference = 1CF65A932A452D270042FFBD /* Werkout_watch Watch App.app */; productType = "com.apple.product-type.application"; @@ -250,6 +258,7 @@ ); mainGroup = 1CF65A192A3972840042FFBD; packageReferences = ( + D00100012E00000100000003 /* XCLocalSwiftPackageReference "../SharedCore" */, ); productRefGroup = 1CF65A232A3972840042FFBD /* Products */; projectDirPath = ""; @@ -314,6 +323,13 @@ }; /* End PBXTargetDependency section */ +/* Begin XCLocalSwiftPackageReference section */ + D00100012E00000100000003 /* XCLocalSwiftPackageReference "../SharedCore" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../SharedCore; + }; +/* End XCLocalSwiftPackageReference section */ + /* Begin XCBuildConfiguration section */ 1CF65A342A3972850042FFBD /* Debug */ = { isa = XCBuildConfiguration; @@ -442,12 +458,14 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Werkout_ios/Preview Content\""; DEVELOPMENT_TEAM = V3PF3M6B6U; + ENABLE_APP_INTENTS_METADATA_GENERATION = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = "Werkout_ios/Resources/Werkout-ios-Info.plist"; - INFOPLIST_KEY_NSHealthShareUsageDescription = "Read your heart reate"; - INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Read your heart reate"; + EXCLUDED_SOURCE_FILE_NAMES = "Werkout_ios/Resources/Werkout-ios-Info.plist"; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Werkout-ios-Info.plist"; + INFOPLIST_KEY_NSHealthShareUsageDescription = "Read your heart rate"; + INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Read your heart rate"; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; @@ -486,12 +504,14 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Werkout_ios/Preview Content\""; DEVELOPMENT_TEAM = V3PF3M6B6U; + ENABLE_APP_INTENTS_METADATA_GENERATION = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = "Werkout_ios/Resources/Werkout-ios-Info.plist"; - INFOPLIST_KEY_NSHealthShareUsageDescription = "Read your heart reate"; - INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Read your heart reate"; + EXCLUDED_SOURCE_FILE_NAMES = "Werkout_ios/Resources/Werkout-ios-Info.plist"; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Werkout-ios-Info.plist"; + INFOPLIST_KEY_NSHealthShareUsageDescription = "Read your heart rate"; + INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Read your heart rate"; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; @@ -529,12 +549,13 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Werkout_watch Watch App/Preview Content\""; DEVELOPMENT_TEAM = V3PF3M6B6U; + ENABLE_APP_INTENTS_METADATA_GENERATION = NO; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Werkout-watch-Watch-App-Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = Werkout; - INFOPLIST_KEY_NSHealthShareUsageDescription = "Read your heart reate"; - INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Read your heart reate"; + INFOPLIST_KEY_NSHealthShareUsageDescription = "Read your heart rate"; + INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Read your heart rate"; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = "com.t-t.Werkout-ios"; LD_RUNPATH_SEARCH_PATHS = ( @@ -564,12 +585,13 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Werkout_watch Watch App/Preview Content\""; DEVELOPMENT_TEAM = V3PF3M6B6U; + ENABLE_APP_INTENTS_METADATA_GENERATION = NO; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Werkout-watch-Watch-App-Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = Werkout; - INFOPLIST_KEY_NSHealthShareUsageDescription = "Read your heart reate"; - INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Read your heart reate"; + INFOPLIST_KEY_NSHealthShareUsageDescription = "Read your heart rate"; + INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Read your heart rate"; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = "com.t-t.Werkout-ios"; LD_RUNPATH_SEARCH_PATHS = ( @@ -591,6 +613,14 @@ }; /* End XCBuildConfiguration section */ +/* Begin XCSwiftPackageProductDependency section */ + D00100012E00000100000004 /* SharedCore */ = { + isa = XCSwiftPackageProductDependency; + package = D00100012E00000100000003 /* XCLocalSwiftPackageReference "../SharedCore" */; + productName = SharedCore; + }; +/* End XCSwiftPackageProductDependency section */ + /* Begin XCConfigurationList section */ 1CF65A1D2A3972840042FFBD /* Build configuration list for PBXProject "Werkout_ios" */ = { isa = XCConfigurationList; diff --git a/iphone/Werkout_ios/APIModels/Exercise.swift b/iphone/Werkout_ios/APIModels/Exercise.swift index 4bca2a5..7637141 100644 --- a/iphone/Werkout_ios/APIModels/Exercise.swift +++ b/iphone/Werkout_ios/APIModels/Exercise.swift @@ -79,8 +79,9 @@ struct Exercise: Identifiable, Codable, Equatable, Hashable { } var extName: String { - if side != nil && side!.count > 0 { - var returnString = name + " - " + side! + if let side = side, + side.isEmpty == false { + var returnString = name + " - " + side returnString = returnString.replacingOccurrences(of: "_", with: " ") return returnString.capitalized } diff --git a/iphone/Werkout_ios/APIModels/PlannedWorkout.swift b/iphone/Werkout_ios/APIModels/PlannedWorkout.swift index aecc8b4..e196f51 100644 --- a/iphone/Werkout_ios/APIModels/PlannedWorkout.swift +++ b/iphone/Werkout_ios/APIModels/PlannedWorkout.swift @@ -19,11 +19,15 @@ struct PlannedWorkout: Codable { case onDate = "on_date" case workout } + + private static let plannedDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter + }() var date: Date? { - let df = DateFormatter() - df.dateFormat = "yyyy-MM-dd" - df.locale = Locale(identifier: "en_US_POSIX") - return df.date(from: self.onDate) + Self.plannedDateFormatter.date(from: onDate) } } diff --git a/iphone/Werkout_ios/APIModels/Workout.swift b/iphone/Werkout_ios/APIModels/Workout.swift index 2d88ac8..c736b36 100644 --- a/iphone/Werkout_ios/APIModels/Workout.swift +++ b/iphone/Werkout_ios/APIModels/Workout.swift @@ -31,6 +31,37 @@ struct Workout: Codable, Identifiable, Equatable { case estimatedTime = "estimated_time" case allSupersetExecercise = "all_superset_exercise" } + + private static let createdAtFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssX" + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter + }() + + init(id: Int, + name: String, + description: String? = nil, + supersets: [Superset]? = nil, + registeredUser: RegisteredUser? = nil, + muscles: [String]? = nil, + equipment: [String]? = nil, + exercise_count: Int? = nil, + createdAt: Date? = nil, + estimatedTime: Double? = nil, + allSupersetExecercise: [SupersetExercise]? = nil) { + self.id = id + self.name = name + self.description = description + self.supersets = supersets + self.registeredUser = registeredUser + self.muscles = muscles + self.equipment = equipment + self.exercise_count = exercise_count + self.createdAt = createdAt + self.estimatedTime = estimatedTime + self.allSupersetExecercise = allSupersetExecercise + } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -46,15 +77,7 @@ struct Workout: Codable, Identifiable, Equatable { self.exercise_count = try container.decodeIfPresent(Int.self, forKey: .exercise_count) let createdAtStr = try container.decodeIfPresent(String.self, forKey: .createdAt) - if let createdAtStr = createdAtStr { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" - let date = formatter.date(from: createdAtStr) - self.createdAt = date - } else { - self.createdAt = nil - } + self.createdAt = createdAtStr.flatMap { Self.createdAtFormatter.date(from: $0) } self.estimatedTime = try container.decodeIfPresent(Double.self, forKey: .estimatedTime) diff --git a/iphone/Werkout_ios/AudioEngine.swift b/iphone/Werkout_ios/AudioEngine.swift index 25eebf2..ab6402a 100644 --- a/iphone/Werkout_ios/AudioEngine.swift +++ b/iphone/Werkout_ios/AudioEngine.swift @@ -7,10 +7,12 @@ import AVKit import AVFoundation +import SharedCore class AudioEngine { static let shared = AudioEngine() private init() { } + private let runtimeReporter = RuntimeReporter.shared var audioPlayer: AVAudioPlayer? var avPlayer: AVPlayer? @@ -24,10 +26,11 @@ class AudioEngine { options: [.mixWithOthers]) try AVAudioSession.sharedInstance().setActive(true) + avPlayer?.pause() avPlayer = AVPlayer(playerItem: playerItem) avPlayer?.play() } catch { - print("ERROR") + runtimeReporter.recordError("Failed playing remote audio", metadata: ["error": error.localizedDescription]) } #endif } @@ -41,10 +44,11 @@ class AudioEngine { options: [.mixWithOthers]) try AVAudioSession.sharedInstance().setActive(true) + audioPlayer?.stop() audioPlayer = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: path)) audioPlayer?.play() } catch { - print("ERROR") + runtimeReporter.recordError("Failed playing short beep", metadata: ["error": error.localizedDescription]) } } #endif @@ -59,10 +63,11 @@ class AudioEngine { options: [.mixWithOthers]) try AVAudioSession.sharedInstance().setActive(true) + audioPlayer?.stop() audioPlayer = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: path)) audioPlayer?.play() } catch { - print("ERROR") + runtimeReporter.recordError("Failed playing long beep", metadata: ["error": error.localizedDescription]) } } #endif diff --git a/iphone/Werkout_ios/BridgeModule+Timer.swift b/iphone/Werkout_ios/BridgeModule+Timer.swift index 7b17e82..a42637a 100644 --- a/iphone/Werkout_ios/BridgeModule+Timer.swift +++ b/iphone/Werkout_ios/BridgeModule+Timer.swift @@ -11,10 +11,12 @@ extension BridgeModule { func startWorkoutTimer() { currentWorkoutRunTimer?.invalidate() currentWorkoutRunTimer = nil - currentWorkoutRunTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { timer in + currentWorkoutRunTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { [weak self] _ in + guard let self else { return } self.currentWorkoutRunTimeInSeconds += 1 self.sendCurrentExerciseToWatch() }) + currentWorkoutRunTimer?.tolerance = 0.1 currentWorkoutRunTimer?.fire() } @@ -28,6 +30,7 @@ extension BridgeModule { selector: #selector(self.updateCurrentExerciseTimer), userInfo: nil, repeats: true) + self.currentExerciseTimer?.tolerance = 0.1 self.currentExerciseTimer?.fire() } } diff --git a/iphone/Werkout_ios/BridgeModule+Watch.swift b/iphone/Werkout_ios/BridgeModule+Watch.swift index bf7a553..96d81dd 100644 --- a/iphone/Werkout_ios/BridgeModule+Watch.swift +++ b/iphone/Werkout_ios/BridgeModule+Watch.swift @@ -9,27 +9,63 @@ import Foundation import WatchConnectivity import AVFoundation import HealthKit +import os +import SharedCore + +private let watchBridgeLogger = Logger(subsystem: "com.werkout.ios", category: "watch-bridge") extension BridgeModule: WCSessionDelegate { + private func send(action: Action) { + do { + let data = try JSONEncoder().encode(action) + send(data) + } catch { + watchBridgeLogger.error("Failed to encode watch action: \(error.localizedDescription, privacy: .public)") + } + } + + private func sendInExerciseAction(_ model: WatchPackageModel) { + do { + let action = PhoneToWatchActions.inExercise(model) + let payload = try JSONEncoder().encode(action) + guard payload != lastSentInExercisePayload else { + return + } + lastSentInExercisePayload = payload + send(payload) + } catch { + watchBridgeLogger.error("Failed to encode in-exercise watch action: \(error.localizedDescription, privacy: .public)") + } + } + + private func flushQueuedWatchMessages() { + let queuedMessages = queuedWatchMessages.dequeueAll() + guard queuedMessages.isEmpty == false else { + return + } + queuedMessages.forEach { send($0) } + } + + private func enqueueWatchMessage(_ data: Data) { + let droppedCount = queuedWatchMessages.enqueue(data) + if droppedCount > 0 { + watchBridgeLogger.warning("Dropping oldest queued watch message to enforce queue cap") + } + } + func sendResetToWatch() { - let watchModel = PhoneToWatchActions.reset - let data = try! JSONEncoder().encode(watchModel) - send(data) -// self.session.transferUserInfo(["package": data]) + lastSentInExercisePayload = nil + send(action: PhoneToWatchActions.reset) } func sendStartWorkoutToWatch() { - let model = PhoneToWatchActions.startWorkout - let data = try! JSONEncoder().encode(model) - send(data) -// self.session.transferUserInfo(["package": data]) + lastSentInExercisePayload = nil + send(action: PhoneToWatchActions.startWorkout) } func sendWorkoutCompleteToWatch() { - let model = PhoneToWatchActions.endWorkout - let data = try! JSONEncoder().encode(model) - send(data) -// self.session.transferUserInfo(["package": data]) + lastSentInExercisePayload = nil + send(action: PhoneToWatchActions.endWorkout) } func sendCurrentExerciseToWatch() { @@ -40,9 +76,7 @@ extension BridgeModule: WCSessionDelegate { currentExerciseID: currentExercise.id ?? -1, currentTimeLeft: currentExerciseTimeLeft, workoutStartDate: workoutStartDate ?? Date()) - let model = PhoneToWatchActions.inExercise(watchModel) - let data = try! JSONEncoder().encode(model) - send(data) + sendInExerciseAction(watchModel) } else { if let currentExercise = currentWorkoutInfo.currentExercise, let reps = currentExercise.reps, @@ -51,33 +85,38 @@ extension BridgeModule: WCSessionDelegate { // if not a timer we need to set the watch display with number of reps // if timer it will set when timer updates let watchModel = WatchPackageModel(currentExerciseName: currentExercise.exercise.name, currentExerciseID: currentExercise.id ?? -1, currentTimeLeft: reps, workoutStartDate: self.workoutStartDate ?? Date()) - let model = PhoneToWatchActions.inExercise(watchModel) - let data = try! JSONEncoder().encode(model) - self.send(data) + self.sendInExerciseAction(watchModel) } } } func session(_ session: WCSession, didReceiveMessageData messageData: Data) { - if let model = try? JSONDecoder().decode(WatchActions.self, from: messageData) { - switch model { - case .nextExercise: - nextExercise() - AudioEngine.shared.playFinished() - case .workoutComplete(let data): - DispatchQueue.main.async { - let model = try! JSONDecoder().decode(WatchFinishWorkoutModel.self, from: data) - self.healthKitUUID = model.healthKitUUID + do { + let model = try WatchPayloadValidation.decode(WatchActions.self, from: messageData) + DispatchQueue.main.async { + switch model { + case .nextExercise: + self.nextExercise() + AudioEngine.shared.playFinished() + case .workoutComplete(let data): + do { + let finishModel = try WatchPayloadValidation.decode(WatchFinishWorkoutModel.self, from: data) + self.healthKitUUID = finishModel.healthKitUUID + } catch { + watchBridgeLogger.error("Rejected watch completion payload: \(error.localizedDescription, privacy: .public)") + } + case .restartExercise: + self.restartExercise() + case .previousExercise: + self.previousExercise() + case .stopWorkout: + self.completeWorkout() + case .pauseWorkout: + self.pauseWorkout() } - case .restartExercise: - restartExercise() - case .previousExercise: - previousExercise() - case .stopWorkout: - completeWorkout() - case .pauseWorkout: - pauseWorkout() } + } catch { + watchBridgeLogger.error("Rejected WatchActions payload: \(error.localizedDescription, privacy: .public)") } } @@ -85,25 +124,30 @@ extension BridgeModule: WCSessionDelegate { func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { - switch activationState { - case .notActivated: - print("notActivated") - case .inactive: - print("inactive") - case .activated: - print("activated") + DispatchQueue.main.async { + switch activationState { + case .notActivated: + watchBridgeLogger.info("Watch session notActivated") + case .inactive: + watchBridgeLogger.info("Watch session inactive") + case .activated: + watchBridgeLogger.info("Watch session activated") + self.flushQueuedWatchMessages() #if os(iOS) - let workoutConfiguration = HKWorkoutConfiguration() - workoutConfiguration.activityType = .functionalStrengthTraining - workoutConfiguration.locationType = .indoor - if WCSession.isSupported(), session.activationState == .activated, session.isWatchAppInstalled { - HKHealthStore().startWatchApp(with: workoutConfiguration, completion: { (success, error) in - print(error.debugDescription) - }) - } + let workoutConfiguration = HKWorkoutConfiguration() + workoutConfiguration.activityType = .functionalStrengthTraining + workoutConfiguration.locationType = .indoor + if WCSession.isSupported(), session.activationState == .activated, session.isWatchAppInstalled { + HKHealthStore().startWatchApp(with: workoutConfiguration, completion: { (success, error) in + if let error = error { + watchBridgeLogger.error("Failed to start watch app: \(error.localizedDescription, privacy: .public)") + } + }) + } #endif - @unknown default: - print("default") + @unknown default: + watchBridgeLogger.error("Unknown WCSession activation state") + } } } #if os(iOS) @@ -116,7 +160,13 @@ extension BridgeModule: WCSessionDelegate { } #endif func send(_ data: Data) { + guard WCSession.isSupported() else { + return + } + guard session.activationState == .activated else { + enqueueWatchMessage(data) + session.activate() return } #if os(iOS) @@ -128,8 +178,15 @@ extension BridgeModule: WCSessionDelegate { return } #endif - session.sendMessageData(data, replyHandler: nil) { error in - print("Cannot send message: \(String(describing: error))") + if session.isReachable { + session.sendMessageData(data, replyHandler: nil) { error in + watchBridgeLogger.error("Cannot send watch message: \(error.localizedDescription, privacy: .public)") + DispatchQueue.main.async { + self.enqueueWatchMessage(data) + } + } + } else { + session.transferUserInfo(["package": data]) } } } diff --git a/iphone/Werkout_ios/BridgeModule+WorkoutActions.swift b/iphone/Werkout_ios/BridgeModule+WorkoutActions.swift index 6d70370..75109c3 100644 --- a/iphone/Werkout_ios/BridgeModule+WorkoutActions.swift +++ b/iphone/Werkout_ios/BridgeModule+WorkoutActions.swift @@ -54,6 +54,9 @@ extension BridgeModule { } func completeWorkout() { + if isInWorkout { + sendWorkoutCompleteToWatch() + } self.currentExerciseTimer?.invalidate() self.currentExerciseTimer = nil self.isInWorkout = false @@ -67,10 +70,7 @@ extension BridgeModule { } func start(workout: Workout) { - currentWorkoutInfo.complete = { - self.completeWorkout() - } - + lastSentInExercisePayload = nil currentWorkoutInfo.start(workout: workout) currentWorkoutRunTimeInSeconds = 0 currentWorkoutRunTimer?.invalidate() @@ -97,9 +97,6 @@ extension BridgeModule { func resetCurrentWorkout() { DispatchQueue.main.async { - if self.isInWorkout { - self.sendWorkoutCompleteToWatch() - } self.currentWorkoutRunTimeInSeconds = 0 self.currentWorkoutRunTimer?.invalidate() self.currentWorkoutRunTimer = nil @@ -109,6 +106,7 @@ extension BridgeModule { self.currentWorkoutRunTimeInSeconds = -1 self.currentWorkoutInfo.reset() + self.lastSentInExercisePayload = nil self.isInWorkout = false self.workoutStartDate = nil diff --git a/iphone/Werkout_ios/BridgeModule.swift b/iphone/Werkout_ios/BridgeModule.swift index facb7b4..594a418 100644 --- a/iphone/Werkout_ios/BridgeModule.swift +++ b/iphone/Werkout_ios/BridgeModule.swift @@ -9,6 +9,7 @@ import Foundation import WatchConnectivity import AVFoundation import HealthKit +import SharedCore enum WatchActions: Codable { case nextExercise @@ -54,4 +55,6 @@ class BridgeModule: NSObject, ObservableObject { @Published var isPaused = false let session: WCSession = WCSession.default + var queuedWatchMessages = BoundedFIFOQueue(maxCount: 100) + var lastSentInExercisePayload: Data? } diff --git a/iphone/Werkout_ios/CurrentWorkoutInfo.swift b/iphone/Werkout_ios/CurrentWorkoutInfo.swift index 7861dd1..4627908 100644 --- a/iphone/Werkout_ios/CurrentWorkoutInfo.swift +++ b/iphone/Werkout_ios/CurrentWorkoutInfo.swift @@ -11,7 +11,6 @@ class CurrentWorkoutInfo { var supersetIndex: Int = 0 var exerciseIndex: Int = -1 var workout: Workout? - var complete: (() -> Void)? var currentRound = 1 var allSupersetExecerciseIndex = 0 @@ -21,8 +20,8 @@ class CurrentWorkoutInfo { } var numberOfRoundsInCurrentSuperSet: Int { - guard let workout = workout else { return -1 } - guard let supersets = workout.supersets else { return -1 } + let supersets = superset + guard supersets.isEmpty == false else { return -1 } if supersetIndex >= supersets.count { return -1 @@ -37,14 +36,15 @@ class CurrentWorkoutInfo { } var currentExercise: SupersetExercise? { - guard let supersets = workout?.supersets else { return nil } + let supersets = superset + guard supersets.isEmpty == false else { return nil } if supersetIndex >= supersets.count { return nil } let superset = supersets[supersetIndex] // will be -1 for a moment while going to previous workout / superset if exerciseIndex < 0 { return nil } - if exerciseIndex > superset.exercises.count { return nil } + if exerciseIndex >= superset.exercises.count { return nil } let exercise = superset.exercises[exerciseIndex] return exercise } @@ -67,8 +67,8 @@ class CurrentWorkoutInfo { // this needs to set stuff for iphone var goToNextExercise: SupersetExercise? { - guard let workout = workout else { return nil } - guard let supersets = workout.supersets else { return nil } + let supersets = superset + guard supersets.isEmpty == false else { return nil } exerciseIndex += 1 let currentSuperSet = supersets[supersetIndex] @@ -95,8 +95,8 @@ class CurrentWorkoutInfo { } var previousExercise: SupersetExercise? { - guard let workout = workout else { return nil } - guard let supersets = workout.supersets else { return nil } + let supersets = superset + guard supersets.isEmpty == false else { return nil } exerciseIndex -= 1 if exerciseIndex < 0 { diff --git a/iphone/Werkout_ios/DataStore.swift b/iphone/Werkout_ios/DataStore.swift index 0101a73..2c0af44 100644 --- a/iphone/Werkout_ios/DataStore.swift +++ b/iphone/Werkout_ios/DataStore.swift @@ -7,6 +7,7 @@ import Foundation import SwiftUI +import SharedCore class DataStore: ObservableObject { enum DataStoreStatus { @@ -15,6 +16,7 @@ class DataStore: ObservableObject { } static let shared = DataStore() + private let runtimeReporter = RuntimeReporter.shared public private(set) var allWorkouts: [Workout]? public private(set) var allMuscles: [Muscle]? @@ -23,8 +25,7 @@ class DataStore: ObservableObject { public private(set) var allNSFWVideos: [NSFWVideo]? @Published public private(set) var status = DataStoreStatus.idle - - private let fetchAllDataQueue = DispatchGroup() + private var pendingFetchCompletions = [() -> Void]() public func randomVideoFor(gender: String) -> String? { return allNSFWVideos?.filter({ @@ -52,7 +53,15 @@ class DataStore: ObservableObject { } public func fetchAllData(completion: @escaping (() -> Void)) { + if status == .loading { + pendingFetchCompletions.append(completion) + runtimeReporter.recordInfo("fetchAllData called while already loading") + return + } + + pendingFetchCompletions = [completion] status = .loading + let fetchAllDataQueue = DispatchGroup() fetchAllDataQueue.enter() fetchAllDataQueue.enter() @@ -62,7 +71,9 @@ class DataStore: ObservableObject { fetchAllDataQueue.notify(queue: .main) { self.status = .idle - completion() + let completions = self.pendingFetchCompletions + self.pendingFetchCompletions.removeAll() + completions.forEach { $0() } } AllWorkoutFetchable().fetch(completion: { result in @@ -70,9 +81,9 @@ class DataStore: ObservableObject { case .success(let model): self.allWorkouts = model case .failure(let error): - print(error) + self.runtimeReporter.recordError("Failed to fetch workouts", metadata: ["error": error.localizedDescription]) } - self.fetchAllDataQueue.leave() + fetchAllDataQueue.leave() }) AllMusclesFetchable().fetch(completion: { result in @@ -82,9 +93,9 @@ class DataStore: ObservableObject { $0.name < $1.name }) case .failure(let error): - print(error) + self.runtimeReporter.recordError("Failed to fetch muscles", metadata: ["error": error.localizedDescription]) } - self.fetchAllDataQueue.leave() + fetchAllDataQueue.leave() }) AllEquipmentFetchable().fetch(completion: { result in @@ -94,9 +105,9 @@ class DataStore: ObservableObject { $0.name < $1.name }) case .failure(let error): - print(error) + self.runtimeReporter.recordError("Failed to fetch equipment", metadata: ["error": error.localizedDescription]) } - self.fetchAllDataQueue.leave() + fetchAllDataQueue.leave() }) AllExerciseFetchable().fetch(completion: { result in @@ -106,9 +117,9 @@ class DataStore: ObservableObject { $0.name < $1.name }) case .failure(let error): - print(error) + self.runtimeReporter.recordError("Failed to fetch exercises", metadata: ["error": error.localizedDescription]) } - self.fetchAllDataQueue.leave() + fetchAllDataQueue.leave() }) AllNSFWVideosFetchable().fetch(completion: { result in @@ -116,9 +127,9 @@ class DataStore: ObservableObject { case .success(let model): self.allNSFWVideos = model case .failure(let error): - print(error) + self.runtimeReporter.recordError("Failed to fetch NSFW videos", metadata: ["error": error.localizedDescription]) } - self.fetchAllDataQueue.leave() + fetchAllDataQueue.leave() }) } diff --git a/iphone/Werkout_ios/Extensions.swift b/iphone/Werkout_ios/Extensions.swift index acccdf7..e2a260e 100644 --- a/iphone/Werkout_ios/Extensions.swift +++ b/iphone/Werkout_ios/Extensions.swift @@ -9,6 +9,67 @@ import Foundation import UIKit import SwiftUI +private enum DateFormatterCache { + static let lock = NSLock() + + static let serverDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssX" + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter + }() + + static let plannedDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter + }() + + static let weekDayFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "EEE" + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter + }() + + static let monthFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "MMM" + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter + }() + + static let dayFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "d" + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter + }() + + static func withLock(_ block: () -> T) -> T { + lock.lock() + defer { lock.unlock() } + return block() + } +} + +private enum DurationFormatterCache { + static let lock = NSLock() + static let formatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.hour, .minute, .second, .nanosecond] + return formatter + }() + + static func string(from seconds: Double, style: DateComponentsFormatter.UnitsStyle) -> String { + lock.lock() + defer { lock.unlock() } + formatter.unitsStyle = style + return formatter.string(from: seconds) ?? "" + } +} + extension Dictionary { func percentEncoded() -> Data? { map { key, value in @@ -34,25 +95,23 @@ extension CharacterSet { extension Date { var timeFormatForUpload: String { - let isoFormatter = DateFormatter() - isoFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssX" - return isoFormatter.string(from: self) + DateFormatterCache.withLock { + DateFormatterCache.serverDateFormatter.string(from: self) + } } } extension String { var dateFromServerDate: Date? { - let df = DateFormatter() - df.dateFormat = "yyyy-MM-dd'T'HH:mm:ssX" - df.locale = Locale(identifier: "en_US_POSIX") - return df.date(from: self) + DateFormatterCache.withLock { + DateFormatterCache.serverDateFormatter.date(from: self) + } } var plannedDate: Date? { - let df = DateFormatter() - df.dateFormat = "yyyy-MM-dd" - df.locale = Locale(identifier: "en_US_POSIX") - return df.date(from: self) + DateFormatterCache.withLock { + DateFormatterCache.plannedDateFormatter.date(from: self) + } } } @@ -66,31 +125,27 @@ extension Date { } var formatForPlannedWorkout: String { - let df = DateFormatter() - df.dateFormat = "yyyy-MM-dd" - df.locale = Locale(identifier: "en_US_POSIX") - return df.string(from: self) + DateFormatterCache.withLock { + DateFormatterCache.plannedDateFormatter.string(from: self) + } } var weekDay: String { - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "EEE" - let weekDay = dateFormatter.string(from: self) - return weekDay + DateFormatterCache.withLock { + DateFormatterCache.weekDayFormatter.string(from: self) + } } var monthString: String { - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "MMM" - let weekDay = dateFormatter.string(from: self) - return weekDay + DateFormatterCache.withLock { + DateFormatterCache.monthFormatter.string(from: self) + } } var dateString: String { - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "d" - let weekDay = dateFormatter.string(from: self) - return weekDay + DateFormatterCache.withLock { + DateFormatterCache.dayFormatter.string(from: self) + } } } @@ -116,10 +171,7 @@ extension Double { 10000.asString(style: .brief) // 2hr 46min 40sec */ func asString(style: DateComponentsFormatter.UnitsStyle) -> String { - let formatter = DateComponentsFormatter() - formatter.allowedUnits = [.hour, .minute, .second, .nanosecond] - formatter.unitsStyle = style - return formatter.string(from: self) ?? "" + DurationFormatterCache.string(from: self, style: style) } } diff --git a/iphone/Werkout_ios/HealthKitHelper.swift b/iphone/Werkout_ios/HealthKitHelper.swift index a19f9c7..495d700 100644 --- a/iphone/Werkout_ios/HealthKitHelper.swift +++ b/iphone/Werkout_ios/HealthKitHelper.swift @@ -7,6 +7,7 @@ import Foundation import HealthKit +import SharedCore struct HealthKitWorkoutData { var caloriesBurned: Double? @@ -16,111 +17,155 @@ struct HealthKitWorkoutData { } class HealthKitHelper { - // this is dirty and i dont care - var returnCount = 0 + private let runtimeReporter = RuntimeReporter.shared let healthStore = HKHealthStore() - - var healthKitWorkoutData = HealthKitWorkoutData( - caloriesBurned: nil, - minHeartRate: nil, - maxHeartRate: nil, - avgHeartRate: nil) - - var completion: ((HealthKitWorkoutData?) -> Void)? - + func getDetails(forHealthKitUUID uuid: UUID, completion: @escaping ((HealthKitWorkoutData?) -> Void)) { - self.completion = completion - self.returnCount = 0 - - print("get details \(uuid.uuidString)") - + runtimeReporter.recordInfo("Fetching HealthKit workout details", metadata: ["uuid": uuid.uuidString]) + let query = HKSampleQuery(sampleType: HKWorkoutType.workoutType(), predicate: HKQuery.predicateForObject(with: uuid), - limit: HKObjectQueryNoLimit, + limit: 1, sortDescriptors: nil) - { (sampleQuery, results, error ) -> Void in - if let queryError = error { - self.shitReturned() - self.shitReturned() - print( "There was an error while reading the samples: \(queryError.localizedDescription)") - self.completion?(nil) - } else { - for samples: HKSample in results! { - let workout: HKWorkout = (samples as! HKWorkout) - self.getTotalBurned(forWorkout: workout) - self.getHeartRateStuff(forWorkout: workout) - print("got workout") + { [weak self] (_, results, error) -> Void in + guard let self else { + DispatchQueue.main.async { + completion(nil) } + return } + + if let queryError = error { + self.runtimeReporter.recordError( + "Failed querying HealthKit workout", + metadata: ["error": queryError.localizedDescription] + ) + DispatchQueue.main.async { + completion(nil) + } + return + } + + guard let workout = results?.compactMap({ $0 as? HKWorkout }).first else { + self.runtimeReporter.recordWarning("No HealthKit workout found for UUID", metadata: ["uuid": uuid.uuidString]) + DispatchQueue.main.async { + completion(nil) + } + return + } + + self.collectDetails(forWorkout: workout, completion: completion) } - + healthStore.execute(query) } - - func getHeartRateStuff(forWorkout workout: HKWorkout) { - print("get heart") - let heartType = HKQuantityType.quantityType(forIdentifier: .heartRate) - let heartPredicate: NSPredicate? = HKQuery.predicateForSamples(withStart: workout.startDate, - end: workout.endDate, - options: HKQueryOptions.strictEndDate) - - let heartQuery = HKStatisticsQuery(quantityType: heartType!, + + private func collectDetails(forWorkout workout: HKWorkout, completion: @escaping ((HealthKitWorkoutData?) -> Void)) { + let aggregateQueue = DispatchQueue(label: "com.werkout.healthkit.aggregate") + var workoutData = HealthKitWorkoutData( + caloriesBurned: nil, + minHeartRate: nil, + maxHeartRate: nil, + avgHeartRate: nil + ) + + let group = DispatchGroup() + group.enter() + getTotalBurned(forWorkout: workout) { calories in + aggregateQueue.async { + workoutData.caloriesBurned = calories + group.leave() + } + } + + group.enter() + getHeartRateStuff(forWorkout: workout) { heartRateData in + aggregateQueue.async { + workoutData.minHeartRate = heartRateData?.minHeartRate + workoutData.maxHeartRate = heartRateData?.maxHeartRate + workoutData.avgHeartRate = heartRateData?.avgHeartRate + group.leave() + } + } + + group.notify(queue: .main) { + let hasValues = workoutData.caloriesBurned != nil || + workoutData.minHeartRate != nil || + workoutData.maxHeartRate != nil || + workoutData.avgHeartRate != nil + + completion(hasValues ? workoutData : nil) + } + } + + private func getHeartRateStuff(forWorkout workout: HKWorkout, completion: @escaping ((HealthKitWorkoutData?) -> Void)) { + guard let heartType = HKQuantityType.quantityType(forIdentifier: .heartRate) else { + completion(nil) + return + } + + let heartPredicate = HKQuery.predicateForSamples(withStart: workout.startDate, + end: workout.endDate, + options: HKQueryOptions.strictEndDate) + + let heartQuery = HKStatisticsQuery(quantityType: heartType, quantitySamplePredicate: heartPredicate, options: [.discreteAverage, .discreteMin, .discreteMax], - completionHandler: {(query: HKStatisticsQuery, result: HKStatistics?, error: Error?) -> Void in - if let result = result, - let minValue = result.minimumQuantity(), - let maxValue = result.maximumQuantity(), - let avgValue = result.averageQuantity() { - - let _minHeartRate = minValue.doubleValue( - for: HKUnit(from: "count/min") + completionHandler: { [weak self] (_, result, error) -> Void in + if let error { + self?.runtimeReporter.recordError( + "Failed querying HealthKit heart rate stats", + metadata: ["error": error.localizedDescription] ) - - let _maxHeartRate = maxValue.doubleValue( - for: HKUnit(from: "count/min") - ) - - let _avgHeartRate = avgValue.doubleValue( - for: HKUnit(from: "count/min") - ) - self.healthKitWorkoutData.avgHeartRate = _avgHeartRate - self.healthKitWorkoutData.minHeartRate = _minHeartRate - self.healthKitWorkoutData.maxHeartRate = _maxHeartRate - print("got heart") + completion(nil) + return } - self.shitReturned() + + guard let result, + let minValue = result.minimumQuantity(), + let maxValue = result.maximumQuantity(), + let avgValue = result.averageQuantity() else { + completion(nil) + return + } + + let unit = HKUnit(from: "count/min") + let data = HealthKitWorkoutData( + caloriesBurned: nil, + minHeartRate: minValue.doubleValue(for: unit), + maxHeartRate: maxValue.doubleValue(for: unit), + avgHeartRate: avgValue.doubleValue(for: unit) + ) + completion(data) }) healthStore.execute(heartQuery) } - - func getTotalBurned(forWorkout workout: HKWorkout) { - print("get total burned") - let calType = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned) - let calPredicate: NSPredicate? = HKQuery.predicateForSamples(withStart: workout.startDate, - end: workout.endDate, - options: HKQueryOptions.strictEndDate) - - let calQuery = HKStatisticsQuery(quantityType: calType!, + + private func getTotalBurned(forWorkout workout: HKWorkout, completion: @escaping ((Double?) -> Void)) { + guard let calType = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned) else { + completion(nil) + return + } + + let calPredicate = HKQuery.predicateForSamples(withStart: workout.startDate, + end: workout.endDate, + options: HKQueryOptions.strictEndDate) + + let calQuery = HKStatisticsQuery(quantityType: calType, quantitySamplePredicate: calPredicate, options: [.cumulativeSum], - completionHandler: {(query: HKStatisticsQuery, result: HKStatistics?, error: Error?) -> Void in - if let result = result { - self.healthKitWorkoutData.caloriesBurned = result.sumQuantity()?.doubleValue(for: HKUnit.kilocalorie()) ?? -1 - print("got total burned") + completionHandler: { [weak self] (_, result, error) -> Void in + if let error { + self?.runtimeReporter.recordError( + "Failed querying HealthKit calories", + metadata: ["error": error.localizedDescription] + ) + completion(nil) + return } - self.shitReturned() + + completion(result?.sumQuantity()?.doubleValue(for: HKUnit.kilocalorie())) }) healthStore.execute(calQuery) } - - func shitReturned() { - DispatchQueue.main.async { - self.returnCount += 1 - print("\(self.returnCount)") - if self.returnCount == 2 { - self.completion?(self.healthKitWorkoutData) - } - } - } } diff --git a/iphone/Werkout_ios/JSON/PreviewData.swift b/iphone/Werkout_ios/JSON/PreviewData.swift index 4bd51ec..e28590c 100644 --- a/iphone/Werkout_ios/JSON/PreviewData.swift +++ b/iphone/Werkout_ios/JSON/PreviewData.swift @@ -8,100 +8,56 @@ import Foundation class PreviewData { + private class func decodeFromBundle(_ fileName: String, + as type: T.Type) -> T? { + guard let filepath = Bundle.main.path(forResource: fileName, ofType: "json"), + let data = try? Data(NSData(contentsOfFile: filepath)) else { + return nil + } + + return try? JSONDecoder().decode(T.self, from: data) + } + class func workout() -> Workout { - let filepath = Bundle.main.path(forResource: "WorkoutDetail", ofType: "json")! - let data = try! Data(NSData(contentsOfFile: filepath)) - let workout = try! JSONDecoder().decode(Workout.self, from: data) - return workout + if let workout = decodeFromBundle("WorkoutDetail", as: Workout.self) { + return workout + } + if let firstWorkout = allWorkouts().first { + return firstWorkout + } + + return Workout(id: -1, name: "Unavailable") } class func allWorkouts() -> [Workout] { - if let filepath = Bundle.main.path(forResource: "AllWorkouts", ofType: "json") { - do { - let data = try Data(NSData(contentsOfFile: filepath)) - let workout = try JSONDecoder().decode([Workout].self, from: data) - return workout - } catch { - print(error) - fatalError() - } - } else { - fatalError() - } + decodeFromBundle("AllWorkouts", as: [Workout].self) ?? [] } class func parseExercises() -> [Exercise] { - if let filepath = Bundle.main.path(forResource: "Exercises", ofType: "json") { - do { - let data = try Data(NSData(contentsOfFile: filepath)) - let exercises = try JSONDecoder().decode([Exercise].self, from: data) - return exercises - } catch { - print(error) - fatalError() - } - } else { - fatalError() - } + decodeFromBundle("Exercises", as: [Exercise].self) ?? [] } class func parseEquipment() -> [Equipment] { - if let filepath = Bundle.main.path(forResource: "Equipment", ofType: "json") { - do { - let data = try Data(NSData(contentsOfFile: filepath)) - let equipment = try JSONDecoder().decode([Equipment].self, from: data) - return equipment - } catch { - print(error) - fatalError() - } - } else { - fatalError() - } + decodeFromBundle("Equipment", as: [Equipment].self) ?? [] } class func parseMuscle() -> [Muscle] { - if let filepath = Bundle.main.path(forResource: "AllMuscles", ofType: "json") { - do { - let data = try Data(NSData(contentsOfFile: filepath)) - let muscles = try JSONDecoder().decode([Muscle].self, from: data) - return muscles - } catch { - print(error) - fatalError() - } - } else { - fatalError() - } + decodeFromBundle("AllMuscles", as: [Muscle].self) ?? [] } class func parseRegisterdUser() -> RegisteredUser { - if let filepath = Bundle.main.path(forResource: "RegisteredUser", ofType: "json") { - do { - let data = try Data(NSData(contentsOfFile: filepath)) - let muscles = try JSONDecoder().decode(RegisteredUser.self, from: data) - return muscles - } catch { - print(error) - fatalError() - } - } else { - fatalError() - } + decodeFromBundle("RegisteredUser", as: RegisteredUser.self) ?? + RegisteredUser(id: -1, + firstName: nil, + lastName: nil, + image: nil, + nickName: nil, + token: nil, + email: nil, + hasNSFWToggle: nil) } class func parseCompletedWorkouts() -> [CompletedWorkout] { - if let filepath = Bundle.main.path(forResource: "CompletedWorkouts", ofType: "json") { - do { - let data = try Data(NSData(contentsOfFile: filepath)) - let muscles = try JSONDecoder().decode([CompletedWorkout].self, from: data) - return muscles - } catch { - print(error) - fatalError() - } - } else { - fatalError() - } + decodeFromBundle("CompletedWorkouts", as: [CompletedWorkout].self) ?? [] } } diff --git a/iphone/Werkout_ios/JSON/RegisteredUser.json b/iphone/Werkout_ios/JSON/RegisteredUser.json index a591b61..cc45cbe 100644 --- a/iphone/Werkout_ios/JSON/RegisteredUser.json +++ b/iphone/Werkout_ios/JSON/RegisteredUser.json @@ -7,5 +7,5 @@ "last_name": "test1_last", "image": "", "nick_name": "NickkkkName", - "token": "8f10a5b8c7532f7f8602193767b46a2625a85c52" + "token": "REDACTED_TOKEN" } diff --git a/iphone/Werkout_ios/Network/Network.swift b/iphone/Werkout_ios/Network/Network.swift index 01f728f..9f8138f 100644 --- a/iphone/Werkout_ios/Network/Network.swift +++ b/iphone/Werkout_ios/Network/Network.swift @@ -6,6 +6,10 @@ // import Foundation +import SharedCore + +private let runtimeReporter = RuntimeReporter.shared +private let requestTimeout: TimeInterval = 30 enum FetchableError: Error { case apiError(Error) @@ -17,6 +21,27 @@ enum FetchableError: Error { case statusError(Int, String?) } +extension FetchableError: LocalizedError { + var errorDescription: String? { + switch self { + case .apiError(let error): + return "API error: \(error.localizedDescription)" + case .noData: + return "No response data was returned." + case .decodeError(let error): + return "Failed to decode response: \(error.localizedDescription)" + case .endOfFileError: + return "Unexpected end of file while parsing response." + case .noPostData: + return "Missing POST payload." + case .noToken: + return "Authentication token is missing or expired." + case .statusError(let statusCode, _): + return "Request failed with status code \(statusCode)." + } + } +} + protocol Fetchable { associatedtype Response: Codable var attachToken: Bool { get } @@ -34,105 +59,175 @@ extension Fetchable { var baseURL: String { BaseURLs.currentBaseURL } - + var attachToken: Bool { - return true + true } - + func fetch(completion: @escaping (Result) -> Void) { - let url = URL(string: baseURL+endPoint)! - - var request = URLRequest(url: url,timeoutInterval: Double.infinity) + guard let url = URL(string: baseURL + endPoint) else { + completeOnMain(completion, with: .failure(.noData)) + return + } + + var request = URLRequest(url: url, timeoutInterval: requestTimeout) if attachToken { guard let token = UserStore.shared.token else { - completion(.failure(.noPostData)) + runtimeReporter.recordWarning("Missing auth token", metadata: ["method": "GET", "endpoint": endPoint]) + completeOnMain(completion, with: .failure(.noToken)) return } request.addValue(token, forHTTPHeaderField: "Authorization") } request.addValue("application/json", forHTTPHeaderField: "Content-Type") - - let task = URLSession.shared.dataTask(with: request, completionHandler: { (data, response, error) in - if let error = error { - completion(.failure(.apiError(error))) + + let task = URLSession.shared.dataTask(with: request, completionHandler: { data, response, error in + if let error { + runtimeReporter.recordError( + "GET request failed", + metadata: ["url": url.absoluteString, "error": error.localizedDescription] + ) + completeOnMain(completion, with: .failure(.apiError(error))) return } - guard let data = data else { - completion(.failure(.noData)) + + if let httpResponse = response as? HTTPURLResponse, + !(200...299).contains(httpResponse.statusCode) { + let responseBody = data.flatMap { String(data: $0, encoding: .utf8) } + handleHTTPFailure(statusCode: httpResponse.statusCode, + responseBody: responseBody, + endpoint: endPoint, + method: "GET") + completeOnMain(completion, with: .failure(.statusError(httpResponse.statusCode, responseBody))) return } - + + guard let data else { + runtimeReporter.recordError("GET request returned no data", metadata: ["url": url.absoluteString]) + completeOnMain(completion, with: .failure(.noData)) + return + } + do { let model = try JSONDecoder().decode(Response.self, from: data) - completion(.success(model)) - return + completeOnMain(completion, with: .success(model)) } catch { - completion(.failure(.decodeError(error))) - return + runtimeReporter.recordError( + "Failed decoding GET response", + metadata: ["url": url.absoluteString, "error": error.localizedDescription] + ) + completeOnMain(completion, with: .failure(.decodeError(error))) } }) - + task.resume() } } extension Postable { - func fetch(completion: @escaping (Result) -> Void) { - guard let postableData = postableData else { - completion(.failure(.noPostData)) + func fetch(completion: @escaping (Result) -> Void) { + guard let postableData else { + completeOnMain(completion, with: .failure(.noPostData)) return } - - let url = URL(string: baseURL+endPoint)! - - let postData = try! JSONSerialization.data(withJSONObject:postableData) - - var request = URLRequest(url: url,timeoutInterval: Double.infinity) - if attachToken { - guard let token = UserStore.shared.token else { - completion(.failure(.noPostData)) - return - } - request.addValue(token, forHTTPHeaderField: "Authorization") - } - request.addValue("application/json", forHTTPHeaderField: "Content-Type") - - request.httpMethod = "POST" - request.httpBody = postData - + + guard let url = URL(string: baseURL + endPoint) else { + completeOnMain(completion, with: .failure(.noData)) + return + } + + let postData: Data + do { + postData = try JSONSerialization.data(withJSONObject: postableData) + } catch { + runtimeReporter.recordError( + "Failed encoding POST payload", + metadata: ["url": url.absoluteString, "error": error.localizedDescription] + ) + completeOnMain(completion, with: .failure(.apiError(error))) + return + } + + var request = URLRequest(url: url, timeoutInterval: requestTimeout) + if attachToken { + guard let token = UserStore.shared.token else { + runtimeReporter.recordWarning("Missing auth token", metadata: ["method": "POST", "endpoint": endPoint]) + completeOnMain(completion, with: .failure(.noToken)) + return + } + request.addValue(token, forHTTPHeaderField: "Authorization") + } + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + + request.httpMethod = "POST" + request.httpBody = postData + let task = URLSession.shared.dataTask(with: request, completionHandler: { data, response, error in - if let error = error { - completion(.failure(.apiError(error))) + if let error { + runtimeReporter.recordError( + "POST request failed", + metadata: ["url": url.absoluteString, "error": error.localizedDescription] + ) + completeOnMain(completion, with: .failure(.apiError(error))) return } - - if let httpRespone = response as? HTTPURLResponse { - if httpRespone.statusCode != successStatus { - var returnStr: String? - if let data = data { - returnStr = String(data: data, encoding: .utf8) - } - - completion(.failure(.statusError(httpRespone.statusCode, returnStr))) - return - } - } - - guard let data = data else { - completion(.failure(.noData)) + + if let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode != successStatus { + let responseBody = data.flatMap { String(data: $0, encoding: .utf8) } + handleHTTPFailure(statusCode: httpResponse.statusCode, + responseBody: responseBody, + endpoint: endPoint, + method: "POST") + completeOnMain(completion, with: .failure(.statusError(httpResponse.statusCode, responseBody))) return } - + + guard let data else { + runtimeReporter.recordError("POST request returned no data", metadata: ["url": url.absoluteString]) + completeOnMain(completion, with: .failure(.noData)) + return + } + do { let model = try JSONDecoder().decode(Response.self, from: data) - completion(.success(model)) - return + completeOnMain(completion, with: .success(model)) } catch { - completion(.failure(.decodeError(error))) - return + runtimeReporter.recordError( + "Failed decoding POST response", + metadata: ["url": url.absoluteString, "error": error.localizedDescription] + ) + completeOnMain(completion, with: .failure(.decodeError(error))) } }) - + task.resume() } } + +private func handleHTTPFailure(statusCode: Int, responseBody: String?, endpoint: String, method: String) { + runtimeReporter.recordError( + "HTTP request failed", + metadata: [ + "method": method, + "endpoint": endpoint, + "status_code": "\(statusCode)", + "has_body": responseBody == nil ? "false" : "true" + ] + ) + UserStore.shared.handleUnauthorizedResponse(statusCode: statusCode, responseBody: responseBody) +} + +private func completeOnMain( + _ completion: @escaping (Result) -> Void, + with result: Result +) { + if Thread.isMainThread { + completion(result) + return + } + + DispatchQueue.main.async { + completion(result) + } +} diff --git a/iphone/Werkout_ios/Persistence.swift b/iphone/Werkout_ios/Persistence.swift index 7ebe759..311f854 100644 --- a/iphone/Werkout_ios/Persistence.swift +++ b/iphone/Werkout_ios/Persistence.swift @@ -6,9 +6,11 @@ // import CoreData +import SharedCore struct PersistenceController { static let shared = PersistenceController() + private static let runtimeReporter = RuntimeReporter.shared static var preview: PersistenceController = { let result = PersistenceController(inMemory: true) @@ -20,10 +22,14 @@ struct PersistenceController { do { try viewContext.save() } catch { - // Replace this implementation with code to handle the error appropriately. - // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. let nsError = error as NSError - fatalError("Unresolved error \(nsError), \(nsError.userInfo)") + runtimeReporter.recordError( + "Failed to save preview context", + metadata: [ + "code": "\(nsError.code)", + "domain": nsError.domain + ] + ) } return result }() @@ -33,22 +39,17 @@ struct PersistenceController { init(inMemory: Bool = false) { container = NSPersistentContainer(name: "Werkout_ios") if inMemory { - container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null") + container.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null") } container.loadPersistentStores(completionHandler: { (storeDescription, error) in if let error = error as NSError? { - // Replace this implementation with code to handle the error appropriately. - // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. - - /* - Typical reasons for an error here include: - * The parent directory does not exist, cannot be created, or disallows writing. - * The persistent store is not accessible, due to permissions or data protection when the device is locked. - * The device is out of space. - * The store could not be migrated to the current model version. - Check the error message to determine what the actual problem was. - */ - fatalError("Unresolved error \(error), \(error.userInfo)") + Self.runtimeReporter.recordError( + "Failed to load persistent store", + metadata: [ + "code": "\(error.code)", + "domain": error.domain + ] + ) } }) container.viewContext.automaticallyMergesChangesFromParent = true diff --git a/iphone/Werkout_ios/Resources/Werkout-ios-Info.plist b/iphone/Werkout_ios/Resources/Werkout-ios-Info.plist index b2d450c..e98ba81 100644 --- a/iphone/Werkout_ios/Resources/Werkout-ios-Info.plist +++ b/iphone/Werkout_ios/Resources/Werkout-ios-Info.plist @@ -6,8 +6,19 @@ NSAppTransportSecurity - NSAllowsArbitraryLoads - + NSExceptionDomains + + 127.0.0.1 + + NSExceptionAllowsInsecureHTTPLoads + + + localhost + + NSExceptionAllowsInsecureHTTPLoads + + + UIBackgroundModes diff --git a/iphone/Werkout_ios/UserStore.swift b/iphone/Werkout_ios/UserStore.swift index 38ece0d..3de7184 100644 --- a/iphone/Werkout_ios/UserStore.swift +++ b/iphone/Werkout_ios/UserStore.swift @@ -6,99 +6,198 @@ // import Foundation +import SharedCore class UserStore: ObservableObject { - static let userNameKeychainValue = "username" - static let passwordKeychainValue = "password" - + static let tokenKeychainValue = "auth_token" + static let userDefaultsRegisteredUserKey = "registeredUserKey" + private static let userDefaultsTokenExpirationKey = "registeredUserTokenExpiration" static let shared = UserStore() - + + private let runtimeReporter = RuntimeReporter.shared + private let authRefreshQueue = DispatchQueue(label: "com.werkout.auth.refresh") + private var lastTokenRefreshAttempt = Date.distantPast + private let tokenRotationWindow: TimeInterval = 30 * 60 + private let tokenRefreshThrottle: TimeInterval = 5 * 60 + @Published public private(set) var registeredUser: RegisteredUser? - - var plannedWorkouts = [PlannedWorkout]() - + + @Published public private(set) var plannedWorkouts = [PlannedWorkout]() + init(registeredUser: RegisteredUser? = nil) { - self.registeredUser = registeredUser - if let data = UserDefaults.standard.data(forKey: UserStore.userDefaultsRegisteredUserKey), - let model = try? JSONDecoder().decode(RegisteredUser.self, from: data) { - self.registeredUser = model - } + self.registeredUser = registeredUser ?? loadPersistedUser() } - + public var token: String? { - guard let token = registeredUser?.token else { + guard let rawToken = normalizedToken(from: registeredUser?.token) else { return nil } - return "Token \(token)" + + if isTokenExpired(rawToken) { + runtimeReporter.recordWarning("Auth token expired before request", metadata: ["action": "force_logout"]) + DispatchQueue.main.async { + self.logout(reason: "token_expired") + } + return nil + } + + maybeRefreshTokenIfNearExpiry(rawToken) + return "Token \(rawToken)" } - + + func handleUnauthorizedResponse(statusCode: Int, responseBody: String?) { + guard statusCode == 401 || statusCode == 403 else { + return + } + + runtimeReporter.recordError( + "Unauthorized response from server", + metadata: [ + "status_code": "\(statusCode)", + "has_body": responseBody == nil ? "false" : "true" + ] + ) + + DispatchQueue.main.async { + self.logout(reason: "unauthorized_\(statusCode)") + } + } + + private func loadPersistedUser() -> RegisteredUser? { + guard let data = UserDefaults.standard.data(forKey: UserStore.userDefaultsRegisteredUserKey), + let model = try? JSONDecoder().decode(RegisteredUser.self, from: data) else { + return nil + } + + if let keychainToken = loadTokenFromKeychain() { + if isTokenExpired(keychainToken) { + runtimeReporter.recordWarning("Persisted token is expired", metadata: ["source": "keychain"]) + clearPersistedTokenOnly() + return userByReplacingToken(model, token: nil) + } + + persistTokenExpirationMetadata(token: keychainToken) + return userByReplacingToken(model, token: keychainToken) + } + + if let legacyToken = normalizedToken(from: model.token) { + migrateLegacyTokenToKeychain(legacyToken) + if isTokenExpired(legacyToken) { + runtimeReporter.recordWarning("Persisted token is expired", metadata: ["source": "legacy_defaults"]) + clearPersistedTokenOnly() + return userByReplacingToken(model, token: nil) + } + + persistSanitizedModel(userByReplacingToken(model, token: nil)) + persistTokenExpirationMetadata(token: legacyToken) + return userByReplacingToken(model, token: legacyToken) + } + + persistTokenExpirationMetadata(token: nil) + return userByReplacingToken(model, token: nil) + } + + private func persistRegisteredUser(_ model: RegisteredUser) { + let sanitizedToken = normalizedToken(from: model.token) + persistSanitizedModel(userByReplacingToken(model, token: nil)) + + if let sanitizedToken, + let tokenData = sanitizedToken.data(using: .utf8) { + do { + try KeychainInterface.save(password: tokenData, account: UserStore.tokenKeychainValue) + } catch KeychainInterface.KeychainError.duplicateItem { + try? KeychainInterface.update(password: tokenData, account: UserStore.tokenKeychainValue) + } catch { + runtimeReporter.recordError( + "Failed saving token in keychain", + metadata: ["error": error.localizedDescription] + ) + } + } else { + try? KeychainInterface.deletePassword(account: UserStore.tokenKeychainValue) + } + + persistTokenExpirationMetadata(token: sanitizedToken) + } + + private func persistSanitizedModel(_ model: RegisteredUser) { + if let data = try? JSONEncoder().encode(model) { + UserDefaults.standard.set(data, forKey: UserStore.userDefaultsRegisteredUserKey) + } + } + func login(postData: [String: Any], completion: @escaping (Bool)-> Void) { LoginFetchable(postData: postData).fetch(completion: { result in switch result { case .success(let model): - if let email = postData["email"] as? String, - let password = postData["password"] as? String, - let data = password.data(using: .utf8) { - try? KeychainInterface.save(password: data, - account: email) - } - + let sanitizedModel = self.userByReplacingToken(model, token: self.normalizedToken(from: model.token)) + DispatchQueue.main.async { - self.registeredUser = model - let data = try! JSONEncoder().encode(model) - UserDefaults.standard.set(data, forKey: UserStore.userDefaultsRegisteredUserKey) + self.registeredUser = sanitizedModel + self.persistRegisteredUser(sanitizedModel) completion(true) } - case .failure(let failure): - completion(false) + case .failure(let error): + self.runtimeReporter.recordError("Login failed", metadata: ["error": error.localizedDescription]) + DispatchQueue.main.async { + completion(false) + } } }) } - + public func refreshUserData() { RefreshUserInfoFetcable().fetch(completion: { result in switch result { case .success(let registeredUser): + let sanitizedModel = self.userByReplacingToken(registeredUser, token: self.normalizedToken(from: registeredUser.token)) DispatchQueue.main.async { - if let data = try? JSONEncoder().encode(registeredUser) { - UserDefaults.standard.set(data, forKey: UserStore.userDefaultsRegisteredUserKey) - } - if let data = UserDefaults.standard.data(forKey: UserStore.userDefaultsRegisteredUserKey), - let model = try? JSONDecoder().decode(RegisteredUser.self, from: data) { - self.registeredUser = model - } + self.persistRegisteredUser(sanitizedModel) + self.registeredUser = sanitizedModel } case .failure(let failure): - fatalError() + self.runtimeReporter.recordError("Failed refreshing user", metadata: ["error": failure.localizedDescription]) } }) } - + func logout() { + logout(reason: "manual_logout") + } + + private func logout(reason: String) { + let email = registeredUser?.email self.registeredUser = nil - UserDefaults.standard.set(nil, forKey: UserStore.userDefaultsRegisteredUserKey) + UserDefaults.standard.removeObject(forKey: UserStore.userDefaultsRegisteredUserKey) + persistTokenExpirationMetadata(token: nil) + try? KeychainInterface.deletePassword(account: UserStore.tokenKeychainValue) + if let email, email.isEmpty == false { + try? KeychainInterface.deletePassword(account: email) + } + plannedWorkouts.removeAll() + runtimeReporter.recordInfo("User logged out", metadata: ["reason": reason]) + DispatchQueue.main.async { - NotificationCenter.default.post(name: NSNotification.Name("CreatedNewWorkout"), object: nil, userInfo: nil) + NotificationCenter.default.post(name: AppNotifications.createdNewWorkout, object: nil, userInfo: nil) } } - + func setFakeUser() { self.registeredUser = PreviewData.parseRegisterdUser() } - + func fetchPlannedWorkouts() { PlannedWorkoutFetchable().fetch(completion: { result in switch result { case .success(let models): self.plannedWorkouts = models case .failure(let failure): - UserStore.shared.logout() -// fatalError("shit broke") + self.runtimeReporter.recordError("Failed fetching planned workouts", metadata: ["error": failure.localizedDescription]) } }) } - + func plannedWorkoutFor(date: Date) -> PlannedWorkout? { for plannedWorkout in plannedWorkouts { if let plannedworkoutDate = plannedWorkout.date { @@ -109,8 +208,90 @@ class UserStore: ObservableObject { } return nil } - + func setTreyDevRegisterdUser() { - self.registeredUser = RegisteredUser(id: 1, firstName: "t", lastName: "t", image: nil, nickName: "t", token: "15d7565cde9e8c904ae934f8235f68f6a24b4a03", email: nil, hasNSFWToggle: nil) + self.registeredUser = RegisteredUser(id: 1, + firstName: "t", + lastName: "t", + image: nil, + nickName: "t", + token: nil, + email: nil, + hasNSFWToggle: nil) + } + + private func migrateLegacyTokenToKeychain(_ token: String) { + guard let tokenData = token.data(using: .utf8) else { + return + } + + do { + try KeychainInterface.save(password: tokenData, account: UserStore.tokenKeychainValue) + } catch KeychainInterface.KeychainError.duplicateItem { + try? KeychainInterface.update(password: tokenData, account: UserStore.tokenKeychainValue) + } catch { + runtimeReporter.recordError("Failed migrating legacy token", metadata: ["error": error.localizedDescription]) + } + } + + private func loadTokenFromKeychain() -> String? { + guard let tokenData = try? KeychainInterface.readPassword(account: UserStore.tokenKeychainValue), + let token = String(data: tokenData, encoding: .utf8) else { + return nil + } + return normalizedToken(from: token) + } + + private func normalizedToken(from rawToken: String?) -> String? { + TokenSecurity.sanitizeToken(rawToken) + } + + private func persistTokenExpirationMetadata(token: String?) { + guard let token, + let expirationDate = TokenSecurity.jwtExpiration(token) else { + UserDefaults.standard.removeObject(forKey: UserStore.userDefaultsTokenExpirationKey) + return + } + + UserDefaults.standard.set(expirationDate.timeIntervalSince1970, forKey: UserStore.userDefaultsTokenExpirationKey) + } + + private func clearPersistedTokenOnly() { + try? KeychainInterface.deletePassword(account: UserStore.tokenKeychainValue) + persistTokenExpirationMetadata(token: nil) + } + + private func maybeRefreshTokenIfNearExpiry(_ token: String) { + guard TokenSecurity.shouldRotate(token, rotationWindow: tokenRotationWindow) else { + return + } + + authRefreshQueue.async { + let throttleCutoff = Date().addingTimeInterval(-self.tokenRefreshThrottle) + guard self.lastTokenRefreshAttempt <= throttleCutoff else { + return + } + + self.lastTokenRefreshAttempt = Date() + self.runtimeReporter.recordInfo("Token nearing expiry; refreshing user") + DispatchQueue.main.async { + self.refreshUserData() + } + } + } + + private func isTokenExpired(_ token: String) -> Bool { + TokenSecurity.isExpired(token) + } + + private func userByReplacingToken(_ model: RegisteredUser, token: String?) -> RegisteredUser { + RegisteredUser(id: model.id, + firstName: model.firstName, + lastName: model.lastName, + image: model.image, + nickName: model.nickName, + token: token, + email: model.email, + hasNSFWToggle: model.hasNSFWToggle) } } diff --git a/iphone/Werkout_ios/Views/AllWorkouts/AllWorkoutsListView.swift b/iphone/Werkout_ios/Views/AllWorkouts/AllWorkoutsListView.swift index 2d6235e..ea4ed37 100644 --- a/iphone/Werkout_ios/Views/AllWorkouts/AllWorkoutsListView.swift +++ b/iphone/Werkout_ios/Views/AllWorkouts/AllWorkoutsListView.swift @@ -19,7 +19,7 @@ struct AllWorkoutsListView: View { @Binding var uniqueWorkoutUsers: [RegisteredUser]? @State private var filteredRegisterdUser: RegisteredUser? - @State var workouts: [Workout] + let workouts: [Workout] let selectedWorkout: ((Workout) -> Void) @State var filteredWorkouts = [Workout]() var refresh: (() -> Void) @@ -38,19 +38,23 @@ struct AllWorkoutsListView: View { uniqueWorkoutUsers: $uniqueWorkoutUsers, filteredRegisterdUser: $filteredRegisterdUser, filteredWorkouts: $filteredWorkouts, - workouts: $workouts, + workouts: .constant(workouts), currentSort: $currentSort) } ScrollView { LazyVStack(spacing: 10) { ForEach(filteredWorkouts, id:\.id) { workout in - WorkoutOverviewView(workout: workout) - .padding([.leading, .trailing], 4) - .contentShape(Rectangle()) - .onTapGesture { - selectedWorkout(workout) - } + Button(action: { + selectedWorkout(workout) + }, label: { + WorkoutOverviewView(workout: workout) + .padding([.leading, .trailing], 4) + .contentShape(Rectangle()) + }) + .buttonStyle(.plain) + .accessibilityLabel("Open \(workout.name)") + .accessibilityHint("Shows workout details") } } } @@ -71,6 +75,9 @@ struct AllWorkoutsListView: View { .onAppear{ filterWorkouts() } + .onChange(of: workouts) { _ in + filterWorkouts() + } } func filterWorkouts() { diff --git a/iphone/Werkout_ios/Views/AllWorkouts/AllWorkoutsView.swift b/iphone/Werkout_ios/Views/AllWorkouts/AllWorkoutsView.swift index df02a9d..c5a6196 100644 --- a/iphone/Werkout_ios/Views/AllWorkouts/AllWorkoutsView.swift +++ b/iphone/Werkout_ios/Views/AllWorkouts/AllWorkoutsView.swift @@ -8,6 +8,7 @@ import Foundation import SwiftUI import HealthKit +import SharedCore enum MainViewTypes: Int, CaseIterable { case AllWorkout = 0 @@ -33,6 +34,7 @@ struct AllWorkoutsView: View { @State public var needsUpdating: Bool = true @ObservedObject var dataStore = DataStore.shared + @ObservedObject var userStore = UserStore.shared @State private var showWorkoutDetail = false @State private var selectedWorkout: Workout? { @@ -50,8 +52,9 @@ struct AllWorkoutsView: View { @State private var showLoginView = false @State private var selectedSegment: MainViewTypes = .AllWorkout @State var selectedDate: Date = Date() + private let runtimeReporter = RuntimeReporter.shared - let pub = NotificationCenter.default.publisher(for: NSNotification.Name("CreatedNewWorkout")) + let pub = NotificationCenter.default.publisher(for: AppNotifications.createdNewWorkout) var body: some View { ZStack { @@ -76,12 +79,12 @@ struct AllWorkoutsView: View { selectedWorkout = workout }, refresh: { self.needsUpdating = true - maybeUpdateShit() + maybeRefreshData() }) Divider() case .MyWorkouts: - PlannedWorkoutView(workouts: UserStore.shared.plannedWorkouts, + PlannedWorkoutView(workouts: userStore.plannedWorkouts, selectedPlannedWorkout: $selectedPlannedWorkout) } } @@ -93,7 +96,7 @@ struct AllWorkoutsView: View { .onAppear{ // UserStore.shared.logout() authorizeHealthKit() - maybeUpdateShit() + maybeRefreshData() } .sheet(item: $selectedWorkout) { item in let isPreview = item.id == bridgeModule.currentWorkoutInfo.workout?.id @@ -107,20 +110,20 @@ struct AllWorkoutsView: View { .sheet(isPresented: $showLoginView) { LoginView(completion: { self.needsUpdating = true - maybeUpdateShit() + maybeRefreshData() }) .interactiveDismissDisabled() } .onReceive(pub) { (output) in self.needsUpdating = true - maybeUpdateShit() + maybeRefreshData() } } - func maybeUpdateShit() { - if UserStore.shared.token != nil{ - if UserStore.shared.plannedWorkouts.isEmpty { - UserStore.shared.fetchPlannedWorkouts() + func maybeRefreshData() { + if userStore.token != nil{ + if userStore.plannedWorkouts.isEmpty { + userStore.fetchPlannedWorkouts() } if needsUpdating { @@ -128,6 +131,7 @@ struct AllWorkoutsView: View { dataStore.fetchAllData(completion: { DispatchQueue.main.async { guard let allWorkouts = dataStore.allWorkouts else { + self.isUpdating = false return } self.workouts = allWorkouts.sorted(by: { @@ -140,22 +144,31 @@ struct AllWorkoutsView: View { }) } } else { + isUpdating = false showLoginView = true } } func authorizeHealthKit() { - let healthKitTypes: Set = [ - HKObjectType.quantityType(forIdentifier: .heartRate)!, - HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)!, - HKObjectType.quantityType(forIdentifier: .oxygenSaturation)!, - HKObjectType.activitySummaryType(), - HKQuantityType.workoutType() - ] + let quantityTypes = [ + HKObjectType.quantityType(forIdentifier: .heartRate), + HKObjectType.quantityType(forIdentifier: .activeEnergyBurned), + HKObjectType.quantityType(forIdentifier: .oxygenSaturation) + ].compactMap { $0 } + + let healthKitTypes: Set = Set( + quantityTypes + [ + HKObjectType.activitySummaryType(), + HKQuantityType.workoutType() + ] + ) - healthStore.requestAuthorization(toShare: nil, read: healthKitTypes) { (succ, error) in - if !succ { -// fatalError("Error requesting authorization from health store: \(String(describing: error)))") + healthStore.requestAuthorization(toShare: nil, read: healthKitTypes) { (success, error) in + if success == false { + runtimeReporter.recordWarning( + "HealthKit authorization request did not succeed", + metadata: ["error": error?.localizedDescription ?? "unknown"] + ) } } } diff --git a/iphone/Werkout_ios/Views/CompletedWorkout/CompletedWorkoutView.swift b/iphone/Werkout_ios/Views/CompletedWorkout/CompletedWorkoutView.swift index 5f7589e..137f6dc 100644 --- a/iphone/Werkout_ios/Views/CompletedWorkout/CompletedWorkoutView.swift +++ b/iphone/Werkout_ios/Views/CompletedWorkout/CompletedWorkoutView.swift @@ -7,6 +7,7 @@ import SwiftUI import HealthKit +import SharedCore struct CompletedWorkoutView: View { @ObservedObject var bridgeModule = BridgeModule.shared @@ -15,6 +16,9 @@ struct CompletedWorkoutView: View { @State var notes: String = "" @State var isUploading: Bool = false @State var gettingHealthKitData: Bool = false + @State private var hasError = false + @State private var errorMessage = "" + private let runtimeReporter = RuntimeReporter.shared var postData: [String: Any] let healthKitHelper = HealthKitHelper() @@ -60,7 +64,6 @@ struct CompletedWorkoutView: View { Spacer() Button("Upload", action: { - isUploading = true upload(postBody: postData) }) .frame(maxWidth: .infinity, alignment: .center) @@ -70,11 +73,14 @@ struct CompletedWorkoutView: View { .cornerRadius(Constants.buttonRadius) .padding() .frame(maxWidth: .infinity) + .disabled(isUploading || gettingHealthKitData) } .padding([.leading, .trailing]) } - .onAppear{ - bridgeModule.sendWorkoutCompleteToWatch() + .alert("Upload Failed", isPresented: $hasError) { + Button("OK", role: .cancel) {} + } message: { + Text(errorMessage) } // .onChange(of: bridgeModule.healthKitUUID, perform: { healthKitUUID in // if let healthKitUUID = healthKitUUID { @@ -92,6 +98,11 @@ struct CompletedWorkoutView: View { } func upload(postBody: [String: Any]) { + guard isUploading == false else { + return + } + isUploading = true + var _postBody = postBody _postBody["difficulty"] = difficulty _postBody["notes"] = notes @@ -103,6 +114,7 @@ struct CompletedWorkoutView: View { switch result { case .success(_): DispatchQueue.main.async { + self.isUploading = false bridgeModule.resetCurrentWorkout() dismiss() completedWorkoutDismissed?(true) @@ -110,8 +122,13 @@ struct CompletedWorkoutView: View { case .failure(let failure): DispatchQueue.main.async { self.isUploading = false + self.errorMessage = failure.localizedDescription + self.hasError = true } - print(failure) + runtimeReporter.recordError( + "Completed workout upload failed", + metadata: ["error": failure.localizedDescription] + ) } }) } diff --git a/iphone/Werkout_ios/Views/CreateWorkout/CreateExerciseActionsView.swift b/iphone/Werkout_ios/Views/CreateWorkout/CreateExerciseActionsView.swift index f192160..a0ef016 100644 --- a/iphone/Werkout_ios/Views/CreateWorkout/CreateExerciseActionsView.swift +++ b/iphone/Werkout_ios/Views/CreateWorkout/CreateExerciseActionsView.swift @@ -10,15 +10,16 @@ import AVFoundation struct CreateExerciseActionsView: View { @ObservedObject var workoutExercise: CreateWorkoutExercise - var superset: CreateWorkoutSuperSet + @ObservedObject var superset: CreateWorkoutSuperSet var viewModel: WorkoutViewModel - @State var avPlayer = AVPlayer(url: URL(string: "https://dev.werkout.fitness/media/exercise_videos/2_Dumbbell_Lateral_Lunges.mp4")!) + @State var avPlayer = AVPlayer(url: URL(string: "https://dev.werkout.fitness/media/exercise_videos/2_Dumbbell_Lateral_Lunges.mp4") ?? URL(fileURLWithPath: "/dev/null")) + @State private var currentVideoURL: URL? @State var videoExercise: Exercise? { didSet { if let viddd = self.videoExercise?.videoURL, let url = URL(string: BaseURLs.currentBaseURL + viddd) { - self.avPlayer = AVPlayer(url: url) + updatePlayer(for: url) } } } @@ -37,6 +38,7 @@ struct CreateExerciseActionsView: View { }, onDecrement: { workoutExercise.decreaseReps() }) + .accessibilityLabel("Reps") } } @@ -49,6 +51,7 @@ struct CreateExerciseActionsView: View { }, onDecrement: { workoutExercise.decreaseWeight() }) + .accessibilityLabel("Weight") } HStack { @@ -66,6 +69,7 @@ struct CreateExerciseActionsView: View { }, onDecrement: { workoutExercise.decreaseDuration() }) + .accessibilityLabel("Duration") } HStack { @@ -80,6 +84,8 @@ struct CreateExerciseActionsView: View { .background(.blue) .cornerRadius(Constants.buttonRadius) .buttonStyle(BorderlessButtonStyle()) + .accessibilityLabel("Preview exercise video") + .accessibilityHint("Opens a video preview for this exercise") Spacer() @@ -89,7 +95,6 @@ struct CreateExerciseActionsView: View { superset .deleteExerciseForChosenSuperset(exercise: workoutExercise) viewModel.increaseRandomNumberForUpdating() - viewModel.objectWillChange.send() }) { Image(systemName: "trash.fill") } @@ -98,6 +103,8 @@ struct CreateExerciseActionsView: View { .background(.red) .cornerRadius(Constants.buttonRadius) .buttonStyle(BorderlessButtonStyle()) + .accessibilityLabel("Delete exercise") + .accessibilityHint("Removes this exercise from the superset") Spacer() } @@ -109,6 +116,23 @@ struct CreateExerciseActionsView: View { avPlayer.play() } } + .onDisappear { + avPlayer.pause() + } + } + + private func updatePlayer(for url: URL) { + if currentVideoURL == url { + avPlayer.seek(to: .zero) + avPlayer.isMuted = true + avPlayer.play() + return + } + + currentVideoURL = url + avPlayer = AVPlayer(url: url) + avPlayer.isMuted = true + avPlayer.play() } } diff --git a/iphone/Werkout_ios/Views/CreateWorkout/CreateViewModels.swift b/iphone/Werkout_ios/Views/CreateWorkout/CreateViewModels.swift index 67f422b..5f0b402 100644 --- a/iphone/Werkout_ios/Views/CreateWorkout/CreateViewModels.swift +++ b/iphone/Werkout_ios/Views/CreateWorkout/CreateViewModels.swift @@ -6,6 +6,7 @@ // import SwiftUI +import SharedCore class CreateWorkoutExercise: ObservableObject, Identifiable { let id = UUID() @@ -49,7 +50,7 @@ class CreateWorkoutExercise: ObservableObject, Identifiable { } func decreaseWeight() { - self.weight -= 15 + self.weight -= 5 if self.weight < 0 { self.weight = 0 } @@ -90,6 +91,8 @@ class WorkoutViewModel: ObservableObject { @Published var superSets = [CreateWorkoutSuperSet]() @Published var title = String() @Published var description = String() + @Published var validationError: String? + @Published var isUploading = false @Published var randomValueForUpdatingValue = 0 func increaseRandomNumberForUpdating() { @@ -111,60 +114,82 @@ class WorkoutViewModel: ObservableObject { } func showRoundsError() { - + validationError = "Each superset must have at least one round." } func showNoDurationOrReps() { - + validationError = "Each exercise must have reps or duration." } func uploadWorkout() { + guard isUploading == false else { + return + } + + validationError = nil + let normalizedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) + guard normalizedTitle.isEmpty == false else { + validationError = "Workout title is required." + return + } + var supersets = [[String: Any]]() - var supersetOrder = 1 - superSets.forEach({ superset in - if superset.numberOfRounds == 0 { - showRoundsError() - return - } + for (supersetOffset, superset) in superSets.enumerated() { var supersetInfo = [String: Any]() supersetInfo["name"] = "" supersetInfo["rounds"] = superset.numberOfRounds - supersetInfo["order"] = supersetOrder + supersetInfo["order"] = supersetOffset + 1 var exercises = [[String: Any]]() - var exerciseOrder = 1 - for exercise in superset.exercises { - if exercise.reps == 0 && exercise.duration == 0 { - showNoDurationOrReps() - return - } - + for (exerciseOffset, exercise) in superset.exercises.enumerated() { let item = ["id": exercise.exercise.id, "reps": exercise.reps, "weight": exercise.weight, "duration": exercise.duration, - "order": exerciseOrder] as [String : Any] + "order": exerciseOffset + 1] as [String : Any] exercises.append(item) - exerciseOrder += 1 } supersetInfo["exercises"] = exercises supersets.append(supersetInfo) - supersetOrder += 1 - }) - let uploadBody = ["name": title, + } + + if supersets.isEmpty { + validationError = "Add at least one superset before uploading." + return + } + + let issues = WorkoutValidation.validateSupersets(supersets) + if let issue = issues.first { + switch issue.code { + case "invalid_rounds": + showRoundsError() + case "invalid_exercise_payload": + showNoDurationOrReps() + default: + validationError = issue.message + } + return + } + + let uploadBody = ["name": normalizedTitle, "description": description, "supersets": supersets] as [String : Any] + isUploading = true CreateWorkoutFetchable(postData: uploadBody).fetch(completion: { result in - DispatchQueue.main.async { - switch result { - case .success(_): - self.superSets.removeAll() - self.title = "" - NotificationCenter.default.post(name: NSNotification.Name("CreatedNewWorkout"), object: nil, userInfo: nil) - case .failure(let failure): - print(failure) - } + self.isUploading = false + switch result { + case .success(_): + self.superSets.removeAll() + self.title = "" + self.description = "" + NotificationCenter.default.post( + name: AppNotifications.createdNewWorkout, + object: nil, + userInfo: nil + ) + case .failure(let failure): + self.validationError = "Failed to upload workout: \(failure.localizedDescription)" } }) } diff --git a/iphone/Werkout_ios/Views/CreateWorkout/CreateWorkoutMainView.swift b/iphone/Werkout_ios/Views/CreateWorkout/CreateWorkoutMainView.swift index 00e3597..c44785f 100644 --- a/iphone/Werkout_ios/Views/CreateWorkout/CreateWorkoutMainView.swift +++ b/iphone/Werkout_ios/Views/CreateWorkout/CreateWorkoutMainView.swift @@ -11,6 +11,11 @@ struct CreateWorkoutMainView: View { @StateObject var viewModel = WorkoutViewModel() @State var selectedCreateWorkoutSuperSet: CreateWorkoutSuperSet? @State private var showAddExercise = false + + private var canSubmit: Bool { + viewModel.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false && + viewModel.isUploading == false + } var body: some View { VStack { @@ -28,7 +33,7 @@ struct CreateWorkoutMainView: View { ScrollViewReader { proxy in List() { - ForEach($viewModel.superSets, id: \.id) { superset in + ForEach(viewModel.superSets) { superset in CreateWorkoutSupersetView( selectedCreateWorkoutSuperSet: $selectedCreateWorkoutSuperSet, showAddExercise: $showAddExercise, @@ -38,7 +43,9 @@ struct CreateWorkoutMainView: View { // after adding new exercise we have to scroll to the bottom // where the new exercise is sooo keep this so we can scroll // to id 999 - Text("this is the bottom 🤷‍♂️") + Color.clear + .frame(height: 1) + .accessibilityHidden(true) .id(999) .listRowSeparator(.hidden) } @@ -57,7 +64,7 @@ struct CreateWorkoutMainView: View { // if left or right auto add the other side // with a recover in between b/c its // eaiser to delete a recover than add one - if exercise.side != nil && exercise.side!.count > 0 { + if exercise.side?.isEmpty == false { let exercises = DataStore.shared.allExercise?.filter({ $0.name == exercise.name }) @@ -79,7 +86,6 @@ struct CreateWorkoutMainView: View { } viewModel.increaseRandomNumberForUpdating() - viewModel.objectWillChange.send() selectedCreateWorkoutSuperSet = nil }) } @@ -95,11 +101,21 @@ struct CreateWorkoutMainView: View { .cornerRadius(Constants.buttonRadius) .padding() .frame(maxWidth: .infinity) + .accessibilityLabel("Add superset") + .accessibilityHint("Adds a new superset section to this workout") Divider() - Button("Done", action: { + Button(action: { viewModel.uploadWorkout() + }, label: { + if viewModel.isUploading { + ProgressView() + .progressViewStyle(.circular) + .tint(.white) + } else { + Text("Done") + } }) .frame(maxWidth: .infinity, alignment: .center) .frame(height: 44) @@ -108,13 +124,23 @@ struct CreateWorkoutMainView: View { .cornerRadius(Constants.buttonRadius) .padding() .frame(maxWidth: .infinity) - .disabled(viewModel.title.isEmpty) + .disabled(canSubmit == false) + .accessibilityLabel("Upload workout") + .accessibilityHint("Uploads this workout to your account") } .frame(height: 44) Divider() } .background(Color(uiColor: .systemGray5)) + .alert("Create Workout", isPresented: Binding( + get: { viewModel.validationError != nil }, + set: { _ in viewModel.validationError = nil } + )) { + Button("OK", role: .cancel) {} + } message: { + Text(viewModel.validationError ?? "") + } } } diff --git a/iphone/Werkout_ios/Views/ExternalWorkoutDetailView.swift b/iphone/Werkout_ios/Views/ExternalWorkoutDetailView.swift index 643588a..6eec289 100644 --- a/iphone/Werkout_ios/Views/ExternalWorkoutDetailView.swift +++ b/iphone/Werkout_ios/Views/ExternalWorkoutDetailView.swift @@ -10,8 +10,10 @@ import AVKit struct ExternalWorkoutDetailView: View { @StateObject var bridgeModule = BridgeModule.shared - @State var avPlayer = AVPlayer(url: URL(string: "https://dev.werkout.fitness/media/exercise_videos/2_Dumbbell_Lateral_Lunges.mp4")!) - @State var smallAVPlayer = AVPlayer(url: URL(string: "https://dev.werkout.fitness/media/exercise_videos/2_Dumbbell_Lateral_Lunges.mp4")!) + @State var avPlayer = AVPlayer(url: URL(string: "https://dev.werkout.fitness/media/exercise_videos/2_Dumbbell_Lateral_Lunges.mp4") ?? URL(fileURLWithPath: "/dev/null")) + @State var smallAVPlayer = AVPlayer(url: URL(string: "https://dev.werkout.fitness/media/exercise_videos/2_Dumbbell_Lateral_Lunges.mp4") ?? URL(fileURLWithPath: "/dev/null")) + @State private var currentVideoURL: URL? + @State private var currentSmallVideoURL: URL? @AppStorage(Constants.extThotStyle) private var extThotStyle: ThotStyle = .never @AppStorage(Constants.extShowNextVideo) private var extShowNextVideo: Bool = false @AppStorage(Constants.thotGenderOption) private var thotGenderOption: String = "female" @@ -49,8 +51,8 @@ struct ExternalWorkoutDetailView: View { .frame(width: metrics.size.width * 0.2, height: metrics.size.height * 0.2) .onAppear{ - avPlayer.isMuted = true - avPlayer.play() + smallAVPlayer.isMuted = true + smallAVPlayer.play() } } } @@ -105,6 +107,10 @@ struct ExternalWorkoutDetailView: View { avPlayer.play() smallAVPlayer.play() } + .onDisappear { + avPlayer.pause() + smallAVPlayer.pause() + } } func playVideos() { @@ -115,8 +121,7 @@ struct ExternalWorkoutDetailView: View { defaultVideoURLStr: currentExtercise.exercise.videoURL, exerciseName: currentExtercise.exercise.name, workout: bridgeModule.currentWorkoutInfo.workout) { - avPlayer = AVPlayer(url: videoURL) - avPlayer.play() + updateMainPlayer(for: videoURL) } if let smallVideoURL = VideoURLCreator.videoURL( @@ -126,11 +131,34 @@ struct ExternalWorkoutDetailView: View { exerciseName: BridgeModule.shared.currentWorkoutInfo.nextExerciseInfo?.exercise.name, workout: bridgeModule.currentWorkoutInfo.workout), extShowNextVideo { - smallAVPlayer = AVPlayer(url: smallVideoURL) - smallAVPlayer.play() + updateSmallPlayer(for: smallVideoURL) } } } + + private func updateMainPlayer(for url: URL) { + if currentVideoURL == url { + avPlayer.seek(to: .zero) + avPlayer.play() + return + } + + currentVideoURL = url + avPlayer = AVPlayer(url: url) + avPlayer.play() + } + + private func updateSmallPlayer(for url: URL) { + if currentSmallVideoURL == url { + smallAVPlayer.seek(to: .zero) + smallAVPlayer.play() + return + } + + currentSmallVideoURL = url + smallAVPlayer = AVPlayer(url: url) + smallAVPlayer.play() + } } //struct ExternalWorkoutDetailView_Previews: PreviewProvider { diff --git a/iphone/Werkout_ios/Views/Login/LoginView.swift b/iphone/Werkout_ios/Views/Login/LoginView.swift index f298642..db0ee50 100644 --- a/iphone/Werkout_ios/Views/Login/LoginView.swift +++ b/iphone/Werkout_ios/Views/Login/LoginView.swift @@ -12,7 +12,7 @@ struct LoginView: View { @State var password: String = "" @Environment(\.dismiss) var dismiss let completion: (() -> Void) - @State var doingNetworkShit: Bool = false + @State var isLoggingIn: Bool = false @State var errorTitle = "" @State var errorMessage = "" @@ -23,13 +23,17 @@ struct LoginView: View { VStack { TextField("Email", text: $email) .textContentType(.username) - .autocapitalization(.none) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .keyboardType(.emailAddress) .padding() .background(Color.white) .cornerRadius(8) .padding(.horizontal) .padding(.top, 25) .foregroundStyle(.black) + .accessibilityLabel("Email") + .submitLabel(.next) SecureField("Password", text: $password) .textContentType(.password) @@ -37,10 +41,13 @@ struct LoginView: View { .background(Color.white) .cornerRadius(8) .padding(.horizontal) - .autocapitalization(.none) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) .foregroundStyle(.black) + .accessibilityLabel("Password") + .submitLabel(.go) - if doingNetworkShit { + if isLoggingIn { ProgressView("Logging In") .padding() .foregroundColor(.white) @@ -58,6 +65,8 @@ struct LoginView: View { .padding() .frame(maxWidth: .infinity) .disabled(password.isEmpty || email.isEmpty) + .accessibilityLabel("Log in") + .accessibilityHint("Logs in using the entered email and password") } Spacer() @@ -68,11 +77,12 @@ struct LoginView: View { .resizable() .edgesIgnoringSafeArea(.all) .scaledToFill() + .accessibilityHidden(true) ) .alert(errorTitle, isPresented: $hasError, actions: { }, message: { - + Text(errorMessage) }) } @@ -81,9 +91,9 @@ struct LoginView: View { "email": email, "password": password ] - doingNetworkShit = true + isLoggingIn = true UserStore.shared.login(postData: postData, completion: { success in - doingNetworkShit = false + isLoggingIn = false if success { completion() dismiss() @@ -103,4 +113,3 @@ struct LoginView_Previews: PreviewProvider { }) } } - diff --git a/iphone/Werkout_ios/Views/PlanWorkoutView.swift b/iphone/Werkout_ios/Views/PlanWorkoutView.swift index aeec5a5..e9cb51e 100644 --- a/iphone/Werkout_ios/Views/PlanWorkoutView.swift +++ b/iphone/Werkout_ios/Views/PlanWorkoutView.swift @@ -9,6 +9,8 @@ import SwiftUI struct PlanWorkoutView: View { @State var selectedDate = Date() + @State private var hasError = false + @State private var errorMessage = "" let workout: Workout @Environment(\.dismiss) var dismiss var addedPlannedWorkout: (() -> Void)? @@ -48,6 +50,8 @@ struct PlanWorkoutView: View { .background(.red) .cornerRadius(Constants.buttonRadius) .padding() + .accessibilityLabel("Cancel planning") + .accessibilityHint("Closes this screen without planning a workout") Button(action: { planWorkout() @@ -62,11 +66,18 @@ struct PlanWorkoutView: View { .background(.yellow) .cornerRadius(Constants.buttonRadius) .padding() + .accessibilityLabel("Plan workout") + .accessibilityHint("Adds this workout to your selected date") } Spacer() } .padding() + .alert("Unable to Plan Workout", isPresented: $hasError) { + Button("OK", role: .cancel) {} + } message: { + Text(errorMessage) + } } func planWorkout() { @@ -78,11 +89,16 @@ struct PlanWorkoutView: View { PlanWorkoutFetchable(postData: postData).fetch(completion: { result in switch result { case .success(_): - UserStore.shared.fetchPlannedWorkouts() - dismiss() - addedPlannedWorkout?() - case .failure(_): - fatalError("shit broke") + DispatchQueue.main.async { + UserStore.shared.fetchPlannedWorkouts() + dismiss() + addedPlannedWorkout?() + } + case .failure(let failure): + DispatchQueue.main.async { + errorMessage = failure.localizedDescription + hasError = true + } } }) } diff --git a/iphone/Werkout_ios/Views/WorkoutDetail/ExerciseListView.swift b/iphone/Werkout_ios/Views/WorkoutDetail/ExerciseListView.swift index af44fbb..ab1330c 100644 --- a/iphone/Werkout_ios/Views/WorkoutDetail/ExerciseListView.swift +++ b/iphone/Werkout_ios/Views/WorkoutDetail/ExerciseListView.swift @@ -11,7 +11,8 @@ import AVKit struct ExerciseListView: View { @AppStorage(Constants.phoneThotStyle) private var phoneThotStyle: ThotStyle = .never @ObservedObject var bridgeModule = BridgeModule.shared - @State var avPlayer = AVPlayer(url: URL(string: "https://dev.werkout.fitness/media/exercise_videos/2_Dumbbell_Lateral_Lunges.mp4")!) + @State var avPlayer = AVPlayer(url: URL(string: "https://dev.werkout.fitness/media/exercise_videos/2_Dumbbell_Lateral_Lunges.mp4") ?? URL(fileURLWithPath: "/dev/null")) + @State private var previewVideoURL: URL? var workout: Workout @Binding var showExecersizeInfo: Bool @AppStorage(Constants.thotGenderOption) private var thotGenderOption: String = "female" @@ -24,15 +25,14 @@ struct ExerciseListView: View { defaultVideoURLStr: self.videoExercise?.videoURL, exerciseName: self.videoExercise?.name, workout: bridgeModule.currentWorkoutInfo.workout) { - avPlayer = AVPlayer(url: videoURL) - avPlayer.isMuted = true - avPlayer.play() + updatePreviewPlayer(for: videoURL) } } } var body: some View { - if let supersets = workout.supersets { + let supersets = workout.supersets?.sorted(by: { $0.order < $1.order }) ?? [] + if supersets.isEmpty == false { ScrollViewReader { proxy in List() { ForEach(supersets.indices, id: \.self) { supersetIndex in @@ -40,58 +40,13 @@ struct ExerciseListView: View { Section(content: { ForEach(superset.exercises.indices, id: \.self) { exerciseIndex in let supersetExecercise = superset.exercises[exerciseIndex] + let rowID = rowIdentifier( + supersetIndex: supersetIndex, + exerciseIndex: exerciseIndex, + exercise: supersetExecercise + ) VStack { - HStack { - if bridgeModule.isInWorkout && - supersetIndex == bridgeModule.currentWorkoutInfo.supersetIndex && - exerciseIndex == bridgeModule.currentWorkoutInfo.exerciseIndex { - Image(systemName: "figure.run") - .foregroundColor(Color("appColor")) - } - - Text(supersetExecercise.exercise.extName) - - Spacer() - - if let reps = supersetExecercise.reps, - reps > 0 { - HStack { - Image(systemName: "number") - .foregroundColor(.white) - .frame(width: 20, alignment: .leading) - Text("\(reps)") - .foregroundColor(.white) - .frame(width: 30, alignment: .trailing) - - } - .padding([.top, .bottom], 5) - .padding([.leading], 10) - .padding([.trailing], 15) - .background(.blue) - .cornerRadius(5, corners: [.topLeft, .bottomLeft]) - .frame(alignment: .trailing) - } - - if let duration = supersetExecercise.duration, - duration > 0 { - HStack { - Image(systemName: "stopwatch") - .foregroundColor(.white) - .frame(width: 20, alignment: .leading) - Text("\(duration)") - .foregroundColor(.white) - .frame(width: 30, alignment: .trailing) - } - .padding([.top, .bottom], 5) - .padding([.leading], 10) - .padding([.trailing], 15) - .background(.green) - .cornerRadius(5, corners: [.topLeft, .bottomLeft]) - } - } - .padding(.trailing, -20) - .contentShape(Rectangle()) - .onTapGesture { + Button(action: { if bridgeModule.isInWorkout { bridgeModule.currentWorkoutInfo.goToExerciseAt( supersetIndex: supersetIndex, @@ -99,7 +54,61 @@ struct ExerciseListView: View { } else { videoExercise = supersetExecercise.exercise } - } + }, label: { + HStack { + if bridgeModule.isInWorkout && + supersetIndex == bridgeModule.currentWorkoutInfo.supersetIndex && + exerciseIndex == bridgeModule.currentWorkoutInfo.exerciseIndex { + Image(systemName: "figure.run") + .foregroundColor(Color("appColor")) + } + + Text(supersetExecercise.exercise.extName) + + Spacer() + + if let reps = supersetExecercise.reps, + reps > 0 { + HStack { + Image(systemName: "number") + .foregroundColor(.white) + .frame(width: 20, alignment: .leading) + Text("\(reps)") + .foregroundColor(.white) + .frame(width: 30, alignment: .trailing) + + } + .padding([.top, .bottom], 5) + .padding([.leading], 10) + .padding([.trailing], 15) + .background(.blue) + .cornerRadius(5, corners: [.topLeft, .bottomLeft]) + .frame(alignment: .trailing) + } + + if let duration = supersetExecercise.duration, + duration > 0 { + HStack { + Image(systemName: "stopwatch") + .foregroundColor(.white) + .frame(width: 20, alignment: .leading) + Text("\(duration)") + .foregroundColor(.white) + .frame(width: 30, alignment: .trailing) + } + .padding([.top, .bottom], 5) + .padding([.leading], 10) + .padding([.trailing], 15) + .background(.green) + .cornerRadius(5, corners: [.topLeft, .bottomLeft]) + } + } + .padding(.trailing, -20) + .contentShape(Rectangle()) + }) + .buttonStyle(.plain) + .accessibilityLabel("Exercise \(supersetExecercise.exercise.extName)") + .accessibilityHint(bridgeModule.isInWorkout ? "Jump to this exercise in the workout" : "Preview exercise video") if bridgeModule.isInWorkout && supersetIndex == bridgeModule.currentWorkoutInfo.supersetIndex && @@ -107,7 +116,7 @@ struct ExerciseListView: View { showExecersizeInfo { detailView(forExercise: supersetExecercise) } - }.id(supersetExecercise.id) + }.id(rowID) } }, header: { HStack { @@ -131,7 +140,16 @@ struct ExerciseListView: View { .onChange(of: bridgeModule.currentWorkoutInfo.allSupersetExecerciseIndex, perform: { newValue in if let newCurrentExercise = bridgeModule.currentWorkoutInfo.currentExercise { withAnimation { - proxy.scrollTo(newCurrentExercise.id, anchor: .top) + let currentSupersetIndex = bridgeModule.currentWorkoutInfo.supersetIndex + let currentExerciseIndex = bridgeModule.currentWorkoutInfo.exerciseIndex + proxy.scrollTo( + rowIdentifier( + supersetIndex: currentSupersetIndex, + exerciseIndex: currentExerciseIndex, + exercise: newCurrentExercise + ), + anchor: .top + ) } } }) @@ -142,9 +160,36 @@ struct ExerciseListView: View { avPlayer.play() } } + .onDisappear { + avPlayer.pause() + } } } } + + private func rowIdentifier(supersetIndex: Int, exerciseIndex: Int, exercise: SupersetExercise) -> String { + if let uniqueID = exercise.uniqueID, uniqueID.isEmpty == false { + return uniqueID + } + if let id = exercise.id { + return "exercise-\(id)" + } + return "superset-\(supersetIndex)-exercise-\(exerciseIndex)" + } + + private func updatePreviewPlayer(for url: URL) { + if previewVideoURL == url { + avPlayer.seek(to: .zero) + avPlayer.isMuted = true + avPlayer.play() + return + } + + previewVideoURL = url + avPlayer = AVPlayer(url: url) + avPlayer.isMuted = true + avPlayer.play() + } func detailView(forExercise supersetExecercise: SupersetExercise) -> some View { VStack { diff --git a/iphone/Werkout_ios/Views/WorkoutDetail/WorkoutDetailView.swift b/iphone/Werkout_ios/Views/WorkoutDetail/WorkoutDetailView.swift index a72a301..a95fc89 100644 --- a/iphone/Werkout_ios/Views/WorkoutDetail/WorkoutDetailView.swift +++ b/iphone/Werkout_ios/Views/WorkoutDetail/WorkoutDetailView.swift @@ -10,7 +10,8 @@ import AVKit struct WorkoutDetailView: View { @StateObject var viewModel: WorkoutDetailViewModel - @State var avPlayer = AVPlayer(url: URL(string: "https://dev.werkout.fitness/media/exercise_videos/2_Dumbbell_Lateral_Lunges.mp4")!) + @State var avPlayer = AVPlayer(url: URL(string: "https://dev.werkout.fitness/media/exercise_videos/2_Dumbbell_Lateral_Lunges.mp4") ?? URL(fileURLWithPath: "/dev/null")) + @State private var currentVideoURL: URL? @StateObject var bridgeModule = BridgeModule.shared @Environment(\.dismiss) var dismiss @@ -31,6 +32,16 @@ struct WorkoutDetailView: View { switch viewModel.status { case .loading: Text("Loading") + case .failed(let errorMessage): + VStack(spacing: 16) { + Text("Unable to load workout") + .font(.headline) + Text(errorMessage) + .font(.footnote) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + } + .padding() case .showWorkout(let workout): VStack(spacing: 0) { if bridgeModule.isInWorkout { @@ -59,9 +70,7 @@ struct WorkoutDetailView: View { defaultVideoURLStr: currentExtercise.exercise.videoURL, exerciseName: currentExtercise.exercise.name, workout: bridgeModule.currentWorkoutInfo.workout) { - avPlayer = AVPlayer(url: otherVideoURL) - avPlayer.isMuted = true - avPlayer.play() + updatePlayer(for: otherVideoURL) } }, label: { Image(systemName: "arrow.triangle.2.circlepath.camera.fill") @@ -72,6 +81,8 @@ struct WorkoutDetailView: View { .cornerRadius(Constants.buttonRadius) .frame(width: 160, height: 120) .position(x: metrics.size.width - 22, y: metrics.size.height - 30) + .accessibilityLabel("Switch video style") + .accessibilityHint("Toggles between alternate and default exercise videos") Button(action: { showExecersizeInfo.toggle() @@ -84,6 +95,8 @@ struct WorkoutDetailView: View { .cornerRadius(Constants.buttonRadius) .frame(width: 120, height: 120) .position(x: 22, y: metrics.size.height - 30) + .accessibilityLabel(showExecersizeInfo ? "Hide exercise info" : "Show exercise info") + .accessibilityHint("Shows exercise description and target muscles") } } .padding([.top, .bottom]) @@ -109,7 +122,7 @@ struct WorkoutDetailView: View { CurrentWorkoutElapsedTimeView() .frame(maxWidth: .infinity, alignment: .center) - Text("\(bridgeModule.currentWorkoutInfo.allSupersetExecerciseIndex+1)/\(bridgeModule.currentWorkoutInfo.workout?.allSupersetExecercise?.count ?? -99)") + Text(progressText) .font(.title3) .bold() .frame(maxWidth: .infinity, alignment: .trailing) @@ -163,18 +176,7 @@ struct WorkoutDetailView: View { playVideos() }) .onAppear{ - if let currentExtercise = bridgeModule.currentWorkoutInfo.currentExercise { - if let videoURL = VideoURLCreator.videoURL( - thotStyle: phoneThotStyle, - gender: thotGenderOption, - defaultVideoURLStr: currentExtercise.exercise.videoURL, - exerciseName: currentExtercise.exercise.name, - workout: bridgeModule.currentWorkoutInfo.workout) { - avPlayer = AVPlayer(url: videoURL) - avPlayer.isMuted = true - avPlayer.play() - } - } + playVideos() bridgeModule.completedWorkout = { if let workoutData = createWorkoutData() { @@ -187,6 +189,10 @@ struct WorkoutDetailView: View { avPlayer.isMuted = true avPlayer.play() } + .onDisappear { + avPlayer.pause() + bridgeModule.completedWorkout = nil + } } func playVideos() { @@ -197,16 +203,38 @@ struct WorkoutDetailView: View { defaultVideoURLStr: currentExtercise.exercise.videoURL, exerciseName: currentExtercise.exercise.name, workout: bridgeModule.currentWorkoutInfo.workout) { - avPlayer = AVPlayer(url: videoURL) - avPlayer.isMuted = true - avPlayer.play() + updatePlayer(for: videoURL) } } } + + private func updatePlayer(for url: URL) { + if currentVideoURL == url { + avPlayer.seek(to: .zero) + avPlayer.isMuted = true + avPlayer.play() + return + } + + currentVideoURL = url + avPlayer = AVPlayer(url: url) + avPlayer.isMuted = true + avPlayer.play() + } func startWorkout(workout: Workout) { bridgeModule.start(workout: workout) } + + private var progressText: String { + let totalExercises = bridgeModule.currentWorkoutInfo.workout?.allSupersetExecercise?.count ?? 0 + guard totalExercises > 0 else { + return "0/0" + } + + let current = min(totalExercises, max(1, bridgeModule.currentWorkoutInfo.allSupersetExecerciseIndex + 1)) + return "\(current)/\(totalExercises)" + } func createWorkoutData() -> [String:Any]? { guard let workoutid = bridgeModule.currentWorkoutInfo.workout?.id, diff --git a/iphone/Werkout_ios/Views/WorkoutDetail/WorkoutDetailViewModel.swift b/iphone/Werkout_ios/Views/WorkoutDetail/WorkoutDetailViewModel.swift index e9880d1..7af687a 100644 --- a/iphone/Werkout_ios/Views/WorkoutDetail/WorkoutDetailViewModel.swift +++ b/iphone/Werkout_ios/Views/WorkoutDetail/WorkoutDetailViewModel.swift @@ -12,6 +12,7 @@ class WorkoutDetailViewModel: ObservableObject { enum WorkoutDetailViewModelStatus { case loading case showWorkout(Workout) + case failed(String) } @Published var status: WorkoutDetailViewModelStatus @@ -31,7 +32,9 @@ class WorkoutDetailViewModel: ObservableObject { self.status = .showWorkout(model) } case .failure(let failure): - fatalError("failed \(failure.localizedDescription)") + DispatchQueue.main.async { + self.status = .failed("Failed to load workout details: \(failure.localizedDescription)") + } } }) } diff --git a/iphone/Werkout_ios/Views/WorkoutHistoryView.swift b/iphone/Werkout_ios/Views/WorkoutHistoryView.swift index 59b203e..ecaaed0 100644 --- a/iphone/Werkout_ios/Views/WorkoutHistoryView.swift +++ b/iphone/Werkout_ios/Views/WorkoutHistoryView.swift @@ -42,7 +42,7 @@ struct WorkoutHistoryView: View { HStack { VStack { if let date = completedWorkout.workoutStartTime.dateFromServerDate { - Text(DateFormatter().shortMonthSymbols[date.get(.month) - 1]) + Text(date.monthString) Text("\(date.get(.day))") Text("\(date.get(.hour))") diff --git a/iphone/Werkout_ios/Werkout_iosApp.swift b/iphone/Werkout_ios/Werkout_iosApp.swift index acb8f48..1502ebd 100644 --- a/iphone/Werkout_ios/Werkout_iosApp.swift +++ b/iphone/Werkout_ios/Werkout_iosApp.swift @@ -8,6 +8,7 @@ import SwiftUI import Combine import AVKit +import SharedCore struct Constants { @@ -24,21 +25,24 @@ struct Werkout_iosApp: App { let persistenceController = PersistenceController.shared @State var additionalWindows: [UIWindow] = [] @State private var tabSelection = 1 + @Environment(\.scenePhase) private var scenePhase - let pub = NotificationCenter.default.publisher(for: NSNotification.Name("CreatedNewWorkout")) + let pub = NotificationCenter.default.publisher(for: AppNotifications.createdNewWorkout) private var screenDidConnectPublisher: AnyPublisher { NotificationCenter.default - .publisher(for: UIScreen.didConnectNotification) - .compactMap { $0.object as? UIScreen } + .publisher(for: UIScene.willConnectNotification) + .compactMap { ($0.object as? UIWindowScene)?.screen } + .filter { $0 != UIScreen.main } .receive(on: RunLoop.main) .eraseToAnyPublisher() } private var screenDidDisconnectPublisher: AnyPublisher { NotificationCenter.default - .publisher(for: UIScreen.didDisconnectNotification) - .compactMap { $0.object as? UIScreen } + .publisher(for: UIScene.didDisconnectNotification) + .compactMap { ($0.object as? UIWindowScene)?.screen } + .filter { $0 != UIScreen.main } .receive(on: RunLoop.main) .eraseToAnyPublisher() } @@ -74,10 +78,13 @@ struct Werkout_iosApp: App { } .accentColor(Color("appColor")) .onAppear{ - UIApplication.shared.isIdleTimerDisabled = true + UIApplication.shared.isIdleTimerDisabled = scenePhase == .active _ = try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback, mode: .default, options: .mixWithOthers) // UserStore.shared.logout() } + .onChange(of: scenePhase) { phase in + UIApplication.shared.isIdleTimerDisabled = phase == .active + } .onReceive(pub) { (output) in self.tabSelection = 1 } diff --git a/iphone/Werkout_ios/subview/ActionsView.swift b/iphone/Werkout_ios/subview/ActionsView.swift index b351cda..5b971ea 100644 --- a/iphone/Werkout_ios/subview/ActionsView.swift +++ b/iphone/Werkout_ios/subview/ActionsView.swift @@ -32,6 +32,7 @@ struct ActionsView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .background(.red) .foregroundColor(.white) + .accessibilityLabel("Close workout") if showAddToCalendar { Button(action: { @@ -44,6 +45,7 @@ struct ActionsView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .background(.blue) .foregroundColor(.white) + .accessibilityLabel("Plan workout") } Button(action: { @@ -56,6 +58,7 @@ struct ActionsView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .background(.green) .foregroundColor(.white) + .accessibilityLabel("Start workout") } else { Button(action: { showCompleteSheet.toggle() @@ -67,6 +70,7 @@ struct ActionsView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .background(.blue) .foregroundColor(.white) + .accessibilityLabel("Complete workout") Button(action: { AudioEngine.shared.playFinished() @@ -84,6 +88,7 @@ struct ActionsView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .background(bridgeModule.isPaused ? .mint : .yellow) .foregroundColor(.white) + .accessibilityLabel(bridgeModule.isPaused ? "Resume workout" : "Pause workout") Button(action: { AudioEngine.shared.playFinished() @@ -96,6 +101,7 @@ struct ActionsView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .background(.green) .foregroundColor(.white) + .accessibilityLabel("Next exercise") } } .alert("Complete Workout", isPresented: $showCompleteSheet) { diff --git a/iphone/Werkout_ios/subview/AddSupersetView.swift b/iphone/Werkout_ios/subview/AddSupersetView.swift index d235e73..59f03a4 100644 --- a/iphone/Werkout_ios/subview/AddSupersetView.swift +++ b/iphone/Werkout_ios/subview/AddSupersetView.swift @@ -7,7 +7,7 @@ import SwiftUI struct AddSupersetView: View { - @Binding var createWorkoutSuperSet: CreateWorkoutSuperSet + @ObservedObject var createWorkoutSuperSet: CreateWorkoutSuperSet var viewModel: WorkoutViewModel @Binding var selectedCreateWorkoutSuperSet: CreateWorkoutSuperSet? @@ -18,8 +18,9 @@ struct AddSupersetView: View { Text(createWorkoutExercise.exercise.name) .font(.title2) .frame(maxWidth: .infinity) - if createWorkoutExercise.exercise.side != nil && createWorkoutExercise.exercise.side!.count > 0 { - Text(createWorkoutExercise.exercise.side!) + if let side = createWorkoutExercise.exercise.side, + side.isEmpty == false { + Text(side) .font(.title3) .frame(maxWidth: .infinity, alignment: .center) } diff --git a/iphone/Werkout_ios/subview/AllExerciseView.swift b/iphone/Werkout_ios/subview/AllExerciseView.swift index 36f8f34..2a87e44 100644 --- a/iphone/Werkout_ios/subview/AllExerciseView.swift +++ b/iphone/Werkout_ios/subview/AllExerciseView.swift @@ -14,12 +14,13 @@ struct AllExerciseView: View { @Binding var filteredExercises: [Exercise] var selectedExercise: ((Exercise) -> Void) - @State var avPlayer = AVPlayer(url: URL(string: "https://dev.werkout.fitness/media/exercise_videos/2_Dumbbell_Lateral_Lunges.mp4")!) + @State var avPlayer = AVPlayer(url: URL(string: "https://dev.werkout.fitness/media/exercise_videos/2_Dumbbell_Lateral_Lunges.mp4") ?? URL(fileURLWithPath: "/dev/null")) + @State private var currentVideoURL: URL? @State var videoExercise: Exercise? { didSet { if let viddd = self.videoExercise?.videoURL, let url = URL(string: BaseURLs.currentBaseURL + viddd) { - self.avPlayer = AVPlayer(url: url) + updatePlayer(for: url) } } } @@ -38,19 +39,20 @@ struct AllExerciseView: View { Text(exercise.name) .frame(maxWidth: .infinity, alignment: .leading) - if exercise.side != nil && !exercise.side!.isEmpty { - Text(exercise.side!) + if let side = exercise.side, + side.isEmpty == false { + Text(side) .font(.footnote) .frame(maxWidth: .infinity, alignment: .leading) } - if exercise.equipmentRequired != nil && !exercise.equipmentRequired!.isEmpty { + if exercise.equipmentRequired?.isEmpty == false { Text(exercise.spacedEquipmentRequired) .font(.footnote) .frame(maxWidth: .infinity, alignment: .leading) } - if exercise.muscleGroups != nil && !exercise.muscleGroups!.isEmpty { + if exercise.muscleGroups?.isEmpty == false { Text(exercise.spacedMuscleGroups) .font(.footnote) .frame(maxWidth: .infinity, alignment: .leading) @@ -86,6 +88,23 @@ struct AllExerciseView: View { avPlayer.play() } } + .onDisappear { + avPlayer.pause() + } + } + + private func updatePlayer(for url: URL) { + if currentVideoURL == url { + avPlayer.seek(to: .zero) + avPlayer.isMuted = true + avPlayer.play() + return + } + + currentVideoURL = url + avPlayer = AVPlayer(url: url) + avPlayer.isMuted = true + avPlayer.play() } } // diff --git a/iphone/Werkout_ios/subview/CompletedWorkoutsView.swift b/iphone/Werkout_ios/subview/CompletedWorkoutsView.swift index 05c5246..5603d8a 100644 --- a/iphone/Werkout_ios/subview/CompletedWorkoutsView.swift +++ b/iphone/Werkout_ios/subview/CompletedWorkoutsView.swift @@ -10,6 +10,7 @@ import SwiftUI struct CompletedWorkoutsView: View { @State var completedWorkouts: [CompletedWorkout]? @State var showCompletedWorkouts: Bool = false + @State private var loadError: String? var body: some View { VStack(alignment: .leading) { @@ -41,7 +42,11 @@ struct CompletedWorkoutsView: View { } } else { - Text("loading completed workouts") + if let loadError = loadError { + Text(loadError) + } else { + Text("loading completed workouts") + } } } .onAppear{ @@ -58,9 +63,14 @@ struct CompletedWorkoutsView: View { CompletedWorkoutFetchable().fetch(completion: { result in switch result { case .success(let model): - completedWorkouts = model + DispatchQueue.main.async { + completedWorkouts = model + loadError = nil + } case .failure(let failure): - fatalError(failure.localizedDescription) + DispatchQueue.main.async { + loadError = "Unable to load workout history: \(failure.localizedDescription)" + } } }) } diff --git a/iphone/Werkout_ios/subview/CreateWorkoutSupersetView.swift b/iphone/Werkout_ios/subview/CreateWorkoutSupersetView.swift index d80107f..58e6c21 100644 --- a/iphone/Werkout_ios/subview/CreateWorkoutSupersetView.swift +++ b/iphone/Werkout_ios/subview/CreateWorkoutSupersetView.swift @@ -9,13 +9,13 @@ import SwiftUI struct CreateWorkoutSupersetView: View { @Binding var selectedCreateWorkoutSuperSet: CreateWorkoutSuperSet? @Binding var showAddExercise: Bool - @Binding var superset: CreateWorkoutSuperSet + @ObservedObject var superset: CreateWorkoutSuperSet @ObservedObject var viewModel: WorkoutViewModel var body: some View { Section(content: { AddSupersetView( - createWorkoutSuperSet: $superset, + createWorkoutSuperSet: superset, viewModel: viewModel, selectedCreateWorkoutSuperSet: $selectedCreateWorkoutSuperSet) }, header: { @@ -34,23 +34,25 @@ struct CreateWorkoutSupersetView: View { Spacer() Button(action: { - selectedCreateWorkoutSuperSet = $superset.wrappedValue - showAddExercise.toggle() + selectedCreateWorkoutSuperSet = superset + showAddExercise = true }, label: { Image(systemName: "dumbbell.fill") .font(.title2) }) + .accessibilityLabel("Add exercise") + .accessibilityHint("Adds an exercise to this superset") Divider() Button(action: { - viewModel.delete(superset: $superset.wrappedValue) - //viewModel.increaseRandomNumberForUpdating() - viewModel.objectWillChange.send() + viewModel.delete(superset: superset) }, label: { Image(systemName: "trash") .font(.title2) }) + .accessibilityLabel("Delete superset") + .accessibilityHint("Removes this superset") } Divider() @@ -59,18 +61,14 @@ struct CreateWorkoutSupersetView: View { HStack { Text("Rounds: ") - Text("\($superset.wrappedValue.numberOfRounds)") - .foregroundColor($superset.wrappedValue.numberOfRounds > 0 ? Color(uiColor: .label) : .red) + Text("\(superset.numberOfRounds)") + .foregroundColor(superset.numberOfRounds > 0 ? Color(uiColor: .label) : .red) .bold() } }, onIncrement: { - $superset.wrappedValue.increaseNumberOfRounds() - //viewModel.increaseRandomNumberForUpdating() - viewModel.objectWillChange.send() + superset.increaseNumberOfRounds() }, onDecrement: { - $superset.wrappedValue.decreaseNumberOfRounds() - //viewModel.increaseRandomNumberForUpdating() - viewModel.objectWillChange.send() + superset.decreaseNumberOfRounds() }) } } diff --git a/iphone/Werkout_ios/subview/PlannedWorkoutView.swift b/iphone/Werkout_ios/subview/PlannedWorkoutView.swift index 33cbe4b..1ced27d 100644 --- a/iphone/Werkout_ios/subview/PlannedWorkoutView.swift +++ b/iphone/Werkout_ios/subview/PlannedWorkoutView.swift @@ -13,34 +13,38 @@ struct PlannedWorkoutView: View { var body: some View { List { - ForEach(workouts.sorted(by: { $0.onDate < $1.onDate }), id:\.workout.name) { plannedWorkout in - HStack { - VStack(alignment: .leading) { - Text(plannedWorkout.onDate.plannedDate?.weekDay ?? "-") - .font(.title) + ForEach(workouts.sorted(by: { $0.onDate < $1.onDate }), id: \.id) { plannedWorkout in + Button(action: { + selectedPlannedWorkout = plannedWorkout.workout + }, label: { + HStack { + VStack(alignment: .leading) { + Text(plannedWorkout.onDate.plannedDate?.weekDay ?? "-") + .font(.title) + + Text(plannedWorkout.onDate.plannedDate?.monthString ?? "-") + .font(.title) + + Text(plannedWorkout.onDate.plannedDate?.dateString ?? "-") + .font(.title) + } - Text(plannedWorkout.onDate.plannedDate?.monthString ?? "-") - .font(.title) + Divider() - Text(plannedWorkout.onDate.plannedDate?.dateString ?? "-") - .font(.title) + VStack { + Text(plannedWorkout.workout.name) + .font(.title) + .frame(maxWidth: .infinity, alignment: .leading) + + Text(plannedWorkout.workout.description ?? "") + .font(.body) + .frame(maxWidth: .infinity, alignment: .leading) + } } - - Divider() - - VStack { - Text(plannedWorkout.workout.name) - .font(.title) - .frame(maxWidth: .infinity, alignment: .leading) - - Text(plannedWorkout.workout.description ?? "") - .font(.body) - .frame(maxWidth: .infinity, alignment: .leading) - } - .onTapGesture { - selectedPlannedWorkout = plannedWorkout.workout - } - } + }) + .buttonStyle(.plain) + .accessibilityLabel("Open planned workout \(plannedWorkout.workout.name)") + .accessibilityHint("Shows workout details") } } } diff --git a/iphone/Werkout_ios/subview/PlayerUIView.swift b/iphone/Werkout_ios/subview/PlayerUIView.swift index 4ff5602..85c69f4 100644 --- a/iphone/Werkout_ios/subview/PlayerUIView.swift +++ b/iphone/Werkout_ios/subview/PlayerUIView.swift @@ -21,7 +21,8 @@ class PlayerUIView: UIView { } required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") + super.init(coder: coder) + self.playerSetup(player: AVPlayer()) } init(player: AVPlayer) { @@ -76,11 +77,20 @@ struct PlayerView: UIViewRepresentable { } func updateUIView(_ uiView: PlayerUIView, context: UIViewRepresentableContext) { + if uiView.playerLayer.player !== player { + uiView.playerLayer.player?.pause() + } uiView.playerLayer.player = player //Add player observer. uiView.setObserver() } + + static func dismantleUIView(_ uiView: PlayerUIView, coordinator: ()) { + uiView.playerLayer.player?.pause() + uiView.playerLayer.player = nil + NotificationCenter.default.removeObserver(uiView) + } } class VideoURLCreator { diff --git a/iphone/Werkout_watch Watch App/MainWatchView.swift b/iphone/Werkout_watch Watch App/MainWatchView.swift index e20b175..126f026 100644 --- a/iphone/Werkout_watch Watch App/MainWatchView.swift +++ b/iphone/Werkout_watch Watch App/MainWatchView.swift @@ -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) } } } diff --git a/iphone/Werkout_watch Watch App/WatchControlView.swift b/iphone/Werkout_watch Watch App/WatchControlView.swift index d547e38..d3eb90e 100644 --- a/iphone/Werkout_watch Watch App/WatchControlView.swift +++ b/iphone/Werkout_watch Watch App/WatchControlView.swift @@ -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") } } } diff --git a/iphone/Werkout_watch Watch App/WatchDelegate.swift b/iphone/Werkout_watch Watch App/WatchDelegate.swift index 8553b51..8d524cb 100644 --- a/iphone/Werkout_watch Watch App/WatchDelegate.swift +++ b/iphone/Werkout_watch Watch App/WatchDelegate.swift @@ -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 = [ + 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)") } } } diff --git a/iphone/Werkout_watch Watch App/WatchMainViewModel+WCSessionDelegate.swift b/iphone/Werkout_watch Watch App/WatchMainViewModel+WCSessionDelegate.swift index 2b53e2f..d3b8a7a 100644 --- a/iphone/Werkout_watch Watch App/WatchMainViewModel+WCSessionDelegate.swift +++ b/iphone/Werkout_watch Watch App/WatchMainViewModel+WCSessionDelegate.swift @@ -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(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") } } } diff --git a/iphone/Werkout_watch Watch App/WatchMainViewModel.swift b/iphone/Werkout_watch Watch App/WatchMainViewModel.swift index 2db7aad..25e0c47 100644 --- a/iphone/Werkout_watch Watch App/WatchMainViewModel.swift +++ b/iphone/Werkout_watch Watch App/WatchMainViewModel.swift @@ -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)") } } } diff --git a/iphone/Werkout_watch Watch App/WatchWorkout.swift b/iphone/Werkout_watch Watch App/WatchWorkout.swift index 9eddb70..6c70c34 100644 --- a/iphone/Werkout_watch Watch App/WatchWorkout.swift +++ b/iphone/Werkout_watch Watch App/WatchWorkout.swift @@ -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) { 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)") } } diff --git a/scripts/ci/scan_tokens.sh b/scripts/ci/scan_tokens.sh new file mode 100755 index 0000000..c48d53c --- /dev/null +++ b/scripts/ci/scan_tokens.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT_DIR" + +PATTERN='(Token[[:space:]]+[A-Za-z0-9._-]{20,}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}|\b[a-fA-F0-9]{40,}\b)' + +MATCHES="$(rg -n --no-heading -S "$PATTERN" \ + iphone WekoutThotViewer SharedCore \ + --glob '!**/*.xcodeproj/**' \ + --glob '!**/Tests/**' \ + --glob '!**/*.md' \ + --glob '!**/.build/**' || true)" + +if [[ -n "$MATCHES" ]]; then + echo "Potential hardcoded token(s) detected:" >&2 + echo "$MATCHES" >&2 + echo "If a match is intentional, redact it or move it to secure runtime configuration." >&2 + exit 1 +fi + +echo "Token scan passed." diff --git a/scripts/hardware/watch_disconnect_hardware_pass.sh b/scripts/hardware/watch_disconnect_hardware_pass.sh new file mode 100755 index 0000000..8f2a644 --- /dev/null +++ b/scripts/hardware/watch_disconnect_hardware_pass.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT_DIR" + +TS="$(date -u +"%Y%m%dT%H%M%SZ")" +OUT_DIR="${TMPDIR:-/tmp}/werkout_watch_hardware_pass_${TS}" +mkdir -p "$OUT_DIR" + +export SWIFTPM_MODULECACHE_OVERRIDE="${TMPDIR:-/tmp}/werkout_swiftpm_module_cache" +export CLANG_MODULE_CACHE_PATH="${TMPDIR:-/tmp}/werkout_clang_module_cache" +export XDG_CACHE_HOME="${TMPDIR:-/tmp}/werkout_xdg_cache" +mkdir -p "$SWIFTPM_MODULECACHE_OVERRIDE" "$CLANG_MODULE_CACHE_PATH" "$XDG_CACHE_HOME" + +PKG_CACHE_DIR="$OUT_DIR/package_cache" +PKG_CLONES_DIR="$OUT_DIR/source_packages" +XCODE_TEMP_HOME="$OUT_DIR/home" +mkdir -p "$PKG_CACHE_DIR" "$PKG_CLONES_DIR" "$XCODE_TEMP_HOME" + +IOS_DEST_FILE="$OUT_DIR/ios_showdestinations.txt" +WATCH_DEST_FILE="$OUT_DIR/watch_showdestinations.txt" + +HOME="$XCODE_TEMP_HOME" xcodebuild -project iphone/Werkout_ios.xcodeproj \ + -scheme 'Werkout_ios' \ + -disableAutomaticPackageResolution \ + -clonedSourcePackagesDirPath "$PKG_CLONES_DIR" \ + -packageCachePath "$PKG_CACHE_DIR" \ + -showdestinations > "$IOS_DEST_FILE" 2>&1 || { + echo "Failed to query iOS destinations." + echo "Inspect: $IOS_DEST_FILE" + tail -n 80 "$IOS_DEST_FILE" || true + exit 3 + } + +HOME="$XCODE_TEMP_HOME" xcodebuild -project iphone/Werkout_ios.xcodeproj \ + -scheme 'Werkout_watch Watch App' \ + -disableAutomaticPackageResolution \ + -clonedSourcePackagesDirPath "$PKG_CLONES_DIR" \ + -packageCachePath "$PKG_CACHE_DIR" \ + -showdestinations > "$WATCH_DEST_FILE" 2>&1 || { + echo "Failed to query watchOS destinations." + echo "Inspect: $WATCH_DEST_FILE" + tail -n 80 "$WATCH_DEST_FILE" || true + exit 3 + } + +IOS_ELIGIBLE_LINE="$(awk '/Available destinations/{flag=1;next}/Ineligible destinations/{flag=0}flag' "$IOS_DEST_FILE" \ + | rg "platform:iOS, arch:arm64, id:" \ + | rg -v "placeholder" \ + | head -n 1 || true)" + +WATCH_ELIGIBLE_LINE="$(awk '/Available destinations/{flag=1;next}/Ineligible destinations/{flag=0}flag' "$WATCH_DEST_FILE" \ + | rg "platform:watchOS, arch:" \ + | rg -v "undefined_arch|placeholder" \ + | head -n 1 || true)" + +if [[ -z "$IOS_ELIGIBLE_LINE" ]]; then + echo "No eligible physical iOS destination found." + echo "Inspect: $IOS_DEST_FILE" + exit 2 +fi + +if [[ -z "$WATCH_ELIGIBLE_LINE" ]]; then + echo "No eligible physical watchOS destination found." + echo "Inspect: $WATCH_DEST_FILE" + exit 2 +fi + +IOS_ID="$(echo "$IOS_ELIGIBLE_LINE" | sed -E 's/.*id:([^,]+),.*/\1/')" +WATCH_ID="$(echo "$WATCH_ELIGIBLE_LINE" | sed -E 's/.*id:([^,]+),.*/\1/')" + +echo "Using iOS destination: $IOS_ELIGIBLE_LINE" +echo "Using watchOS destination: $WATCH_ELIGIBLE_LINE" + +echo +echo "Preflight compile for selected hardware destinations..." + +set -o pipefail +HOME="$XCODE_TEMP_HOME" xcodebuild -project iphone/Werkout_ios.xcodeproj \ + -scheme 'Werkout_ios' \ + -configuration Debug \ + -destination "id=$IOS_ID" \ + -disableAutomaticPackageResolution \ + -clonedSourcePackagesDirPath "$PKG_CLONES_DIR" \ + -packageCachePath "$PKG_CACHE_DIR" \ + CODE_SIGNING_ALLOWED=NO \ + build > "$OUT_DIR/ios_hardware_build.log" 2>&1 + +HOME="$XCODE_TEMP_HOME" xcodebuild -project iphone/Werkout_ios.xcodeproj \ + -scheme 'Werkout_watch Watch App' \ + -configuration Debug \ + -destination "id=$WATCH_ID" \ + -disableAutomaticPackageResolution \ + -clonedSourcePackagesDirPath "$PKG_CLONES_DIR" \ + -packageCachePath "$PKG_CACHE_DIR" \ + CODE_SIGNING_ALLOWED=NO \ + build > "$OUT_DIR/watch_hardware_build.log" 2>&1 + +cat > "$OUT_DIR/manual_disconnect_reconnect_checklist.md" <<'EOF' +# Manual Hardware Disconnect/Reconnect Pass + +1. Launch iOS app on the selected physical iPhone/iPad. +2. Launch watch app on the paired physical Apple Watch. +3. Start a workout from iOS and confirm watch receives first exercise. +4. Disconnect watch from phone transport: + - Disable Bluetooth on iPhone for 30 seconds, or + - Enable Airplane Mode on watch for 30 seconds. +5. While disconnected, trigger 5+ state changes on phone: + - Next/previous exercise + - Pause/resume + - Complete workout +6. Reconnect transport. +7. Verify on watch: + - Latest state is applied. + - No crash. + - No infinite stale replay loop. +8. Repeat with two cycles of disconnect/reconnect in same workout. + +Pass criteria: +- Watch converges to current exercise/time state after each reconnect. +- Queue replay does not exceed recent max-capped payload behavior. +- Completion payload arrives exactly once. + +Log capture suggestion: +log stream --style compact --predicate '(subsystem == "com.werkout.ios" || subsystem == "com.werkout.watch")' +EOF + +echo +echo "Hardware preflight complete." +echo "Artifacts: $OUT_DIR" +echo "Runbook: $OUT_DIR/manual_disconnect_reconnect_checklist.md" diff --git a/scripts/smoke/build_ios.sh b/scripts/smoke/build_ios.sh new file mode 100755 index 0000000..d932649 --- /dev/null +++ b/scripts/smoke/build_ios.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT_DIR" + +LOG_FILE="${TMPDIR:-/tmp}/werkout_smoke_ios.log" + +set -o pipefail +xcodebuild -project iphone/Werkout_ios.xcodeproj \ + -scheme 'Werkout_ios' \ + -configuration Debug \ + -destination 'generic/platform=iOS' \ + -derivedDataPath /tmp/werkout_smoke_ios_dd \ + CODE_SIGNING_ALLOWED=NO \ + build 2>&1 | tee "$LOG_FILE" + +FILTERED_ISSUES="$(rg -n "warning:|error:" "$LOG_FILE" | rg -v "Metadata extraction skipped. No AppIntents.framework dependency found." || true)" +if [[ -n "$FILTERED_ISSUES" ]]; then + echo "iOS build produced warnings/errors. See $LOG_FILE" >&2 + echo "$FILTERED_ISSUES" | sed -n '1,120p' >&2 + exit 1 +fi diff --git a/scripts/smoke/build_tvos.sh b/scripts/smoke/build_tvos.sh new file mode 100755 index 0000000..811d48a --- /dev/null +++ b/scripts/smoke/build_tvos.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT_DIR" + +LOG_FILE="${TMPDIR:-/tmp}/werkout_smoke_tv.log" + +set -o pipefail +xcodebuild -project WekoutThotViewer/WekoutThotViewer.xcodeproj \ + -scheme WekoutThotViewer \ + -configuration Debug \ + -destination 'generic/platform=tvOS' \ + -derivedDataPath /tmp/werkout_smoke_tv_dd \ + CODE_SIGNING_ALLOWED=NO \ + build 2>&1 | tee "$LOG_FILE" + +FILTERED_ISSUES="$(rg -n "warning:|error:" "$LOG_FILE" | rg -v "Metadata extraction skipped. No AppIntents.framework dependency found." || true)" +if [[ -n "$FILTERED_ISSUES" ]]; then + echo "tvOS build produced warnings/errors. See $LOG_FILE" >&2 + echo "$FILTERED_ISSUES" | sed -n '1,120p' >&2 + exit 1 +fi diff --git a/scripts/smoke/build_watch.sh b/scripts/smoke/build_watch.sh new file mode 100755 index 0000000..70e643e --- /dev/null +++ b/scripts/smoke/build_watch.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT_DIR" + +LOG_FILE="${TMPDIR:-/tmp}/werkout_smoke_watch.log" + +set -o pipefail +xcodebuild -project iphone/Werkout_ios.xcodeproj \ + -scheme 'Werkout_watch Watch App' \ + -configuration Debug \ + -destination 'generic/platform=watchOS' \ + -derivedDataPath /tmp/werkout_smoke_watch_dd \ + CODE_SIGNING_ALLOWED=NO \ + build 2>&1 | tee "$LOG_FILE" + +FILTERED_ISSUES="$(rg -n "warning:|error:" "$LOG_FILE" | rg -v "Metadata extraction skipped. No AppIntents.framework dependency found." || true)" +if [[ -n "$FILTERED_ISSUES" ]]; then + echo "watchOS build produced warnings/errors. See $LOG_FILE" >&2 + echo "$FILTERED_ISSUES" | sed -n '1,120p' >&2 + exit 1 +fi diff --git a/scripts/smoke/smoke_all.sh b/scripts/smoke/smoke_all.sh new file mode 100755 index 0000000..c83b447 --- /dev/null +++ b/scripts/smoke/smoke_all.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT_DIR" + +./scripts/ci/scan_tokens.sh + +cd SharedCore +export SWIFTPM_MODULECACHE_OVERRIDE="${TMPDIR:-/tmp}/werkout_swiftpm_module_cache" +export CLANG_MODULE_CACHE_PATH="${TMPDIR:-/tmp}/werkout_clang_module_cache" +export XDG_CACHE_HOME="${TMPDIR:-/tmp}/werkout_xdg_cache" +mkdir -p "$SWIFTPM_MODULECACHE_OVERRIDE" "$CLANG_MODULE_CACHE_PATH" "$XDG_CACHE_HOME" +swift test --disable-sandbox --scratch-path "${TMPDIR:-/tmp}/werkout_sharedcore_scratch" +cd "$ROOT_DIR" + +./scripts/smoke/build_ios.sh +./scripts/smoke/build_watch.sh +./scripts/smoke/build_tvos.sh + +echo "Smoke suite passed (token scan + SharedCore tests + iOS/watchOS/tvOS builds)."