diff --git a/apps/HeartCoach/Shared/Models/HeartModels.swift b/apps/HeartCoach/Shared/Models/HeartModels.swift index 6d0586de..5e0ba841 100644 --- a/apps/HeartCoach/Shared/Models/HeartModels.swift +++ b/apps/HeartCoach/Shared/Models/HeartModels.swift @@ -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(), @@ -1417,7 +1420,8 @@ public struct UserProfile: Codable, Equatable, Sendable { lastStreakCreditDate: Date? = nil, nudgeCompletionDates: Set = [], dateOfBirth: Date? = nil, - biologicalSex: BiologicalSex = .notSet + biologicalSex: BiologicalSex = .notSet, + email: String? = nil ) { self.displayName = displayName self.joinDate = joinDate @@ -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. diff --git a/apps/HeartCoach/Shared/Services/LocalStore.swift b/apps/HeartCoach/Shared/Services/LocalStore.swift index 2af2382a..011606b8 100644 --- a/apps/HeartCoach/Shared/Services/LocalStore.swift +++ b/apps/HeartCoach/Shared/Services/LocalStore.swift @@ -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 } } @@ -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 } diff --git a/apps/HeartCoach/iOS/Services/AnalyticsEvents.swift b/apps/HeartCoach/iOS/Services/AnalyticsEvents.swift index 74ab37f2..e7b90e24 100644 --- a/apps/HeartCoach/iOS/Services/AnalyticsEvents.swift +++ b/apps/HeartCoach/iOS/Services/AnalyticsEvents.swift @@ -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" } diff --git a/apps/HeartCoach/iOS/Services/AppleSignInService.swift b/apps/HeartCoach/iOS/Services/AppleSignInService.swift new file mode 100644 index 00000000..fb0767a4 --- /dev/null +++ b/apps/HeartCoach/iOS/Services/AppleSignInService.swift @@ -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 + } + } +} diff --git a/apps/HeartCoach/iOS/Services/ConnectivityService.swift b/apps/HeartCoach/iOS/Services/ConnectivityService.swift index ef3ace18..4593e328 100644 --- a/apps/HeartCoach/iOS/Services/ConnectivityService.swift +++ b/apps/HeartCoach/iOS/Services/ConnectivityService.swift @@ -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 @@ -85,7 +85,7 @@ 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 } @@ -93,7 +93,7 @@ final class ConnectivityService: NSObject, ObservableObject { assessment, type: .assessment ) else { - debugPrint("[ConnectivityService] Failed to encode assessment payload.") + AppLogger.sync.warning("[ConnectivityService] Failed to encode assessment payload.") return } @@ -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 } @@ -160,7 +160,7 @@ 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 } @@ -168,7 +168,7 @@ final class ConnectivityService: NSObject, ObservableObject { plan, type: .actionPlan ) else { - debugPrint("[ConnectivityService] Failed to encode action plan payload.") + AppLogger.sync.warning("[ConnectivityService] Failed to encode action plan payload.") return } @@ -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 } @@ -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)") } } @@ -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 } @@ -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)") } } @@ -286,7 +286,7 @@ 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. @@ -294,7 +294,7 @@ extension ConnectivityService: WCSessionDelegate { /// 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() } diff --git a/apps/HeartCoach/iOS/Services/MetricKitService.swift b/apps/HeartCoach/iOS/Services/MetricKitService.swift index 9421cd15..aa7a8118 100644 --- a/apps/HeartCoach/iOS/Services/MetricKitService.swift +++ b/apps/HeartCoach/iOS/Services/MetricKitService.swift @@ -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)") diff --git a/apps/HeartCoach/iOS/Services/SubscriptionService.swift b/apps/HeartCoach/iOS/Services/SubscriptionService.swift index c4ea4c67..ff3b9a44 100644 --- a/apps/HeartCoach/iOS/Services/SubscriptionService.swift +++ b/apps/HeartCoach/iOS/Services/SubscriptionService.swift @@ -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 } @@ -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.") } } @@ -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 diff --git a/apps/HeartCoach/iOS/ThumpiOSApp.swift b/apps/HeartCoach/iOS/ThumpiOSApp.swift index 1392e720..8d2f281f 100644 --- a/apps/HeartCoach/iOS/ThumpiOSApp.swift +++ b/apps/HeartCoach/iOS/ThumpiOSApp.swift @@ -67,15 +67,17 @@ struct ThumpiOSApp: App { } } - // MARK: - Legal Acceptance State + // MARK: - Authentication & Legal State + + /// Tracks whether the user has signed in with Apple. + @AppStorage("thump_signed_in") private var isSignedIn: Bool = false /// Tracks whether the user has accepted the Terms of Service and Privacy Policy. @AppStorage("thump_legal_accepted_v1") private var legalAccepted: Bool = false // MARK: - Root View Routing - /// Routes to legal gate, onboarding, or main tab view based on - /// the user's acceptance and onboarding state. + /// Routes through: Sign In → Legal Gate → Onboarding → Main Tab View. /// Whether the app is running in UI test mode (launched with `-UITestMode`). private var isUITestMode: Bool { CommandLine.arguments.contains("-UITestMode") @@ -89,8 +91,12 @@ struct ThumpiOSApp: App { @ViewBuilder private var rootView: some View { if isUITestMode { - // Skip legal gate and onboarding for UI tests + // Skip all gates for UI tests MainTabView() + } else if !isSignedIn { + AppleSignInView { + isSignedIn = true + } } else if !legalAccepted { LegalGateView { legalAccepted = true @@ -122,6 +128,15 @@ struct ThumpiOSApp: App { AppLogger.info("Notification authorization request failed: \(error.localizedDescription)") } + // Verify Apple Sign-In credential is still valid + if isSignedIn { + let credentialValid = await AppleSignInService.isCredentialValid() + if !credentialValid { + await MainActor.run { isSignedIn = false } + AppLogger.info("Apple Sign-In credential revoked — returning to sign-in") + } + } + // Start MetricKit crash reporting and performance monitoring MetricKitService.shared.start() diff --git a/apps/HeartCoach/iOS/Views/AppleSignInView.swift b/apps/HeartCoach/iOS/Views/AppleSignInView.swift new file mode 100644 index 00000000..4be621a1 --- /dev/null +++ b/apps/HeartCoach/iOS/Views/AppleSignInView.swift @@ -0,0 +1,172 @@ +// AppleSignInView.swift +// Thump iOS +// +// Sign in with Apple screen — the first thing a new user sees. +// Displays the ThumpBuddy, app name, and a native Apple sign-in button. +// On success, stores the credential and calls the onSignedIn closure. +// +// Platforms: iOS 17+ + +import SwiftUI +import AuthenticationServices + +// MARK: - AppleSignInView + +/// Full-screen Sign in with Apple gate shown before legal acceptance +/// and onboarding. Uses Apple's native `SignInWithAppleButton` for +/// a consistent, trustworthy sign-in experience. +struct AppleSignInView: View { + + /// Called when the user successfully signs in. + let onSignedIn: () -> Void + + /// Environment object for storing the user's name. + @EnvironmentObject var localStore: LocalStore + + /// Error message shown in an alert if sign-in fails. + @State private var errorMessage: String? + + /// Controls the error alert presentation. + @State private var showError = false + + var body: some View { + VStack(spacing: 0) { + Spacer() + + // Buddy greeting + ThumpBuddy(mood: .content, size: 120, tappable: false) + .padding(.bottom, 16) + + // App name + Text("Thump") + .font(.system(size: 40, weight: .bold, design: .rounded)) + .foregroundStyle( + LinearGradient( + colors: [Color(hex: 0x2563EB), Color(hex: 0x7C3AED)], + startPoint: .leading, + endPoint: .trailing + ) + ) + + Text("Your Heart, Your Coach") + .font(.title3) + .foregroundStyle(.secondary) + .padding(.top, 4) + + Spacer() + + // Privacy reassurance + Label( + "Your health data stays on your device", + systemImage: "lock.shield.fill" + ) + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.bottom, 24) + + // Sign in with Apple button + SignInWithAppleButton(.signIn) { request in + request.requestedScopes = [.fullName, .email] + } onCompletion: { result in + handleSignInResult(result) + } + .signInWithAppleButtonStyle(.black) + .frame(height: 54) + .cornerRadius(14) + .padding(.horizontal, 32) + .padding(.bottom, 16) + + // Skip option for development/testing + #if DEBUG + Button("Skip Sign-In (Debug)") { + onSignedIn() + } + .font(.caption) + .foregroundStyle(.tertiary) + .padding(.bottom, 8) + #endif + + Spacer() + .frame(height: 40) + } + .padding(.horizontal, 24) + .background(Color(.systemBackground)) + .alert("Sign-In Error", isPresented: $showError) { + Button("OK") { } + } message: { + Text(errorMessage ?? "An unexpected error occurred. Please try again.") + } + .accessibilityIdentifier("apple_sign_in_view") + } + + // MARK: - Sign-In Handler + + private func handleSignInResult(_ result: Result) { + switch result { + case .success(let authorization): + guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential else { + AppLogger.error("Sign-in succeeded but credential type is unexpected") + errorMessage = "Unexpected credential type. Please try again." + showError = true + return + } + + // Store the stable user identifier in Keychain + AppleSignInService.saveUserIdentifier(credential.user) + + // Store name if provided (Apple only sends this on first sign-in) + if let fullName = credential.fullName { + let name = [fullName.givenName, fullName.familyName] + .compactMap { $0 } + .joined(separator: " ") + if !name.isEmpty { + localStore.profile.displayName = name + localStore.saveProfile() + } + } + + // Store email if provided + if let email = credential.email { + localStore.profile.email = email + localStore.saveProfile() + } + + InteractionLog.log( + .buttonTap, + element: "sign_in_with_apple", + page: "SignIn", + details: "success" + ) + + AppLogger.info("Sign in with Apple completed successfully") + onSignedIn() + + case .failure(let error): + // User cancelled is not a real error — don't show alert + let nsError = error as NSError + if nsError.domain == ASAuthorizationError.errorDomain, + nsError.code == ASAuthorizationError.canceled.rawValue { + AppLogger.info("Sign in with Apple cancelled by user") + return + } + + AppLogger.error("Sign in with Apple failed: \(error.localizedDescription)") + errorMessage = "Sign-in failed: \(error.localizedDescription)" + showError = true + + InteractionLog.log( + .buttonTap, + element: "sign_in_with_apple", + page: "SignIn", + details: "error: \(error.localizedDescription)" + ) + } + } +} + +// MARK: - Preview + +#Preview("Sign In") { + AppleSignInView { } + .environmentObject(LocalStore()) +} diff --git a/apps/HeartCoach/iOS/iOS.entitlements b/apps/HeartCoach/iOS/iOS.entitlements index 5d1c5b9e..486d07a2 100644 --- a/apps/HeartCoach/iOS/iOS.entitlements +++ b/apps/HeartCoach/iOS/iOS.entitlements @@ -11,5 +11,9 @@ group.com.thump.shared + com.apple.developer.applesignin + + Default + diff --git a/apps/HeartCoach/project.yml b/apps/HeartCoach/project.yml index d1bef5dc..68e45287 100644 --- a/apps/HeartCoach/project.yml +++ b/apps/HeartCoach/project.yml @@ -22,6 +22,7 @@ settings: SWIFT_VERSION: "5.9" ENABLE_USER_SCRIPT_SANDBOXING: true DEAD_CODE_STRIPPING: true + CODE_SIGN_STYLE: Automatic ############################################################ # Targets @@ -54,6 +55,7 @@ targets: - sdk: WatchConnectivity.framework - sdk: StoreKit.framework - sdk: AppIntents.framework + - sdk: AuthenticationServices.framework scheme: testTargets: - ThumpCoreTests