diff --git a/.github/workflows/swiftui-auth.yml b/.github/workflows/swiftui-auth.yml index a9fef9d711..abf108ebb1 100644 --- a/.github/workflows/swiftui-auth.yml +++ b/.github/workflows/swiftui-auth.yml @@ -176,8 +176,7 @@ jobs: xcodebuild test-without-building \ -scheme FirebaseSwiftUIExampleUITests \ -destination 'platform=iOS Simulator,name=iPhone 17' \ - -parallel-testing-enabled YES \ - -maximum-concurrent-test-simulator-destinations 2 \ + -parallel-testing-enabled NO \ -enableCodeCoverage YES \ -resultBundlePath FirebaseSwiftUIExampleUITests.xcresult | tee FirebaseSwiftUIExampleUITests.log | xcpretty --test --color --simple diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift index 5bdf1f221a..c65da21167 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift @@ -156,7 +156,7 @@ extension AuthPickerView: View { VStack { authService.renderButtons() } - .padding(.horizontal, proxy.size.width * 0.18) + .padding(.horizontal, proxy.size.width * 0.14) } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift index 35608ca6fb..b0c88a2ab3 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift @@ -111,7 +111,7 @@ extension EmailAuthView: View { keyboardType: .emailAddress, contentType: .emailAddress, validations: [ - FormValidators.email + FormValidators.email, ], maintainsValidationMessage: authService.authenticationFlow == .signUp, onSubmit: { _ in @@ -161,7 +161,7 @@ extension EmailAuthView: View { contentType: .password, isSecureTextField: true, validations: [ - FormValidators.confirmPassword(password: password) + FormValidators.confirmPassword(password: password), ], maintainsValidationMessage: true, onSubmit: { _ in diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift index 2bcf12969f..2eca4ec7c5 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift @@ -50,7 +50,7 @@ extension EmailLinkView: View { keyboardType: .emailAddress, contentType: .emailAddress, validations: [ - FormValidators.email + FormValidators.email, ], leading: { Image(systemName: "at") diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift index 27189e5994..95152335ca 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift @@ -39,7 +39,7 @@ struct EnterPhoneNumberView: View { keyboardType: .phonePad, contentType: .telephoneNumber, validations: [ - FormValidators.phoneNumber + FormValidators.phoneNumber, ], onChange: { _ in } ) { diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift index 88adc6135d..5d0be1a6e2 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift @@ -52,7 +52,7 @@ struct EnterVerificationCodeView: View { VerificationCodeInputField( code: $verificationCode, validations: [ - FormValidators.verificationCode + FormValidators.verificationCode, ], maintainsValidationMessage: true ) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift index f195edabb7..6f11974503 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift @@ -26,6 +26,7 @@ private enum FocusableField: Hashable { @MainActor public struct MFAEnrolmentView { @Environment(AuthService.self) private var authService + @Environment(\.reportError) private var reportError @State private var selectedFactorType: SecondFactorType = .sms @State private var phoneNumber = "" @@ -81,12 +82,16 @@ public struct MFAEnrolmentView { isLoading = true defer { isLoading = false } - let session = try await authService.startMfaEnrollment( - type: selectedFactorType, - accountName: authService.currentUser?.email, - issuer: authService.configuration.mfaIssuer - ) - currentSession = session + do { + let session = try await authService.startMfaEnrollment( + type: selectedFactorType, + accountName: authService.currentUser?.email, + issuer: authService.configuration.mfaIssuer + ) + currentSession = session + } catch { + reportError?(error) + } } } @@ -97,23 +102,27 @@ public struct MFAEnrolmentView { isLoading = true defer { isLoading = false } - let fullPhoneNumber = selectedCountry.dialCode + phoneNumber - let verificationId = try await authService.sendSmsVerificationForEnrollment( - session: session, - phoneNumber: fullPhoneNumber - ) - // Update session status - currentSession = EnrollmentSession( - id: session.id, - type: session.type, - session: session.session, - totpInfo: session.totpInfo, - phoneNumber: fullPhoneNumber, - verificationId: verificationId, - status: .verificationSent, - createdAt: session.createdAt, - expiresAt: session.expiresAt - ) + do { + let fullPhoneNumber = selectedCountry.dialCode + phoneNumber + let verificationId = try await authService.sendSmsVerificationForEnrollment( + session: session, + phoneNumber: fullPhoneNumber + ) + // Update session status + currentSession = EnrollmentSession( + id: session.id, + type: session.type, + session: session.session, + totpInfo: session.totpInfo, + phoneNumber: fullPhoneNumber, + verificationId: verificationId, + status: .verificationSent, + createdAt: session.createdAt, + expiresAt: session.expiresAt + ) + } catch { + reportError?(error) + } } } @@ -124,18 +133,22 @@ public struct MFAEnrolmentView { isLoading = true defer { isLoading = false } - let code = session.type == .sms ? verificationCode : totpCode - try await authService.completeEnrollment( - session: session, - verificationId: session.verificationId, - verificationCode: code, - displayName: displayName - ) - - // Reset form state on success - resetForm() - - authService.navigator.clear() + do { + let code = session.type == .sms ? verificationCode : totpCode + try await authService.completeEnrollment( + session: session, + verificationId: session.verificationId, + verificationCode: code, + displayName: displayName + ) + + // Reset form state on success + resetForm() + + authService.navigator.clear() + } catch { + reportError?(error) + } } } @@ -375,7 +388,7 @@ extension MFAEnrolmentView: View { keyboardType: .phonePad, contentType: .telephoneNumber, validations: [ - FormValidators.phoneNumber + FormValidators.phoneNumber, ], maintainsValidationMessage: true, onChange: { _ in } @@ -393,7 +406,7 @@ extension MFAEnrolmentView: View { label: authService.string.displayNameFieldLabel, prompt: authService.string.enterDisplayNameForDevicePrompt, validations: [ - FormValidators.notEmpty(label: "Display name") + FormValidators.notEmpty(label: "Display name"), ], maintainsValidationMessage: true, leading: { @@ -441,7 +454,7 @@ extension MFAEnrolmentView: View { VerificationCodeInputField( code: $verificationCode, validations: [ - FormValidators.verificationCode + FormValidators.verificationCode, ], maintainsValidationMessage: true ) @@ -584,7 +597,7 @@ extension MFAEnrolmentView: View { label: authService.string.displayNameFieldLabel, prompt: authService.string.enterDisplayNameForAuthenticatorPrompt, validations: [ - FormValidators.notEmpty(label: "Display name") + FormValidators.notEmpty(label: "Display name"), ], maintainsValidationMessage: true, leading: { @@ -596,7 +609,7 @@ extension MFAEnrolmentView: View { VerificationCodeInputField( code: $totpCode, validations: [ - FormValidators.verificationCode + FormValidators.verificationCode, ], maintainsValidationMessage: true ) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift index fcbed901a8..ec5bf668dd 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift @@ -23,6 +23,7 @@ extension MultiFactorInfo: Identifiable { @MainActor public struct MFAManagementView { @Environment(AuthService.self) private var authService + @Environment(\.reportError) private var reportError @State private var enrolledFactors: [MultiFactorInfo] = [] @State private var isLoading = false @@ -43,6 +44,7 @@ public struct MFAManagementView { enrolledFactors = freshFactors isLoading = false } catch { + reportError?(error) isLoading = false } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift index a5efb32640..03e56a1e98 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift @@ -24,6 +24,7 @@ private enum FocusableField: Hashable { @MainActor public struct MFAResolutionView { @Environment(AuthService.self) private var authService + @Environment(\.reportError) private var reportError @State private var verificationCode = "" @State private var totpCode = "" @@ -72,6 +73,7 @@ public struct MFAResolutionView { self.verificationId = verificationId isLoading = false } catch { + reportError?(error) isLoading = false } } @@ -93,6 +95,7 @@ public struct MFAResolutionView { authService.navigator.clear() isLoading = false } catch { + reportError?(error) isLoading = false } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift index b8b1692f1c..8b3a496d43 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift @@ -45,7 +45,7 @@ extension PasswordRecoveryView: View { keyboardType: .emailAddress, contentType: .emailAddress, validations: [ - FormValidators.email + FormValidators.email, ], leading: { Image(systemName: "at") diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePasswordView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePasswordView.swift index 474e041567..d6ff5eea26 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePasswordView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePasswordView.swift @@ -46,9 +46,7 @@ public struct UpdatePasswordView { do { try await authService.updatePassword(to: confirmPassword) showAlert = true - } catch { - - } + } catch {} } } } @@ -64,7 +62,7 @@ extension UpdatePasswordView: View { contentType: .password, isSecureTextField: true, validations: [ - FormValidators.atLeast6Characters + FormValidators.atLeast6Characters, ], maintainsValidationMessage: true, leading: { @@ -81,7 +79,7 @@ extension UpdatePasswordView: View { contentType: .password, isSecureTextField: true, validations: [ - FormValidators.confirmPassword(password: password) + FormValidators.confirmPassword(password: password), ], maintainsValidationMessage: true, leading: { diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift index 5978fa6624..a60df7dee6 100644 --- a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift @@ -18,7 +18,7 @@ public struct AuthTextField: View { @FocusState private var isFocused: Bool @State var obscured: Bool = true @State var hasInteracted: Bool = false - + @Binding var text: String let label: String let prompt: String @@ -32,7 +32,7 @@ public struct AuthTextField: View { var onSubmit: ((String) -> Void)? = nil var onChange: ((String) -> Void)? = nil private let leading: () -> Leading? - + public init(text: Binding, label: String, prompt: String, @@ -60,11 +60,11 @@ public struct AuthTextField: View { self.onChange = onChange self.leading = leading } - + var allRequirementsMet: Bool { validations.allSatisfy { $0.isValid(input: text) } } - + public var body: some View { VStack(alignment: .leading) { Text(LocalizedStringResource(stringLiteral: label)) @@ -142,7 +142,8 @@ public struct AuthTextField: View { isFocused = true } } - if !validations.isEmpty && hasInteracted && (maintainsValidationMessage || !allRequirementsMet) { + if !validations + .isEmpty && hasInteracted && (maintainsValidationMessage || !allRequirementsMet) { VStack(alignment: .leading, spacing: 4) { ForEach(validations) { validator in let isValid = validator.isValid(input: text) diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift index e8b6929a05..d57041775e 100644 --- a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift @@ -96,7 +96,8 @@ public struct VerificationCodeInputField: View { .frame(maxWidth: .infinity, alignment: .leading) } - if !validations.isEmpty && hasInteracted && (maintainsValidationMessage || !allRequirementsMet) { + if !validations + .isEmpty && hasInteracted && (maintainsValidationMessage || !allRequirementsMet) { VStack(alignment: .leading, spacing: 4) { ForEach(validations) { validator in let isValid = validator.isValid(input: code) diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Validation/FormValidator.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Validation/FormValidator.swift index de0bdd61ec..9326fc76d9 100644 --- a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Validation/FormValidator.swift +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Validation/FormValidator.swift @@ -41,7 +41,8 @@ public struct FormValidators { } ) - public static func confirmPassword(password: @autoclosure @escaping () -> String) -> FormValidator { + public static func confirmPassword(password: @autoclosure @escaping () -> String) + -> FormValidator { return FormValidator( message: "Passwords must match", validate: { input in diff --git a/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Services/FacebookProviderAuthUI.swift b/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Services/FacebookProviderAuthUI.swift index 13f371d1c2..97abaa4c0f 100644 --- a/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Services/FacebookProviderAuthUI.swift +++ b/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Services/FacebookProviderAuthUI.swift @@ -105,7 +105,7 @@ public class FacebookProviderSwift: CredentialAuthProviderSwift { "`rawNonce` has not been generated for Facebook limited login" ) } - let credential = OAuthProvider.credential(withProviderID: providerId, + let credential = OAuthProvider.credential(providerID: .facebook, idToken: idToken.tokenString, rawNonce: nonce) return credential diff --git a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExample.xcodeproj/xcshareddata/xcschemes/FirebaseSwiftUIExample.xcscheme b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExample.xcodeproj/xcshareddata/xcschemes/FirebaseSwiftUIExample.xcscheme index 30faacec20..6ee478a6b1 100644 --- a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExample.xcodeproj/xcshareddata/xcschemes/FirebaseSwiftUIExample.xcscheme +++ b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExample.xcodeproj/xcshareddata/xcschemes/FirebaseSwiftUIExample.xcscheme @@ -28,7 +28,25 @@ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES" - shouldAutocreateTestPlan = "YES"> + shouldAutocreateTestPlan = "YES" + codeCoverageEnabled = "NO" + onlyGenerateCoverageForSpecifiedTargets = "NO"> + + + + + + + + String { "idToken": idToken, ]) - let (_, sendResp) = try await URLSession.shared.data(for: sendReq) + let (sendData, sendResp) = try await URLSession.shared.data(for: sendReq) guard let http = sendResp as? HTTPURLResponse, http.statusCode == 200 else { + let errorBody = String(data: sendData, encoding: .utf8) ?? "Unknown error" throw NSError(domain: "EmulatorError", code: 1, - userInfo: [NSLocalizedDescriptionKey: "Failed to send verification email"]) + userInfo: [NSLocalizedDescriptionKey: "Failed to send verification email: \(errorBody)"]) } - // Step 2: Fetch OOB codes from emulator - let oobURL = URL(string: "\(base)/emulator/v1/projects/\(projectID)/oobCodes")! - let (oobData, oobResp) = try await URLSession.shared.data(from: oobURL) - guard (oobResp as? HTTPURLResponse)?.statusCode == 200 else { - throw NSError(domain: "EmulatorError", code: 2, - userInfo: [NSLocalizedDescriptionKey: "Failed to fetch OOB codes"]) - } + // Add a small delay to ensure the OOB code is registered in the emulator + try await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds + // Define structs for OOB response parsing struct OobEnvelope: Decodable { let oobCodes: [OobItem] } struct OobItem: Decodable { let oobCode: String @@ -118,20 +115,50 @@ func createEmail() -> String { let creationTime: String? } - let envelope = try JSONDecoder().decode(OobEnvelope.self, from: oobData) - - // Step 3: Find most recent VERIFY_EMAIL code for this email - let iso = ISO8601DateFormatter() - let codeItem = envelope.oobCodes - .filter { - $0.email.caseInsensitiveCompare(email) == .orderedSame && $0.requestType == "VERIFY_EMAIL" + // Step 2: Fetch OOB codes from emulator with retry logic + let oobURL = URL(string: "\(base)/emulator/v1/projects/\(projectID)/oobCodes")! + + var codeItem: OobItem? + var attempts = 0 + let maxAttempts = 5 + + while codeItem == nil && attempts < maxAttempts { + let (oobData, oobResp) = try await URLSession.shared.data(from: oobURL) + guard (oobResp as? HTTPURLResponse)?.statusCode == 200 else { + throw NSError(domain: "EmulatorError", code: 2, + userInfo: [NSLocalizedDescriptionKey: "Failed to fetch OOB codes"]) } - .sorted { - let d0 = $0.creationTime.flatMap { iso.date(from: $0) } ?? .distantPast - let d1 = $1.creationTime.flatMap { iso.date(from: $0) } ?? .distantPast - return d0 > d1 + + let envelope = try JSONDecoder().decode(OobEnvelope.self, from: oobData) + + // Step 3: Find most recent VERIFY_EMAIL code for this email + let iso = ISO8601DateFormatter() + codeItem = envelope.oobCodes + .filter { + $0.email.caseInsensitiveCompare(email) == .orderedSame && $0.requestType == "VERIFY_EMAIL" + } + .sorted { + let d0 = $0.creationTime.flatMap { iso.date(from: $0) } ?? .distantPast + let d1 = $1.creationTime.flatMap { iso.date(from: $0) } ?? .distantPast + return d0 > d1 + } + .first + + if codeItem == nil { + attempts += 1 + if attempts < maxAttempts { + // Wait before retrying + try await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds + } else { + // Log available codes for debugging + let availableCodes = envelope.oobCodes.map { "Email: \($0.email), Type: \($0.requestType)" }.joined(separator: "; ") + throw NSError(domain: "EmulatorError", code: 3, + userInfo: [ + NSLocalizedDescriptionKey: "No VERIFY_EMAIL OOB code found for \(email) after \(maxAttempts) attempts. Available codes: \(availableCodes)", + ]) + } } - .first + } guard let oobCode = codeItem?.oobCode else { throw NSError(domain: "EmulatorError", code: 3,