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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apps/HeartCoach/Shared/Models/HeartModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1409,6 +1409,9 @@ public struct UserProfile: Codable, Equatable, Sendable {
/// Biological sex for metric norm stratification.
public var biologicalSex: BiologicalSex

/// Email address from Sign in with Apple (optional, only provided on first sign-in).
public var email: String?

public init(
displayName: String = "",
joinDate: Date = Date(),
Expand All @@ -1417,7 +1420,8 @@ public struct UserProfile: Codable, Equatable, Sendable {
lastStreakCreditDate: Date? = nil,
nudgeCompletionDates: Set<String> = [],
dateOfBirth: Date? = nil,
biologicalSex: BiologicalSex = .notSet
biologicalSex: BiologicalSex = .notSet,
email: String? = nil
) {
self.displayName = displayName
self.joinDate = joinDate
Expand All @@ -1427,6 +1431,7 @@ public struct UserProfile: Codable, Equatable, Sendable {
self.nudgeCompletionDates = nudgeCompletionDates
self.dateOfBirth = dateOfBirth
self.biologicalSex = biologicalSex
self.email = email
}

/// Computed chronological age in years from date of birth.
Expand Down
6 changes: 6 additions & 0 deletions apps/HeartCoach/Shared/Services/LocalStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -299,13 +299,17 @@ public final class LocalStore: ObservableObject {
// Encryption unavailable — do NOT fall back to plaintext for health data.
// Data is dropped rather than stored unencrypted. The next successful
// save will restore it. This protects PHI at the cost of temporary data loss.
#if DEBUG
print("[LocalStore] ERROR: Encryption unavailable for key \(key.rawValue). Data NOT saved to protect health data privacy.")
#endif
#if DEBUG
assertionFailure("CryptoService.encrypt() returned nil for key \(key.rawValue). Fix Keychain access or mock CryptoService in tests.")
#endif
}
} catch {
#if DEBUG
print("[LocalStore] ERROR: Failed to encode \(T.self) for key \(key.rawValue): \(error)")
#endif
}
}

Expand Down Expand Up @@ -342,10 +346,12 @@ public final class LocalStore: ObservableObject {
// Both encrypted and plain-text decoding failed — data is corrupted
// or from an incompatible schema version. Remove the bad entry so the
// app can start fresh instead of crashing on every launch.
#if DEBUG
print(
"[LocalStore] WARNING: Removing unreadable \(T.self) "
+ "from key \(key.rawValue). Stored data was corrupted or incompatible."
)
#endif
defaults.removeObject(forKey: key.rawValue)
return nil
}
Expand Down
4 changes: 4 additions & 0 deletions apps/HeartCoach/iOS/Services/AnalyticsEvents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ enum AnalyticsEventName: String, CaseIterable, Sendable {
// Watch
case watchFeedbackReceived = "watch_feedback_received"

// Sign In
case appleSignInCompleted = "apple_sign_in_completed"
case appleSignInFailed = "apple_sign_in_failed"

// AI / Assessment
case assessmentGenerated = "assessment_generated"
}
Expand Down
142 changes: 142 additions & 0 deletions apps/HeartCoach/iOS/Services/AppleSignInService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// AppleSignInService.swift
// Thump iOS
//
// Handles Sign in with Apple authentication flow.
// Stores the Apple user identifier in the Keychain via the Security
// framework (not CryptoService, which is for data encryption).
// On subsequent launches, verifies the credential is still valid.
//
// Platforms: iOS 17+

import AuthenticationServices
import Foundation

// MARK: - Apple Sign-In Service

/// Manages Sign in with Apple credential storage and validation.
///
/// The Apple-issued `userIdentifier` is a stable, opaque string that
/// persists across app reinstalls on the same device. We store it in
/// the Keychain so it survives app updates and UserDefaults resets.
///
/// Usage:
/// ```swift
/// // Save after successful sign-in
/// AppleSignInService.saveUserIdentifier("001234.abc...")
///
/// // Check on app launch
/// let isValid = await AppleSignInService.isCredentialValid()
/// ```
public enum AppleSignInService {

// MARK: - Keychain Constants

/// Keychain item identifier for the Apple user ID.
private static let keychainAccount = "com.thump.appleUserIdentifier"

/// Service name used in the Keychain query.
private static let keychainService = "com.thump.AppleSignIn"

// MARK: - Credential Storage

/// Save the Apple user identifier to the Keychain.
///
/// - Parameter userIdentifier: The stable user ID from
/// `ASAuthorizationAppleIDCredential.user`.
public static func saveUserIdentifier(_ userIdentifier: String) {
guard let data = userIdentifier.data(using: .utf8) else { return }

// Delete any existing entry first
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecAttrAccount as String: keychainAccount
]
SecItemDelete(deleteQuery as CFDictionary)

// Add new entry
let addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecAttrAccount as String: keychainAccount,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
]
SecItemAdd(addQuery as CFDictionary, nil)
}

/// Retrieve the stored Apple user identifier from the Keychain.
///
/// - Returns: The user identifier string, or `nil` if not stored.
public static func loadUserIdentifier() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecAttrAccount as String: keychainAccount,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]

var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)

guard status == errSecSuccess,
let data = result as? Data,
let identifier = String(data: data, encoding: .utf8) else {
return nil
}
return identifier
}

/// Delete the stored Apple user identifier from the Keychain.
/// Used when credential is revoked or user signs out.
public static func deleteUserIdentifier() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecAttrAccount as String: keychainAccount
]
SecItemDelete(query as CFDictionary)
}

// MARK: - Credential Validation

/// Check whether the stored Apple ID credential is still valid.
///
/// Apple can revoke credentials if the user disconnects the app
/// from their Apple ID settings. This async check contacts Apple's
/// servers to verify.
///
/// - Returns: `true` if credential is authorized, `false` if revoked,
/// not found, or check failed.
public static func isCredentialValid() async -> Bool {
guard let userIdentifier = loadUserIdentifier() else {
return false
}

let provider = ASAuthorizationAppleIDProvider()

do {
let state = try await provider.credentialState(forUserID: userIdentifier)
switch state {
case .authorized:
return true
case .revoked, .notFound:
// Credential is no longer valid — clear stored data
deleteUserIdentifier()
return false
case .transferred:
// App ownership transferred — treat as valid
return true
@unknown default:
return false
}
} catch {
#if DEBUG
print("[AppleSignInService] Credential state check failed: \(error.localizedDescription)")
#endif
// Network error — assume valid to avoid locking user out offline
return true
}
}
}
26 changes: 13 additions & 13 deletions apps/HeartCoach/iOS/Services/ConnectivityService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ final class ConnectivityService: NSObject, ObservableObject {
/// Activates the WCSession if Watch Connectivity is supported.
private func activateSessionIfSupported() {
guard WCSession.isSupported() else {
debugPrint("[ConnectivityService] WCSession not supported on this device.")
AppLogger.sync.warning("[ConnectivityService] WCSession not supported on this device.")
return
}
let wcSession = WCSession.default
Expand Down Expand Up @@ -85,15 +85,15 @@ final class ConnectivityService: NSObject, ObservableObject {
/// - Parameter assessment: The assessment to transmit to the watch.
func sendAssessment(_ assessment: HeartAssessment) {
guard let session = session else {
debugPrint("[ConnectivityService] No active session.")
AppLogger.sync.warning("[ConnectivityService] No active session.")
return
}

guard let message = ConnectivityMessageCodec.encode(
assessment,
type: .assessment
) else {
debugPrint("[ConnectivityService] Failed to encode assessment payload.")
AppLogger.sync.warning("[ConnectivityService] Failed to encode assessment payload.")
return
}

Expand Down Expand Up @@ -124,7 +124,7 @@ final class ConnectivityService: NSObject, ObservableObject {
/// - Parameter nudge: The breathing nudge to send.
func sendBreathPrompt(_ nudge: DailyNudge) {
guard let session = session else {
debugPrint("[ConnectivityService] No active session for breath prompt.")
AppLogger.sync.warning("[ConnectivityService] No active session for breath prompt.")
return
}

Expand Down Expand Up @@ -160,15 +160,15 @@ final class ConnectivityService: NSObject, ObservableObject {
/// - Parameter plan: The action plan to transmit.
func sendActionPlan(_ plan: WatchActionPlan) {
guard let session = session else {
debugPrint("[ConnectivityService] No active session for action plan.")
AppLogger.sync.warning("[ConnectivityService] No active session for action plan.")
return
}

guard let message = ConnectivityMessageCodec.encode(
plan,
type: .actionPlan
) else {
debugPrint("[ConnectivityService] Failed to encode action plan payload.")
AppLogger.sync.warning("[ConnectivityService] Failed to encode action plan payload.")
return
}

Expand Down Expand Up @@ -219,7 +219,7 @@ final class ConnectivityService: NSObject, ObservableObject {
/// Called from nonisolated WCSessionDelegate callbacks.
nonisolated private func handleIncomingMessage(_ message: [String: Any]) {
guard let type = message["type"] as? String else {
debugPrint("[ConnectivityService] Received message without type key.")
AppLogger.sync.warning("[ConnectivityService] Received message without type key.")
return
}

Expand All @@ -231,7 +231,7 @@ final class ConnectivityService: NSObject, ObservableObject {
// This is handled via the reply handler in didReceiveMessage.
break
default:
debugPrint("[ConnectivityService] Unknown message type: \(type)")
AppLogger.sync.warning("[ConnectivityService] Unknown message type: \(type)")
}
}

Expand All @@ -241,7 +241,7 @@ final class ConnectivityService: NSObject, ObservableObject {
WatchFeedbackPayload.self,
from: message
) else {
debugPrint("[ConnectivityService] Feedback message missing or invalid payload.")
AppLogger.sync.warning("[ConnectivityService] Feedback message missing or invalid payload.")
return
}

Expand Down Expand Up @@ -275,9 +275,9 @@ extension ConnectivityService: WCSessionDelegate {
}

if let error = error {
debugPrint("[ConnectivityService] Activation error: \(error.localizedDescription)")
AppLogger.sync.warning("[ConnectivityService] Activation error: \(error.localizedDescription)")
} else {
debugPrint("[ConnectivityService] Activation completed with state: \(activationState.rawValue)")
AppLogger.sync.warning("[ConnectivityService] Activation completed with state: \(activationState.rawValue)")
}
}

Expand All @@ -286,15 +286,15 @@ extension ConnectivityService: WCSessionDelegate {
/// Required for iOS WCSessionDelegate conformance. No-op; the session
/// will be reactivated automatically.
nonisolated func sessionDidBecomeInactive(_ session: WCSession) {
debugPrint("[ConnectivityService] Session became inactive.")
AppLogger.sync.warning("[ConnectivityService] Session became inactive.")
}

/// Called when the session transitions to the deactivated state.
///
/// Required for iOS WCSessionDelegate conformance. Reactivates the session
/// to prepare for a new paired watch.
nonisolated func sessionDidDeactivate(_ session: WCSession) {
debugPrint("[ConnectivityService] Session deactivated. Reactivating...")
AppLogger.sync.warning("[ConnectivityService] Session deactivated. Reactivating...")
session.activate()
}

Expand Down
3 changes: 3 additions & 0 deletions apps/HeartCoach/iOS/Services/MetricKitService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ final class MetricKitService: NSObject, MXMetricManagerSubscriber {
///
/// - Parameter payloads: One or more diagnostic snapshots.
func didReceive(_ payloads: [MXDiagnosticPayload]) {
// Dump interaction breadcrumbs so crash context is visible in logs
CrashBreadcrumbs.shared.dump()

for payload in payloads {
AppLogger.error("Received diagnostic payload (potential crash)")

Expand Down
10 changes: 5 additions & 5 deletions apps/HeartCoach/iOS/Services/SubscriptionService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ final class SubscriptionService: ObservableObject {
self.productLoadError = nil
}
} catch {
debugPrint("[SubscriptionService] Failed to load products: \(error.localizedDescription)")
AppLogger.subscription.warning("[SubscriptionService] Failed to load products: \(error.localizedDescription)")
await MainActor.run {
self.productLoadError = error
}
Expand Down Expand Up @@ -147,13 +147,13 @@ final class SubscriptionService: ObservableObject {
await updateSubscriptionStatus()

case .userCancelled:
debugPrint("[SubscriptionService] User cancelled purchase.")
AppLogger.subscription.info("[SubscriptionService] User cancelled purchase.")

case .pending:
debugPrint("[SubscriptionService] Purchase pending (e.g., Ask to Buy).")
AppLogger.subscription.info("[SubscriptionService] Purchase pending (e.g., Ask to Buy).")

@unknown default:
debugPrint("[SubscriptionService] Unknown purchase result.")
AppLogger.subscription.warning("[SubscriptionService] Unknown purchase result.")
}
}

Expand Down Expand Up @@ -264,7 +264,7 @@ final class SubscriptionService: ObservableObject {
) throws -> T {
switch result {
case .unverified(_, let error):
debugPrint("[SubscriptionService] Unverified transaction: \(error.localizedDescription)")
AppLogger.subscription.warning("[SubscriptionService] Unverified transaction: \(error.localizedDescription)")
throw error
case .verified(let value):
return value
Expand Down
Loading
Loading