From 1dcddf94fe35e47c49bbdd9f8b2a93202143539e Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Wed, 24 Jul 2024 16:14:13 +0200 Subject: [PATCH 01/26] First draft to upgrade to SpeziAccount 2.0 --- Package.swift | 2 +- .../FirebaseAccountError.swift | 6 +- .../FirebaseAccountService.swift | 136 ------ .../FirebaseEmailPasswordAccountService.swift | 165 ------- ...rebaseIdentityProviderAccountService.swift | 279 ----------- .../ReauthenticationOperationResult.swift | 13 + .../ValidationRule+FirebasePassword.swift | 26 + .../FirebaseOAuthCredential.swift | 1 + .../FirebaseAccountConfiguration.swift | 454 ++++++++++++++++-- .../Models/FirebaseContext.swift | 137 ++---- .../Resources/Localizable.xcstrings | 2 + .../Views/FirebaseLoginView.swift | 29 ++ .../Views/FirebaseSignInWithAppleButton.swift | 18 +- .../Views/ReauthenticationAlertModifier.swift | 4 +- 14 files changed, 545 insertions(+), 727 deletions(-) delete mode 100644 Sources/SpeziFirebaseAccount/Account Services/FirebaseAccountService.swift delete mode 100644 Sources/SpeziFirebaseAccount/Account Services/FirebaseEmailPasswordAccountService.swift delete mode 100644 Sources/SpeziFirebaseAccount/Account Services/FirebaseIdentityProviderAccountService.swift create mode 100644 Sources/SpeziFirebaseAccount/Account Services/ReauthenticationOperationResult.swift create mode 100644 Sources/SpeziFirebaseAccount/Account Services/ValidationRule+FirebasePassword.swift create mode 100644 Sources/SpeziFirebaseAccount/Views/FirebaseLoginView.swift diff --git a/Package.swift b/Package.swift index bf2fe69..5061009 100644 --- a/Package.swift +++ b/Package.swift @@ -28,7 +28,7 @@ let package = Package( .package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.0.0"), .package(url: "https://github.com/StanfordSpezi/SpeziViews.git", from: "1.0.0"), .package(url: "https://github.com/StanfordSpezi/SpeziStorage", from: "1.0.0"), - .package(url: "https://github.com/StanfordSpezi/SpeziAccount", from: "1.2.2"), + .package(url: "https://github.com/StanfordSpezi/SpeziAccount", branch: "feature/account-service-singleton"), .package(url: "https://github.com/firebase/firebase-ios-sdk", from: "10.13.0") ], targets: [ diff --git a/Sources/SpeziFirebaseAccount/Account Services/FirebaseAccountError.swift b/Sources/SpeziFirebaseAccount/Account Services/FirebaseAccountError.swift index bfccbed..70097a7 100644 --- a/Sources/SpeziFirebaseAccount/Account Services/FirebaseAccountError.swift +++ b/Sources/SpeziFirebaseAccount/Account Services/FirebaseAccountError.swift @@ -9,8 +9,10 @@ import FirebaseAuth import Foundation +// TODO: move whole folder -enum FirebaseAccountError: LocalizedError { + +enum FirebaseAccountError: LocalizedError { // TODO: make public? case invalidEmail case accountAlreadyInUse case weakPassword @@ -93,7 +95,7 @@ enum FirebaseAccountError: LocalizedError { init(authErrorCode: AuthErrorCode) { - FirebaseEmailPasswordAccountService.logger.debug("Received authError with code \(authErrorCode)") + FirebaseAccountConfiguration.logger.debug("Received authError with code \(authErrorCode)") // TODO: what? switch authErrorCode.code { case .invalidEmail, .invalidRecipientEmail: diff --git a/Sources/SpeziFirebaseAccount/Account Services/FirebaseAccountService.swift b/Sources/SpeziFirebaseAccount/Account Services/FirebaseAccountService.swift deleted file mode 100644 index 7fc01bf..0000000 --- a/Sources/SpeziFirebaseAccount/Account Services/FirebaseAccountService.swift +++ /dev/null @@ -1,136 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import AuthenticationServices -import FirebaseAuth -import OSLog -import SpeziAccount -import SwiftUI - -enum ReauthenticationOperationResult { - case cancelled - case success -} - - -protocol FirebaseAccountService: AnyActor, AccountService { - static var logger: Logger { get } - - var account: Account { get async } - var context: FirebaseContext { get async } - - /// This method is called upon startup to configure the Firebase-based AccountService. - /// - /// - Important: You must call `FirebaseContext/share(account:)` with your `@AccountReference`-acquired - /// `Account` object within this method call. - /// - Parameter context: The global firebase context - func configure(with context: FirebaseContext) async - - /// This method is called to re-authenticate the current user credentials. - /// - /// - Parameter user: The user instance to reauthenticate. - /// - Returns: `true` if authentication was successful, `false` if authentication was cancelled by the user. - /// - Throws: If authentication failed. - func reauthenticateUser(user: User) async throws -> ReauthenticationOperationResult -} - - -extension FirebaseAccountService { - func inject(authorizationController: AuthorizationController) async {} -} - - -// MARK: - Default Account Service Implementations -extension FirebaseAccountService { - func logout() async throws { - guard Auth.auth().currentUser != nil else { - if await account.signedIn { - try await context.notifyUserRemoval(for: self) - return - } else { - throw FirebaseAccountError.notSignedIn - } - } - - try await context.dispatchFirebaseAuthAction(on: self) { - try Auth.auth().signOut() - try await Task.sleep(for: .milliseconds(10)) - Self.logger.debug("signOut() for user.") - } - } - - func delete() async throws { - guard let currentUser = Auth.auth().currentUser else { - if await account.signedIn { - try await context.notifyUserRemoval(for: self) - } - throw FirebaseAccountError.notSignedIn - } - - try await context.dispatchFirebaseAuthAction(on: self) { - let result = try await reauthenticateUser(user: currentUser) // delete requires a recent sign in - guard case .success = result else { - Self.logger.debug("Re-authentication was cancelled. Not deleting the account.") - return // cancelled - } - - try await currentUser.delete() - Self.logger.debug("delete() for user.") - } - } - - func updateAccountDetails(_ modifications: AccountModifications) async throws { - guard let currentUser = Auth.auth().currentUser else { - if await account.signedIn { - try await context.notifyUserRemoval(for: self) - } - throw FirebaseAccountError.notSignedIn - } - - do { - // if we modify sensitive credentials and require a recent login - if modifications.modifiedDetails.storage[UserIdKey.self] != nil || modifications.modifiedDetails.password != nil { - let result = try await reauthenticateUser(user: currentUser) - guard case .success = result else { - Self.logger.debug("Re-authentication was cancelled. Not deleting the account.") - return // got cancelled! - } - } - - if let userId = modifications.modifiedDetails.storage[UserIdKey.self] { - Self.logger.debug("updateEmail(to:) for user.") - try await currentUser.updateEmail(to: userId) - } - - if let password = modifications.modifiedDetails.password { - Self.logger.debug("updatePassword(to:) for user.") - try await currentUser.updatePassword(to: password) - } - - if let name = modifications.modifiedDetails.name { - try await updateDisplayName(of: currentUser, name) - } - - // None of the above requests will trigger our state change listener, therefore, we just call it manually. - try await context.notifyUserSignIn(user: currentUser, for: self) - } catch let error as NSError { - Self.logger.error("Received NSError on firebase dispatch: \(error)") - throw FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) - } catch { - Self.logger.error("Received error on firebase dispatch: \(error)") - throw FirebaseAccountError.unknown(.internalError) - } - } - - func updateDisplayName(of user: User, _ name: PersonNameComponents) async throws { - Self.logger.debug("Creating change request for updated display name.") - let changeRequest = user.createProfileChangeRequest() - changeRequest.displayName = name.formatted(.name(style: .long)) - try await changeRequest.commitChanges() - } -} diff --git a/Sources/SpeziFirebaseAccount/Account Services/FirebaseEmailPasswordAccountService.swift b/Sources/SpeziFirebaseAccount/Account Services/FirebaseEmailPasswordAccountService.swift deleted file mode 100644 index 463cf21..0000000 --- a/Sources/SpeziFirebaseAccount/Account Services/FirebaseEmailPasswordAccountService.swift +++ /dev/null @@ -1,165 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import AuthenticationServices -import FirebaseAuth -import OSLog -import SpeziAccount -import SpeziSecureStorage -import SpeziValidation -import SwiftUI - - -struct EmailPasswordViewStyle: UserIdPasswordAccountSetupViewStyle { - let service: FirebaseEmailPasswordAccountService - - var securityRelatedViewModifier: any ViewModifier { - ReauthenticationAlertModifier() - } -} - - -actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService, FirebaseAccountService { - static let logger = Logger(subsystem: "edu.stanford.spezi.firebase", category: "AccountService") - - private static let supportedKeys = AccountKeyCollection { - \.accountId - \.userId - \.password - \.name - } - - - @AccountReference var account: Account - @_WeakInjectable var context: FirebaseContext - - let configuration: AccountServiceConfiguration - let firebaseModel: FirebaseAccountModel - - nonisolated var viewStyle: EmailPasswordViewStyle { - EmailPasswordViewStyle(service: self) - } - - - init(_ model: FirebaseAccountModel, passwordValidationRules: [ValidationRule] = [.minimumFirebasePassword]) { - self.configuration = AccountServiceConfiguration( - name: LocalizedStringResource("FIREBASE_EMAIL_AND_PASSWORD", bundle: .atURL(from: .module)), - supportedKeys: .exactly(Self.supportedKeys) - ) { - AccountServiceImage(Image(systemName: "envelope.fill")) - RequiredAccountKeys { - \.userId - \.password - } - UserIdConfiguration(type: .emailAddress, keyboardType: .emailAddress) - - FieldValidationRules(for: \.userId, rules: .minimalEmail) - FieldValidationRules(for: \.password, rules: passwordValidationRules) - } - self.firebaseModel = model - } - - - func configure(with context: FirebaseContext) async { - self._context.inject(context) - await context.share(account: account) - } - - func login(userId: String, password: String) async throws { - Self.logger.debug("Received new login request...") - - try await context.dispatchFirebaseAuthAction(on: self) { - try await Auth.auth().signIn(withEmail: userId, password: password) - Self.logger.debug("signIn(withEmail:password:)") - } - } - - func signUp(signupDetails: SignupDetails) async throws { - Self.logger.debug("Received new signup request...") - - guard let password = signupDetails.password else { - throw FirebaseAccountError.invalidCredentials - } - - try await context.dispatchFirebaseAuthAction(on: self) { - if let currentUser = Auth.auth().currentUser, - currentUser.isAnonymous { - let credential = EmailAuthProvider.credential(withEmail: signupDetails.userId, password: password) - Self.logger.debug("Linking email-password credentials with current anonymous user account ...") - let result = try await currentUser.link(with: credential) - - if let displayName = signupDetails.name { - try await updateDisplayName(of: result.user, displayName) - } - - try await context.notifyUserSignIn(user: result.user, for: self) - - return - } - - let authResult = try await Auth.auth().createUser(withEmail: signupDetails.userId, password: password) - Self.logger.debug("createUser(withEmail:password:) for user.") - - Self.logger.debug("Sending email verification link now...") - try await authResult.user.sendEmailVerification() - - if let displayName = signupDetails.name { - try await updateDisplayName(of: authResult.user, displayName) - } - } - } - - func resetPassword(userId: String) async throws { - do { - try await Auth.auth().sendPasswordReset(withEmail: userId) - Self.logger.debug("sendPasswordReset(withEmail:) for user.") - } catch let error as NSError { - let firebaseError = FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) - if case .invalidCredentials = firebaseError { - return // make sure we don't leak any information - } else { - throw firebaseError - } - } catch { - throw FirebaseAccountError.unknown(.internalError) - } - } - - func reauthenticateUser(user: User) async throws -> ReauthenticationOperationResult { - guard let userId = user.email else { - return .cancelled - } - - Self.logger.debug("Requesting credentials for re-authentication...") - let passwordQuery = await firebaseModel.reauthenticateUser(userId: userId) - guard case let .password(password) = passwordQuery else { - return .cancelled - } - - Self.logger.debug("Re-authenticating password-based user now ...") - try await user.reauthenticate(with: EmailAuthProvider.credential(withEmail: userId, password: password)) - return .success - } -} - - -extension ValidationRule { - static var minimumFirebasePassword: ValidationRule { - // Firebase as a non-configurable limit of 6 characters for an account password. - // Refer to https://stackoverflow.com/questions/38064248/firebase-password-validation-allowed-regex - guard let regex = try? Regex(#"(?=.*[0-9a-zA-Z]).{6,}"#) else { - fatalError("Invalid minimumFirebasePassword regex at construction.") - } - - return ValidationRule( - regex: regex, - message: "FIREBASE_ACCOUNT_DEFAULT_PASSWORD_RULE_ERROR \(6)", - bundle: .module - ) - } -} diff --git a/Sources/SpeziFirebaseAccount/Account Services/FirebaseIdentityProviderAccountService.swift b/Sources/SpeziFirebaseAccount/Account Services/FirebaseIdentityProviderAccountService.swift deleted file mode 100644 index 2410898..0000000 --- a/Sources/SpeziFirebaseAccount/Account Services/FirebaseIdentityProviderAccountService.swift +++ /dev/null @@ -1,279 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import AuthenticationServices -import FirebaseAuth -import OSLog -import SpeziAccount -import SwiftUI - - -struct FirebaseIdentityProviderViewStyle: IdentityProviderViewStyle { - func makeSignInButton(_ provider: any IdentityProvider) -> some View { - if let backed = provider as? any _StandardBacked, - let underlyingService = backed.underlyingService as? FirebaseIdentityProviderAccountService { - FirebaseSignInWithAppleButton(service: underlyingService) - } else if let service = provider as? FirebaseIdentityProviderAccountService { - FirebaseSignInWithAppleButton(service: service) - } else { - preconditionFailure("Unexpected account service found: \(provider)") - } - } -} - - -actor FirebaseIdentityProviderAccountService: IdentityProvider, FirebaseAccountService { - static let logger = Logger(subsystem: "edu.stanford.spezi.firebase", category: "IdentityProvider") - - private static let supportedKeys = AccountKeyCollection { - \.accountId - \.userId - \.name - } - - let viewStyle = FirebaseIdentityProviderViewStyle() - - let configuration: AccountServiceConfiguration - let firebaseModel: FirebaseAccountModel - - @MainActor @AccountReference var account: Account // property wrappers cannot be non-isolated, so we isolate it to main actor - @MainActor private var lastNonce: String? - - @_WeakInjectable var context: FirebaseContext - - init(_ model: FirebaseAccountModel) { - self.configuration = AccountServiceConfiguration( - name: LocalizedStringResource("FIREBASE_IDENTITY_PROVIDER", bundle: .atURL(from: .module)), - supportedKeys: .exactly(Self.supportedKeys) - ) { - RequiredAccountKeys { - \.userId - } - UserIdConfiguration(type: .emailAddress, keyboardType: .emailAddress) - } - self.firebaseModel = model - } - - - func configure(with context: FirebaseContext) async { - self._context.inject(context) - await context.share(account: account) - } - - func reauthenticateUser(user: User) async throws -> ReauthenticationOperationResult { - guard let appleIdCredential = try await requestAppleSignInCredential() else { - return .cancelled - } - - let credential = try await oAuthCredential(from: appleIdCredential) - - try await user.reauthenticate(with: credential) - return .success - } - - func signUp(signupDetails: SignupDetails) async throws { - guard let credential = signupDetails.oauthCredential else { - throw FirebaseAccountError.invalidCredentials - } - - try await context.dispatchFirebaseAuthAction(on: self) { - if let currentUser = Auth.auth().currentUser, - currentUser.isAnonymous { - Self.logger.debug("Linking oauth credentials with current anonymous user account ...") - let result = try await currentUser.link(with: credential) - - try await context.notifyUserSignIn(user: currentUser, for: self, isNewUser: true) - - return result - } - - let authResult = try await Auth.auth().signIn(with: credential) - Self.logger.debug("signIn(with:) credential for user.") - - return authResult - } - } - - func delete() async throws { - guard let currentUser = Auth.auth().currentUser else { - if await account.signedIn { - try await context.notifyUserRemoval(for: self) - } - throw FirebaseAccountError.notSignedIn - } - - try await context.dispatchFirebaseAuthAction(on: self) { - guard let credential = try await requestAppleSignInCredential() else { - return // user canceled - } - - guard let authorizationCode = credential.authorizationCode else { - Self.logger.error("Unable to fetch authorizationCode from ASAuthorizationAppleIDCredential.") - throw FirebaseAccountError.setupError - } - - guard let authorizationCodeString = String(data: authorizationCode, encoding: .utf8) else { - Self.logger.error("Unable to serialize authorizationCode to utf8 string.") - throw FirebaseAccountError.setupError - } - - Self.logger.debug("Re-Authenticating Apple Credential before deleting user account ...") - let authCredential = try await oAuthCredential(from: credential) - try await currentUser.reauthenticate(with: authCredential) - - do { - Self.logger.debug("Revoking Apple Id Token ...") - try await Auth.auth().revokeToken(withAuthorizationCode: authorizationCodeString) - } catch let error as NSError { - #if targetEnvironment(simulator) - // token revocation for Sign in with Apple is currently unsupported for Firebase - // see https://github.com/firebase/firebase-tools/issues/6028 - // and https://github.com/firebase/firebase-tools/pull/6050 - if AuthErrorCode(_nsError: error).code != .invalidCredential { - throw error - } - #else - throw error - #endif - } catch { - throw error - } - - try await currentUser.delete() - Self.logger.debug("delete() for user.") - } - } - - @MainActor - func onAppleSignInRequest(request: ASAuthorizationAppleIDRequest) { - let nonce = CryptoUtils.randomNonceString(length: 32) - // we configured userId as `required` in the account service - var requestedScopes: [ASAuthorization.Scope] = [.email] - - let nameRequirement = account.configuration[PersonNameKey.self]?.requirement - if nameRequirement == .required { // .collected names will be collected later-on - requestedScopes.append(.fullName) - } - - request.nonce = CryptoUtils.sha256(nonce) - request.requestedScopes = requestedScopes - - self.lastNonce = nonce // save the nonce for later use to be passed to FirebaseAuth - } - - @MainActor - func onAppleSignInCompletion(result: Result) async throws { - defer { // cleanup tasks - self.lastNonce = nil - } - - switch result { - case let .success(authorization): - guard let appleIdCredential = authorization.credential as? ASAuthorizationAppleIDCredential else { - Self.logger.error("Unable to obtain credential as ASAuthorizationAppleIDCredential") - throw FirebaseAccountError.setupError - } - - let credential = try oAuthCredential(from: appleIdCredential) - - Self.logger.info("onAppleSignInCompletion creating firebase apple credential from authorization credential") - - let signupDetails = SignupDetails.Builder() - .set(\.oauthCredential, value: .init(credential: credential)) - .build() - - - // We are currently calling the signup method directly. This, in theory, makes a difference. - // In SpeziAccount, AccountServices might be wrapped by other (so called StandardBacked) account services - // that add additional implementation. E.g., if a SignupDetails request contains data that is not storable - // by this account service, this would get automatically handled by the wrapping account service. - // As we know exactly, that this won't happen, we don't have to bother routing this request to - // an potentially encapsulating account service. But this should be a heads up for future development. - try await signUp(signupDetails: signupDetails) - case let .failure(error): - guard let authorizationError = error as? ASAuthorizationError else { - Self.logger.error("onAppleSignInCompletion received unknown error: \(error)") - throw error - } - - Self.logger.error("Received ASAuthorizationError error: \(authorizationError)") - - switch ASAuthorizationError.Code(rawValue: authorizationError.errorCode) { - case .unknown, .canceled: // 1000, 1001 - // unknown is thrown if e.g. user is not logged in at all. Apple will show a pop up then! - // cancelled is user interaction, no need to show anything - break - case .invalidResponse, .notHandled, .failed, .notInteractive: // 1002, 1003, 1004, 1005 - throw FirebaseAccountError.appleFailed - default: - throw FirebaseAccountError.setupError - } - } - } - - - private func requestAppleSignInCredential() async throws -> ASAuthorizationAppleIDCredential? { - Self.logger.debug("Requesting on the fly Sign in with Apple") - let appleIDProvider = ASAuthorizationAppleIDProvider() - let request = appleIDProvider.createRequest() - - await onAppleSignInRequest(request: request) - - guard let result = try await performRequest(request), - case let .appleID(credential) = result else { - return nil - } - - guard await lastNonce != nil else { - Self.logger.error("onAppleSignInCompletion was received though no login request was found.") - throw FirebaseAccountError.setupError - } - - return credential - } - - private func performRequest(_ request: ASAuthorizationAppleIDRequest) async throws -> ASAuthorizationResult? { - guard let authorizationController = firebaseModel.authorizationController else { - Self.logger.error("Failed to perform AppleID request. We are missing access to the AuthorizationController.") - throw FirebaseAccountError.setupError - } - - do { - return try await authorizationController.performRequest(request) - } catch { - try await onAppleSignInCompletion(result: .failure(error)) - } - - return nil - } - - @MainActor - private func oAuthCredential(from credential: ASAuthorizationAppleIDCredential) throws -> OAuthCredential { - guard let lastNonce else { - Self.logger.error("AppleIdCredential was received though no login request was found.") - throw FirebaseAccountError.setupError - } - - guard let identityToken = credential.identityToken else { - Self.logger.error("Unable to fetch identityToken from ASAuthorizationAppleIDCredential.") - throw FirebaseAccountError.setupError - } - - guard let identityTokenString = String(data: identityToken, encoding: .utf8) else { - Self.logger.error("Unable to serialize identityToken to utf8 string.") - throw FirebaseAccountError.setupError - } - - // the fullName is only provided on first contact. After that Apple won't supply that anymore! - return OAuthProvider.appleCredential( - withIDToken: identityTokenString, - rawNonce: lastNonce, - fullName: credential.fullName - ) - } -} diff --git a/Sources/SpeziFirebaseAccount/Account Services/ReauthenticationOperationResult.swift b/Sources/SpeziFirebaseAccount/Account Services/ReauthenticationOperationResult.swift new file mode 100644 index 0000000..a8e9fa3 --- /dev/null +++ b/Sources/SpeziFirebaseAccount/Account Services/ReauthenticationOperationResult.swift @@ -0,0 +1,13 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + + +enum ReauthenticationOperationResult { + case cancelled + case success +} diff --git a/Sources/SpeziFirebaseAccount/Account Services/ValidationRule+FirebasePassword.swift b/Sources/SpeziFirebaseAccount/Account Services/ValidationRule+FirebasePassword.swift new file mode 100644 index 0000000..72f867b --- /dev/null +++ b/Sources/SpeziFirebaseAccount/Account Services/ValidationRule+FirebasePassword.swift @@ -0,0 +1,26 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziValidation + + +extension ValidationRule { // TODO: move! + static var minimumFirebasePassword: ValidationRule { + // Firebase as a non-configurable limit of 6 characters for an account password. + // Refer to https://stackoverflow.com/questions/38064248/firebase-password-validation-allowed-regex + guard let regex = try? Regex(#"(?=.*[0-9a-zA-Z]).{6,}"#) else { + fatalError("Invalid minimumFirebasePassword regex at construction.") + } + + return ValidationRule( + regex: regex, + message: "FIREBASE_ACCOUNT_DEFAULT_PASSWORD_RULE_ERROR \(6)", + bundle: .module + ) + } +} diff --git a/Sources/SpeziFirebaseAccount/AccountValues/FirebaseOAuthCredential.swift b/Sources/SpeziFirebaseAccount/AccountValues/FirebaseOAuthCredential.swift index 0fda2a4..68c69d3 100644 --- a/Sources/SpeziFirebaseAccount/AccountValues/FirebaseOAuthCredential.swift +++ b/Sources/SpeziFirebaseAccount/AccountValues/FirebaseOAuthCredential.swift @@ -15,6 +15,7 @@ struct OAuthCredentialWrapper: Equatable { } +// TODO: just remove that whole file! struct FirebaseOAuthCredentialKey: AccountKey { typealias Value = OAuthCredentialWrapper diff --git a/Sources/SpeziFirebaseAccount/FirebaseAccountConfiguration.swift b/Sources/SpeziFirebaseAccount/FirebaseAccountConfiguration.swift index 1661daf..c07fc59 100644 --- a/Sources/SpeziFirebaseAccount/FirebaseAccountConfiguration.swift +++ b/Sources/SpeziFirebaseAccount/FirebaseAccountConfiguration.swift @@ -6,16 +6,13 @@ // SPDX-License-Identifier: MIT // +import AuthenticationServices +import FirebaseAuth +import OSLog import Spezi import SpeziAccount -@_exported import class FirebaseAuth.User -import class FirebaseAuth.Auth -import protocol FirebaseAuth.AuthStateDidChangeListenerHandle -import FirebaseCore -import Foundation import SpeziFirebaseConfiguration -import SpeziLocalStorage -import SpeziSecureStorage +import SwiftUI /// Configures an `AccountService` to interact with Firebase Auth. @@ -31,24 +28,31 @@ import SpeziSecureStorage /// } /// } /// ``` -public final class FirebaseAccountConfiguration: Module { - @Dependency private var configureFirebaseApp: ConfigureFirebaseApp - @Dependency private var secureStorage: SecureStorage - @Dependency private var localStorage: LocalStorage - @Dependency private var speziAccount: AccountConfiguration? +public final class FirebaseAccountConfiguration: AccountService { + // TODO: replace with Spezi logger! + static nonisolated let logger = Logger(subsystem: "edu.stanford.spezi.firebase", category: "AccountService") - @Provide private var accountServices: [any AccountService] + // TODO: remove this in favor for the account service? + @Dependency private var configureFirebaseApp: ConfigureFirebaseApp + @Dependency private var account: Account + @Dependency private var context: FirebaseContext - @Model private var accountModel = FirebaseAccountModel() + @Model private var firebaseModel = FirebaseAccountModel() @Modifier private var firebaseModifier = FirebaseAccountModifier() private let emulatorSettings: (host: String, port: Int)? private let authenticationMethods: FirebaseAuthAuthenticationMethods + public let configuration: AccountServiceConfiguration + @IdentityProvider(placement: .embedded) private var loginWithPassword = FirebaseLoginView() + @IdentityProvider(placement: .external) private var signInWithApple = FirebaseSignInWithAppleButton() + + @SecurityRelatedModifier private var emailPasswordReauth = ReauthenticationAlertModifier() + + + // TODO: manage state order stuff! + @MainActor private var lastNonce: String? - /// Central context management for all account service implementations. - private var context: FirebaseContext? - /// - Parameters: /// - authenticationMethods: The authentication methods that should be supported. /// - emulatorSettings: The emulator settings. The default value is `nil`, connecting the FirebaseAccount module to the Firebase Auth cloud instance. @@ -58,13 +62,36 @@ public final class FirebaseAccountConfiguration: Module { ) { self.emulatorSettings = emulatorSettings self.authenticationMethods = authenticationMethods - self.accountServices = [] - if authenticationMethods.contains(.emailAndPassword) { - self.accountServices.append(FirebaseEmailPasswordAccountService(accountModel)) + let supportedKeys = AccountKeyCollection { + \.accountId + \.userId + + // TODO: how does that translate to the new model of singleton Account Services? + if authenticationMethods.contains(.emailAndPassword) { + \.password + } + \.name + } + + self.configuration = AccountServiceConfiguration(supportedKeys: .exactly(supportedKeys)) { + RequiredAccountKeys { + \.userId + if authenticationMethods.contains(.emailAndPassword) { + \.password // TODO: how does that translate to the new model? + } + } + + UserIdConfiguration.emailAddress + FieldValidationRules(for: \.userId, rules: .minimalEmail) + FieldValidationRules(for: \.password, rules: .minimumFirebasePassword) // TODO: still support overriding this? + } + + if !authenticationMethods.contains(.emailAndPassword) { + $loginWithPassword.isEnabled = false } - if authenticationMethods.contains(.signInWithApple) { - self.accountServices.append(FirebaseIdentityProviderAccountService(accountModel)) + if !authenticationMethods.contains(.signInWithApple) { + $signInWithApple.isEnabled = false } } @@ -72,28 +99,383 @@ public final class FirebaseAccountConfiguration: Module { if let emulatorSettings { Auth.auth().useEmulator(withHost: emulatorSettings.host, port: emulatorSettings.port) } + } + + func login(userId: String, password: String) async throws { + Self.logger.debug("Received new login request...") + + try await context.dispatchFirebaseAuthAction { + try await Auth.auth().signIn(withEmail: userId, password: password) + Self.logger.debug("signIn(withEmail:password:)") + } + } + + func signUp(signupDetails: SignupDetails) async throws { + Self.logger.debug("Received new signup request...") + + guard let password = signupDetails.password else { + throw FirebaseAccountError.invalidCredentials + } + + try await context.dispatchFirebaseAuthAction { + if let currentUser = Auth.auth().currentUser, + currentUser.isAnonymous { + let credential = EmailAuthProvider.credential(withEmail: signupDetails.userId, password: password) + Self.logger.debug("Linking email-password credentials with current anonymous user account ...") + let result = try await currentUser.link(with: credential) + + if let displayName = signupDetails.name { // TODO: we are not doing that thing with Apple? + try await updateDisplayName(of: result.user, displayName) + } + + try await context.notifyUserSignIn(user: result.user) + + return + } + + let authResult = try await Auth.auth().createUser(withEmail: signupDetails.userId, password: password) + Self.logger.debug("createUser(withEmail:password:) for user.") + + Self.logger.debug("Sending email verification link now...") + try await authResult.user.sendEmailVerification() + + if let displayName = signupDetails.name { + try await updateDisplayName(of: authResult.user, displayName) + } + } + } + + func signupWithCredential(_ credential: OAuthCredential) async throws { + // TODO: the whole firebase auth action complexity is not necessary anymore is it? (We are a single account service now!) + try await context.dispatchFirebaseAuthAction { + if let currentUser = Auth.auth().currentUser, + currentUser.isAnonymous { + Self.logger.debug("Linking oauth credentials with current anonymous user account ...") + let result = try await currentUser.link(with: credential) + + try await context.notifyUserSignIn(user: currentUser, isNewUser: true) + + return result + } + + let authResult = try await Auth.auth().signIn(with: credential) + Self.logger.debug("signIn(with:) credential for user.") + + return authResult // TODO: resolve the slight "isNewUser" difference! just make the "ask for potential differences" explicit! + } + } + + func resetPassword(userId: String) async throws { + do { + try await Auth.auth().sendPasswordReset(withEmail: userId) + Self.logger.debug("sendPasswordReset(withEmail:) for user.") + } catch let error as NSError { + let firebaseError = FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) + if case .invalidCredentials = firebaseError { + return // make sure we don't leak any information // TODO: we are not throwing? + } else { + throw firebaseError + } + } catch { + throw FirebaseAccountError.unknown(.internalError) + } + } + func reauthenticateUserPassword(user: User) async throws -> ReauthenticationOperationResult { + guard let userId = user.email else { + return .cancelled + } - guard speziAccount != nil else { - preconditionFailure(""" - Missing Account Configuration! - FirebaseAccount was configured but no \(AccountConfiguration.self) was provided. Please \ - refer to the initial setup instructions of SpeziAccount: https://swiftpackageindex.com/stanfordspezi/speziaccount/documentation/speziaccount/initial-setup - """) + Self.logger.debug("Requesting credentials for re-authentication...") + let passwordQuery = await firebaseModel.reauthenticateUser(userId: userId) + guard case let .password(password) = passwordQuery else { + return .cancelled } - Task { - let context = FirebaseContext(local: localStorage, secure: secureStorage) - let firebaseServices = accountServices.compactMap { service in - service as? any FirebaseAccountService + Self.logger.debug("Re-authenticating password-based user now ...") + try await user.reauthenticate(with: EmailAuthProvider.credential(withEmail: userId, password: password)) + return .success + } + + func reauthenticateUserApple(user: User) async throws -> ReauthenticationOperationResult { + guard let appleIdCredential = try await requestAppleSignInCredential() else { + return .cancelled + } + + let credential = try oAuthCredential(from: appleIdCredential) + + try await user.reauthenticate(with: credential) + return .success + } + + public func logout() async throws { + guard Auth.auth().currentUser != nil else { + if account.signedIn { + try await context.notifyUserRemoval() + return + } else { + throw FirebaseAccountError.notSignedIn } + } + + try await context.dispatchFirebaseAuthAction { + try Auth.auth().signOut() + try await Task.sleep(for: .milliseconds(10)) + Self.logger.debug("signOut() for user.") + } + } + + public func delete() async throws { + // TODO: how to navigate? + } + + public func deleteUserIDCredential() async throws { + guard let currentUser = Auth.auth().currentUser else { + if account.signedIn { + try await context.notifyUserRemoval() + } + throw FirebaseAccountError.notSignedIn + } - for service in firebaseServices { - await service.configure(with: context) + try await context.dispatchFirebaseAuthAction { + let result = try await reauthenticateUserPassword(user: currentUser) // delete requires a recent sign in + guard case .success = result else { + Self.logger.debug("Re-authentication was cancelled. Not deleting the account.") + return // cancelled } - await context.setup(firebaseServices) - self.context = context // we inject as weak, so ensure to keep the reference here! + try await currentUser.delete() + Self.logger.debug("delete() for user.") } } + + public func deleteApple() async throws { + guard let currentUser = Auth.auth().currentUser else { + if account.signedIn { + try await context.notifyUserRemoval() + } + throw FirebaseAccountError.notSignedIn + } + + try await context.dispatchFirebaseAuthAction { + guard let credential = try await requestAppleSignInCredential() else { + return // user canceled + } + + guard let authorizationCode = credential.authorizationCode else { + Self.logger.error("Unable to fetch authorizationCode from ASAuthorizationAppleIDCredential.") + throw FirebaseAccountError.setupError + } + + guard let authorizationCodeString = String(data: authorizationCode, encoding: .utf8) else { + Self.logger.error("Unable to serialize authorizationCode to utf8 string.") + throw FirebaseAccountError.setupError + } + + Self.logger.debug("Re-Authenticating Apple Credential before deleting user account ...") + let authCredential = try oAuthCredential(from: credential) + try await currentUser.reauthenticate(with: authCredential) + + do { + Self.logger.debug("Revoking Apple Id Token ...") + try await Auth.auth().revokeToken(withAuthorizationCode: authorizationCodeString) + } catch let error as NSError { +#if targetEnvironment(simulator) + // token revocation for Sign in with Apple is currently unsupported for Firebase + // see https://github.com/firebase/firebase-tools/issues/6028 + // and https://github.com/firebase/firebase-tools/pull/6050 + if AuthErrorCode(_nsError: error).code != .invalidCredential { + throw error + } +#else + throw error +#endif + } catch { + throw error + } + + try await currentUser.delete() + Self.logger.debug("delete() for user.") + } + } + + public func updateAccountDetails(_ modifications: AccountModifications) async throws { + guard let currentUser = Auth.auth().currentUser else { + if account.signedIn { + try await context.notifyUserRemoval() + } + throw FirebaseAccountError.notSignedIn + } + + do { + // if we modify sensitive credentials and require a recent login + if modifications.modifiedDetails.storage[UserIdKey.self] != nil || modifications.modifiedDetails.password != nil { + let result: ReauthenticationOperationResult + + // TODO: which reauthentication to call? (Just prefer Apple for simplicity?) + if currentUser.providerData.contains(where: {$0.providerID == "apple.com" }) { // TODO: that's how we check? + result = try await reauthenticateUserApple(user: currentUser) + } else { + result = try await reauthenticateUserPassword(user: currentUser) + } + guard case .success = result else { + Self.logger.debug("Re-authentication was cancelled. Not deleting the account.") + return // got cancelled! + } + } + + if let userId = modifications.modifiedDetails.storage[UserIdKey.self] { + Self.logger.debug("updateEmail(to:) for user.") + // TODO: try await currentUser.sendEmailVerification(beforeUpdatingEmail: userId) (show in UI that they need to accept!) + try await currentUser.updateEmail(to: userId) + } + + if let password = modifications.modifiedDetails.password { + Self.logger.debug("updatePassword(to:) for user.") + try await currentUser.updatePassword(to: password) + } + + if let name = modifications.modifiedDetails.name { + try await updateDisplayName(of: currentUser, name) + } + + // None of the above requests will trigger our state change listener, therefore, we just call it manually. + try await context.notifyUserSignIn(user: currentUser) + } catch let error as NSError { + Self.logger.error("Received NSError on firebase dispatch: \(error)") + throw FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) + } catch { + Self.logger.error("Received error on firebase dispatch: \(error)") + throw FirebaseAccountError.unknown(.internalError) + } + } + + private func updateDisplayName(of user: User, _ name: PersonNameComponents) async throws { + Self.logger.debug("Creating change request for updated display name.") + let changeRequest = user.createProfileChangeRequest() + changeRequest.displayName = name.formatted(.name(style: .long)) + try await changeRequest.commitChanges() + } +} + + +// MARK: - Sign In With Apple + +extension FirebaseAccountConfiguration { + @MainActor + func onAppleSignInRequest(request: ASAuthorizationAppleIDRequest) { + let nonce = CryptoUtils.randomNonceString(length: 32) + // we configured userId as `required` in the account service + var requestedScopes: [ASAuthorization.Scope] = [.email] + + let nameRequirement = account.configuration[PersonNameKey.self]?.requirement + if nameRequirement == .required { // .collected names will be collected later-on + requestedScopes.append(.fullName) + } + + request.nonce = CryptoUtils.sha256(nonce) + request.requestedScopes = requestedScopes + + self.lastNonce = nonce // save the nonce for later use to be passed to FirebaseAuth + } + + @MainActor + func onAppleSignInCompletion(result: Result) async throws { + defer { // cleanup tasks + self.lastNonce = nil + } + + switch result { + case let .success(authorization): + guard let appleIdCredential = authorization.credential as? ASAuthorizationAppleIDCredential else { + Self.logger.error("Unable to obtain credential as ASAuthorizationAppleIDCredential") + throw FirebaseAccountError.setupError + } + + let credential = try oAuthCredential(from: appleIdCredential) + + Self.logger.info("onAppleSignInCompletion creating firebase apple credential from authorization credential") + + try await signupWithCredential(credential) + case let .failure(error): + guard let authorizationError = error as? ASAuthorizationError else { + Self.logger.error("onAppleSignInCompletion received unknown error: \(error)") + throw error + } + + Self.logger.error("Received ASAuthorizationError error: \(authorizationError)") + + switch ASAuthorizationError.Code(rawValue: authorizationError.errorCode) { + case .unknown, .canceled: // 1000, 1001 + // unknown is thrown if e.g. user is not logged in at all. Apple will show a pop up then! + // cancelled is user interaction, no need to show anything + break + case .invalidResponse, .notHandled, .failed, .notInteractive: // 1002, 1003, 1004, 1005 + throw FirebaseAccountError.appleFailed + default: + throw FirebaseAccountError.setupError + } + } + } + + + private func requestAppleSignInCredential() async throws -> ASAuthorizationAppleIDCredential? { + Self.logger.debug("Requesting on the fly Sign in with Apple") + let appleIDProvider = ASAuthorizationAppleIDProvider() + let request = appleIDProvider.createRequest() + + onAppleSignInRequest(request: request) + + guard let result = try await performRequest(request), + case let .appleID(credential) = result else { + return nil + } + + guard lastNonce != nil else { + Self.logger.error("onAppleSignInCompletion was received though no login request was found.") + throw FirebaseAccountError.setupError + } + + return credential + } + + private func performRequest(_ request: ASAuthorizationAppleIDRequest) async throws -> ASAuthorizationResult? { + guard let authorizationController = firebaseModel.authorizationController else { + Self.logger.error("Failed to perform AppleID request. We are missing access to the AuthorizationController.") + throw FirebaseAccountError.setupError + } + + do { + return try await authorizationController.performRequest(request) + } catch { + try await onAppleSignInCompletion(result: .failure(error)) + } + + return nil + } + + @MainActor + private func oAuthCredential(from credential: ASAuthorizationAppleIDCredential) throws -> OAuthCredential { + guard let lastNonce else { + Self.logger.error("AppleIdCredential was received though no login request was found.") + throw FirebaseAccountError.setupError + } + + guard let identityToken = credential.identityToken else { + Self.logger.error("Unable to fetch identityToken from ASAuthorizationAppleIDCredential.") + throw FirebaseAccountError.setupError + } + + guard let identityTokenString = String(data: identityToken, encoding: .utf8) else { + Self.logger.error("Unable to serialize identityToken to utf8 string.") + throw FirebaseAccountError.setupError + } + + // the fullName is only provided on first contact. After that Apple won't supply that anymore! + return OAuthProvider.appleCredential( + withIDToken: identityTokenString, + rawNonce: lastNonce, + fullName: credential.fullName + ) + } } diff --git a/Sources/SpeziFirebaseAccount/Models/FirebaseContext.swift b/Sources/SpeziFirebaseAccount/Models/FirebaseContext.swift index 1b7d91a..40189b5 100644 --- a/Sources/SpeziFirebaseAccount/Models/FirebaseContext.swift +++ b/Sources/SpeziFirebaseAccount/Models/FirebaseContext.swift @@ -8,6 +8,7 @@ import FirebaseAuth import OSLog +import Spezi import SpeziAccount import SpeziLocalStorage import SpeziSecureStorage @@ -19,50 +20,38 @@ private enum UserChange { } private struct UserUpdate { - let service: (any FirebaseAccountService)? let change: UserChange var authResult: AuthDataResult? } -actor FirebaseContext { +actor FirebaseContext: Module, DefaultInitializable { static let logger = Logger(subsystem: "edu.stanford.spezi.firebase", category: "InternalStorage") - private let localStorage: LocalStorage - private let secureStorage: SecureStorage - @_WeakInjectable private var account: Account + @Dependency private var localStorage: LocalStorage + @Dependency private var secureStorage: SecureStorage + @Dependency private var account: Account - private var authStateDidChangeListenerHandle: AuthStateDidChangeListenerHandle? - - private var lastActiveAccountServiceId: String? - private var lastActiveAccountService: (any FirebaseAccountService)? + @MainActor private var authStateDidChangeListenerHandle: AuthStateDidChangeListenerHandle? // dispatch of user updates private var shouldQueue = false private var queuedUpdate: UserUpdate? - init(local localStorage: LocalStorage, secure secureStorage: SecureStorage) { - self.localStorage = localStorage - self.secureStorage = secureStorage - } - - - func share(account: Account) { - self._account.inject(account) - } - - func setup(_ registeredServices: [any FirebaseAccountService]) { - self.loadLastActiveAccountService() - - if let lastActiveAccountServiceId, - let service = registeredServices.first(where: { $0.id == lastActiveAccountServiceId }) { - self.lastActiveAccountService = service - Self.logger.debug("Last active account service is \(service.id)") - } + init() {} + @MainActor + func configure() { // get notified about changes of the User reference - authStateDidChangeListenerHandle = Auth.auth().addStateDidChangeListener(stateDidChangeListener) + authStateDidChangeListenerHandle = Auth.auth().addStateDidChangeListener { [weak self] auth, user in + guard let self else { + return + } + Task { + await self.stateDidChangeListener(auth: auth, user: user) + } + } // if there is a cached user, we refresh the authentication token Auth.auth().currentUser?.getIDTokenForcingRefresh(true) { _, error in @@ -74,18 +63,17 @@ actor FirebaseContext { } Task { - try await self.notifyUserRemoval(for: self.lastActiveAccountService) + try await self.notifyUserRemoval() } } } } // a overload that just returns void - func dispatchFirebaseAuthAction( - on service: Service, + func dispatchFirebaseAuthAction( action: () async throws -> Void ) async throws { - try await self.dispatchFirebaseAuthAction(on: service) { + try await self.dispatchFirebaseAuthAction { try await action() return nil } @@ -101,8 +89,7 @@ actor FirebaseContext { /// - action: The action. If you doing an authentication action, return the auth data result. This way /// we can forward additional information back to SpeziAccount. @_disfavoredOverload - func dispatchFirebaseAuthAction( - on service: Service, + func dispatchFirebaseAuthAction( action: () async throws -> AuthDataResult? ) async throws { defer { @@ -110,7 +97,6 @@ actor FirebaseContext { } shouldQueue = true - setActiveAccountService(to: service) do { let result = try await action() @@ -125,7 +111,7 @@ actor FirebaseContext { } } - private nonisolated func removeCredentials(userId: String, server: String) { + private func removeCredentials(userId: String, server: String) { // TODO: remove legacy keys! do { try secureStorage.deleteCredentials(userId, server: server) } catch SecureStorageError.notFound { @@ -135,53 +121,7 @@ actor FirebaseContext { } } - private func setActiveAccountService(to service: any FirebaseAccountService) { - self.lastActiveAccountServiceId = service.id - self.lastActiveAccountService = service - - - do { - try secureStorage.store( - credentials: Credentials(username: "_", password: service.id), - server: StorageKeys.activeAccountService, - removeDuplicate: true - ) - } catch { - Self.logger.error("Failed to store active account service: \(error)") - } - } - - private func loadLastActiveAccountService() { - let id: String - do { - let credential = try secureStorage.retrieveCredentials("_", server: StorageKeys.activeAccountService) - if let credential { - id = credential.password - } else { - // In previous versions we used the plain local storage for the active key. - do { - id = try localStorage.read(storageKey: StorageKeys.activeAccountService) - } catch { - if let cocoaError = error as? CocoaError, - cocoaError.isFileError { - return // silence any file errors (e.g. file doesn't exist) - } - Self.logger.error("Failed to read last active account service from local storage: \(error)") - return - } - } - } catch { - Self.logger.error("Failed to retrieve last active account service from secure storage: \(error)") - return - } - - self.lastActiveAccountServiceId = id - } - private func resetActiveAccountService() { - self.lastActiveAccountService = nil - self.lastActiveAccountServiceId = nil - do { try secureStorage.deleteCredentials("_", server: StorageKeys.activeAccountService) } catch SecureStorageError.notFound { @@ -205,7 +145,7 @@ actor FirebaseContext { change = .removed } - let update = UserUpdate(service: lastActiveAccountService, change: change) + let update = UserUpdate(change: change) if shouldQueue { Self.logger.debug("Received stateDidChange that is queued to be dispatched in active call.") @@ -266,6 +206,8 @@ actor FirebaseContext { return } + /* + // TODO: investigate if this is still an issue? guard let service = update.service else { Self.logger.error("Failed to dispatch user update due to missing account service identifier on disk!") do { @@ -277,14 +219,15 @@ actor FirebaseContext { } throw FirebaseAccountError.setupError } + */ - try await notifyUserSignIn(user: user, for: service, isNewUser: isNewUser) + try await notifyUserSignIn(user: user, isNewUser: isNewUser) case .removed: - try await notifyUserRemoval(for: update.service) + try await notifyUserRemoval() } } - func notifyUserSignIn(user: User, for service: any FirebaseAccountService, isNewUser: Bool = false) async throws { + func notifyUserSignIn(user: User, isNewUser: Bool = false) async throws { guard let email = user.email else { Self.logger.error("Failed to associate firebase account due to missing email address.") throw FirebaseAccountError.invalidEmail @@ -292,18 +235,18 @@ actor FirebaseContext { Self.logger.debug("Notifying SpeziAccount with updated user details.") - let builder = AccountDetails.Builder() - .set(\.accountId, value: user.uid) - .set(\.userId, value: email) - .set(\.isEmailVerified, value: user.isEmailVerified) - if let displayName = user.displayName, - let nameComponents = try? PersonNameComponents(displayName, strategy: .name) { - // we wouldn't be here if we couldn't create the person name components from the given string - builder.set(\.name, value: nameComponents) - } + let details: AccountDetails = .build { details in + details.accountId = user.uid + details.userId = email + details.isEmailVerified = user.isEmailVerified - let details = builder.build(owner: service) + if let displayName = user.displayName, + let nameComponents = try? PersonNameComponents(displayName, strategy: .name) { + // we wouldn't be here if we couldn't create the person name components from the given string + details.name = nameComponents + } + } // Previous SpeziFirebase releases used to store the password within the keychain. // We keep this for now, to clear the keychain of all users. @@ -312,7 +255,7 @@ actor FirebaseContext { try await account.supplyUserDetails(details, isNewUser: isNewUser) } - func notifyUserRemoval(for service: (any FirebaseAccountService)?) async throws { + func notifyUserRemoval() async throws { Self.logger.debug("Notifying SpeziAccount of removed user details.") await account.removeUserDetails() diff --git a/Sources/SpeziFirebaseAccount/Resources/Localizable.xcstrings b/Sources/SpeziFirebaseAccount/Resources/Localizable.xcstrings index a6cdf84..f437fa2 100644 --- a/Sources/SpeziFirebaseAccount/Resources/Localizable.xcstrings +++ b/Sources/SpeziFirebaseAccount/Resources/Localizable.xcstrings @@ -414,6 +414,7 @@ } }, "FIREBASE_EMAIL_AND_PASSWORD" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -430,6 +431,7 @@ } }, "FIREBASE_IDENTITY_PROVIDER" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { diff --git a/Sources/SpeziFirebaseAccount/Views/FirebaseLoginView.swift b/Sources/SpeziFirebaseAccount/Views/FirebaseLoginView.swift new file mode 100644 index 0000000..eac15f7 --- /dev/null +++ b/Sources/SpeziFirebaseAccount/Views/FirebaseLoginView.swift @@ -0,0 +1,29 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziAccount +import SwiftUI + + +struct FirebaseLoginView: View { + @Environment(FirebaseAccountConfiguration.self) + private var service + + + var body: some View { + UserIdPasswordEmbeddedView { credential in + try await service.login(userId: credential.userId, password: credential.password) + } signup: { details in + try await service.signUp(signupDetails: details) + } resetPassword: { userId in + try await service.resetPassword(userId: userId) + } + } + + nonisolated init() {} +} diff --git a/Sources/SpeziFirebaseAccount/Views/FirebaseSignInWithAppleButton.swift b/Sources/SpeziFirebaseAccount/Views/FirebaseSignInWithAppleButton.swift index edd551a..0460c84 100644 --- a/Sources/SpeziFirebaseAccount/Views/FirebaseSignInWithAppleButton.swift +++ b/Sources/SpeziFirebaseAccount/Views/FirebaseSignInWithAppleButton.swift @@ -12,7 +12,8 @@ import SwiftUI struct FirebaseSignInWithAppleButton: View { - private let accountService: FirebaseIdentityProviderAccountService + @Environment(FirebaseAccountConfiguration.self) + private var service @Environment(\.colorScheme) private var colorScheme @@ -22,12 +23,12 @@ struct FirebaseSignInWithAppleButton: View { @State private var viewState: ViewState = .idle var body: some View { - SignInWithAppleButton(onRequest: { request in - accountService.onAppleSignInRequest(request: request) - }, onCompletion: { result in + SignInWithAppleButton { request in + service.onAppleSignInRequest(request: request) + } onCompletion: { result in Task { do { - try await accountService.onAppleSignInCompletion(result: result) + try await service.onAppleSignInCompletion(result: result) } catch { if let localizedError = error as? LocalizedError { viewState = .error(localizedError) @@ -39,14 +40,11 @@ struct FirebaseSignInWithAppleButton: View { } } } - }) + } .frame(height: 55) .signInWithAppleButtonStyle(colorScheme == .light ? .black : .white) .viewStateAlert(state: $viewState) } - - init(service: FirebaseIdentityProviderAccountService) { - self.accountService = service - } + nonisolated init() {} } diff --git a/Sources/SpeziFirebaseAccount/Views/ReauthenticationAlertModifier.swift b/Sources/SpeziFirebaseAccount/Views/ReauthenticationAlertModifier.swift index 2eeaca6..61608f5 100644 --- a/Sources/SpeziFirebaseAccount/Views/ReauthenticationAlertModifier.swift +++ b/Sources/SpeziFirebaseAccount/Views/ReauthenticationAlertModifier.swift @@ -48,7 +48,7 @@ struct ReauthenticationAlertModifier: ViewModifier { SecureField(text: $password) { Text(PasswordFieldType.password.localizedStringResource) } - .textContentType(.newPassword) + .textContentType(.password) // TODO: this is not a newPassword? .autocorrectionDisabled() .textInputAutocapitalization(.never) .validate(input: password, rules: .nonEmpty) @@ -76,6 +76,8 @@ struct ReauthenticationAlertModifier: ViewModifier { Text("Please enter your password for \(context.userId).") } } + + nonisolated init() {} } From 6c3a0f8060b616608dfb3c0139dfecddccfb15e2 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Wed, 24 Jul 2024 16:20:45 +0200 Subject: [PATCH 02/26] Reorder files --- .../FirebaseEmailVerifiedKey.swift | 0 .../FirebaseOAuthCredential.swift | 78 ------------------- ...ion.swift => FirebaseAccountService.swift} | 4 +- .../FirebaseAccountError.swift | 2 +- .../ReauthenticationOperationResult.swift | 0 .../ValidationRule+FirebasePassword.swift | 0 .../Views/FirebaseLoginView.swift | 2 +- .../Views/FirebaseSignInWithAppleButton.swift | 2 +- 8 files changed, 5 insertions(+), 83 deletions(-) rename Sources/SpeziFirebaseAccount/{AccountValues => AccountKeys}/FirebaseEmailVerifiedKey.swift (100%) delete mode 100644 Sources/SpeziFirebaseAccount/AccountValues/FirebaseOAuthCredential.swift rename Sources/SpeziFirebaseAccount/{FirebaseAccountConfiguration.swift => FirebaseAccountService.swift} (99%) rename Sources/SpeziFirebaseAccount/{Account Services => Models}/FirebaseAccountError.swift (97%) rename Sources/SpeziFirebaseAccount/{Account Services => Models}/ReauthenticationOperationResult.swift (100%) rename Sources/SpeziFirebaseAccount/{Account Services => Models}/ValidationRule+FirebasePassword.swift (100%) diff --git a/Sources/SpeziFirebaseAccount/AccountValues/FirebaseEmailVerifiedKey.swift b/Sources/SpeziFirebaseAccount/AccountKeys/FirebaseEmailVerifiedKey.swift similarity index 100% rename from Sources/SpeziFirebaseAccount/AccountValues/FirebaseEmailVerifiedKey.swift rename to Sources/SpeziFirebaseAccount/AccountKeys/FirebaseEmailVerifiedKey.swift diff --git a/Sources/SpeziFirebaseAccount/AccountValues/FirebaseOAuthCredential.swift b/Sources/SpeziFirebaseAccount/AccountValues/FirebaseOAuthCredential.swift deleted file mode 100644 index 68c69d3..0000000 --- a/Sources/SpeziFirebaseAccount/AccountValues/FirebaseOAuthCredential.swift +++ /dev/null @@ -1,78 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import FirebaseAuth -import SpeziAccount -import SwiftUI - -struct OAuthCredentialWrapper: Equatable { - let credential: OAuthCredential -} - - -// TODO: just remove that whole file! -struct FirebaseOAuthCredentialKey: AccountKey { - typealias Value = OAuthCredentialWrapper - - static let name: LocalizedStringResource = "OAuth Credential" // not translated as never shown - static let category: AccountKeyCategory = .credentials - static var initialValue: InitialValue { - preconditionFailure("Cannot enter a new oauth credential manually") - } -} - -// Codable is required by SpeziAccount such that external Storage Providers can easily store keys. -// As this is an signup-only value, we make sure this isn't ever encoded. -extension OAuthCredentialWrapper: Codable { - init(from decoder: Decoder) throws { // swiftlint:disable:this unavailable_function - preconditionFailure("OAuthCredential must not be decoded!") - } - - func encode(to encoder: Encoder) throws { // swiftlint:disable:this unavailable_function - preconditionFailure("OAuthCredential must not be encoded!") - } -} - - -extension AccountKeys { - /// The OAuth Credential `AccountKey` metatype. - var oauthCredential: FirebaseOAuthCredentialKey.Type { - FirebaseOAuthCredentialKey.self - } -} - - -extension SignupDetails { - /// Access the OAuth Credential of a firebase user. - var oauthCredential: OAuthCredential? { - storage[FirebaseOAuthCredentialKey.self]?.credential - } -} - - -extension FirebaseOAuthCredentialKey { - public struct DataEntry: DataEntryView { - public typealias Key = FirebaseOAuthCredentialKey - - public var body: some View { - Text(verbatim: "The FirebaseOAuthCredentialKey cannot be set!") - } - - public init(_ value: Binding) {} - } - - public struct DataDisplay: DataDisplayView { - public typealias Key = FirebaseOAuthCredentialKey - - public var body: some View { - Text(verbatim: "The FirebaseOAuthCredentialKey cannot be displayed!") - } - - public init(_ value: Value) {} - } -} diff --git a/Sources/SpeziFirebaseAccount/FirebaseAccountConfiguration.swift b/Sources/SpeziFirebaseAccount/FirebaseAccountService.swift similarity index 99% rename from Sources/SpeziFirebaseAccount/FirebaseAccountConfiguration.swift rename to Sources/SpeziFirebaseAccount/FirebaseAccountService.swift index c07fc59..576bce0 100644 --- a/Sources/SpeziFirebaseAccount/FirebaseAccountConfiguration.swift +++ b/Sources/SpeziFirebaseAccount/FirebaseAccountService.swift @@ -28,7 +28,7 @@ import SwiftUI /// } /// } /// ``` -public final class FirebaseAccountConfiguration: AccountService { +public final class FirebaseAccountService: AccountService { // TODO: update all docs! // TODO: replace with Spezi logger! static nonisolated let logger = Logger(subsystem: "edu.stanford.spezi.firebase", category: "AccountService") @@ -361,7 +361,7 @@ public final class FirebaseAccountConfiguration: AccountService { // MARK: - Sign In With Apple -extension FirebaseAccountConfiguration { +extension FirebaseAccountService { @MainActor func onAppleSignInRequest(request: ASAuthorizationAppleIDRequest) { let nonce = CryptoUtils.randomNonceString(length: 32) diff --git a/Sources/SpeziFirebaseAccount/Account Services/FirebaseAccountError.swift b/Sources/SpeziFirebaseAccount/Models/FirebaseAccountError.swift similarity index 97% rename from Sources/SpeziFirebaseAccount/Account Services/FirebaseAccountError.swift rename to Sources/SpeziFirebaseAccount/Models/FirebaseAccountError.swift index 70097a7..b904865 100644 --- a/Sources/SpeziFirebaseAccount/Account Services/FirebaseAccountError.swift +++ b/Sources/SpeziFirebaseAccount/Models/FirebaseAccountError.swift @@ -95,7 +95,7 @@ enum FirebaseAccountError: LocalizedError { // TODO: make public? init(authErrorCode: AuthErrorCode) { - FirebaseAccountConfiguration.logger.debug("Received authError with code \(authErrorCode)") // TODO: what? + FirebaseAccountService.logger.debug("Received authError with code \(authErrorCode)") // TODO: what? switch authErrorCode.code { case .invalidEmail, .invalidRecipientEmail: diff --git a/Sources/SpeziFirebaseAccount/Account Services/ReauthenticationOperationResult.swift b/Sources/SpeziFirebaseAccount/Models/ReauthenticationOperationResult.swift similarity index 100% rename from Sources/SpeziFirebaseAccount/Account Services/ReauthenticationOperationResult.swift rename to Sources/SpeziFirebaseAccount/Models/ReauthenticationOperationResult.swift diff --git a/Sources/SpeziFirebaseAccount/Account Services/ValidationRule+FirebasePassword.swift b/Sources/SpeziFirebaseAccount/Models/ValidationRule+FirebasePassword.swift similarity index 100% rename from Sources/SpeziFirebaseAccount/Account Services/ValidationRule+FirebasePassword.swift rename to Sources/SpeziFirebaseAccount/Models/ValidationRule+FirebasePassword.swift diff --git a/Sources/SpeziFirebaseAccount/Views/FirebaseLoginView.swift b/Sources/SpeziFirebaseAccount/Views/FirebaseLoginView.swift index eac15f7..37700da 100644 --- a/Sources/SpeziFirebaseAccount/Views/FirebaseLoginView.swift +++ b/Sources/SpeziFirebaseAccount/Views/FirebaseLoginView.swift @@ -11,7 +11,7 @@ import SwiftUI struct FirebaseLoginView: View { - @Environment(FirebaseAccountConfiguration.self) + @Environment(FirebaseAccountService.self) private var service diff --git a/Sources/SpeziFirebaseAccount/Views/FirebaseSignInWithAppleButton.swift b/Sources/SpeziFirebaseAccount/Views/FirebaseSignInWithAppleButton.swift index 0460c84..56a56ce 100644 --- a/Sources/SpeziFirebaseAccount/Views/FirebaseSignInWithAppleButton.swift +++ b/Sources/SpeziFirebaseAccount/Views/FirebaseSignInWithAppleButton.swift @@ -12,7 +12,7 @@ import SwiftUI struct FirebaseSignInWithAppleButton: View { - @Environment(FirebaseAccountConfiguration.self) + @Environment(FirebaseAccountService.self) private var service @Environment(\.colorScheme) From 6f4a9a9960867dfb52c28fe4f22f52b47a73f5d9 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Wed, 24 Jul 2024 16:33:11 +0200 Subject: [PATCH 03/26] Tests compile --- Package.swift | 64 ++++++++-- .../FirebaseAccountService.swift | 4 +- .../Resources/Localizable.xcstrings | 1 + .../FirebaseAccountTestsView.swift | 2 +- .../TestApp/Shared/TestAppDelegate.swift | 24 ++-- .../UITests/UITests.xcodeproj/project.pbxproj | 3 + .../xcshareddata/swiftpm/Package.resolved | 114 ++++++++++++++++-- 7 files changed, 180 insertions(+), 32 deletions(-) diff --git a/Package.swift b/Package.swift index 5061009..4401a31 100644 --- a/Package.swift +++ b/Package.swift @@ -8,9 +8,17 @@ // SPDX-License-Identifier: MIT // +import class Foundation.ProcessInfo import PackageDescription +#if swift(<6) +let swiftConcurrency: SwiftSetting = .enableExperimentalFeature("StrictConcurrency") +#else +let swiftConcurrency: SwiftSetting = .enableUpcomingFeature("StrictConcurrency") +#endif + + let package = Package( name: "SpeziFirebase", defaultLocalization: "en", @@ -30,7 +38,7 @@ let package = Package( .package(url: "https://github.com/StanfordSpezi/SpeziStorage", from: "1.0.0"), .package(url: "https://github.com/StanfordSpezi/SpeziAccount", branch: "feature/account-service-singleton"), .package(url: "https://github.com/firebase/firebase-ios-sdk", from: "10.13.0") - ], + ] + swiftLintPackage(), targets: [ .target( name: "SpeziFirebaseAccount", @@ -42,14 +50,22 @@ let package = Package( .product(name: "SpeziLocalStorage", package: "SpeziStorage"), .product(name: "SpeziSecureStorage", package: "SpeziStorage"), .product(name: "FirebaseAuth", package: "firebase-ios-sdk") - ] + ], + swiftSettings: [ + swiftConcurrency + ], + plugins: [] + swiftLintPlugin() ), .target( name: "SpeziFirebaseConfiguration", dependencies: [ .product(name: "Spezi", package: "Spezi"), .product(name: "FirebaseFirestore", package: "firebase-ios-sdk") - ] + ], + swiftSettings: [ + swiftConcurrency + ], + plugins: [] + swiftLintPlugin() ), .target( name: "SpeziFirestore", @@ -58,7 +74,11 @@ let package = Package( .product(name: "Spezi", package: "Spezi"), .product(name: "FirebaseFirestore", package: "firebase-ios-sdk"), .product(name: "FirebaseFirestoreSwift", package: "firebase-ios-sdk") - ] + ], + swiftSettings: [ + swiftConcurrency + ], + plugins: [] + swiftLintPlugin() ), .target( name: "SpeziFirebaseStorage", @@ -66,7 +86,11 @@ let package = Package( .target(name: "SpeziFirebaseConfiguration"), .product(name: "Spezi", package: "Spezi"), .product(name: "FirebaseStorage", package: "firebase-ios-sdk") - ] + ], + swiftSettings: [ + swiftConcurrency + ], + plugins: [] + swiftLintPlugin() ), .target( name: "SpeziFirebaseAccountStorage", @@ -75,7 +99,11 @@ let package = Package( .product(name: "Spezi", package: "Spezi"), .product(name: "SpeziAccount", package: "SpeziAccount"), .target(name: "SpeziFirestore") - ] + ], + swiftSettings: [ + swiftConcurrency + ], + plugins: [] + swiftLintPlugin() ), .testTarget( name: "SpeziFirebaseTests", @@ -83,7 +111,29 @@ let package = Package( .target(name: "SpeziFirebaseAccount"), .target(name: "SpeziFirebaseConfiguration"), .target(name: "SpeziFirestore") - ] + ], + swiftSettings: [ + swiftConcurrency + ], + plugins: [] + swiftLintPlugin() ) ] ) + + +func swiftLintPlugin() -> [Target.PluginUsage] { + // Fully quit Xcode and open again with `open --env SPEZI_DEVELOPMENT_SWIFTLINT /Applications/Xcode.app` + if ProcessInfo.processInfo.environment["SPEZI_DEVELOPMENT_SWIFTLINT"] != nil { + [.plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLint")] + } else { + [] + } +} + +func swiftLintPackage() -> [PackageDescription.Package.Dependency] { + if ProcessInfo.processInfo.environment["SPEZI_DEVELOPMENT_SWIFTLINT"] != nil { + [.package(url: "https://github.com/realm/SwiftLint.git", .upToNextMinor(from: "0.55.1"))] + } else { + [] + } +} diff --git a/Sources/SpeziFirebaseAccount/FirebaseAccountService.swift b/Sources/SpeziFirebaseAccount/FirebaseAccountService.swift index 576bce0..6f890ff 100644 --- a/Sources/SpeziFirebaseAccount/FirebaseAccountService.swift +++ b/Sources/SpeziFirebaseAccount/FirebaseAccountService.swift @@ -28,7 +28,9 @@ import SwiftUI /// } /// } /// ``` -public final class FirebaseAccountService: AccountService { // TODO: update all docs! +public final class FirebaseAccountService: AccountService { // swiftlint:disable:this type_body_length + // TODO: body length! + // TODO: update all docs! // TODO: replace with Spezi logger! static nonisolated let logger = Logger(subsystem: "edu.stanford.spezi.firebase", category: "AccountService") diff --git a/Sources/SpeziFirebaseAccount/Resources/Localizable.xcstrings b/Sources/SpeziFirebaseAccount/Resources/Localizable.xcstrings index f437fa2..579628c 100644 --- a/Sources/SpeziFirebaseAccount/Resources/Localizable.xcstrings +++ b/Sources/SpeziFirebaseAccount/Resources/Localizable.xcstrings @@ -451,6 +451,7 @@ }, "OAuth Credential" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { diff --git a/Tests/UITests/TestApp/FirebaseAccountTests/FirebaseAccountTestsView.swift b/Tests/UITests/TestApp/FirebaseAccountTests/FirebaseAccountTestsView.swift index 74dfc6e..a4dc096 100644 --- a/Tests/UITests/TestApp/FirebaseAccountTests/FirebaseAccountTestsView.swift +++ b/Tests/UITests/TestApp/FirebaseAccountTests/FirebaseAccountTestsView.swift @@ -44,7 +44,7 @@ struct FirebaseAccountTestsView: View { } AsyncButton("Logout", role: .destructive, state: $viewState) { - try await details.accountService.logout() + try await account.accountService.logout() } } Button("Account Setup") { diff --git a/Tests/UITests/TestApp/Shared/TestAppDelegate.swift b/Tests/UITests/TestApp/Shared/TestAppDelegate.swift index ea6e81c..0006ec2 100644 --- a/Tests/UITests/TestApp/Shared/TestAppDelegate.swift +++ b/Tests/UITests/TestApp/Shared/TestAppDelegate.swift @@ -30,23 +30,25 @@ class TestAppDelegate: SpeziAppDelegate { } @ModuleBuilder var configurations: ModuleCollection { - if FeatureFlags.accountStorageTests { - AccountConfiguration(configuration: [ + let configuration: AccountValueConfiguration = FeatureFlags.accountStorageTests + ? [ .requires(\.userId), .requires(\.name), .requires(\.biography) - ]) - } else { - AccountConfiguration(configuration: [ + ] + : [ .requires(\.userId), .collects(\.name) - ]) - } - Firestore(settings: .emulator) - FirebaseAccountConfiguration( - authenticationMethods: [.emailAndPassword, .signInWithApple], - emulatorSettings: (host: "localhost", port: 9099) + ] + + AccountConfiguration( + service: FirebaseAccountService( + authenticationMethods: [.emailAndPassword, .signInWithApple], + emulatorSettings: (host: "localhost", port: 9099) + ), + configuration: configuration ) + Firestore(settings: .emulator) FirebaseStorageConfiguration(emulatorSettings: (host: "localhost", port: 9199)) } } diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index 6cbb278..842bf4d 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -402,6 +402,7 @@ SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = complete; }; name = Debug; }; @@ -456,6 +457,7 @@ SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_STRICT_CONCURRENCY = complete; VALIDATE_PRODUCT = YES; }; name = Release; @@ -634,6 +636,7 @@ SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = TEST; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = complete; }; name = Test; }; diff --git a/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e260efd..6ee9daf 100644 --- a/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -18,6 +18,24 @@ "version" : "10.19.1" } }, + { + "identity" : "collectionconcurrencykit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/JohnSundell/CollectionConcurrencyKit.git", + "state" : { + "revision" : "b4f23e24b5a1bff301efc5e70871083ca029ff95", + "version" : "0.2.0" + } + }, + { + "identity" : "cryptoswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/krzyzanowskim/CryptoSwift.git", + "state" : { + "revision" : "c9c3df6ab812de32bae61fc0cd1bf6d45170ebf0", + "version" : "1.8.2" + } + }, { "identity" : "firebase-ios-sdk", "kind" : "remoteSourceControl", @@ -108,13 +126,22 @@ "version" : "2.4.0" } }, + { + "identity" : "sourcekitten", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jpsim/SourceKitten.git", + "state" : { + "revision" : "fd4df99170f5e9d7cf9aa8312aa8506e0e7a44e7", + "version" : "0.35.0" + } + }, { "identity" : "spezi", "kind" : "remoteSourceControl", "location" : "https://github.com/StanfordSpezi/Spezi", "state" : { - "revision" : "734f90c19422a4196762b0e1dd055471066e89ee", - "version" : "1.3.0" + "revision" : "d87e3d8104a0732c0e294e9ae6354db4a7058800", + "version" : "1.6.0" } }, { @@ -122,8 +149,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/StanfordSpezi/SpeziAccount", "state" : { - "revision" : "2de07209430fe7b13c44790eab948b30482fcb9d", - "version" : "1.2.4" + "branch" : "feature/account-service-singleton", + "revision" : "97422b3a046d8e0adbac4f695a1cf9157cd0c870" } }, { @@ -131,8 +158,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/StanfordSpezi/SpeziFoundation", "state" : { - "revision" : "01af5b91a54f30ddd121258e81aff2ddc2a99ff9", - "version" : "1.0.4" + "revision" : "4781d96a09587f3d47ac3f3e71d197149b288146", + "version" : "1.1.3" } }, { @@ -149,8 +176,26 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/StanfordSpezi/SpeziViews.git", "state" : { - "revision" : "4d2a724d97c8f19ac7de7aa2c046b1cb3ef7b279", - "version" : "1.3.1" + "revision" : "ff61e6594677572df051b96905cc2a7a12cffd10", + "version" : "1.5.0" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "41982a3656a71c768319979febd796c6fd111d5c", + "version" : "1.5.0" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "cd142fd2f64be2100422d658e7411e39489da985", + "version" : "1.2.0" } }, { @@ -158,8 +203,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "ee97538f5b81ae89698fd95938896dec5217b148", - "version" : "1.1.1" + "revision" : "3d2dc41a01f9e49d84f0a3925fb858bed64f702d", + "version" : "1.1.2" } }, { @@ -171,6 +216,42 @@ "version" : "1.26.0" } }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax.git", + "state" : { + "revision" : "303e5c5c36d6a558407d364878df131c3546fad8", + "version" : "510.0.2" + } + }, + { + "identity" : "swiftlint", + "kind" : "remoteSourceControl", + "location" : "https://github.com/realm/SwiftLint.git", + "state" : { + "revision" : "b515723b16eba33f15c4677ee65f3fef2ce8c255", + "version" : "0.55.1" + } + }, + { + "identity" : "swiftytexttable", + "kind" : "remoteSourceControl", + "location" : "https://github.com/scottrhoyt/SwiftyTextTable.git", + "state" : { + "revision" : "c6df6cf533d120716bff38f8ff9885e1ce2a4ac3", + "version" : "0.9.0" + } + }, + { + "identity" : "swxmlhash", + "kind" : "remoteSourceControl", + "location" : "https://github.com/drmohundro/SWXMLHash.git", + "state" : { + "revision" : "a853604c9e9a83ad9954c7e3d2a565273982471f", + "version" : "7.0.2" + } + }, { "identity" : "xctestextensions", "kind" : "remoteSourceControl", @@ -185,8 +266,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/StanfordBDHG/XCTRuntimeAssertions", "state" : { - "revision" : "51da3403f128b120705571ce61e0fe190f8889e6", - "version" : "1.0.1" + "revision" : "7ce28015c0bee62b523a860e343e3d6b7ec40fda", + "version" : "1.1.1" + } + }, + { + "identity" : "yams", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jpsim/Yams.git", + "state" : { + "revision" : "3036ba9d69cf1fd04d433527bc339dc0dc75433d", + "version" : "5.1.3" } } ], From e907b7058db3ed32cf1a063ca9fbc16a3fb1a0cd Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Wed, 24 Jul 2024 16:55:41 +0200 Subject: [PATCH 04/26] Minor bump --- .../AccountKeys/FirebaseEmailVerifiedKey.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/SpeziFirebaseAccount/AccountKeys/FirebaseEmailVerifiedKey.swift b/Sources/SpeziFirebaseAccount/AccountKeys/FirebaseEmailVerifiedKey.swift index 7d32861..17731b5 100644 --- a/Sources/SpeziFirebaseAccount/AccountKeys/FirebaseEmailVerifiedKey.swift +++ b/Sources/SpeziFirebaseAccount/AccountKeys/FirebaseEmailVerifiedKey.swift @@ -16,9 +16,9 @@ import SwiftUI /// - Important: This key is read-only and cannot be modified. public struct FirebaseEmailVerifiedKey: AccountKey { public typealias Value = Bool - public static var name: LocalizedStringResource = "E-Mail Verified" // not translated as never shown - public static var category: AccountKeyCategory = .other - public static var initialValue: InitialValue = .default(false) + public static let name: LocalizedStringResource = "E-Mail Verified" // not translated as never shown + public static let category: AccountKeyCategory = .other + public static let initialValue: InitialValue = .default(false) } From c2c2908b2d4eff439de1b4be68fc706b689beb13 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 25 Jul 2024 15:35:29 +0200 Subject: [PATCH 05/26] Rewrite firestore account storage to new model --- .../FirebaseAccountService.swift | 256 +++++++++--------- .../Models/FirebaseAccountError.swift | 2 +- .../Models/FirebaseContext.swift | 6 +- .../ReauthenticationOperationResult.swift | 38 ++- .../Views/FirebaseLoginView.swift | 4 + .../FirestoreAccountStorage.swift | 141 +++++++--- .../Visitor/FirestoreDecodeVisitor.swift | 5 +- 7 files changed, 274 insertions(+), 178 deletions(-) diff --git a/Sources/SpeziFirebaseAccount/FirebaseAccountService.swift b/Sources/SpeziFirebaseAccount/FirebaseAccountService.swift index 6f890ff..2f73cdd 100644 --- a/Sources/SpeziFirebaseAccount/FirebaseAccountService.swift +++ b/Sources/SpeziFirebaseAccount/FirebaseAccountService.swift @@ -7,7 +7,7 @@ // import AuthenticationServices -import FirebaseAuth +@preconcurrency import FirebaseAuth import OSLog import Spezi import SpeziAccount @@ -28,17 +28,18 @@ import SwiftUI /// } /// } /// ``` -public final class FirebaseAccountService: AccountService { // swiftlint:disable:this type_body_length - // TODO: body length! +public final class FirebaseAccountService: AccountService { // TODO: update all docs! - // TODO: replace with Spezi logger! - static nonisolated let logger = Logger(subsystem: "edu.stanford.spezi.firebase", category: "AccountService") + @Application(\.logger) + private var logger - // TODO: remove this in favor for the account service? @Dependency private var configureFirebaseApp: ConfigureFirebaseApp - @Dependency private var account: Account @Dependency private var context: FirebaseContext + @Dependency private var account: Account + @Dependency private var notifications: AccountNotifications + @Dependency private var externalStorage: ExternalAccountStorage + @Model private var firebaseModel = FirebaseAccountModel() @Modifier private var firebaseModifier = FirebaseAccountModifier() @@ -46,8 +47,10 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable private let authenticationMethods: FirebaseAuthAuthenticationMethods public let configuration: AccountServiceConfiguration - @IdentityProvider(placement: .embedded) private var loginWithPassword = FirebaseLoginView() - @IdentityProvider(placement: .external) private var signInWithApple = FirebaseSignInWithAppleButton() + @IdentityProvider(placement: .embedded) + private var loginWithPassword = FirebaseLoginView() + @IdentityProvider(placement: .external) + private var signInWithApple = FirebaseSignInWithAppleButton() @SecurityRelatedModifier private var emailPasswordReauth = ReauthenticationAlertModifier() @@ -62,10 +65,12 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable authenticationMethods: FirebaseAuthAuthenticationMethods, emulatorSettings: (host: String, port: Int)? = nil ) { + // TODO: how do we support anonymous login and e.g. a invitation code setup with FirebaseAccountService? => anonymous account and signup only (however login page at when already used). self.emulatorSettings = emulatorSettings self.authenticationMethods = authenticationMethods let supportedKeys = AccountKeyCollection { + // TODO: try to remove the supportedKeys, account service makes sure keys are there anyways? \.accountId \.userId @@ -101,29 +106,44 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable if let emulatorSettings { Auth.auth().useEmulator(withHost: emulatorSettings.host, port: emulatorSettings.port) } + + let subscription = externalStorage.detailUpdates + Task { [weak self] in + for await updatedDetails in subscription { + guard let self else { + return + } + + handleUpdatedDetailsFromExternalStorage(for: updatedDetails.accountId, details: updatedDetails.details) + } + } + } + + private func handleUpdatedDetailsFromExternalStorage(for accountId: String, details: AccountDetails) { + // TODO: merge with local representation and notify account of the new details? } func login(userId: String, password: String) async throws { - Self.logger.debug("Received new login request...") + logger.debug("Received new login request...") - try await context.dispatchFirebaseAuthAction { + try await context.dispatchFirebaseAuthAction { @MainActor in try await Auth.auth().signIn(withEmail: userId, password: password) - Self.logger.debug("signIn(withEmail:password:)") + logger.debug("signIn(withEmail:password:)") } } - func signUp(signupDetails: SignupDetails) async throws { - Self.logger.debug("Received new signup request...") + func signUp(signupDetails: AccountDetails) async throws { + logger.debug("Received new signup request...") guard let password = signupDetails.password else { throw FirebaseAccountError.invalidCredentials } - try await context.dispatchFirebaseAuthAction { + try await context.dispatchFirebaseAuthAction { @MainActor in if let currentUser = Auth.auth().currentUser, currentUser.isAnonymous { let credential = EmailAuthProvider.credential(withEmail: signupDetails.userId, password: password) - Self.logger.debug("Linking email-password credentials with current anonymous user account ...") + logger.debug("Linking email-password credentials with current anonymous user account ...") let result = try await currentUser.link(with: credential) if let displayName = signupDetails.name { // TODO: we are not doing that thing with Apple? @@ -136,9 +156,9 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable } let authResult = try await Auth.auth().createUser(withEmail: signupDetails.userId, password: password) - Self.logger.debug("createUser(withEmail:password:) for user.") + logger.debug("createUser(withEmail:password:) for user.") - Self.logger.debug("Sending email verification link now...") + logger.debug("Sending email verification link now...") try await authResult.user.sendEmailVerification() if let displayName = signupDetails.name { @@ -149,10 +169,10 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable func signupWithCredential(_ credential: OAuthCredential) async throws { // TODO: the whole firebase auth action complexity is not necessary anymore is it? (We are a single account service now!) - try await context.dispatchFirebaseAuthAction { + try await context.dispatchFirebaseAuthAction { @MainActor in if let currentUser = Auth.auth().currentUser, currentUser.isAnonymous { - Self.logger.debug("Linking oauth credentials with current anonymous user account ...") + logger.debug("Linking oauth credentials with current anonymous user account ...") let result = try await currentUser.link(with: credential) try await context.notifyUserSignIn(user: currentUser, isNewUser: true) @@ -161,7 +181,7 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable } let authResult = try await Auth.auth().signIn(with: credential) - Self.logger.debug("signIn(with:) credential for user.") + logger.debug("signIn(with:) credential for user.") return authResult // TODO: resolve the slight "isNewUser" difference! just make the "ask for potential differences" explicit! } @@ -170,7 +190,7 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable func resetPassword(userId: String) async throws { do { try await Auth.auth().sendPasswordReset(withEmail: userId) - Self.logger.debug("sendPasswordReset(withEmail:) for user.") + logger.debug("sendPasswordReset(withEmail:) for user.") } catch let error as NSError { let firebaseError = FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) if case .invalidCredentials = firebaseError { @@ -183,33 +203,6 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable } } - func reauthenticateUserPassword(user: User) async throws -> ReauthenticationOperationResult { - guard let userId = user.email else { - return .cancelled - } - - Self.logger.debug("Requesting credentials for re-authentication...") - let passwordQuery = await firebaseModel.reauthenticateUser(userId: userId) - guard case let .password(password) = passwordQuery else { - return .cancelled - } - - Self.logger.debug("Re-authenticating password-based user now ...") - try await user.reauthenticate(with: EmailAuthProvider.credential(withEmail: userId, password: password)) - return .success - } - - func reauthenticateUserApple(user: User) async throws -> ReauthenticationOperationResult { - guard let appleIdCredential = try await requestAppleSignInCredential() else { - return .cancelled - } - - let credential = try oAuthCredential(from: appleIdCredential) - - try await user.reauthenticate(with: credential) - return .success - } - public func logout() async throws { guard Auth.auth().currentUser != nil else { if account.signedIn { @@ -220,18 +213,14 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable } } - try await context.dispatchFirebaseAuthAction { + try await context.dispatchFirebaseAuthAction { @MainActor in try Auth.auth().signOut() try await Task.sleep(for: .milliseconds(10)) - Self.logger.debug("signOut() for user.") + logger.debug("signOut() for user.") } } public func delete() async throws { - // TODO: how to navigate? - } - - public func deleteUserIDCredential() async throws { guard let currentUser = Auth.auth().currentUser else { if account.signedIn { try await context.notifyUserRemoval() @@ -239,65 +228,49 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable throw FirebaseAccountError.notSignedIn } - try await context.dispatchFirebaseAuthAction { - let result = try await reauthenticateUserPassword(user: currentUser) // delete requires a recent sign in + try await notifications.reportEvent(.deletingAccount, for: currentUser.uid) + + try await context.dispatchFirebaseAuthAction { @MainActor in + // TODO: always use Apple Id if we can, we need the token! + let result = try await reauthenticateUser(user: currentUser) // delete requires a recent sign in guard case .success = result else { - Self.logger.debug("Re-authentication was cancelled. Not deleting the account.") + logger.debug("Re-authentication was cancelled by user. Not deleting the account.") return // cancelled } - try await currentUser.delete() - Self.logger.debug("delete() for user.") - } - } - - public func deleteApple() async throws { - guard let currentUser = Auth.auth().currentUser else { - if account.signedIn { - try await context.notifyUserRemoval() - } - throw FirebaseAccountError.notSignedIn - } - - try await context.dispatchFirebaseAuthAction { - guard let credential = try await requestAppleSignInCredential() else { - return // user canceled - } - - guard let authorizationCode = credential.authorizationCode else { - Self.logger.error("Unable to fetch authorizationCode from ASAuthorizationAppleIDCredential.") - throw FirebaseAccountError.setupError - } - - guard let authorizationCodeString = String(data: authorizationCode, encoding: .utf8) else { - Self.logger.error("Unable to serialize authorizationCode to utf8 string.") - throw FirebaseAccountError.setupError - } + if let credential = result.credential { + // re-authentication was made through sign in provider, delete SSO account as well + guard let authorizationCode = credential.authorizationCode else { + logger.error("Unable to fetch authorizationCode from ASAuthorizationAppleIDCredential.") + throw FirebaseAccountError.setupError + } - Self.logger.debug("Re-Authenticating Apple Credential before deleting user account ...") - let authCredential = try oAuthCredential(from: credential) - try await currentUser.reauthenticate(with: authCredential) + guard let authorizationCodeString = String(data: authorizationCode, encoding: .utf8) else { + logger.error("Unable to serialize authorizationCode to utf8 string.") + throw FirebaseAccountError.setupError + } - do { - Self.logger.debug("Revoking Apple Id Token ...") - try await Auth.auth().revokeToken(withAuthorizationCode: authorizationCodeString) - } catch let error as NSError { + do { + logger.debug("Revoking Apple Id Token ...") + try await Auth.auth().revokeToken(withAuthorizationCode: authorizationCodeString) + } catch let error as NSError { #if targetEnvironment(simulator) - // token revocation for Sign in with Apple is currently unsupported for Firebase - // see https://github.com/firebase/firebase-tools/issues/6028 - // and https://github.com/firebase/firebase-tools/pull/6050 - if AuthErrorCode(_nsError: error).code != .invalidCredential { - throw error - } + // token revocation for Sign in with Apple is currently unsupported for Firebase + // see https://github.com/firebase/firebase-tools/issues/6028 + // and https://github.com/firebase/firebase-tools/pull/6050 + if AuthErrorCode(_nsError: error).code != .invalidCredential { + throw error + } #else - throw error + throw error #endif - } catch { - throw error + } catch { + throw error + } } try await currentUser.delete() - Self.logger.debug("delete() for user.") + logger.debug("delete() for user.") } } @@ -312,28 +285,21 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable do { // if we modify sensitive credentials and require a recent login if modifications.modifiedDetails.storage[UserIdKey.self] != nil || modifications.modifiedDetails.password != nil { - let result: ReauthenticationOperationResult - - // TODO: which reauthentication to call? (Just prefer Apple for simplicity?) - if currentUser.providerData.contains(where: {$0.providerID == "apple.com" }) { // TODO: that's how we check? - result = try await reauthenticateUserApple(user: currentUser) - } else { - result = try await reauthenticateUserPassword(user: currentUser) - } + let result = try await reauthenticateUser(user: currentUser) guard case .success = result else { - Self.logger.debug("Re-authentication was cancelled. Not deleting the account.") + logger.debug("Re-authentication was cancelled. Not updating sensitive user details.") return // got cancelled! } } if let userId = modifications.modifiedDetails.storage[UserIdKey.self] { - Self.logger.debug("updateEmail(to:) for user.") + logger.debug("updateEmail(to:) for user.") // TODO: try await currentUser.sendEmailVerification(beforeUpdatingEmail: userId) (show in UI that they need to accept!) try await currentUser.updateEmail(to: userId) } if let password = modifications.modifiedDetails.password { - Self.logger.debug("updatePassword(to:) for user.") + logger.debug("updatePassword(to:) for user.") try await currentUser.updatePassword(to: password) } @@ -344,16 +310,54 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable // None of the above requests will trigger our state change listener, therefore, we just call it manually. try await context.notifyUserSignIn(user: currentUser) } catch let error as NSError { - Self.logger.error("Received NSError on firebase dispatch: \(error)") + logger.error("Received NSError on firebase dispatch: \(error)") throw FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) } catch { - Self.logger.error("Received error on firebase dispatch: \(error)") + logger.error("Received error on firebase dispatch: \(error)") throw FirebaseAccountError.unknown(.internalError) } } + private func reauthenticateUser(user: User) async throws -> ReauthenticationOperation { + // TODO: which reauthentication to call? (Just prefer Apple for simplicity?) => any way to build UI for a selection? + + if user.providerData.contains(where: { $0.providerID == "apple.com" }) { + try await reauthenticateUserApple(user: user) + } else { + try await reauthenticateUserPassword(user: user) + } + } + + private func reauthenticateUserPassword(user: User) async throws -> ReauthenticationOperation { + guard let userId = user.email else { + return .cancelled + } + + logger.debug("Requesting credentials for re-authentication...") + let passwordQuery = await firebaseModel.reauthenticateUser(userId: userId) + guard case let .password(password) = passwordQuery else { + return .cancelled + } + + logger.debug("Re-authenticating password-based user now ...") + try await user.reauthenticate(with: EmailAuthProvider.credential(withEmail: userId, password: password)) + return .success + } + + private func reauthenticateUserApple(user: User) async throws -> ReauthenticationOperation { + guard let appleIdCredential = try await requestAppleSignInCredential() else { + return .cancelled + } + + let credential = try oAuthCredential(from: appleIdCredential) + logger.debug("Re-Authenticating Apple credential ...") + try await user.reauthenticate(with: credential) + + return .success(with: appleIdCredential) + } + private func updateDisplayName(of user: User, _ name: PersonNameComponents) async throws { - Self.logger.debug("Creating change request for updated display name.") + logger.debug("Creating change request for updated display name.") let changeRequest = user.createProfileChangeRequest() changeRequest.displayName = name.formatted(.name(style: .long)) try await changeRequest.commitChanges() @@ -390,22 +394,22 @@ extension FirebaseAccountService { switch result { case let .success(authorization): guard let appleIdCredential = authorization.credential as? ASAuthorizationAppleIDCredential else { - Self.logger.error("Unable to obtain credential as ASAuthorizationAppleIDCredential") + logger.error("Unable to obtain credential as ASAuthorizationAppleIDCredential") throw FirebaseAccountError.setupError } let credential = try oAuthCredential(from: appleIdCredential) - Self.logger.info("onAppleSignInCompletion creating firebase apple credential from authorization credential") + logger.info("onAppleSignInCompletion creating firebase apple credential from authorization credential") try await signupWithCredential(credential) case let .failure(error): guard let authorizationError = error as? ASAuthorizationError else { - Self.logger.error("onAppleSignInCompletion received unknown error: \(error)") + logger.error("onAppleSignInCompletion received unknown error: \(error)") throw error } - Self.logger.error("Received ASAuthorizationError error: \(authorizationError)") + logger.error("Received ASAuthorizationError error: \(authorizationError)") switch ASAuthorizationError.Code(rawValue: authorizationError.errorCode) { case .unknown, .canceled: // 1000, 1001 @@ -422,7 +426,7 @@ extension FirebaseAccountService { private func requestAppleSignInCredential() async throws -> ASAuthorizationAppleIDCredential? { - Self.logger.debug("Requesting on the fly Sign in with Apple") + logger.debug("Requesting on the fly Sign in with Apple") let appleIDProvider = ASAuthorizationAppleIDProvider() let request = appleIDProvider.createRequest() @@ -434,7 +438,7 @@ extension FirebaseAccountService { } guard lastNonce != nil else { - Self.logger.error("onAppleSignInCompletion was received though no login request was found.") + logger.error("onAppleSignInCompletion was received though no login request was found.") throw FirebaseAccountError.setupError } @@ -443,7 +447,7 @@ extension FirebaseAccountService { private func performRequest(_ request: ASAuthorizationAppleIDRequest) async throws -> ASAuthorizationResult? { guard let authorizationController = firebaseModel.authorizationController else { - Self.logger.error("Failed to perform AppleID request. We are missing access to the AuthorizationController.") + logger.error("Failed to perform AppleID request. We are missing access to the AuthorizationController.") throw FirebaseAccountError.setupError } @@ -459,17 +463,17 @@ extension FirebaseAccountService { @MainActor private func oAuthCredential(from credential: ASAuthorizationAppleIDCredential) throws -> OAuthCredential { guard let lastNonce else { - Self.logger.error("AppleIdCredential was received though no login request was found.") + logger.error("AppleIdCredential was received though no login request was found.") throw FirebaseAccountError.setupError } guard let identityToken = credential.identityToken else { - Self.logger.error("Unable to fetch identityToken from ASAuthorizationAppleIDCredential.") + logger.error("Unable to fetch identityToken from ASAuthorizationAppleIDCredential.") throw FirebaseAccountError.setupError } guard let identityTokenString = String(data: identityToken, encoding: .utf8) else { - Self.logger.error("Unable to serialize identityToken to utf8 string.") + logger.error("Unable to serialize identityToken to utf8 string.") throw FirebaseAccountError.setupError } diff --git a/Sources/SpeziFirebaseAccount/Models/FirebaseAccountError.swift b/Sources/SpeziFirebaseAccount/Models/FirebaseAccountError.swift index b904865..e971b90 100644 --- a/Sources/SpeziFirebaseAccount/Models/FirebaseAccountError.swift +++ b/Sources/SpeziFirebaseAccount/Models/FirebaseAccountError.swift @@ -95,7 +95,7 @@ enum FirebaseAccountError: LocalizedError { // TODO: make public? init(authErrorCode: AuthErrorCode) { - FirebaseAccountService.logger.debug("Received authError with code \(authErrorCode)") // TODO: what? + // TODO: FirebaseAccountService.logger.debug("Received authError with code \(authErrorCode)") // TODO: what? switch authErrorCode.code { case .invalidEmail, .invalidRecipientEmail: diff --git a/Sources/SpeziFirebaseAccount/Models/FirebaseContext.swift b/Sources/SpeziFirebaseAccount/Models/FirebaseContext.swift index 40189b5..d4ab1d2 100644 --- a/Sources/SpeziFirebaseAccount/Models/FirebaseContext.swift +++ b/Sources/SpeziFirebaseAccount/Models/FirebaseContext.swift @@ -6,7 +6,7 @@ // SPDX-License-Identifier: MIT // -import FirebaseAuth +@preconcurrency import FirebaseAuth import OSLog import Spezi import SpeziAccount @@ -71,7 +71,7 @@ actor FirebaseContext: Module, DefaultInitializable { // a overload that just returns void func dispatchFirebaseAuthAction( - action: () async throws -> Void + action: @Sendable () async throws -> Void ) async throws { try await self.dispatchFirebaseAuthAction { try await action() @@ -90,7 +90,7 @@ actor FirebaseContext: Module, DefaultInitializable { /// we can forward additional information back to SpeziAccount. @_disfavoredOverload func dispatchFirebaseAuthAction( - action: () async throws -> AuthDataResult? + action: @Sendable () async throws -> AuthDataResult? ) async throws { defer { cleanupQueuedChanges() diff --git a/Sources/SpeziFirebaseAccount/Models/ReauthenticationOperationResult.swift b/Sources/SpeziFirebaseAccount/Models/ReauthenticationOperationResult.swift index a8e9fa3..186457b 100644 --- a/Sources/SpeziFirebaseAccount/Models/ReauthenticationOperationResult.swift +++ b/Sources/SpeziFirebaseAccount/Models/ReauthenticationOperationResult.swift @@ -6,8 +6,40 @@ // SPDX-License-Identifier: MIT // +import AuthenticationServices -enum ReauthenticationOperationResult { - case cancelled - case success + +struct ReauthenticationOperation { + enum Result { + case success + case cancelled + } + + let result: Result + /// The OAuth Credential if re-authentication was made through Single-Sign-On Provider. + let credential: ASAuthorizationAppleIDCredential? + + private init(result: Result, credential: ASAuthorizationAppleIDCredential? = nil) { + self.result = result + self.credential = credential + } } + + +extension ReauthenticationOperation { + static var cancelled: ReauthenticationOperation { + .init(result: .cancelled) + } + + static var success: ReauthenticationOperation { + .init(result: .success) + } + + + static func success(with credential: ASAuthorizationAppleIDCredential) -> ReauthenticationOperation { + .init(result: .success, credential: credential) + } +} + + +extension ReauthenticationOperation: Hashable {} diff --git a/Sources/SpeziFirebaseAccount/Views/FirebaseLoginView.swift b/Sources/SpeziFirebaseAccount/Views/FirebaseLoginView.swift index 37700da..54a9fc6 100644 --- a/Sources/SpeziFirebaseAccount/Views/FirebaseLoginView.swift +++ b/Sources/SpeziFirebaseAccount/Views/FirebaseLoginView.swift @@ -16,6 +16,10 @@ struct FirebaseLoginView: View { var body: some View { + // TODO: configure preferred visual way: + // => signup view + Already have an account? + // => login view + Don't have an account yet? + // TODO: emebded login view styles? UserIdPasswordEmbeddedView { credential in try await service.login(userId: credential.userId, password: credential.password) } signup: { details in diff --git a/Sources/SpeziFirebaseAccountStorage/FirestoreAccountStorage.swift b/Sources/SpeziFirebaseAccountStorage/FirestoreAccountStorage.swift index be8c324..a503ecd 100644 --- a/Sources/SpeziFirebaseAccountStorage/FirestoreAccountStorage.swift +++ b/Sources/SpeziFirebaseAccountStorage/FirestoreAccountStorage.swift @@ -6,7 +6,7 @@ // SPDX-License-Identifier: MIT // -import FirebaseFirestore +@preconcurrency import FirebaseFirestore import Spezi import SpeziAccount import SpeziFirestore @@ -69,74 +69,103 @@ import SpeziFirestore /// } /// } /// ``` -public actor FirestoreAccountStorage: Module, AccountStorageConstraint { +public actor FirestoreAccountStorage: AccountStorageProvider { // TODO: completely restructure docs! @Dependency private var firestore: SpeziFirestore.Firestore // ensure firestore is configured + @Dependency private var externalStorage: ExternalAccountStorage - private let collection: () -> CollectionReference + private let collection: @Sendable () -> CollectionReference + private var listenerRegistrations: [String: ListenerRegistration] = [:] + private var localCache: [String: AccountDetails] = [:] + public init(storeIn collection: @Sendable @autoclosure @escaping () -> CollectionReference) { self.collection = collection } - private func userDocument(for accountId: String) -> DocumentReference { + private nonisolated func userDocument(for accountId: String) -> DocumentReference { collection().document(accountId) } - public func create(_ identifier: AdditionalRecordId, _ details: SignupDetails) async throws { - let result = details.acceptAll(FirestoreEncodeVisitor()) + private func snapshotListener(for accountId: String, with keys: [any AccountKey.Type]) { + if let existingListener = listenerRegistrations[accountId] { + existingListener.remove() + } + let document = userDocument(for: accountId) - do { - switch result { - case let .success(data): - try await userDocument(for: identifier.accountId) - .setData(data, merge: true) - case let .failure(error): - throw error + listenerRegistrations[accountId] = document.addSnapshotListener { [weak self] snapshot, error in + guard let self else { + return + } + + guard let snapshot else { + // TODO: error happened, how to best notify about error? + return } + + Task { + await self.processUpdatedSnapshot(for: accountId, with: keys, snapshot) + } + } + } + + private func processUpdatedSnapshot(for accountId: String, with keys: [any AccountKey.Type], _ snapshot: DocumentSnapshot) { + do { + let details = try buildAccountDetails(from: snapshot, keys: keys) + localCache[accountId] = details + + externalStorage.notifyAboutUpdatedDetails(for: accountId, details) } catch { - throw FirestoreError(error) + // TODO: log or do something with that info! + // TODO: does it make sense to notify the account service about the error? } } - public func load(_ identifier: AdditionalRecordId, _ keys: [any AccountKey.Type]) async throws -> PartialAccountDetails { - let builder = PartialAccountDetails.Builder() + private nonisolated func buildAccountDetails(from snapshot: DocumentSnapshot, keys: [any AccountKey.Type]) throws -> AccountDetails { + guard let data = snapshot.data() else { + return AccountDetails() + } - let document = userDocument(for: identifier.accountId) + return try .build { details in + for key in keys { + guard let value = data[key.identifier] else { + continue + } - do { - let data = try await document - .getDocument() - .data() - - if let data { - for key in keys { - guard let value = data[key.identifier] else { - continue - } - - let visitor = FirestoreDecodeVisitor(value: value, builder: builder, in: document) - key.accept(visitor) - if case let .failure(error) = visitor.final() { - throw error - } + let visitor = FirestoreDecodeVisitor(value: value, builder: details, in: snapshot.reference) + key.accept(visitor) + if case let .failure(error) = visitor.final() { + throw FirestoreError(error) } } - } catch { - throw FirestoreError(error) } + } + + public func create(_ accountId: String, _ details: AccountDetails) async throws { + // we just treat it as modifications + let modifications = try AccountModifications(modifiedDetails: details) + try await modify(accountId, modifications) + } + + public func load(_ accountId: String, _ keys: [any AccountKey.Type]) async throws -> AccountDetails? { // TODO: transport keys as set? + let cached = localCache[accountId] + + if listenerRegistrations[accountId] != nil { // check that there is a snapshot listener in place + snapshotListener(for: accountId, with: keys) + } + - return builder.build() + return cached // TODO: also try to load from disk if in-memory cache doesn't work! } - public func modify(_ identifier: AdditionalRecordId, _ modifications: AccountModifications) async throws { + public func modify(_ accountId: String, _ modifications: AccountModifications) async throws { let result = modifications.modifiedDetails.acceptAll(FirestoreEncodeVisitor()) do { switch result { case let .success(data): - try await userDocument(for: identifier.accountId) + try await userDocument(for: accountId) .setData(data, merge: true) case let .failure(error): throw error @@ -146,20 +175,46 @@ public actor FirestoreAccountStorage: Module, AccountStorageConstraint { result[key.identifier] = FieldValue.delete() } - try await userDocument(for: identifier.accountId) + try await userDocument(for: accountId) .updateData(removedFields) } catch { throw FirestoreError(error) } + + // make sure our cache is consistent + let details: AccountDetails = .build { details in + if let cached = localCache[accountId] { + details.add(contentsOf: cached) + } + details.add(contentsOf: modifications.modifiedDetails, merge: true) + details.removeAll(modifications.removedAccountKeys) + } + localCache[accountId] = details + + + // TODO: check if the snapshot listener is in place with the same set of keys (add remove)! + if listenerRegistrations[accountId] != nil { + // TODO: if we have sets, its easier! + // TODO: actually keep track of all account keys, this will fail! + snapshotListener(for: accountId, with: modifications.modifiedDetails.keys) + } } - public func clear(_ identifier: AdditionalRecordId) async { - // nothing we can do ... + public func disassociate(_ accountId: String) { + guard let registration = listenerRegistrations.removeValue(forKey: accountId) else { + return + } + registration.remove() + + localCache.removeValue(forKey: accountId) + // TODO: remove values form disk! don't keep personal data after logout } - public func delete(_ identifier: AdditionalRecordId) async throws { + public func delete(_ accountId: String) async throws { + disassociate(accountId) + do { - try await userDocument(for: identifier.accountId) + try await userDocument(for: accountId) .delete() } catch { throw FirestoreError(error) diff --git a/Sources/SpeziFirebaseAccountStorage/Visitor/FirestoreDecodeVisitor.swift b/Sources/SpeziFirebaseAccountStorage/Visitor/FirestoreDecodeVisitor.swift index 48339e9..7170431 100644 --- a/Sources/SpeziFirebaseAccountStorage/Visitor/FirestoreDecodeVisitor.swift +++ b/Sources/SpeziFirebaseAccountStorage/Visitor/FirestoreDecodeVisitor.swift @@ -11,14 +11,14 @@ import SpeziAccount class FirestoreDecodeVisitor: AccountKeyVisitor { - private let builder: PartialAccountDetails.Builder + private let builder: SimpleBuilder private let value: Any private let reference: DocumentReference private var error: Error? - init(value: Any, builder: PartialAccountDetails.Builder, in reference: DocumentReference) { + init(value: Any, builder: SimpleBuilder, in reference: DocumentReference) { self.value = value self.builder = builder self.reference = reference @@ -29,6 +29,7 @@ class FirestoreDecodeVisitor: AccountKeyVisitor { let decoder = Firestore.Decoder() do { + // TODO: do we really need to pass the doc reference? try builder.set(key, value: decoder.decode(Key.Value.self, from: value, in: reference)) } catch { self.error = error From fc01d442ccaa510225c62a2c6a6a473fface8db9 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Tue, 30 Jul 2024 08:43:03 +0200 Subject: [PATCH 06/26] Minor changes --- Package.swift | 2 +- .../FirebaseEmailVerifiedKey.swift | 8 - .../FirebaseAccountService.swift | 44 +++- .../Models/FirebaseAccountError.swift | 87 ++++--- .../Models/FirebaseAccountModel.swift | 6 +- .../Models/FirebaseContext.swift | 100 +++----- .../Resources/Localizable.xcstrings | 51 ---- .../Views/ReauthenticationAlertModifier.swift | 28 +-- .../FirestoreAccountStorage.swift | 52 ++-- .../LocalDetailsCache.swift | 237 ++++++++++++++++++ .../Visitor/FirestoreDecodeVisitor.swift | 18 +- 11 files changed, 408 insertions(+), 225 deletions(-) create mode 100644 Sources/SpeziFirebaseAccountStorage/LocalDetailsCache.swift diff --git a/Package.swift b/Package.swift index 4401a31..828a854 100644 --- a/Package.swift +++ b/Package.swift @@ -35,7 +35,7 @@ let package = Package( dependencies: [ .package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.0.0"), .package(url: "https://github.com/StanfordSpezi/SpeziViews.git", from: "1.0.0"), - .package(url: "https://github.com/StanfordSpezi/SpeziStorage", from: "1.0.0"), + .package(url: "https://github.com/StanfordSpezi/SpeziStorage", from: "1.1.0"), .package(url: "https://github.com/StanfordSpezi/SpeziAccount", branch: "feature/account-service-singleton"), .package(url: "https://github.com/firebase/firebase-ios-sdk", from: "10.13.0") ] + swiftLintPackage(), diff --git a/Sources/SpeziFirebaseAccount/AccountKeys/FirebaseEmailVerifiedKey.swift b/Sources/SpeziFirebaseAccount/AccountKeys/FirebaseEmailVerifiedKey.swift index 17731b5..966c506 100644 --- a/Sources/SpeziFirebaseAccount/AccountKeys/FirebaseEmailVerifiedKey.swift +++ b/Sources/SpeziFirebaseAccount/AccountKeys/FirebaseEmailVerifiedKey.swift @@ -30,14 +30,6 @@ extension AccountKeys { } -extension AccountValues { - /// Access if the user's email of their firebase account is verified. - public var isEmailVerified: Bool { - storage[FirebaseEmailVerifiedKey.self] ?? false - } -} - - extension FirebaseEmailVerifiedKey { public struct DataEntry: DataEntryView { public typealias Key = FirebaseEmailVerifiedKey diff --git a/Sources/SpeziFirebaseAccount/FirebaseAccountService.swift b/Sources/SpeziFirebaseAccount/FirebaseAccountService.swift index 2f73cdd..5d96a05 100644 --- a/Sources/SpeziFirebaseAccount/FirebaseAccountService.swift +++ b/Sources/SpeziFirebaseAccount/FirebaseAccountService.swift @@ -28,7 +28,7 @@ import SwiftUI /// } /// } /// ``` -public final class FirebaseAccountService: AccountService { +public final class FirebaseAccountService: AccountService { // swiftlint:disable:this type_body_length // TODO: update all docs! @Application(\.logger) private var logger @@ -47,9 +47,9 @@ public final class FirebaseAccountService: AccountService { private let authenticationMethods: FirebaseAuthAuthenticationMethods public let configuration: AccountServiceConfiguration - @IdentityProvider(placement: .embedded) + @IdentityProvider(section: .primary) private var loginWithPassword = FirebaseLoginView() - @IdentityProvider(placement: .external) + @IdentityProvider(section: .singleSignOn) private var signInWithApple = FirebaseSignInWithAppleButton() @SecurityRelatedModifier private var emailPasswordReauth = ReauthenticationAlertModifier() @@ -107,25 +107,36 @@ public final class FirebaseAccountService: AccountService { Auth.auth().useEmulator(withHost: emulatorSettings.host, port: emulatorSettings.port) } - let subscription = externalStorage.detailUpdates + let subscription = externalStorage.updatedDetails Task { [weak self] in for await updatedDetails in subscription { guard let self else { return } - handleUpdatedDetailsFromExternalStorage(for: updatedDetails.accountId, details: updatedDetails.details) + await handleUpdatedDetailsFromExternalStorage(for: updatedDetails.accountId, details: updatedDetails.details) } } } - private func handleUpdatedDetailsFromExternalStorage(for accountId: String, details: AccountDetails) { - // TODO: merge with local representation and notify account of the new details? + private func handleUpdatedDetailsFromExternalStorage(for accountId: String, details: AccountDetails) async { + guard let user = Auth.auth().currentUser else { + return + } + + // TODO: make sure we do not interrupt! anything? + do { + let context = context + try await context.notifyUserSignIn(user: user, mergeWith: details) + } catch { + logger.error("Failed to propagate update details from external storage: \(error)") + } } func login(userId: String, password: String) async throws { logger.debug("Received new login request...") + let context = context try await context.dispatchFirebaseAuthAction { @MainActor in try await Auth.auth().signIn(withEmail: userId, password: password) logger.debug("signIn(withEmail:password:)") @@ -139,6 +150,7 @@ public final class FirebaseAccountService: AccountService { throw FirebaseAccountError.invalidCredentials } + let context = context try await context.dispatchFirebaseAuthAction { @MainActor in if let currentUser = Auth.auth().currentUser, currentUser.isAnonymous { @@ -168,7 +180,7 @@ public final class FirebaseAccountService: AccountService { } func signupWithCredential(_ credential: OAuthCredential) async throws { - // TODO: the whole firebase auth action complexity is not necessary anymore is it? (We are a single account service now!) + let context = context try await context.dispatchFirebaseAuthAction { @MainActor in if let currentUser = Auth.auth().currentUser, currentUser.isAnonymous { @@ -204,6 +216,8 @@ public final class FirebaseAccountService: AccountService { } public func logout() async throws { + let context = context + guard Auth.auth().currentUser != nil else { if account.signedIn { try await context.notifyUserRemoval() @@ -221,6 +235,8 @@ public final class FirebaseAccountService: AccountService { } public func delete() async throws { + let context = context + guard let currentUser = Auth.auth().currentUser else { if account.signedIn { try await context.notifyUserRemoval() @@ -228,7 +244,7 @@ public final class FirebaseAccountService: AccountService { throw FirebaseAccountError.notSignedIn } - try await notifications.reportEvent(.deletingAccount, for: currentUser.uid) + try await notifications.reportEvent(.deletingAccount(currentUser.uid)) try await context.dispatchFirebaseAuthAction { @MainActor in // TODO: always use Apple Id if we can, we need the token! @@ -275,6 +291,8 @@ public final class FirebaseAccountService: AccountService { } public func updateAccountDetails(_ modifications: AccountModifications) async throws { + let context = context + guard let currentUser = Auth.auth().currentUser else { if account.signedIn { try await context.notifyUserRemoval() @@ -284,7 +302,7 @@ public final class FirebaseAccountService: AccountService { do { // if we modify sensitive credentials and require a recent login - if modifications.modifiedDetails.storage[UserIdKey.self] != nil || modifications.modifiedDetails.password != nil { + if modifications.modifiedDetails.contains(UserIdKey.self) || modifications.modifiedDetails.password != nil { let result = try await reauthenticateUser(user: currentUser) guard case .success = result else { logger.debug("Re-authentication was cancelled. Not updating sensitive user details.") @@ -292,10 +310,10 @@ public final class FirebaseAccountService: AccountService { } } - if let userId = modifications.modifiedDetails.storage[UserIdKey.self] { + if modifications.modifiedDetails.contains(UserIdKey.self) { logger.debug("updateEmail(to:) for user.") // TODO: try await currentUser.sendEmailVerification(beforeUpdatingEmail: userId) (show in UI that they need to accept!) - try await currentUser.updateEmail(to: userId) + try await currentUser.updateEmail(to: modifications.modifiedDetails.userId) } if let password = modifications.modifiedDetails.password { @@ -485,3 +503,5 @@ extension FirebaseAccountService { ) } } + +// swiftlint:disable:this file_length diff --git a/Sources/SpeziFirebaseAccount/Models/FirebaseAccountError.swift b/Sources/SpeziFirebaseAccount/Models/FirebaseAccountError.swift index e971b90..8b5ad25 100644 --- a/Sources/SpeziFirebaseAccount/Models/FirebaseAccountError.swift +++ b/Sources/SpeziFirebaseAccount/Models/FirebaseAccountError.swift @@ -9,24 +9,67 @@ import FirebaseAuth import Foundation -// TODO: move whole folder - -enum FirebaseAccountError: LocalizedError { // TODO: make public? +/// Error thrown by the `FirebaseAccountService`. +/// +/// This error type might be thrown by methods of the ``FirebaseAccountService``. +public enum FirebaseAccountError { + /// The provided email is invalid. case invalidEmail + /// The account is already in use. case accountAlreadyInUse + /// The password was rejected because it is too weak. case weakPassword + /// The provided credentials are invalid. case invalidCredentials + /// Internal error occurred when resetting the password. case internalPasswordResetError + /// Internal error when performing the account operation. case setupError + /// An operation was performed that requires an signed in user account. case notSignedIn + /// The security operation requires a recent login. case requireRecentLogin + /// The `ASAuthorizationAppleIDRequest` request failed due do an error reported from the AccountServices framework. case appleFailed + /// Linking the account failed as the account was already linked with this type of account provider. case linkFailedDuplicate + /// Linking the account failed as the credentials are already in use with a different account. case linkFailedAlreadyInUse + /// Unrecognized Firebase account error. case unknown(AuthErrorCode.Code) - - + + + /// Derive the error from the Firebase `AuthErrorCode`. + /// - Parameter authErrorCode: The error code from the NSError reported by Firebase Auth. + public init(authErrorCode: AuthErrorCode) { + switch authErrorCode.code { + case .invalidEmail, .invalidRecipientEmail: + self = .invalidEmail + case .emailAlreadyInUse: + self = .accountAlreadyInUse + case .weakPassword: + self = .weakPassword + case .userDisabled, .wrongPassword, .userNotFound, .userMismatch: + self = .invalidCredentials + case .invalidSender, .invalidMessagePayload: + self = .internalPasswordResetError + case .operationNotAllowed, .invalidAPIKey, .appNotAuthorized, .keychainError, .internalError: + self = .setupError + case .requiresRecentLogin: + self = .requireRecentLogin + case .providerAlreadyLinked: + self = .linkFailedDuplicate + case .credentialAlreadyInUse: + self = .linkFailedAlreadyInUse + default: + self = .unknown(authErrorCode.code) + } + } +} + + +extension FirebaseAccountError: LocalizedError { private var errorDescriptionValue: String.LocalizationValue { switch self { case .invalidEmail: @@ -56,10 +99,10 @@ enum FirebaseAccountError: LocalizedError { // TODO: make public? } } - var errorDescription: String? { + public var errorDescription: String? { .init(localized: errorDescriptionValue, bundle: .module) } - + private var recoverySuggestionValue: String.LocalizationValue { switch self { case .invalidEmail: @@ -89,35 +132,7 @@ enum FirebaseAccountError: LocalizedError { // TODO: make public? } } - var recoverySuggestion: String? { + public var recoverySuggestion: String? { .init(localized: recoverySuggestionValue, bundle: .module) } - - - init(authErrorCode: AuthErrorCode) { - // TODO: FirebaseAccountService.logger.debug("Received authError with code \(authErrorCode)") // TODO: what? - - switch authErrorCode.code { - case .invalidEmail, .invalidRecipientEmail: - self = .invalidEmail - case .emailAlreadyInUse: - self = .accountAlreadyInUse - case .weakPassword: - self = .weakPassword - case .userDisabled, .wrongPassword, .userNotFound, .userMismatch: - self = .invalidCredentials - case .invalidSender, .invalidMessagePayload: - self = .internalPasswordResetError - case .operationNotAllowed, .invalidAPIKey, .appNotAuthorized, .keychainError, .internalError: - self = .setupError - case .requiresRecentLogin: - self = .requireRecentLogin - case .providerAlreadyLinked: - self = .linkFailedDuplicate - case .credentialAlreadyInUse: - self = .linkFailedAlreadyInUse - default: - self = .unknown(authErrorCode.code) - } - } } diff --git a/Sources/SpeziFirebaseAccount/Models/FirebaseAccountModel.swift b/Sources/SpeziFirebaseAccount/Models/FirebaseAccountModel.swift index 88ccead..4224740 100644 --- a/Sources/SpeziFirebaseAccount/Models/FirebaseAccountModel.swift +++ b/Sources/SpeziFirebaseAccount/Models/FirebaseAccountModel.swift @@ -12,13 +12,14 @@ import SwiftUI @Observable +@MainActor class FirebaseAccountModel { var authorizationController: AuthorizationController? var isPresentingReauthentication = false var reauthenticationContext: ReauthenticationContext? - init() {} + nonisolated init() {} func reauthenticateUser(userId: String) async -> ReauthenticationResult { @@ -33,3 +34,6 @@ class FirebaseAccountModel { } } } + + +extension FirebaseAccountModel: Sendable {} diff --git a/Sources/SpeziFirebaseAccount/Models/FirebaseContext.swift b/Sources/SpeziFirebaseAccount/Models/FirebaseContext.swift index d4ab1d2..266a8f1 100644 --- a/Sources/SpeziFirebaseAccount/Models/FirebaseContext.swift +++ b/Sources/SpeziFirebaseAccount/Models/FirebaseContext.swift @@ -25,7 +25,7 @@ private struct UserUpdate { } -actor FirebaseContext: Module, DefaultInitializable { +actor FirebaseContext: Module, DefaultInitializable { // TODO: better name? no actor! static let logger = Logger(subsystem: "edu.stanford.spezi.firebase", category: "InternalStorage") @Dependency private var localStorage: LocalStorage @@ -67,6 +67,12 @@ actor FirebaseContext: Module, DefaultInitializable { } } } + + Task { + // Previous SpeziFirebase releases used to store an identifier for the active account service on disk. + // We keep this for now, to clear the keychain of all users. + await resetActiveAccountService() + } } // a overload that just returns void @@ -93,7 +99,7 @@ actor FirebaseContext: Module, DefaultInitializable { action: @Sendable () async throws -> AuthDataResult? ) async throws { defer { - cleanupQueuedChanges() + shouldQueue = false } shouldQueue = true @@ -111,16 +117,6 @@ actor FirebaseContext: Module, DefaultInitializable { } } - private func removeCredentials(userId: String, server: String) { // TODO: remove legacy keys! - do { - try secureStorage.deleteCredentials(userId, server: server) - } catch SecureStorageError.notFound { - // we don't care if we want to delete something that doesn't exist - } catch { - Self.logger.error("Failed to remove credentials: \(error)") - } - } - private func resetActiveAccountService() { do { try secureStorage.deleteCredentials("_", server: StorageKeys.activeAccountService) @@ -147,25 +143,22 @@ actor FirebaseContext: Module, DefaultInitializable { let update = UserUpdate(change: change) + // TODO: update some of the log messages to be less confusing! if shouldQueue { Self.logger.debug("Received stateDidChange that is queued to be dispatched in active call.") self.queuedUpdate = update } else { Self.logger.debug("Received stateDidChange that that was triggered due to other reasons. Dispatching anonymously...") - anonymouslyDispatch(update: update) - } - } - private func cleanupQueuedChanges() { - shouldQueue = false - - guard let queuedUpdate = self.queuedUpdate else { - return + // just apply update out of band, errors are just logged as we can't throw them somewhere where UI pops up // TODO: (should we try?) + Task { + do { + try await apply(update: update) + } catch { + Self.logger.error("Failed to anonymously dispatch user change due to \(error)") + } + } } - - - self.queuedUpdate = nil - anonymouslyDispatch(update: queuedUpdate) } private func dispatchQueuedChanges(result: AuthDataResult? = nil) async throws { @@ -185,17 +178,6 @@ actor FirebaseContext: Module, DefaultInitializable { try await apply(update: queuedUpdate) } - private func anonymouslyDispatch(update: UserUpdate) { - // anonymous dispatch doesn't forward the error! - Task { - do { - try await apply(update: update) - } catch { - Self.logger.error("Failed to anonymously dispatch user change due to \(error)") - } - } - } - private func apply(update: UserUpdate) async throws { switch update.change { case let .user(user): @@ -203,23 +185,10 @@ actor FirebaseContext: Module, DefaultInitializable { if user.isAnonymous { // We explicitly handle anonymous users on every signup and call our state change handler ourselves. // But generally, we don't care about anonymous users. - return - } - /* - // TODO: investigate if this is still an issue? - guard let service = update.service else { - Self.logger.error("Failed to dispatch user update due to missing account service identifier on disk!") - do { - // This typically happens if there still is a Account associated in the Keychain but the App was recently deleted. - // Therefore, we reset the user account to allow for easily re-authenticating with firebase. - try Auth.auth().signOut() - } catch { - Self.logger.warning("Tried to remove local user. But Firebase signOut failed with \(error)") - } - throw FirebaseAccountError.setupError + // TODO: we do now! restructure this! => rename account service methods to indicate a signup or link! + return } - */ try await notifyUserSignIn(user: user, isNewUser: isNewUser) case .removed: @@ -227,7 +196,7 @@ actor FirebaseContext: Module, DefaultInitializable { } } - func notifyUserSignIn(user: User, isNewUser: Bool = false) async throws { + func notifyUserSignIn(user: User, isNewUser: Bool = false, mergeWith additionalDetails: AccountDetails? = nil) async throws { guard let email = user.email else { Self.logger.error("Failed to associate firebase account due to missing email address.") throw FirebaseAccountError.invalidEmail @@ -236,30 +205,29 @@ actor FirebaseContext: Module, DefaultInitializable { Self.logger.debug("Notifying SpeziAccount with updated user details.") - let details: AccountDetails = .build { details in - details.accountId = user.uid - details.userId = email - details.isEmailVerified = user.isEmailVerified + var details = AccountDetails() + details.accountId = user.uid + details.userId = email + details.isEmailVerified = user.isEmailVerified - if let displayName = user.displayName, - let nameComponents = try? PersonNameComponents(displayName, strategy: .name) { - // we wouldn't be here if we couldn't create the person name components from the given string - details.name = nameComponents - } + if let displayName = user.displayName, + let nameComponents = try? PersonNameComponents(displayName, strategy: .name) { + // we wouldn't be here if we couldn't create the person name components from the given string + details.name = nameComponents } - // Previous SpeziFirebase releases used to store the password within the keychain. - // We keep this for now, to clear the keychain of all users. - removeCredentials(userId: details.userId, server: StorageKeys.emailPasswordCredentials) + if let additionalDetails { + details.add(contentsOf: additionalDetails) + } - try await account.supplyUserDetails(details, isNewUser: isNewUser) + let account = account + await account.supplyUserDetails(details, isNewUser: isNewUser) } func notifyUserRemoval() async throws { Self.logger.debug("Notifying SpeziAccount of removed user details.") + let account = account await account.removeUserDetails() - - resetActiveAccountService() } } diff --git a/Sources/SpeziFirebaseAccount/Resources/Localizable.xcstrings b/Sources/SpeziFirebaseAccount/Resources/Localizable.xcstrings index 579628c..db4439e 100644 --- a/Sources/SpeziFirebaseAccount/Resources/Localizable.xcstrings +++ b/Sources/SpeziFirebaseAccount/Resources/Localizable.xcstrings @@ -413,59 +413,8 @@ } } }, - "FIREBASE_EMAIL_AND_PASSWORD" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "E-Mail und Password" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "E-Mail and Password" - } - } - } - }, - "FIREBASE_IDENTITY_PROVIDER" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Single Sign-On" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Single Sign-On" - } - } - } - }, "Login" : { - }, - "OAuth Credential" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "OAuth Berechtigung" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "OAuth Credential" - } - } - } }, "Please enter your password for %@." : { diff --git a/Sources/SpeziFirebaseAccount/Views/ReauthenticationAlertModifier.swift b/Sources/SpeziFirebaseAccount/Views/ReauthenticationAlertModifier.swift index 61608f5..b7de8f9 100644 --- a/Sources/SpeziFirebaseAccount/Views/ReauthenticationAlertModifier.swift +++ b/Sources/SpeziFirebaseAccount/Views/ReauthenticationAlertModifier.swift @@ -35,6 +35,8 @@ struct ReauthenticationAlertModifier: ViewModifier { firebaseModel.reauthenticationContext } + nonisolated init() {} + func body(content: Content) -> some View { content @@ -48,36 +50,34 @@ struct ReauthenticationAlertModifier: ViewModifier { SecureField(text: $password) { Text(PasswordFieldType.password.localizedStringResource) } - .textContentType(.password) // TODO: this is not a newPassword? - .autocorrectionDisabled() - .textInputAutocapitalization(.never) - .validate(input: password, rules: .nonEmpty) - .receiveValidation(in: $validation) - .onDisappear { - password = "" // make sure we don't hold onto passwords - } + .textContentType(.password) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .validate(input: password, rules: .nonEmpty) + .receiveValidation(in: $validation) + .onDisappear { + password = "" // make sure we don't hold onto passwords + } - Button(role: .cancel, action: { + Button(role: .cancel) { context.continuation.resume(returning: .cancelled) - }) { + } label: { Text("Cancel", bundle: .module) } - Button(action: { + Button { guard validation.validateSubviews() else { context.continuation.resume(returning: .cancelled) return } context.continuation.resume(returning: .password(password)) - }) { + } label: { Text("Login", bundle: .module) } } message: { context in Text("Please enter your password for \(context.userId).") } } - - nonisolated init() {} } diff --git a/Sources/SpeziFirebaseAccountStorage/FirestoreAccountStorage.swift b/Sources/SpeziFirebaseAccountStorage/FirestoreAccountStorage.swift index a503ecd..3349773 100644 --- a/Sources/SpeziFirebaseAccountStorage/FirestoreAccountStorage.swift +++ b/Sources/SpeziFirebaseAccountStorage/FirestoreAccountStorage.swift @@ -70,14 +70,16 @@ import SpeziFirestore /// } /// ``` public actor FirestoreAccountStorage: AccountStorageProvider { // TODO: completely restructure docs! + @Application(\.logger) private var logger + @Dependency private var firestore: SpeziFirestore.Firestore // ensure firestore is configured @Dependency private var externalStorage: ExternalAccountStorage + @Dependency private var localCache: LocalDetailsCache private let collection: @Sendable () -> CollectionReference private var listenerRegistrations: [String: ListenerRegistration] = [:] - private var localCache: [String: AccountDetails] = [:] public init(storeIn collection: @Sendable @autoclosure @escaping () -> CollectionReference) { self.collection = collection @@ -113,10 +115,11 @@ public actor FirestoreAccountStorage: AccountStorageProvider { // TODO: complete private func processUpdatedSnapshot(for accountId: String, with keys: [any AccountKey.Type], _ snapshot: DocumentSnapshot) { do { let details = try buildAccountDetails(from: snapshot, keys: keys) - localCache[accountId] = details + localCache.communicateRemoteChanges(for: accountId, details) externalStorage.notifyAboutUpdatedDetails(for: accountId, details) } catch { + logger.error("") // TODO: log or do something with that info! // TODO: does it make sense to notify the account service about the error? } @@ -127,19 +130,24 @@ public actor FirestoreAccountStorage: AccountStorageProvider { // TODO: complete return AccountDetails() } - return try .build { details in - for key in keys { - guard let value = data[key.identifier] else { - continue - } - - let visitor = FirestoreDecodeVisitor(value: value, builder: details, in: snapshot.reference) - key.accept(visitor) - if case let .failure(error) = visitor.final() { - throw FirestoreError(error) - } + var details = AccountDetails() + + for key in keys { + guard let value = data[key.identifier] else { + continue + } + + var visitor = FirestoreDecodeVisitor(value: value, details: details, in: snapshot.reference) + key.accept(&visitor) + switch visitor.final() { + case let .success(value): + details = value + case let .failure(error): + throw FirestoreError(error) } } + + return details } public func create(_ accountId: String, _ details: AccountDetails) async throws { @@ -149,14 +157,13 @@ public actor FirestoreAccountStorage: AccountStorageProvider { // TODO: complete } public func load(_ accountId: String, _ keys: [any AccountKey.Type]) async throws -> AccountDetails? { // TODO: transport keys as set? - let cached = localCache[accountId] + let cached = localCache.loadEntry(for: accountId, keys) if listenerRegistrations[accountId] != nil { // check that there is a snapshot listener in place snapshotListener(for: accountId, with: keys) } - - return cached // TODO: also try to load from disk if in-memory cache doesn't work! + return cached } public func modify(_ accountId: String, _ modifications: AccountModifications) async throws { @@ -181,15 +188,7 @@ public actor FirestoreAccountStorage: AccountStorageProvider { // TODO: complete throw FirestoreError(error) } - // make sure our cache is consistent - let details: AccountDetails = .build { details in - if let cached = localCache[accountId] { - details.add(contentsOf: cached) - } - details.add(contentsOf: modifications.modifiedDetails, merge: true) - details.removeAll(modifications.removedAccountKeys) - } - localCache[accountId] = details + localCache.communicateModifications(for: accountId, modifications) // TODO: check if the snapshot listener is in place with the same set of keys (add remove)! @@ -206,8 +205,7 @@ public actor FirestoreAccountStorage: AccountStorageProvider { // TODO: complete } registration.remove() - localCache.removeValue(forKey: accountId) - // TODO: remove values form disk! don't keep personal data after logout + localCache.clearEntry(for: accountId) } public func delete(_ accountId: String) async throws { diff --git a/Sources/SpeziFirebaseAccountStorage/LocalDetailsCache.swift b/Sources/SpeziFirebaseAccountStorage/LocalDetailsCache.swift new file mode 100644 index 0000000..2a253f4 --- /dev/null +++ b/Sources/SpeziFirebaseAccountStorage/LocalDetailsCache.swift @@ -0,0 +1,237 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import Spezi +import SpeziAccount +import SpeziLocalStorage + + +struct CachedDetails { + let details: AccountDetails + + init(_ details: AccountDetails) { + self.details = details + } +} + + +final class LocalDetailsCache: Module, DefaultInitializable { + @Application(\.logger) private var logger + + @Dependency private var localStorage: LocalStorage + + private var localCache: [String: AccountDetails] = [:] + + + init() {} + + func loadEntry(for accountId: String, _ keys: [any AccountKey.Type]) -> AccountDetails? { + if let details = localCache[accountId] { + return details + } + + let decoder = JSONDecoder() + decoder.userInfo[.accountDetailsKeys] = keys + + do { + let stored = try localStorage.read( + CachedDetails.self, + decoder: decoder, + storageKey: key(for: accountId), + settings: .encryptedUsingKeyChain(userPresence: false, excludedFromBackup: false) + ) + + localCache[accountId] = stored.details + return stored.details + } catch { + // TODO: silence error if doesn't exist + logger.error("Failed to read cached account details from disk: \(error)") + } + + return nil + } + + func clearEntry(for accountId: String) { + localCache.removeValue(forKey: accountId) + do { + try localStorage.delete(storageKey: key(for: accountId)) + } catch { + // TODO: silence error if doesn't exist + logger.error("Failed to clear cached account details from disk: \(error)") + } + } + + func communicateModifications(for accountId: String, _ modifications: AccountModifications) { + // make sure our cache is consistent + var details = AccountDetails() + if let cached = localCache[accountId] { + details.add(contentsOf: cached) + } + details.add(contentsOf: modifications.modifiedDetails, merge: true) + details.removeAll(modifications.removedAccountKeys) + + communicateRemoteChanges(for: accountId, details) + } + + func communicateRemoteChanges(for accountId: String, _ details: AccountDetails) { + localCache[accountId] = details + + + let storage = CachedDetails(details) + do { + try localStorage.store( + storage, + storageKey: key(for: accountId), + settings: .encryptedUsingKeyChain(userPresence: false, excludedFromBackup: false) + ) + } catch { + // TODO: silence error if doesn't exist + logger.error("Failed to update cached account details to disk: \(error)") + } + } + + private func key(for accountId: String) -> String { + "edu.stanford.spezi.firebase.details.\(accountId)" + } +} + + +extension CachedDetails: Codable { // TODO: can we just add Codable conformance to AccountDetails natively? + struct CodingKeys: CodingKey, RawRepresentable { // TODO: provide a reusable codingKey in SpeziAccount? + var stringValue: String { + rawValue + } + + var intValue: Int? { + nil + } + + let rawValue: String + + init(stringValue rawValue: String) { + self.rawValue = rawValue + } + + init(rawValue: String) { + self.rawValue = rawValue + } + + init?(intValue: Int) { + nil + } + } + + private struct EncoderVisitor: AccountValueVisitor { + private var container: KeyedEncodingContainer + private var firstError: Error? + + init(_ container: KeyedEncodingContainer) { + self.container = container + } + + mutating func visit(_ key: Key.Type, _ value: Key.Value) { + guard firstError == nil else { + return + } + + do { + try container.encode(value, forKey: CodingKeys(rawValue: key.identifier)) + } catch { + firstError = error + } + } + + func final() -> Result { + if let firstError { + .failure(firstError) + } else { + .success(()) + } + } + } + + private struct DecoderVisitor: AccountKeyVisitor { + private let container: KeyedDecodingContainer + private var details = AccountDetails() + private var firstError: Error? + + init(_ container: KeyedDecodingContainer) { + self.container = container + } + + + mutating func visit(_ key: Key.Type) { + guard firstError == nil else { + return + } + + + do { + let value = try container.decode(Key.Value.self, forKey: CodingKeys(rawValue: key.identifier)) + details.set(Key.self, value: value) + } catch { + firstError = error + } + } + + func final() -> Result { + if let firstError { + .failure(firstError) + } else { + .success(details) + } + } + } + + + init(from decoder: any Decoder) throws { + guard let keys = decoder.userInfo[.accountDetailsKeys] as? [any AccountKey.Type] else { + throw DecodingError.dataCorrupted(.init( + codingPath: decoder.codingPath, + debugDescription: """ + AccountKeys unspecified. Do decode AccountDetails you must specify requested AccountKey types \ + via the `accountDetailsKeys` CodingUserInfoKey. + """ + )) + } + + let container = try decoder.container(keyedBy: CodingKeys.self) + + var visitor = DecoderVisitor(container) + let result = keys.acceptAll(&visitor) + + switch result { + case let .success(details): + self.details = details + case let .failure(error): + throw error + } + } + + func encode(to encoder: any Encoder) throws { + let container = encoder.container(keyedBy: CodingKeys.self) + + var visitor = EncoderVisitor(container) + let result = details.acceptAll(&visitor) + + if case let .failure(error) = result { + throw error + } + } +} + + +extension CodingUserInfoKey { + static let accountDetailsKeys = { + guard let key = CodingUserInfoKey(rawValue: "edu.stanford.spezi.account-details") else { + preconditionFailure("Unable to create `accountDetailsKeys` CodingUserInfoKey!") + } + return key + }() +} diff --git a/Sources/SpeziFirebaseAccountStorage/Visitor/FirestoreDecodeVisitor.swift b/Sources/SpeziFirebaseAccountStorage/Visitor/FirestoreDecodeVisitor.swift index 7170431..9306fc7 100644 --- a/Sources/SpeziFirebaseAccountStorage/Visitor/FirestoreDecodeVisitor.swift +++ b/Sources/SpeziFirebaseAccountStorage/Visitor/FirestoreDecodeVisitor.swift @@ -10,37 +10,37 @@ import FirebaseFirestore import SpeziAccount -class FirestoreDecodeVisitor: AccountKeyVisitor { - private let builder: SimpleBuilder +struct FirestoreDecodeVisitor: AccountKeyVisitor { + private var details: AccountDetails private let value: Any private let reference: DocumentReference private var error: Error? - init(value: Any, builder: SimpleBuilder, in reference: DocumentReference) { + init(value: Any, details: AccountDetails, in reference: DocumentReference) { self.value = value - self.builder = builder + self.details = details self.reference = reference } - func visit(_ key: Key.Type) { + mutating func visit(_ key: Key.Type) { let decoder = Firestore.Decoder() do { - // TODO: do we really need to pass the doc reference? - try builder.set(key, value: decoder.decode(Key.Value.self, from: value, in: reference)) + let value = try decoder.decode(Key.Value.self, from: value, in: reference) + details.set(key, value: value) } catch { self.error = error } } - func final() -> Result { + func final() -> Result { if let error { return .failure(error) } else { - return .success(()) + return .success(details) } } } From 55c9a32bf4ab79472e4f0123e02a367f1e4481fd Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Fri, 2 Aug 2024 12:39:26 +0200 Subject: [PATCH 07/26] Update to latest version and make tests work --- Package.swift | 2 +- .../FirebaseEmailVerifiedKey.swift | 44 +-- .../FirebaseAccountService.swift | 362 ++++++++++++++---- ...hods.swift => FirebaseAuthProviders.swift} | 12 +- .../Models/FirebaseContext.swift | 205 +--------- .../ValidationRule+FirebasePassword.swift | 2 +- .../Resources/Localizable.xcstrings | 3 + .../Views/FirebaseAnonymousSignInButton.swift | 44 +++ .../Views/FirebaseLoginView.swift | 9 +- .../Views/FirebaseSignInWithAppleButton.swift | 4 +- .../FirestoreAccountStorage.swift | 99 ++--- .../LocalDetailsCache.swift | 237 ------------ .../Visitor/FirestoreDecodeVisitor.swift | 27 +- .../AccountStorageTestStandard.swift | 42 -- .../FirebaseAccountStorage/BiographyKey.swift | 42 +- .../FirebaseAccountTestsView.swift | 24 +- .../TestApp/Shared/TestAppDelegate.swift | 46 ++- .../FirebaseAccountStorageTests.swift | 13 +- .../TestAppUITests/FirebaseAccountTests.swift | 57 +-- .../UITests/UITests.xcodeproj/project.pbxproj | 4 - .../xcshareddata/swiftpm/Package.resolved | 44 +-- 21 files changed, 537 insertions(+), 785 deletions(-) rename Sources/SpeziFirebaseAccount/{FirebaseAuthAuthenticationMethods.swift => FirebaseAuthProviders.swift} (67%) create mode 100644 Sources/SpeziFirebaseAccount/Views/FirebaseAnonymousSignInButton.swift delete mode 100644 Sources/SpeziFirebaseAccountStorage/LocalDetailsCache.swift delete mode 100644 Tests/UITests/TestApp/FirebaseAccountStorage/AccountStorageTestStandard.swift diff --git a/Package.swift b/Package.swift index 828a854..3d953c4 100644 --- a/Package.swift +++ b/Package.swift @@ -35,7 +35,7 @@ let package = Package( dependencies: [ .package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.0.0"), .package(url: "https://github.com/StanfordSpezi/SpeziViews.git", from: "1.0.0"), - .package(url: "https://github.com/StanfordSpezi/SpeziStorage", from: "1.1.0"), + .package(url: "https://github.com/StanfordSpezi/SpeziStorage", branch: "feature/sendable-modules"), .package(url: "https://github.com/StanfordSpezi/SpeziAccount", branch: "feature/account-service-singleton"), .package(url: "https://github.com/firebase/firebase-ios-sdk", from: "10.13.0") ] + swiftLintPackage(), diff --git a/Sources/SpeziFirebaseAccount/AccountKeys/FirebaseEmailVerifiedKey.swift b/Sources/SpeziFirebaseAccount/AccountKeys/FirebaseEmailVerifiedKey.swift index 966c506..9636653 100644 --- a/Sources/SpeziFirebaseAccount/AccountKeys/FirebaseEmailVerifiedKey.swift +++ b/Sources/SpeziFirebaseAccount/AccountKeys/FirebaseEmailVerifiedKey.swift @@ -10,34 +10,32 @@ import Foundation import SpeziAccount import SwiftUI +private struct EntryView: DataEntryView { + @Binding private var value: Bool -/// Flag indicating if the firebase account has a verified email address. -/// -/// - Important: This key is read-only and cannot be modified. -public struct FirebaseEmailVerifiedKey: AccountKey { - public typealias Value = Bool - public static let name: LocalizedStringResource = "E-Mail Verified" // not translated as never shown - public static let category: AccountKeyCategory = .other - public static let initialValue: InitialValue = .default(false) -} - + var body: some View { + BoolEntryView(\.isEmailVerified, $value) + .disabled(true) // you cannot manually change that + } -extension AccountKeys { - /// The email-verified ``FirebaseEmailVerifiedKey`` metatype. - public var isEmailVerified: FirebaseEmailVerifiedKey.Type { - FirebaseEmailVerifiedKey.self + init(_ value: Binding) { + _value = value } } -extension FirebaseEmailVerifiedKey { - public struct DataEntry: DataEntryView { - public typealias Key = FirebaseEmailVerifiedKey +extension AccountDetails { + /// Flag indicating if the firebase account has a verified email address. + /// + /// - Important: This key is read-only and cannot be modified. + @AccountKey( + name: LocalizedStringResource("E-Mail Verified", bundle: .atURL(from: .module)), + as: Bool.self, + entryView: EntryView.self + ) + public var isEmailVerified: Bool? // swiftlint:disable:this discouraged_optional_boolean +} - public var body: some View { - Text(verbatim: "The FirebaseEmailVerifiedKey cannot be set!") - } - public init(_ value: Binding) {} - } -} +@KeyEntry(\.isEmailVerified) +public extension AccountKeys {} // swiftlint:disable:this no_extension_access_modifier diff --git a/Sources/SpeziFirebaseAccount/FirebaseAccountService.swift b/Sources/SpeziFirebaseAccount/FirebaseAccountService.swift index 5d96a05..19a18ca 100644 --- a/Sources/SpeziFirebaseAccount/FirebaseAccountService.swift +++ b/Sources/SpeziFirebaseAccount/FirebaseAccountService.swift @@ -12,9 +12,23 @@ import OSLog import Spezi import SpeziAccount import SpeziFirebaseConfiguration +import SpeziLocalStorage +import SpeziSecureStorage import SwiftUI +private enum UserChange { + case user(_ user: User) + case removed +} + + +private struct UserUpdate { + let change: UserChange + var authResult: AuthDataResult? +} + + /// Configures an `AccountService` to interact with Firebase Auth. /// /// The `FirebaseAccountConfiguration` can, e.g., be used to to connect to the Firebase Auth emulator: @@ -29,62 +43,66 @@ import SwiftUI /// } /// ``` public final class FirebaseAccountService: AccountService { // swiftlint:disable:this type_body_length + private static let supportedAccountKeys = AccountKeyCollection { + \.accountId + \.userId + \.password + \.name + \.isEmailVerified + } + // TODO: update all docs! @Application(\.logger) private var logger - @Dependency private var configureFirebaseApp: ConfigureFirebaseApp - @Dependency private var context: FirebaseContext + @Dependency private var configureFirebaseApp = ConfigureFirebaseApp() + @Dependency private var localStorage = LocalStorage() + @Dependency private var secureStorage = SecureStorage() - @Dependency private var account: Account - @Dependency private var notifications: AccountNotifications - @Dependency private var externalStorage: ExternalAccountStorage + @Dependency(Account.self) + private var account + @Dependency(AccountNotifications.self) + private var notifications + @Dependency(ExternalAccountStorage.self) + private var externalStorage - @Model private var firebaseModel = FirebaseAccountModel() - @Modifier private var firebaseModifier = FirebaseAccountModifier() - private let emulatorSettings: (host: String, port: Int)? - private let authenticationMethods: FirebaseAuthAuthenticationMethods public let configuration: AccountServiceConfiguration + private let emulatorSettings: (host: String, port: Int)? @IdentityProvider(section: .primary) private var loginWithPassword = FirebaseLoginView() + @IdentityProvider(enabled: false) + private var anonymousSignup = FirebaseAnonymousSignInButton() @IdentityProvider(section: .singleSignOn) private var signInWithApple = FirebaseSignInWithAppleButton() @SecurityRelatedModifier private var emailPasswordReauth = ReauthenticationAlertModifier() + @Model private var firebaseModel = FirebaseAccountModel() + @Modifier private var firebaseModifier = FirebaseAccountModifier() - // TODO: manage state order stuff! + @MainActor private var authStateDidChangeListenerHandle: AuthStateDidChangeListenerHandle? @MainActor private var lastNonce: String? + // dispatch of user updates + private var shouldQueue = false + private var queuedUpdate: UserUpdate? + /// - Parameters: - /// - authenticationMethods: The authentication methods that should be supported. + /// - providers: The authentication methods that should be supported. /// - emulatorSettings: The emulator settings. The default value is `nil`, connecting the FirebaseAccount module to the Firebase Auth cloud instance. public init( - authenticationMethods: FirebaseAuthAuthenticationMethods, + providers: FirebaseAuthProviders, emulatorSettings: (host: String, port: Int)? = nil ) { - // TODO: how do we support anonymous login and e.g. a invitation code setup with FirebaseAccountService? => anonymous account and signup only (however login page at when already used). self.emulatorSettings = emulatorSettings - self.authenticationMethods = authenticationMethods - - let supportedKeys = AccountKeyCollection { - // TODO: try to remove the supportedKeys, account service makes sure keys are there anyways? - \.accountId - \.userId - // TODO: how does that translate to the new model of singleton Account Services? - if authenticationMethods.contains(.emailAndPassword) { - \.password - } - \.name - } - - self.configuration = AccountServiceConfiguration(supportedKeys: .exactly(supportedKeys)) { + // TODO: try to remove the supportedKeys, account service makes sure keys are there anyways? + self.configuration = AccountServiceConfiguration(supportedKeys: .exactly(Self.supportedAccountKeys)) { RequiredAccountKeys { \.userId - if authenticationMethods.contains(.emailAndPassword) { + if providers.contains(.emailAndPassword) { \.password // TODO: how does that translate to the new model? } } @@ -94,19 +112,57 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable FieldValidationRules(for: \.password, rules: .minimumFirebasePassword) // TODO: still support overriding this? } - if !authenticationMethods.contains(.emailAndPassword) { + if !providers.contains(.emailAndPassword) { $loginWithPassword.isEnabled = false } - if !authenticationMethods.contains(.signInWithApple) { + if !providers.contains(.signInWithApple) { $signInWithApple.isEnabled = false } + if providers.contains(.anonymousButton) { + $anonymousSignup.isEnabled = true + } } - + public func configure() { if let emulatorSettings { Auth.auth().useEmulator(withHost: emulatorSettings.host, port: emulatorSettings.port) } + // get notified about changes of the User reference + authStateDidChangeListenerHandle = Auth.auth().addStateDidChangeListener { [weak self] auth, user in + guard let self else { + return + } + Task { + do { + try await self.stateDidChangeListener(auth: auth, user: user) + } catch { + self.logger.error("Failed to apply FirebaseAuth stateDidChangeListener: \(error)") + } + } + } + + // if there is a cached user, we refresh the authentication token + Auth.auth().currentUser?.getIDTokenForcingRefresh(true) { _, error in + if let error { + let code = AuthErrorCode(_nsError: error as NSError) + + guard code.code != .networkError else { + return // we make sure that we don't remove the account when we don't have network (e.g., flight mode) + } + + Task { + self.notifyUserRemoval() + } + } + } + + Task.detached { [logger, secureStorage, localStorage] in + // Previous SpeziFirebase releases used to store an identifier for the active account service on disk. + // We keep this for now, to clear the keychain of all users. + Self.resetLegacyStorage(secureStorage, localStorage, logger) + } + let subscription = externalStorage.updatedDetails Task { [weak self] in for await updatedDetails in subscription { @@ -124,47 +180,50 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable return } - // TODO: make sure we do not interrupt! anything? + // TODO: make sure we do not interrupt! anything? (e.g. shouldQueue is true?) do { - let context = context - try await context.notifyUserSignIn(user: user, mergeWith: details) + try await notifyUserSignIn(user: user, mergeWith: details) } catch { logger.error("Failed to propagate update details from external storage: \(error)") } } - func login(userId: String, password: String) async throws { + public func login(userId: String, password: String) async throws { // TODO: do all docs logger.debug("Received new login request...") - let context = context - try await context.dispatchFirebaseAuthAction { @MainActor in + try await dispatchFirebaseAuthAction { @MainActor in try await Auth.auth().signIn(withEmail: userId, password: password) logger.debug("signIn(withEmail:password:)") } } - func signUp(signupDetails: AccountDetails) async throws { + public func signUpAnonymously() async throws { + try await dispatchFirebaseAuthAction { + try await Auth.auth().signInAnonymously() + } + } + + public func signUp(with signupDetails: AccountDetails) async throws { logger.debug("Received new signup request...") guard let password = signupDetails.password else { throw FirebaseAccountError.invalidCredentials } - let context = context - try await context.dispatchFirebaseAuthAction { @MainActor in + try await dispatchFirebaseAuthAction { @MainActor in if let currentUser = Auth.auth().currentUser, currentUser.isAnonymous { let credential = EmailAuthProvider.credential(withEmail: signupDetails.userId, password: password) logger.debug("Linking email-password credentials with current anonymous user account ...") let result = try await currentUser.link(with: credential) - if let displayName = signupDetails.name { // TODO: we are not doing that thing with Apple? + if let displayName = signupDetails.name { try await updateDisplayName(of: result.user, displayName) } - try await context.notifyUserSignIn(user: result.user) - - return + try await requestExternalStorage(for: result.user.uid, details: signupDetails) + try await notifyUserSignIn(user: result.user) + return result } let authResult = try await Auth.auth().createUser(withEmail: signupDetails.userId, password: password) @@ -176,18 +235,21 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable if let displayName = signupDetails.name { try await updateDisplayName(of: authResult.user, displayName) } + + try await requestExternalStorage(for: authResult.user.uid, details: signupDetails) + + return authResult } } - func signupWithCredential(_ credential: OAuthCredential) async throws { - let context = context - try await context.dispatchFirebaseAuthAction { @MainActor in + public func signup(with credential: OAuthCredential) async throws { + try await dispatchFirebaseAuthAction { @MainActor in if let currentUser = Auth.auth().currentUser, currentUser.isAnonymous { logger.debug("Linking oauth credentials with current anonymous user account ...") let result = try await currentUser.link(with: credential) - try await context.notifyUserSignIn(user: currentUser, isNewUser: true) + try await notifyUserSignIn(user: currentUser, isNewUser: true) return result } @@ -195,18 +257,20 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable let authResult = try await Auth.auth().signIn(with: credential) logger.debug("signIn(with:) credential for user.") - return authResult // TODO: resolve the slight "isNewUser" difference! just make the "ask for potential differences" explicit! + // nothing to store externally + + return authResult } } - func resetPassword(userId: String) async throws { + public func resetPassword(userId: String) async throws { do { try await Auth.auth().sendPasswordReset(withEmail: userId) logger.debug("sendPasswordReset(withEmail:) for user.") } catch let error as NSError { let firebaseError = FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) if case .invalidCredentials = firebaseError { - return // make sure we don't leak any information // TODO: we are not throwing? + return // make sure we don't leak any information } else { throw firebaseError } @@ -216,18 +280,16 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable } public func logout() async throws { - let context = context - guard Auth.auth().currentUser != nil else { if account.signedIn { - try await context.notifyUserRemoval() + notifyUserRemoval() return } else { throw FirebaseAccountError.notSignedIn } } - try await context.dispatchFirebaseAuthAction { @MainActor in + try await dispatchFirebaseAuthAction { @MainActor in try Auth.auth().signOut() try await Task.sleep(for: .milliseconds(10)) logger.debug("signOut() for user.") @@ -235,19 +297,16 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable } public func delete() async throws { - let context = context - guard let currentUser = Auth.auth().currentUser else { if account.signedIn { - try await context.notifyUserRemoval() + notifyUserRemoval() } throw FirebaseAccountError.notSignedIn } try await notifications.reportEvent(.deletingAccount(currentUser.uid)) - try await context.dispatchFirebaseAuthAction { @MainActor in - // TODO: always use Apple Id if we can, we need the token! + try await dispatchFirebaseAuthAction { @MainActor in let result = try await reauthenticateUser(user: currentUser) // delete requires a recent sign in guard case .success = result else { logger.debug("Re-authentication was cancelled by user. Not deleting the account.") @@ -291,18 +350,16 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable } public func updateAccountDetails(_ modifications: AccountModifications) async throws { - let context = context - guard let currentUser = Auth.auth().currentUser else { if account.signedIn { - try await context.notifyUserRemoval() + notifyUserRemoval() } throw FirebaseAccountError.notSignedIn } do { // if we modify sensitive credentials and require a recent login - if modifications.modifiedDetails.contains(UserIdKey.self) || modifications.modifiedDetails.password != nil { + if modifications.modifiedDetails.contains(AccountKeys.userId) || modifications.modifiedDetails.password != nil { let result = try await reauthenticateUser(user: currentUser) guard case .success = result else { logger.debug("Re-authentication was cancelled. Not updating sensitive user details.") @@ -310,9 +367,8 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable } } - if modifications.modifiedDetails.contains(UserIdKey.self) { + if modifications.modifiedDetails.contains(AccountKeys.userId) { logger.debug("updateEmail(to:) for user.") - // TODO: try await currentUser.sendEmailVerification(beforeUpdatingEmail: userId) (show in UI that they need to accept!) try await currentUser.updateEmail(to: modifications.modifiedDetails.userId) } @@ -325,8 +381,12 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable try await updateDisplayName(of: currentUser, name) } + // TODO: remove all keys that were stored already! + let externalStorage = externalStorage + try await externalStorage.updateExternalStorage(with: modifications, for: currentUser.uid) + // None of the above requests will trigger our state change listener, therefore, we just call it manually. - try await context.notifyUserSignIn(user: currentUser) + try await notifyUserSignIn(user: currentUser) } catch let error as NSError { logger.error("Received NSError on firebase dispatch: \(error)") throw FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) @@ -337,8 +397,7 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable } private func reauthenticateUser(user: User) async throws -> ReauthenticationOperation { - // TODO: which reauthentication to call? (Just prefer Apple for simplicity?) => any way to build UI for a selection? - + // we just prefer apple for simplicity, and because for the delete operation we need to token to revoke it if user.providerData.contains(where: { $0.providerID == "apple.com" }) { try await reauthenticateUserApple(user: user) } else { @@ -392,7 +451,7 @@ extension FirebaseAccountService { // we configured userId as `required` in the account service var requestedScopes: [ASAuthorization.Scope] = [.email] - let nameRequirement = account.configuration[PersonNameKey.self]?.requirement + let nameRequirement = account.configuration.name?.requirement if nameRequirement == .required { // .collected names will be collected later-on requestedScopes.append(.fullName) } @@ -420,7 +479,7 @@ extension FirebaseAccountService { logger.info("onAppleSignInCompletion creating firebase apple credential from authorization credential") - try await signupWithCredential(credential) + try await signup(with: credential) case let .failure(error): guard let authorizationError = error as? ASAuthorizationError else { logger.error("onAppleSignInCompletion received unknown error: \(error)") @@ -504,4 +563,165 @@ extension FirebaseAccountService { } } + +extension FirebaseAccountService { + private static nonisolated func resetLegacyStorage(_ secureStorage: SecureStorage, _ localStorage: LocalStorage, _ logger: Logger) { + do { + try secureStorage.deleteCredentials("_", server: StorageKeys.activeAccountService) + } catch SecureStorageError.notFound { + // we don't care if we want to delete something that doesn't exist + } catch { + logger.error("Failed to remove active account service: \(error)") + } + + // we don't care if removal of the legacy item fails + try? localStorage.delete(storageKey: StorageKeys.activeAccountService) + } + + // a overload that just returns void + func dispatchFirebaseAuthAction( + action: @Sendable () async throws -> Void + ) async throws { + try await self.dispatchFirebaseAuthAction { + try await action() + return nil + } + } + + /// Dispatch a firebase auth action. + /// + /// This method will make sure, that the result of a firebase auth command (e.g. resulting in a call of the state change + /// delegate) will be waited for and executed on the same thread. Therefore, any errors thrown in the event handler + /// can be forwarded back to the caller. + /// - Parameters: + /// - service: The service that is calling this method. + /// - action: The action. If you doing an authentication action, return the auth data result. This way + /// we can forward additional information back to SpeziAccount. + @_disfavoredOverload + func dispatchFirebaseAuthAction( + action: @Sendable () async throws -> AuthDataResult? + ) async throws { + defer { + shouldQueue = false + } + + shouldQueue = true + + do { + let result = try await action() + + try await dispatchQueuedChanges(result: result) + } catch let error as NSError { + logger.error("Received NSError on firebase dispatch: \(error)") + throw FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) + } catch { + logger.error("Received error on firebase dispatch: \(error)") + throw FirebaseAccountError.unknown(.internalError) + } + } + + + private func stateDidChangeListener(auth: Auth, user: User?) async throws { + // this is called by the FIRAuth framework. + + let change: UserChange + if let user { + change = .user(user) + } else { + change = .removed + } + + let update = UserUpdate(change: change) + + if shouldQueue { + logger.debug("Received FirebaseAuth stateDidChange that is queued to be dispatched in active call.") + self.queuedUpdate = update + } else { + logger.debug("Received FirebaseAuth stateDidChange that that was triggered due to other reasons. Dispatching anonymously...") + + // just apply update out of band, errors are just logged as we can't throw them somewhere where UI pops up + try await apply(update: update) + } + } + + private func dispatchQueuedChanges(result: AuthDataResult? = nil) async throws { + shouldQueue = false + + guard var queuedUpdate else { + return + } + + self.queuedUpdate = nil + + if let result { // patch the update before we apply it + queuedUpdate.authResult = result + } + + try await apply(update: queuedUpdate) + } + + private func apply(update: UserUpdate) async throws { + switch update.change { + case let .user(user): + let isNewUser = update.authResult?.additionalUserInfo?.isNewUser ?? false + try await notifyUserSignIn(user: user, isNewUser: isNewUser) + case .removed: + notifyUserRemoval() + } + } + + func notifyUserSignIn(user: User, isNewUser: Bool = false, mergeWith additionalDetails: AccountDetails? = nil) async throws { + logger.debug("Notifying SpeziAccount with updated user details.") + + var details = AccountDetails() + details.accountId = user.uid + if let email = user.email { + details.userId = email // userId will fallback to accountId if not present + } + details.isEmailVerified = user.isEmailVerified + details.isNewUser = isNewUser + details.isAnonymous = user.isAnonymous + + if let displayName = user.displayName, + let nameComponents = try? PersonNameComponents(displayName, strategy: .name) { + // we wouldn't be here if we couldn't create the person name components from the given string + details.name = nameComponents + } + + if let additionalDetails { + details.add(contentsOf: additionalDetails) + } + + // TODO: way to retrieve all configured keys => remove all supported => check if non-empty, then retrieve? + let unsupportedKeys = configuration + .unsupportedAccountKeys(basedOn: account.configuration) + .map { $0.key } // TODO: this is a mouthful + if !unsupportedKeys.isEmpty { + let externalStorage = externalStorage + let externalDetails = try await externalStorage.retrieveExternalStorage(for: details.accountId, unsupportedKeys) + details.add(contentsOf: externalDetails) + } + + account.supplyUserDetails(details) + } + + func notifyUserRemoval() { + logger.debug("Notifying SpeziAccount of removed user details.") + + let account = account + account.removeUserDetails() + } + + private func requestExternalStorage(for accountId: String, details: AccountDetails) async throws { + var externallyStoredDetails = details + externallyStoredDetails.removeAll(Self.supportedAccountKeys) + guard !externallyStoredDetails.isEmpty else { + return + } + + let externalStorage = externalStorage + try await externalStorage.requestExternalStorage(of: externallyStoredDetails, for: accountId) + } +} + // swiftlint:disable:this file_length diff --git a/Sources/SpeziFirebaseAccount/FirebaseAuthAuthenticationMethods.swift b/Sources/SpeziFirebaseAccount/FirebaseAuthProviders.swift similarity index 67% rename from Sources/SpeziFirebaseAccount/FirebaseAuthAuthenticationMethods.swift rename to Sources/SpeziFirebaseAccount/FirebaseAuthProviders.swift index f456ddf..1e71b04 100644 --- a/Sources/SpeziFirebaseAccount/FirebaseAuthAuthenticationMethods.swift +++ b/Sources/SpeziFirebaseAccount/FirebaseAuthProviders.swift @@ -8,16 +8,20 @@ /// Definition of the authentication methods supported by the FirebaseAccount module. -public struct FirebaseAuthAuthenticationMethods: OptionSet, Codable, Sendable { +public struct FirebaseAuthProviders: OptionSet, Codable, Sendable { /// E-Mail and password-based authentication. /// /// Please follow the necessary setup steps at [Password Authentication](https://firebase.google.com/docs/auth/ios/password-auth). - public static let emailAndPassword = FirebaseAuthAuthenticationMethods(rawValue: 1 << 0) + public static let emailAndPassword = FirebaseAuthProviders(rawValue: 1 << 0) /// Sign In With Apple Identity Provider. /// /// Please follow the necessary setup steps at [Sign in with Apple](https://firebase.google.com/docs/auth/ios/apple). - public static let signInWithApple = FirebaseAuthAuthenticationMethods(rawValue: 1 << 1) - + public static let signInWithApple = FirebaseAuthProviders(rawValue: 1 << 1) + + /// Sign in anonymously using a button press. + @_spi(Internal) + public static let anonymousButton = FirebaseAuthProviders(rawValue: 1 << 2) + public let rawValue: Int diff --git a/Sources/SpeziFirebaseAccount/Models/FirebaseContext.swift b/Sources/SpeziFirebaseAccount/Models/FirebaseContext.swift index 266a8f1..f574b06 100644 --- a/Sources/SpeziFirebaseAccount/Models/FirebaseContext.swift +++ b/Sources/SpeziFirebaseAccount/Models/FirebaseContext.swift @@ -25,209 +25,14 @@ private struct UserUpdate { } -actor FirebaseContext: Module, DefaultInitializable { // TODO: better name? no actor! +final class FirebaseContext: Module, DefaultInitializable { static let logger = Logger(subsystem: "edu.stanford.spezi.firebase", category: "InternalStorage") - @Dependency private var localStorage: LocalStorage - @Dependency private var secureStorage: SecureStorage - @Dependency private var account: Account - - @MainActor private var authStateDidChangeListenerHandle: AuthStateDidChangeListenerHandle? - - // dispatch of user updates - private var shouldQueue = false - private var queuedUpdate: UserUpdate? + @Dependency private var localStorage = LocalStorage() + @Dependency private var secureStorage = SecureStorage() + @Dependency(Account.self) + private var account init() {} - - @MainActor - func configure() { - // get notified about changes of the User reference - authStateDidChangeListenerHandle = Auth.auth().addStateDidChangeListener { [weak self] auth, user in - guard let self else { - return - } - Task { - await self.stateDidChangeListener(auth: auth, user: user) - } - } - - // if there is a cached user, we refresh the authentication token - Auth.auth().currentUser?.getIDTokenForcingRefresh(true) { _, error in - if let error { - let code = AuthErrorCode(_nsError: error as NSError) - - guard code.code != .networkError else { - return // we make sure that we don't remove the account when we don't have network (e.g., flight mode) - } - - Task { - try await self.notifyUserRemoval() - } - } - } - - Task { - // Previous SpeziFirebase releases used to store an identifier for the active account service on disk. - // We keep this for now, to clear the keychain of all users. - await resetActiveAccountService() - } - } - - // a overload that just returns void - func dispatchFirebaseAuthAction( - action: @Sendable () async throws -> Void - ) async throws { - try await self.dispatchFirebaseAuthAction { - try await action() - return nil - } - } - - /// Dispatch a firebase auth action. - /// - /// This method will make sure, that the result of a firebase auth command (e.g. resulting in a call of the state change - /// delegate) will be waited for and executed on the same thread. Therefore, any errors thrown in the event handler - /// can be forwarded back to the caller. - /// - Parameters: - /// - service: The service that is calling this method. - /// - action: The action. If you doing an authentication action, return the auth data result. This way - /// we can forward additional information back to SpeziAccount. - @_disfavoredOverload - func dispatchFirebaseAuthAction( - action: @Sendable () async throws -> AuthDataResult? - ) async throws { - defer { - shouldQueue = false - } - - shouldQueue = true - - do { - let result = try await action() - - try await dispatchQueuedChanges(result: result) - } catch let error as NSError { - Self.logger.error("Received NSError on firebase dispatch: \(error)") - throw FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) - } catch { - Self.logger.error("Received error on firebase dispatch: \(error)") - throw FirebaseAccountError.unknown(.internalError) - } - } - - private func resetActiveAccountService() { - do { - try secureStorage.deleteCredentials("_", server: StorageKeys.activeAccountService) - } catch SecureStorageError.notFound { - // we don't care if we want to delete something that doesn't exist - } catch { - Self.logger.error("Failed to remove active account service: \(error)") - } - - // we don't care if removal of the legacy item fails - try? localStorage.delete(storageKey: StorageKeys.activeAccountService) - } - - - private func stateDidChangeListener(auth: Auth, user: User?) { - // this is called by the FIRAuth framework. - - let change: UserChange - if let user { - change = .user(user) - } else { - change = .removed - } - - let update = UserUpdate(change: change) - - // TODO: update some of the log messages to be less confusing! - if shouldQueue { - Self.logger.debug("Received stateDidChange that is queued to be dispatched in active call.") - self.queuedUpdate = update - } else { - Self.logger.debug("Received stateDidChange that that was triggered due to other reasons. Dispatching anonymously...") - - // just apply update out of band, errors are just logged as we can't throw them somewhere where UI pops up // TODO: (should we try?) - Task { - do { - try await apply(update: update) - } catch { - Self.logger.error("Failed to anonymously dispatch user change due to \(error)") - } - } - } - } - - private func dispatchQueuedChanges(result: AuthDataResult? = nil) async throws { - shouldQueue = false - - guard var queuedUpdate else { - Self.logger.debug("Didn't find anything to dispatch in the queue!") - return - } - - self.queuedUpdate = nil - - if let result { // patch the update before we apply it - queuedUpdate.authResult = result - } - - try await apply(update: queuedUpdate) - } - - private func apply(update: UserUpdate) async throws { - switch update.change { - case let .user(user): - let isNewUser = update.authResult?.additionalUserInfo?.isNewUser ?? false - if user.isAnonymous { - // We explicitly handle anonymous users on every signup and call our state change handler ourselves. - // But generally, we don't care about anonymous users. - - // TODO: we do now! restructure this! => rename account service methods to indicate a signup or link! - return - } - - try await notifyUserSignIn(user: user, isNewUser: isNewUser) - case .removed: - try await notifyUserRemoval() - } - } - - func notifyUserSignIn(user: User, isNewUser: Bool = false, mergeWith additionalDetails: AccountDetails? = nil) async throws { - guard let email = user.email else { - Self.logger.error("Failed to associate firebase account due to missing email address.") - throw FirebaseAccountError.invalidEmail - } - - Self.logger.debug("Notifying SpeziAccount with updated user details.") - - - var details = AccountDetails() - details.accountId = user.uid - details.userId = email - details.isEmailVerified = user.isEmailVerified - - if let displayName = user.displayName, - let nameComponents = try? PersonNameComponents(displayName, strategy: .name) { - // we wouldn't be here if we couldn't create the person name components from the given string - details.name = nameComponents - } - - if let additionalDetails { - details.add(contentsOf: additionalDetails) - } - - let account = account - await account.supplyUserDetails(details, isNewUser: isNewUser) - } - - func notifyUserRemoval() async throws { - Self.logger.debug("Notifying SpeziAccount of removed user details.") - - let account = account - await account.removeUserDetails() - } } diff --git a/Sources/SpeziFirebaseAccount/Models/ValidationRule+FirebasePassword.swift b/Sources/SpeziFirebaseAccount/Models/ValidationRule+FirebasePassword.swift index 72f867b..f252636 100644 --- a/Sources/SpeziFirebaseAccount/Models/ValidationRule+FirebasePassword.swift +++ b/Sources/SpeziFirebaseAccount/Models/ValidationRule+FirebasePassword.swift @@ -9,7 +9,7 @@ import SpeziValidation -extension ValidationRule { // TODO: move! +extension ValidationRule { static var minimumFirebasePassword: ValidationRule { // Firebase as a non-configurable limit of 6 characters for an account password. // Refer to https://stackoverflow.com/questions/38064248/firebase-password-validation-allowed-regex diff --git a/Sources/SpeziFirebaseAccount/Resources/Localizable.xcstrings b/Sources/SpeziFirebaseAccount/Resources/Localizable.xcstrings index db4439e..bd90c86 100644 --- a/Sources/SpeziFirebaseAccount/Resources/Localizable.xcstrings +++ b/Sources/SpeziFirebaseAccount/Resources/Localizable.xcstrings @@ -1,6 +1,9 @@ { "sourceLanguage" : "en", "strings" : { + "Anonymous Signup" : { + + }, "Authentication Required" : { "localizations" : { "en" : { diff --git a/Sources/SpeziFirebaseAccount/Views/FirebaseAnonymousSignInButton.swift b/Sources/SpeziFirebaseAccount/Views/FirebaseAnonymousSignInButton.swift new file mode 100644 index 0000000..b9781fe --- /dev/null +++ b/Sources/SpeziFirebaseAccount/Views/FirebaseAnonymousSignInButton.swift @@ -0,0 +1,44 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziAccount +import SpeziViews +import SwiftUI + + +struct FirebaseAnonymousSignInButton: View { + @Environment(FirebaseAccountService.self) + private var service + @Environment(\.colorScheme) + private var colorScheme + + @State private var viewState: ViewState = .idle + + private var color: Color { + // see https://firebase.google.com/brand-guidelines/ + switch colorScheme { + case .dark: + return Color(red: 255.0 / 255, green: 145.0 / 255, blue: 0) // firebase orange + case .light: + fallthrough + @unknown default: + return Color(red: 255.0 / 255, green: 196.0 / 255, blue: 0) // firebase yellow + } + } + + var body: some View { + AccountServiceButton(state: $viewState) { + try await service.signUpAnonymously() + } label: { + Text("Anonymous Signup", bundle: .module) + } + .tint(color) + } + + nonisolated init() {} +} diff --git a/Sources/SpeziFirebaseAccount/Views/FirebaseLoginView.swift b/Sources/SpeziFirebaseAccount/Views/FirebaseLoginView.swift index 54a9fc6..429c0f4 100644 --- a/Sources/SpeziFirebaseAccount/Views/FirebaseLoginView.swift +++ b/Sources/SpeziFirebaseAccount/Views/FirebaseLoginView.swift @@ -16,14 +16,11 @@ struct FirebaseLoginView: View { var body: some View { - // TODO: configure preferred visual way: - // => signup view + Already have an account? - // => login view + Don't have an account yet? - // TODO: emebded login view styles? - UserIdPasswordEmbeddedView { credential in + // you can customize appearance using the `preferredAccountSetupStyle(_:)` modifier + AccountSetupProviderView { credential in try await service.login(userId: credential.userId, password: credential.password) } signup: { details in - try await service.signUp(signupDetails: details) + try await service.signUp(with: details) } resetPassword: { userId in try await service.resetPassword(userId: userId) } diff --git a/Sources/SpeziFirebaseAccount/Views/FirebaseSignInWithAppleButton.swift b/Sources/SpeziFirebaseAccount/Views/FirebaseSignInWithAppleButton.swift index 56a56ce..ece72d4 100644 --- a/Sources/SpeziFirebaseAccount/Views/FirebaseSignInWithAppleButton.swift +++ b/Sources/SpeziFirebaseAccount/Views/FirebaseSignInWithAppleButton.swift @@ -6,7 +6,7 @@ // SPDX-License-Identifier: MIT // -import AuthenticationServices +import SpeziAccount import SpeziViews import SwiftUI @@ -41,8 +41,6 @@ struct FirebaseSignInWithAppleButton: View { } } } - .frame(height: 55) - .signInWithAppleButtonStyle(colorScheme == .light ? .black : .white) .viewStateAlert(state: $viewState) } diff --git a/Sources/SpeziFirebaseAccountStorage/FirestoreAccountStorage.swift b/Sources/SpeziFirebaseAccountStorage/FirestoreAccountStorage.swift index 3349773..5c080b1 100644 --- a/Sources/SpeziFirebaseAccountStorage/FirestoreAccountStorage.swift +++ b/Sources/SpeziFirebaseAccountStorage/FirestoreAccountStorage.swift @@ -70,16 +70,18 @@ import SpeziFirestore /// } /// ``` public actor FirestoreAccountStorage: AccountStorageProvider { // TODO: completely restructure docs! - @Application(\.logger) private var logger + @Application(\.logger) + private var logger - @Dependency private var firestore: SpeziFirestore.Firestore // ensure firestore is configured - @Dependency private var externalStorage: ExternalAccountStorage - @Dependency private var localCache: LocalDetailsCache + @Dependency private var firestore = SpeziFirestore.Firestore() // ensure firestore is configured + @Dependency(ExternalAccountStorage.self) + private var externalStorage + @Dependency private var localCache = AccountDetailsCache() private let collection: @Sendable () -> CollectionReference - private var listenerRegistrations: [String: ListenerRegistration] = [:] + private var registeredKeys: [String: [ObjectIdentifier: any AccountKey.Type]] = [:] public init(storeIn collection: @Sendable @autoclosure @escaping () -> CollectionReference) { self.collection = collection @@ -96,55 +98,50 @@ public actor FirestoreAccountStorage: AccountStorageProvider { // TODO: complete } let document = userDocument(for: accountId) + registeredKeys[accountId] = keys.reduce(into: [:]) { result, key in + result[ObjectIdentifier(key)] = key + } + listenerRegistrations[accountId] = document.addSnapshotListener { [weak self] snapshot, error in guard let self else { return } - guard let snapshot else { - // TODO: error happened, how to best notify about error? - return - } - Task { - await self.processUpdatedSnapshot(for: accountId, with: keys, snapshot) + guard let snapshot else { + await self.logger.error("Failed to retrieve user document collection: \(error)") + return + } + await self.processUpdatedSnapshot(for: accountId, snapshot) } } } - private func processUpdatedSnapshot(for accountId: String, with keys: [any AccountKey.Type], _ snapshot: DocumentSnapshot) { - do { - let details = try buildAccountDetails(from: snapshot, keys: keys) - localCache.communicateRemoteChanges(for: accountId, details) - - externalStorage.notifyAboutUpdatedDetails(for: accountId, details) - } catch { - logger.error("") - // TODO: log or do something with that info! - // TODO: does it make sense to notify the account service about the error? + private func processUpdatedSnapshot(for accountId: String, _ snapshot: DocumentSnapshot) async { + guard let keys = registeredKeys[accountId]?.values else { + logger.error("Failed to process updated document snapshot as we couldn't locate registered keys.") + return } + + let details = buildAccountDetails(from: snapshot, keys: Array(keys)) + + externalStorage.notifyAboutUpdatedDetails(for: accountId, details) + + let localCache = localCache + await localCache.communicateRemoteChanges(for: accountId, details) } - private nonisolated func buildAccountDetails(from snapshot: DocumentSnapshot, keys: [any AccountKey.Type]) throws -> AccountDetails { + private func buildAccountDetails(from snapshot: DocumentSnapshot, keys: [any AccountKey.Type]) -> AccountDetails { guard let data = snapshot.data() else { return AccountDetails() } - var details = AccountDetails() + // TODO: just use simple decoder? + var visitor = FirestoreDecodeVisitor(data: data, in: snapshot.reference) + let details = keys.acceptAll(&visitor) - for key in keys { - guard let value = data[key.identifier] else { - continue - } - - var visitor = FirestoreDecodeVisitor(value: value, details: details, in: snapshot.reference) - key.accept(&visitor) - switch visitor.final() { - case let .success(value): - details = value - case let .failure(error): - throw FirestoreError(error) - } + for (key, error) in visitor.errors { + logger.error("Failed to decode account value from firestore snapshot for key \(key.identifier): \(error)") } return details @@ -156,8 +153,9 @@ public actor FirestoreAccountStorage: AccountStorageProvider { // TODO: complete try await modify(accountId, modifications) } - public func load(_ accountId: String, _ keys: [any AccountKey.Type]) async throws -> AccountDetails? { // TODO: transport keys as set? - let cached = localCache.loadEntry(for: accountId, keys) + public func load(_ accountId: String, _ keys: [any AccountKey.Type]) async throws -> AccountDetails? { + let localCache = localCache + let cached = await localCache.loadEntry(for: accountId, keys) if listenerRegistrations[accountId] != nil { // check that there is a snapshot listener in place snapshotListener(for: accountId, with: keys) @@ -188,28 +186,33 @@ public actor FirestoreAccountStorage: AccountStorageProvider { // TODO: complete throw FirestoreError(error) } - localCache.communicateModifications(for: accountId, modifications) - + if var keys = registeredKeys[accountId] { // we have a snapshot listener in place which we need to update the keys for + for newKey in modifications.modifiedDetails.keys where keys[ObjectIdentifier(newKey)] == nil { + keys.updateValue(newKey, forKey: ObjectIdentifier(newKey)) + } - // TODO: check if the snapshot listener is in place with the same set of keys (add remove)! - if listenerRegistrations[accountId] != nil { - // TODO: if we have sets, its easier! - // TODO: actually keep track of all account keys, this will fail! - snapshotListener(for: accountId, with: modifications.modifiedDetails.keys) + for removedKey in modifications.removedAccountKeys { + keys.removeValue(forKey: ObjectIdentifier(removedKey)) + } } + + let localCache = localCache + await localCache.communicateModifications(for: accountId, modifications) } - public func disassociate(_ accountId: String) { + public func disassociate(_ accountId: String) async { guard let registration = listenerRegistrations.removeValue(forKey: accountId) else { return } registration.remove() + registeredKeys.removeValue(forKey: accountId) - localCache.clearEntry(for: accountId) + let localCache = localCache + await localCache.clearEntry(for: accountId) } public func delete(_ accountId: String) async throws { - disassociate(accountId) + await disassociate(accountId) do { try await userDocument(for: accountId) diff --git a/Sources/SpeziFirebaseAccountStorage/LocalDetailsCache.swift b/Sources/SpeziFirebaseAccountStorage/LocalDetailsCache.swift deleted file mode 100644 index 2a253f4..0000000 --- a/Sources/SpeziFirebaseAccountStorage/LocalDetailsCache.swift +++ /dev/null @@ -1,237 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import Foundation -import Spezi -import SpeziAccount -import SpeziLocalStorage - - -struct CachedDetails { - let details: AccountDetails - - init(_ details: AccountDetails) { - self.details = details - } -} - - -final class LocalDetailsCache: Module, DefaultInitializable { - @Application(\.logger) private var logger - - @Dependency private var localStorage: LocalStorage - - private var localCache: [String: AccountDetails] = [:] - - - init() {} - - func loadEntry(for accountId: String, _ keys: [any AccountKey.Type]) -> AccountDetails? { - if let details = localCache[accountId] { - return details - } - - let decoder = JSONDecoder() - decoder.userInfo[.accountDetailsKeys] = keys - - do { - let stored = try localStorage.read( - CachedDetails.self, - decoder: decoder, - storageKey: key(for: accountId), - settings: .encryptedUsingKeyChain(userPresence: false, excludedFromBackup: false) - ) - - localCache[accountId] = stored.details - return stored.details - } catch { - // TODO: silence error if doesn't exist - logger.error("Failed to read cached account details from disk: \(error)") - } - - return nil - } - - func clearEntry(for accountId: String) { - localCache.removeValue(forKey: accountId) - do { - try localStorage.delete(storageKey: key(for: accountId)) - } catch { - // TODO: silence error if doesn't exist - logger.error("Failed to clear cached account details from disk: \(error)") - } - } - - func communicateModifications(for accountId: String, _ modifications: AccountModifications) { - // make sure our cache is consistent - var details = AccountDetails() - if let cached = localCache[accountId] { - details.add(contentsOf: cached) - } - details.add(contentsOf: modifications.modifiedDetails, merge: true) - details.removeAll(modifications.removedAccountKeys) - - communicateRemoteChanges(for: accountId, details) - } - - func communicateRemoteChanges(for accountId: String, _ details: AccountDetails) { - localCache[accountId] = details - - - let storage = CachedDetails(details) - do { - try localStorage.store( - storage, - storageKey: key(for: accountId), - settings: .encryptedUsingKeyChain(userPresence: false, excludedFromBackup: false) - ) - } catch { - // TODO: silence error if doesn't exist - logger.error("Failed to update cached account details to disk: \(error)") - } - } - - private func key(for accountId: String) -> String { - "edu.stanford.spezi.firebase.details.\(accountId)" - } -} - - -extension CachedDetails: Codable { // TODO: can we just add Codable conformance to AccountDetails natively? - struct CodingKeys: CodingKey, RawRepresentable { // TODO: provide a reusable codingKey in SpeziAccount? - var stringValue: String { - rawValue - } - - var intValue: Int? { - nil - } - - let rawValue: String - - init(stringValue rawValue: String) { - self.rawValue = rawValue - } - - init(rawValue: String) { - self.rawValue = rawValue - } - - init?(intValue: Int) { - nil - } - } - - private struct EncoderVisitor: AccountValueVisitor { - private var container: KeyedEncodingContainer - private var firstError: Error? - - init(_ container: KeyedEncodingContainer) { - self.container = container - } - - mutating func visit(_ key: Key.Type, _ value: Key.Value) { - guard firstError == nil else { - return - } - - do { - try container.encode(value, forKey: CodingKeys(rawValue: key.identifier)) - } catch { - firstError = error - } - } - - func final() -> Result { - if let firstError { - .failure(firstError) - } else { - .success(()) - } - } - } - - private struct DecoderVisitor: AccountKeyVisitor { - private let container: KeyedDecodingContainer - private var details = AccountDetails() - private var firstError: Error? - - init(_ container: KeyedDecodingContainer) { - self.container = container - } - - - mutating func visit(_ key: Key.Type) { - guard firstError == nil else { - return - } - - - do { - let value = try container.decode(Key.Value.self, forKey: CodingKeys(rawValue: key.identifier)) - details.set(Key.self, value: value) - } catch { - firstError = error - } - } - - func final() -> Result { - if let firstError { - .failure(firstError) - } else { - .success(details) - } - } - } - - - init(from decoder: any Decoder) throws { - guard let keys = decoder.userInfo[.accountDetailsKeys] as? [any AccountKey.Type] else { - throw DecodingError.dataCorrupted(.init( - codingPath: decoder.codingPath, - debugDescription: """ - AccountKeys unspecified. Do decode AccountDetails you must specify requested AccountKey types \ - via the `accountDetailsKeys` CodingUserInfoKey. - """ - )) - } - - let container = try decoder.container(keyedBy: CodingKeys.self) - - var visitor = DecoderVisitor(container) - let result = keys.acceptAll(&visitor) - - switch result { - case let .success(details): - self.details = details - case let .failure(error): - throw error - } - } - - func encode(to encoder: any Encoder) throws { - let container = encoder.container(keyedBy: CodingKeys.self) - - var visitor = EncoderVisitor(container) - let result = details.acceptAll(&visitor) - - if case let .failure(error) = result { - throw error - } - } -} - - -extension CodingUserInfoKey { - static let accountDetailsKeys = { - guard let key = CodingUserInfoKey(rawValue: "edu.stanford.spezi.account-details") else { - preconditionFailure("Unable to create `accountDetailsKeys` CodingUserInfoKey!") - } - return key - }() -} diff --git a/Sources/SpeziFirebaseAccountStorage/Visitor/FirestoreDecodeVisitor.swift b/Sources/SpeziFirebaseAccountStorage/Visitor/FirestoreDecodeVisitor.swift index 9306fc7..774ab10 100644 --- a/Sources/SpeziFirebaseAccountStorage/Visitor/FirestoreDecodeVisitor.swift +++ b/Sources/SpeziFirebaseAccountStorage/Visitor/FirestoreDecodeVisitor.swift @@ -11,36 +11,35 @@ import SpeziAccount struct FirestoreDecodeVisitor: AccountKeyVisitor { - private var details: AccountDetails - private let value: Any + private let data: [String: Any] private let reference: DocumentReference - private var error: Error? + private var details = AccountDetails() + private(set) var errors: [(any AccountKey.Type, Error)] = [] - init(value: Any, details: AccountDetails, in reference: DocumentReference) { - self.value = value - self.details = details + init(data: [String: Any], in reference: DocumentReference) { + self.data = data self.reference = reference } mutating func visit(_ key: Key.Type) { + guard let dataValue = data[key.identifier] else { + return + } + let decoder = Firestore.Decoder() do { - let value = try decoder.decode(Key.Value.self, from: value, in: reference) + let value = try decoder.decode(Key.Value.self, from: dataValue, in: reference) details.set(key, value: value) } catch { - self.error = error + errors.append((key, error)) } } - func final() -> Result { - if let error { - return .failure(error) - } else { - return .success(details) - } + func final() -> AccountDetails { + details } } diff --git a/Tests/UITests/TestApp/FirebaseAccountStorage/AccountStorageTestStandard.swift b/Tests/UITests/TestApp/FirebaseAccountStorage/AccountStorageTestStandard.swift deleted file mode 100644 index 5c026b6..0000000 --- a/Tests/UITests/TestApp/FirebaseAccountStorage/AccountStorageTestStandard.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import FirebaseFirestore -import Spezi -import SpeziAccount -import SpeziFirebaseAccountStorage - - -actor AccountStorageTestStandard: Standard, AccountStorageConstraint { - static var collection: CollectionReference { - Firestore.firestore().collection("users") - } - - @Dependency var storage = FirestoreAccountStorage(storeIn: collection) - - - func create(_ identifier: AdditionalRecordId, _ details: SignupDetails) async throws { - try await storage.create(identifier, details) - } - - func load(_ identifier: AdditionalRecordId, _ keys: [any AccountKey.Type]) async throws -> PartialAccountDetails { - try await storage.load(identifier, keys) - } - - func modify(_ identifier: AdditionalRecordId, _ modifications: SpeziAccount.AccountModifications) async throws { - try await storage.modify(identifier, modifications) - } - - func clear(_ identifier: AdditionalRecordId) async { - await storage.clear(identifier) - } - - func delete(_ identifier: AdditionalRecordId) async throws { - try await storage.delete(identifier) - } -} diff --git a/Tests/UITests/TestApp/FirebaseAccountStorage/BiographyKey.swift b/Tests/UITests/TestApp/FirebaseAccountStorage/BiographyKey.swift index d9353c8..bb06b9f 100644 --- a/Tests/UITests/TestApp/FirebaseAccountStorage/BiographyKey.swift +++ b/Tests/UITests/TestApp/FirebaseAccountStorage/BiographyKey.swift @@ -11,43 +11,11 @@ import SpeziValidation import SwiftUI -struct BiographyKey: AccountKey { - typealias Value = String - - - static let name: LocalizedStringResource = "Biography" // we don't bother to translate - static let category: AccountKeyCategory = .personalDetails +extension AccountDetails { + @AccountKey(name: "Biography", category: .personalDetails, as: String.self) + var biography: String? } -extension AccountKeys { - var biography: BiographyKey.Type { - BiographyKey.self - } -} - - -extension AccountValues { - var biography: String? { - storage[BiographyKey.self] - } -} - - -extension BiographyKey { - public struct DataEntry: DataEntryView { - public typealias Key = BiographyKey - - - @Binding private var biography: Value - - public init(_ value: Binding) { - self._biography = value - } - - public var body: some View { - VerifiableTextField(Key.name, text: $biography) - .autocorrectionDisabled() - } - } -} +@KeyEntry(\.biography) +extension AccountKeys {} diff --git a/Tests/UITests/TestApp/FirebaseAccountTests/FirebaseAccountTestsView.swift b/Tests/UITests/TestApp/FirebaseAccountTests/FirebaseAccountTestsView.swift index a4dc096..5af999b 100644 --- a/Tests/UITests/TestApp/FirebaseAccountTests/FirebaseAccountTestsView.swift +++ b/Tests/UITests/TestApp/FirebaseAccountTests/FirebaseAccountTestsView.swift @@ -6,7 +6,6 @@ // SPDX-License-Identifier: MIT // -@preconcurrency import FirebaseAuth import Spezi import SpeziAccount import SpeziFirebaseAccount @@ -25,23 +24,19 @@ struct FirebaseAccountTestsView: View { @State var showOverview = false @State var isEditing = false - @State var uiUpdates: Int = 0 - var body: some View { List { - if uiUpdates > 0, // register to UI updates - let user = Auth.auth().currentUser, - user.isAnonymous { - ListRow("User") { - Text("Anonymous") - } - } if let details = account.details { HStack { UserProfileView(name: details.name ?? .init(givenName: "NOT FOUND")) .frame(height: 30) Text(details.userId) } + if details.isAnonymous { + ListRow("User") { + Text("Anonymous") + } + } AsyncButton("Logout", role: .destructive, state: $viewState) { try await account.accountService.logout() @@ -53,15 +48,6 @@ struct FirebaseAccountTestsView: View { Button("Account Overview") { showOverview = true } - if !account.signedIn { - AsyncButton("Login Anonymously", state: $viewState) { - if Auth.auth().currentUser != nil { - try Auth.auth().signOut() - } - try await Auth.auth().signInAnonymously() - uiUpdates += 1 - } - } } .sheet(isPresented: $showSetup) { NavigationStack { diff --git a/Tests/UITests/TestApp/Shared/TestAppDelegate.swift b/Tests/UITests/TestApp/Shared/TestAppDelegate.swift index 0006ec2..3017254 100644 --- a/Tests/UITests/TestApp/Shared/TestAppDelegate.swift +++ b/Tests/UITests/TestApp/Shared/TestAppDelegate.swift @@ -6,9 +6,11 @@ // SPDX-License-Identifier: MIT // +import FirebaseFirestore import Spezi import SpeziAccount -import SpeziFirebaseAccount +@_spi(Internal) import SpeziFirebaseAccount +import SpeziFirebaseAccountStorage import SpeziFirebaseStorage import SpeziFirestore import SwiftUI @@ -16,21 +18,8 @@ import SwiftUI class TestAppDelegate: SpeziAppDelegate { override var configuration: Configuration { - if FeatureFlags.accountStorageTests { - return Configuration(standard: AccountStorageTestStandard(), configurationsClosure) - } else { - return Configuration(configurationsClosure) - } - } - - var configurationsClosure: () -> ModuleCollection { - { - self.configurations - } - } - - @ModuleBuilder var configurations: ModuleCollection { - let configuration: AccountValueConfiguration = FeatureFlags.accountStorageTests + Configuration { + let configuration: AccountValueConfiguration = FeatureFlags.accountStorageTests ? [ .requires(\.userId), .requires(\.name), @@ -41,14 +30,23 @@ class TestAppDelegate: SpeziAppDelegate { .collects(\.name) ] - AccountConfiguration( - service: FirebaseAccountService( - authenticationMethods: [.emailAndPassword, .signInWithApple], + let service = FirebaseAccountService( + providers: [.emailAndPassword, .signInWithApple, .anonymousButton], // TODO: add anonymous button tests emulatorSettings: (host: "localhost", port: 9099) - ), - configuration: configuration - ) - Firestore(settings: .emulator) - FirebaseStorageConfiguration(emulatorSettings: (host: "localhost", port: 9199)) + ) + + if FeatureFlags.accountStorageTests { + AccountConfiguration( + service: service, + storageProvider: FirestoreAccountStorage(storeIn: Firestore.firestore().collection("users")), + configuration: configuration + ) + } else { + AccountConfiguration(service: service, configuration: configuration) + } + + Firestore(settings: .emulator) + FirebaseStorageConfiguration(emulatorSettings: (host: "localhost", port: 9199)) + } } } diff --git a/Tests/UITests/TestAppUITests/FirebaseAccountStorageTests.swift b/Tests/UITests/TestAppUITests/FirebaseAccountStorageTests.swift index 94176b2..ec09f9b 100644 --- a/Tests/UITests/TestAppUITests/FirebaseAccountStorageTests.swift +++ b/Tests/UITests/TestAppUITests/FirebaseAccountStorageTests.swift @@ -11,12 +11,11 @@ import XCTestExtensions final class FirebaseAccountStorageTests: XCTestCase { - @MainActor - override func setUp() async throws { - try await super.setUp() - + override func setUp() { continueAfterFailure = false + } + override func setUp() async throws { try await FirebaseClient.deleteAllAccounts() try await Task.sleep(for: .seconds(0.5)) } @@ -27,10 +26,12 @@ final class FirebaseAccountStorageTests: XCTestCase { app.launchArguments = ["--account-storage"] app.launch() - XCTAssert(app.buttons["FirebaseAccount"].waitForExistence(timeout: 10.0)) + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 2.0)) + + XCTAssert(app.buttons["FirebaseAccount"].waitForExistence(timeout: 2.0)) app.buttons["FirebaseAccount"].tap() - if app.buttons["Logout"].waitForExistence(timeout: 5.0) && app.buttons["Logout"].isHittable { + if app.buttons["Logout"].waitForExistence(timeout: 2.0) && app.buttons["Logout"].isHittable { app.buttons["Logout"].tap() } diff --git a/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift b/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift index c50ef98..c551142 100644 --- a/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift +++ b/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift @@ -14,25 +14,25 @@ import XCTestExtensions /// /// Refer to https://firebase.google.com/docs/emulator-suite/connect_auth about more information about the /// Firebase Local Emulator Suite. -final class FirebaseAccountTests: XCTestCase { // swiftlint:disable:this type_body_length - @MainActor - override func setUp() async throws { - try await super.setUp() - +final class FirebaseAccountTests: XCTestCase { // swiftlint:disable:this type_body_length2 + override func setUp() { continueAfterFailure = false + } + override func setUp() async throws { try await FirebaseClient.deleteAllAccounts() try await Task.sleep(for: .seconds(0.5)) } - @MainActor func testAccountSignUp() async throws { let app = XCUIApplication() app.launchArguments = ["--firebaseAccount"] app.launch() - - XCTAssert(app.buttons["FirebaseAccount"].waitForExistence(timeout: 10.0)) + + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 2.0)) + + XCTAssert(app.buttons["FirebaseAccount"].waitForExistence(timeout: 2.0)) app.buttons["FirebaseAccount"].tap() var accounts = try await FirebaseClient.getAllAccounts() @@ -156,6 +156,8 @@ final class FirebaseAccountTests: XCTestCase { // swiftlint:disable:this type_bo app.launchArguments = ["--firebaseAccount"] app.launch() + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 4.0)) + XCTAssert(app.buttons["FirebaseAccount"].waitForExistence(timeout: 10.0)) app.buttons["FirebaseAccount"].tap() @@ -205,10 +207,12 @@ final class FirebaseAccountTests: XCTestCase { // swiftlint:disable:this type_bo app.launchArguments = ["--firebaseAccount"] app.launch() - XCTAssert(app.buttons["FirebaseAccount"].waitForExistence(timeout: 10.0)) + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 4.0)) + + XCTAssert(app.buttons["FirebaseAccount"].waitForExistence(timeout: 2.0)) app.buttons["FirebaseAccount"].tap() - if app.buttons["Logout"].waitForExistence(timeout: 5.0) && app.buttons["Logout"].isHittable { + if app.buttons["Logout"].waitForExistence(timeout: 2.0) && app.buttons["Logout"].isHittable { app.buttons["Logout"].tap() } @@ -446,22 +450,31 @@ final class FirebaseAccountTests: XCTestCase { // swiftlint:disable:this type_bo app.tap() // that triggers the interruption monitor closure } + @MainActor func testSignupAccountLinking() throws { let app = XCUIApplication() app.launchArguments = ["--account-storage"] app.launch() - XCTAssert(app.buttons["FirebaseAccount"].waitForExistence(timeout: 10.0)) + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 2.0)) + + XCTAssert(app.buttons["FirebaseAccount"].waitForExistence(timeout: 2.0)) app.buttons["FirebaseAccount"].tap() - if app.buttons["Logout"].waitForExistence(timeout: 3.0) && app.buttons["Logout"].isHittable { + if app.buttons["Logout"].waitForExistence(timeout: 2.0) && app.buttons["Logout"].isHittable { app.buttons["Logout"].tap() } - XCTAssertTrue(app.buttons["Login Anonymously"].waitForExistence(timeout: 2.0)) - app.buttons["Login Anonymously"].tap() + XCTAssertTrue(app.buttons["Account Setup"].exists) + app.buttons["Account Setup"].tap() + + XCTAssertTrue(app.buttons["Anonymous Signup"].waitForExistence(timeout: 4.0)) + app.buttons["Anonymous Signup"].tap() + + XCTAssertTrue(app.buttons["Close"].exists) + app.buttons["Close"].tap() - XCTAssertTrue(app.staticTexts["User, Anonymous"].waitForExistence(timeout: 5.0)) + XCTAssertTrue(app.staticTexts["User, Anonymous"].waitForExistence(timeout: 2.0)) try app.signup(username: "test@username2.edu", password: "TestPassword2", givenName: "Leland", familyName: "Stanford", biography: "Bio") @@ -474,6 +487,7 @@ final class FirebaseAccountTests: XCTestCase { // swiftlint:disable:this type_bo extension XCUIApplication { func login(username: String, password: String, close: Bool = true) throws { + XCTAssertTrue(buttons["Account Setup"].exists) buttons["Account Setup"].tap() XCTAssertTrue(self.buttons["Login"].waitForExistence(timeout: 2.0)) @@ -485,36 +499,33 @@ extension XCUIApplication { scrollViews.buttons["Login"].tap() if close { - sleep(3) + sleep(3) // TODO: remove all sleeps! self.buttons["Close"].tap() } } func signup(username: String, password: String, givenName: String, familyName: String, biography: String? = nil) throws { + XCTAssertTrue(buttons["Account Setup"].exists) buttons["Account Setup"].tap() + XCTAssertTrue(buttons["Signup"].waitForExistence(timeout: 2.0)) buttons["Signup"].tap() XCTAssertTrue(staticTexts["Please fill out the details below to create your new account."].waitForExistence(timeout: 6.0)) - sleep(2) try collectionViews.textFields["E-Mail Address"].enter(value: username) try collectionViews.secureTextFields["Password"].enter(value: password) - swipeUp() - try textFields["enter first name"].enter(value: givenName) - swipeUp() - try textFields["enter last name"].enter(value: familyName) - swipeUp() if let biography { try textFields["Biography"].enter(value: biography) } + XCTAssertTrue(buttons["Signup"].exists) collectionViews.buttons["Signup"].tap() - sleep(3) + XCTAssertTrue(buttons["Close"].waitForExistence(timeout: 2.0)) buttons["Close"].tap() } } diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index 842bf4d..7995a69 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -29,7 +29,6 @@ A9D83F9B2B0BDB1D000D0C78 /* BiographyKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9D83F9A2B0BDB1D000D0C78 /* BiographyKey.swift */; }; A9D83F9D2B0BDB3A000D0C78 /* FirebaseAccountStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9D83F9C2B0BDB3A000D0C78 /* FirebaseAccountStorageTests.swift */; }; A9D83F9F2B0BDCC7000D0C78 /* SpeziFirebaseAccountStorage in Frameworks */ = {isa = PBXBuildFile; productRef = A9D83F9E2B0BDCC7000D0C78 /* SpeziFirebaseAccountStorage */; }; - A9D83FA22B0BE048000D0C78 /* AccountStorageTestStandard.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9D83FA12B0BE048000D0C78 /* AccountStorageTestStandard.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -66,7 +65,6 @@ A9D83F982B0BDB13000D0C78 /* FeatureFlags.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeatureFlags.swift; sourceTree = ""; }; A9D83F9A2B0BDB1D000D0C78 /* BiographyKey.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BiographyKey.swift; sourceTree = ""; }; A9D83F9C2B0BDB3A000D0C78 /* FirebaseAccountStorageTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FirebaseAccountStorageTests.swift; sourceTree = ""; }; - A9D83FA12B0BE048000D0C78 /* AccountStorageTestStandard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountStorageTestStandard.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -190,7 +188,6 @@ isa = PBXGroup; children = ( A9D83F9A2B0BDB1D000D0C78 /* BiographyKey.swift */, - A9D83FA12B0BE048000D0C78 /* AccountStorageTestStandard.swift */, ); path = FirebaseAccountStorage; sourceTree = ""; @@ -312,7 +309,6 @@ 2F148C00298BB15900031B7F /* FirebaseAccountTestsView.swift in Sources */, 2FA7382C290ADFAA007ACEB9 /* TestApp.swift in Sources */, 2FE62C3D2966074F00FCBE7F /* FirestoreDataStorageTests.swift in Sources */, - A9D83FA22B0BE048000D0C78 /* AccountStorageTestStandard.swift in Sources */, 2F8A431729130BBC005D2B8F /* TestAppType.swift in Sources */, 2F9F07F129090B0500CDC598 /* TestAppDelegate.swift in Sources */, A9D83F992B0BDB13000D0C78 /* FeatureFlags.swift in Sources */, diff --git a/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6ee9daf..7188a30 100644 --- a/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/abseil-cpp-binary.git", "state" : { - "revision" : "748c7837511d0e6a507737353af268484e1745e2", - "version" : "1.2024011601.1" + "revision" : "194a6706acbd25e4ef639bcaddea16e8758a3e27", + "version" : "1.2024011602.0" } }, { @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/app-check.git", "state" : { - "revision" : "076b241a625e25eac22f8849be256dfb960fcdfe", - "version" : "10.19.1" + "revision" : "3b62f154d00019ae29a71e9738800bb6f18b236d", + "version" : "10.19.2" } }, { @@ -41,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/firebase/firebase-ios-sdk", "state" : { - "revision" : "8bcaf973b1d84e119b7c7c119abad72ed460979f", - "version" : "10.27.0" + "revision" : "eca84fd638116dd6adb633b5a3f31cc7befcbb7d", + "version" : "10.29.0" } }, { @@ -50,8 +50,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleAppMeasurement.git", "state" : { - "revision" : "70df02431e216bed98dd461e0c4665889245ba70", - "version" : "10.27.0" + "revision" : "fe727587518729046fc1465625b9afd80b5ab361", + "version" : "10.28.0" } }, { @@ -86,8 +86,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/gtm-session-fetcher.git", "state" : { - "revision" : "0382ca27f22fb3494cf657d8dc356dc282cd1193", - "version" : "3.4.1" + "revision" : "a2ab612cb980066ee56d90d60d8462992c07f24b", + "version" : "3.5.0" } }, { @@ -140,8 +140,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/StanfordSpezi/Spezi", "state" : { - "revision" : "d87e3d8104a0732c0e294e9ae6354db4a7058800", - "version" : "1.6.0" + "branch" : "feature/dependency-restructure", + "revision" : "48e239a2a8f7a7bb8a60cb4cdec3490e81e3cba4" } }, { @@ -150,7 +150,7 @@ "location" : "https://github.com/StanfordSpezi/SpeziAccount", "state" : { "branch" : "feature/account-service-singleton", - "revision" : "97422b3a046d8e0adbac4f695a1cf9157cd0c870" + "revision" : "bfe48d064f80e1e93b0852f4bcb8dff69e6a4443" } }, { @@ -167,8 +167,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/StanfordSpezi/SpeziStorage", "state" : { - "revision" : "b958df9b31f24800388a7bfc28f457ce7b82556c", - "version" : "1.0.2" + "branch" : "feature/sendable-modules", + "revision" : "772fe846a82616bf01133575198e589e05190446" } }, { @@ -212,17 +212,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-protobuf.git", "state" : { - "revision" : "9f0c76544701845ad98716f3f6a774a892152bcb", - "version" : "1.26.0" + "revision" : "e17d61f26df0f0e06f58f6977ba05a097a720106", + "version" : "1.27.1" } }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-syntax.git", + "location" : "https://github.com/apple/swift-syntax", "state" : { - "revision" : "303e5c5c36d6a558407d364878df131c3546fad8", - "version" : "510.0.2" + "revision" : "2bc86522d115234d1f588efe2bcb4ce4be8f8b82", + "version" : "510.0.3" } }, { @@ -257,8 +257,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/StanfordSpezi/XCTestExtensions", "state" : { - "revision" : "1fe9b8e76aeb7a132af37bfa0892160c9b662dcc", - "version" : "0.4.10" + "revision" : "cc2705fde81978eacd5496e14c9caf58909e2322", + "version" : "0.4.12" } }, { From 78c7ae9076731dab90103c5534be04e68566999d Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Fri, 2 Aug 2024 13:24:36 +0200 Subject: [PATCH 08/26] Update build command --- .github/workflows/build-and-test.yml | 2 +- .../Views/ReauthenticationAlertModifier.swift | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 76bfcff..15307e3 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -32,7 +32,7 @@ jobs: setupfirebaseemulator: true path: Tests/UITests customcommand: | - firebase emulators:exec 'set -o pipefail && xcodebuild test -project UITests.xcodeproj -scheme TestApp -destination "platform=iOS Simulator,name=iPhone 15 Pro" -resultBundlePath UITests.xcresult -derivedDataPath ".derivedData" CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO | xcbeautify' + firebase emulators:exec 'set -o pipefail && xcodebuild test -project UITests.xcodeproj -scheme TestApp -destination "platform=iOS Simulator,name=iPhone 15 Pro" -resultBundlePath UITests.xcresult -derivedDataPath ".derivedData" CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO -skipPackagePluginValidation -skipMacroValidation | xcbeautify' uploadcoveragereport: name: Upload Coverage Report needs: [buildandtest, buildandtestuitests] diff --git a/Sources/SpeziFirebaseAccount/Views/ReauthenticationAlertModifier.swift b/Sources/SpeziFirebaseAccount/Views/ReauthenticationAlertModifier.swift index b7de8f9..e0dcf1d 100644 --- a/Sources/SpeziFirebaseAccount/Views/ReauthenticationAlertModifier.swift +++ b/Sources/SpeziFirebaseAccount/Views/ReauthenticationAlertModifier.swift @@ -23,7 +23,7 @@ struct ReauthenticationAlertModifier: ViewModifier { @State private var isActive = false - private var isPresented: Binding { + @MainActor private var isPresented: Binding { Binding { firebaseModel.isPresentingReauthentication && isActive } set: { newValue in @@ -31,7 +31,7 @@ struct ReauthenticationAlertModifier: ViewModifier { } } - private var context: ReauthenticationContext? { + @MainActor private var context: ReauthenticationContext? { firebaseModel.reauthenticationContext } From 09dd0986046de1f448c3d36bcbeb26bed4750163 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 8 Aug 2024 17:52:24 +0200 Subject: [PATCH 09/26] Update to latest SpeziAccount commit, supply user details within configure --- Package.swift | 9 +- .../FirebaseEmailVerifiedKey.swift | 41 --- .../FirebaseAccountService.swift | 110 +++++-- .../Keys/AccountDetails+FirebaseMetdata.swift | 44 +++ .../Resources/Localizable.xcstrings | 1 + .../Views/FirebaseSignInWithAppleButton.swift | 20 +- .../FirestoreAccountStorage.swift | 76 ++--- .../Visitor/FirestoreDecodeVisitor.swift | 45 --- .../Visitor/FirestoreEncodeVisitor.swift | 57 ---- .../FirebaseEncorderAndDecoder+Sendable.swift | 1 + Sources/SpeziFirestore/Firestore.swift | 5 +- .../FirestoreSettings+Emulator.swift | 3 +- .../FirebaseAccountTestsView.swift | 16 +- .../StorageMetadata+Sendable.swift | 1 + .../TestAppUITests/FirebaseClient.swift | 2 +- .../xcshareddata/swiftpm/Package.resolved | 284 ------------------ 16 files changed, 190 insertions(+), 525 deletions(-) delete mode 100644 Sources/SpeziFirebaseAccount/AccountKeys/FirebaseEmailVerifiedKey.swift create mode 100644 Sources/SpeziFirebaseAccount/Keys/AccountDetails+FirebaseMetdata.swift delete mode 100644 Sources/SpeziFirebaseAccountStorage/Visitor/FirestoreDecodeVisitor.swift delete mode 100644 Sources/SpeziFirebaseAccountStorage/Visitor/FirestoreEncodeVisitor.swift delete mode 100644 Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/Package.swift b/Package.swift index 3d953c4..89ba3b4 100644 --- a/Package.swift +++ b/Package.swift @@ -33,11 +33,10 @@ let package = Package( .library(name: "SpeziFirebaseAccountStorage", targets: ["SpeziFirebaseAccountStorage"]) ], dependencies: [ - .package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.0.0"), - .package(url: "https://github.com/StanfordSpezi/SpeziViews.git", from: "1.0.0"), - .package(url: "https://github.com/StanfordSpezi/SpeziStorage", branch: "feature/sendable-modules"), + .package(url: "https://github.com/StanfordSpezi/Spezi", branch: "feature/dependency-restructure"), + .package(url: "https://github.com/StanfordSpezi/SpeziViews.git", from: "1.5.0"), .package(url: "https://github.com/StanfordSpezi/SpeziAccount", branch: "feature/account-service-singleton"), - .package(url: "https://github.com/firebase/firebase-ios-sdk", from: "10.13.0") + .package(url: "https://github.com/firebase/firebase-ios-sdk", from: "10.29.0") ] + swiftLintPackage(), targets: [ .target( @@ -47,8 +46,6 @@ let package = Package( .product(name: "Spezi", package: "Spezi"), .product(name: "SpeziValidation", package: "SpeziViews"), .product(name: "SpeziAccount", package: "SpeziAccount"), - .product(name: "SpeziLocalStorage", package: "SpeziStorage"), - .product(name: "SpeziSecureStorage", package: "SpeziStorage"), .product(name: "FirebaseAuth", package: "firebase-ios-sdk") ], swiftSettings: [ diff --git a/Sources/SpeziFirebaseAccount/AccountKeys/FirebaseEmailVerifiedKey.swift b/Sources/SpeziFirebaseAccount/AccountKeys/FirebaseEmailVerifiedKey.swift deleted file mode 100644 index 9636653..0000000 --- a/Sources/SpeziFirebaseAccount/AccountKeys/FirebaseEmailVerifiedKey.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import Foundation -import SpeziAccount -import SwiftUI - -private struct EntryView: DataEntryView { - @Binding private var value: Bool - - var body: some View { - BoolEntryView(\.isEmailVerified, $value) - .disabled(true) // you cannot manually change that - } - - init(_ value: Binding) { - _value = value - } -} - - -extension AccountDetails { - /// Flag indicating if the firebase account has a verified email address. - /// - /// - Important: This key is read-only and cannot be modified. - @AccountKey( - name: LocalizedStringResource("E-Mail Verified", bundle: .atURL(from: .module)), - as: Bool.self, - entryView: EntryView.self - ) - public var isEmailVerified: Bool? // swiftlint:disable:this discouraged_optional_boolean -} - - -@KeyEntry(\.isEmailVerified) -public extension AccountKeys {} // swiftlint:disable:this no_extension_access_modifier diff --git a/Sources/SpeziFirebaseAccount/FirebaseAccountService.swift b/Sources/SpeziFirebaseAccount/FirebaseAccountService.swift index 19a18ca..715942c 100644 --- a/Sources/SpeziFirebaseAccount/FirebaseAccountService.swift +++ b/Sources/SpeziFirebaseAccount/FirebaseAccountService.swift @@ -14,6 +14,7 @@ import SpeziAccount import SpeziFirebaseConfiguration import SpeziLocalStorage import SpeziSecureStorage +import SpeziValidation import SwiftUI @@ -48,7 +49,6 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable \.userId \.password \.name - \.isEmailVerified } // TODO: update all docs! @@ -87,29 +87,38 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable // dispatch of user updates private var shouldQueue = false + private var isConfiguring = false private var queuedUpdate: UserUpdate? + + private var unsupportedKeys: AccountKeyCollection { + var unsupportedKeys = account.configuration.keys + unsupportedKeys.removeAll(Self.supportedAccountKeys) + return unsupportedKeys + } + /// - Parameters: /// - providers: The authentication methods that should be supported. /// - emulatorSettings: The emulator settings. The default value is `nil`, connecting the FirebaseAccount module to the Firebase Auth cloud instance. + /// - passwordValidation: Override the default password validation rule. By default firebase enforces a minimum length of 6 characters. public init( providers: FirebaseAuthProviders, - emulatorSettings: (host: String, port: Int)? = nil + emulatorSettings: (host: String, port: Int)? = nil, + passwordValidation: [ValidationRule]? = nil // swiftlint:disable:this discouraged_optional_collection ) { self.emulatorSettings = emulatorSettings - // TODO: try to remove the supportedKeys, account service makes sure keys are there anyways? self.configuration = AccountServiceConfiguration(supportedKeys: .exactly(Self.supportedAccountKeys)) { RequiredAccountKeys { \.userId if providers.contains(.emailAndPassword) { - \.password // TODO: how does that translate to the new model? + \.password } } UserIdConfiguration.emailAddress FieldValidationRules(for: \.userId, rules: .minimalEmail) - FieldValidationRules(for: \.password, rules: .minimumFirebasePassword) // TODO: still support overriding this? + FieldValidationRules(for: \.password, rules: passwordValidation ?? [.minimumFirebasePassword]) } if !providers.contains(.emailAndPassword) { @@ -128,11 +137,44 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable Auth.auth().useEmulator(withHost: emulatorSettings.host, port: emulatorSettings.port) } + isConfiguring = true + defer { + isConfiguring = false + } + // get notified about changes of the User reference authStateDidChangeListenerHandle = Auth.auth().addStateDidChangeListener { [weak self] auth, user in guard let self else { return } + + if isConfiguring { + // We can safely assume main actor isolation, see + // https://firebase.google.com/docs/reference/swift/firebaseauth/api/reference/Classes/Auth#/c:@M@FirebaseAuth@objc(cs)FIRAuth(im)addAuthStateDidChangeListener: + let skip = MainActor.assumeIsolated { + // Ensure that there are details associated as soon as possible. + // Mark them as incomplete if we know there might be account details that are stored externally, + // we update the details later anyways, even if we might be wrong. + if let user { + var details = self.buildUser(user, isNewUser: false) + details.isIncomplete = !self.unsupportedKeys.isEmpty + + self.logger.debug("Supply initial user details of associated Firebase account.") + self.account.supplyUserDetails(details) + return !details.isIncomplete + } else { + self.account.removeUserDetails() + return true + } + } + + guard !skip else { + // don't spin of the task below if we know it wouldn't change anything. + return + } + } + + Task { do { try await self.stateDidChangeListener(auth: auth, user: user) @@ -151,6 +193,7 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable return // we make sure that we don't remove the account when we don't have network (e.g., flight mode) } + // TODO: can we assume main actor? Task { self.notifyUserRemoval() } @@ -181,11 +224,11 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable } // TODO: make sure we do not interrupt! anything? (e.g. shouldQueue is true?) - do { - try await notifyUserSignIn(user: user, mergeWith: details) - } catch { - logger.error("Failed to propagate update details from external storage: \(error)") - } + // TODO: semaphore on the withFirebaseAction? + + let details = buildUser(user, isNewUser: false, mergeWith: details) + logger.debug("Update user details due to updates in the externally stored account details.") + account.supplyUserDetails(details) } public func login(userId: String, password: String) async throws { // TODO: do all docs @@ -197,6 +240,8 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable } } + // TODO: expose methods for email verification? + public func signUpAnonymously() async throws { try await dispatchFirebaseAuthAction { try await Auth.auth().signInAnonymously() @@ -381,9 +426,12 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable try await updateDisplayName(of: currentUser, name) } - // TODO: remove all keys that were stored already! - let externalStorage = externalStorage - try await externalStorage.updateExternalStorage(with: modifications, for: currentUser.uid) + var externalModifications = modifications + externalModifications.removeModifications(for: Self.supportedAccountKeys) + if !externalModifications.isEmpty { + let externalStorage = externalStorage + try await externalStorage.updateExternalStorage(with: externalModifications, for: currentUser.uid) + } // None of the above requests will trigger our state change listener, therefore, we just call it manually. try await notifyUserSignIn(user: currentUser) @@ -444,8 +492,8 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable // MARK: - Sign In With Apple +@MainActor extension FirebaseAccountService { - @MainActor func onAppleSignInRequest(request: ASAuthorizationAppleIDRequest) { let nonce = CryptoUtils.randomNonceString(length: 32) // we configured userId as `required` in the account service @@ -462,7 +510,6 @@ extension FirebaseAccountService { self.lastNonce = nonce // save the nonce for later use to be passed to FirebaseAuth } - @MainActor func onAppleSignInCompletion(result: Result) async throws { defer { // cleanup tasks self.lastNonce = nil @@ -537,7 +584,6 @@ extension FirebaseAccountService { return nil } - @MainActor private func oAuthCredential(from credential: ASAuthorizationAppleIDCredential) throws -> OAuthCredential { guard let lastNonce else { logger.error("AppleIdCredential was received though no login request was found.") @@ -564,6 +610,7 @@ extension FirebaseAccountService { } +@MainActor extension FirebaseAccountService { private static nonisolated func resetLegacyStorage(_ secureStorage: SecureStorage, _ localStorage: LocalStorage, _ logger: Logger) { do { @@ -582,6 +629,7 @@ extension FirebaseAccountService { func dispatchFirebaseAuthAction( action: @Sendable () async throws -> Void ) async throws { + // TODO: use async semaphore here! try await self.dispatchFirebaseAuthAction { try await action() return nil @@ -670,18 +718,22 @@ extension FirebaseAccountService { } } - func notifyUserSignIn(user: User, isNewUser: Bool = false, mergeWith additionalDetails: AccountDetails? = nil) async throws { - logger.debug("Notifying SpeziAccount with updated user details.") - + private func buildUser(_ user: User, isNewUser: Bool, mergeWith additionalDetails: AccountDetails? = nil) -> AccountDetails { var details = AccountDetails() details.accountId = user.uid if let email = user.email { details.userId = email // userId will fallback to accountId if not present } - details.isEmailVerified = user.isEmailVerified + + // flags details.isNewUser = isNewUser + details.isVerified = user.isEmailVerified details.isAnonymous = user.isAnonymous + // metadata + details.creationDate = user.metadata.creationDate + details.lastSignInDate = user.metadata.lastSignInDate + if let displayName = user.displayName, let nameComponents = try? PersonNameComponents(displayName, strategy: .name) { // we wouldn't be here if we couldn't create the person name components from the given string @@ -692,16 +744,26 @@ extension FirebaseAccountService { details.add(contentsOf: additionalDetails) } - // TODO: way to retrieve all configured keys => remove all supported => check if non-empty, then retrieve? - let unsupportedKeys = configuration - .unsupportedAccountKeys(basedOn: account.configuration) - .map { $0.key } // TODO: this is a mouthful + return details + } + + private func buildUserQueryingStorageProvider(user: User, isNewUser: Bool) async throws -> AccountDetails { + var details = buildUser(user, isNewUser: isNewUser) + + let unsupportedKeys = unsupportedKeys if !unsupportedKeys.isEmpty { let externalStorage = externalStorage let externalDetails = try await externalStorage.retrieveExternalStorage(for: details.accountId, unsupportedKeys) details.add(contentsOf: externalDetails) } + return details + } + + func notifyUserSignIn(user: User, isNewUser: Bool = false) async throws { + let details = try await buildUserQueryingStorageProvider(user: user, isNewUser: isNewUser) + + logger.debug("Notifying SpeziAccount with updated user details.") account.supplyUserDetails(details) } diff --git a/Sources/SpeziFirebaseAccount/Keys/AccountDetails+FirebaseMetdata.swift b/Sources/SpeziFirebaseAccount/Keys/AccountDetails+FirebaseMetdata.swift new file mode 100644 index 0000000..e271288 --- /dev/null +++ b/Sources/SpeziFirebaseAccount/Keys/AccountDetails+FirebaseMetdata.swift @@ -0,0 +1,44 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import SpeziAccount +import SpeziFoundation + + +extension AccountDetails { + private struct CreationDateKey: KnowledgeSource { + typealias Anchor = AccountAnchor + typealias Value = Date + } + + private struct LastSignInDateKey: KnowledgeSource { + typealias Anchor = AccountAnchor + typealias Value = Date + } + + /// The creation date of the Firebase user. + public var creationDate: Date? { + get { + self[CreationDateKey.self] + } + set { + self[CreationDateKey.self] = newValue + } + } + + /// The last sign in date of the Firebase user. + public var lastSignInDate: Date? { + get { + self[LastSignInDateKey.self] + } + set { + self[LastSignInDateKey.self] = newValue + } + } +} diff --git a/Sources/SpeziFirebaseAccount/Resources/Localizable.xcstrings b/Sources/SpeziFirebaseAccount/Resources/Localizable.xcstrings index bd90c86..00dbb78 100644 --- a/Sources/SpeziFirebaseAccount/Resources/Localizable.xcstrings +++ b/Sources/SpeziFirebaseAccount/Resources/Localizable.xcstrings @@ -25,6 +25,7 @@ } }, "E-Mail Verified" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { diff --git a/Sources/SpeziFirebaseAccount/Views/FirebaseSignInWithAppleButton.swift b/Sources/SpeziFirebaseAccount/Views/FirebaseSignInWithAppleButton.swift index ece72d4..a206b91 100644 --- a/Sources/SpeziFirebaseAccount/Views/FirebaseSignInWithAppleButton.swift +++ b/Sources/SpeziFirebaseAccount/Views/FirebaseSignInWithAppleButton.swift @@ -17,30 +17,16 @@ struct FirebaseSignInWithAppleButton: View { @Environment(\.colorScheme) private var colorScheme - @Environment(\.defaultErrorDescription) - private var defaultErrorDescription @State private var viewState: ViewState = .idle var body: some View { - SignInWithAppleButton { request in + SignInWithAppleButton(state: $viewState) { request in service.onAppleSignInRequest(request: request) } onCompletion: { result in - Task { - do { - try await service.onAppleSignInCompletion(result: result) - } catch { - if let localizedError = error as? LocalizedError { - viewState = .error(localizedError) - } else { - viewState = .error(AnyLocalizedError( - error: error, - defaultErrorDescription: defaultErrorDescription - )) - } - } - } + try await service.onAppleSignInCompletion(result: result) } + .frame(height: 55) .viewStateAlert(state: $viewState) } diff --git a/Sources/SpeziFirebaseAccountStorage/FirestoreAccountStorage.swift b/Sources/SpeziFirebaseAccountStorage/FirestoreAccountStorage.swift index ad25b3b..2c357dc 100644 --- a/Sources/SpeziFirebaseAccountStorage/FirestoreAccountStorage.swift +++ b/Sources/SpeziFirebaseAccountStorage/FirestoreAccountStorage.swift @@ -69,7 +69,8 @@ import SpeziFirestore /// } /// } /// ``` -public actor FirestoreAccountStorage: AccountStorageProvider { // TODO: completely restructure docs! +public actor FirestoreAccountStorage: AccountStorageProvider { + // TODO: completely restructure docs! @Application(\.logger) private var logger @@ -107,11 +108,16 @@ public actor FirestoreAccountStorage: AccountStorageProvider { // TODO: complete return } - Task { + if snapshot?.metadata.hasPendingWrites == true { + return // ignore updates we caused locally, see https://firebase.google.com/docs/firestore/query-data/listen#events-local-changes + } + + Task { @Sendable in guard let snapshot else { await self.logger.error("Failed to retrieve user document collection: \(error)") return } + await self.processUpdatedSnapshot(for: accountId, snapshot) } } @@ -125,32 +131,30 @@ public actor FirestoreAccountStorage: AccountStorageProvider { // TODO: complete let details = buildAccountDetails(from: snapshot, keys: Array(keys)) - externalStorage.notifyAboutUpdatedDetails(for: accountId, details) + guard !details.isEmpty else { + return + } let localCache = localCache await localCache.communicateRemoteChanges(for: accountId, details) + + externalStorage.notifyAboutUpdatedDetails(for: accountId, details) } private func buildAccountDetails(from snapshot: DocumentSnapshot, keys: [any AccountKey.Type]) -> AccountDetails { - guard let data = snapshot.data() else { + guard snapshot.exists else { return AccountDetails() } - // TODO: just use simple decoder? - var visitor = FirestoreDecodeVisitor(data: data, in: snapshot.reference) - let details = keys.acceptAll(&visitor) + let decoder = Firestore.Decoder() + decoder.userInfo[.accountDetailsKeys] = keys - for (key, error) in visitor.errors { - logger.error("Failed to decode account value from firestore snapshot for key \(key.identifier): \(error)") + do { + return try snapshot.data(as: AccountDetails.self, decoder: decoder) + } catch { + logger.error("Failed to decode account details from firestore snapshot: \(error)") + return AccountDetails() } - - return details - } - - public func create(_ accountId: String, _ details: AccountDetails) async throws { - // we just treat it as modifications - let modifications = try AccountModifications(modifiedDetails: details) - try await modify(accountId, modifications) } public func load(_ accountId: String, _ keys: [any AccountKey.Type]) async throws -> AccountDetails? { @@ -164,32 +168,30 @@ public actor FirestoreAccountStorage: AccountStorageProvider { // TODO: complete return cached } - public func modify(_ accountId: String, _ modifications: AccountModifications) async throws { - let result = modifications.modifiedDetails.acceptAll(FirestoreEncodeVisitor()) + public func store(_ accountId: String, _ modifications: SpeziAccount.AccountModifications) async throws { + let document = userDocument(for: accountId) - do { - switch result { - case let .success(data): - if !data.isEmpty { - try await userDocument(for: accountId) - .setData(data, merge: true) - } - case let .failure(error): - throw error + if !modifications.modifiedDetails.isEmpty { + do { + try await document.setData(from: modifications.modifiedDetails, merge: true) + } catch { + throw FirestoreError(error) } + } - let removedFields: [String: Any] = modifications.removedAccountDetails.keys.reduce(into: [:]) { result, key in - result[key.identifier] = FieldValue.delete() - } + let removedFields: [String: Any] = modifications.removedAccountDetails.keys.reduce(into: [:]) { result, key in + result[key.identifier] = FieldValue.delete() + } - if !removedFields.isEmpty { - try await userDocument(for: accountId) - .updateData(removedFields) + if !removedFields.isEmpty { + do { + try await document.updateData(removedFields) + } catch { + throw FirestoreError(error) } - } catch { - throw FirestoreError(error) } + if var keys = registeredKeys[accountId] { // we have a snapshot listener in place which we need to update the keys for for newKey in modifications.modifiedDetails.keys where keys[ObjectIdentifier(newKey)] == nil { keys.updateValue(newKey, forKey: ObjectIdentifier(newKey)) @@ -198,6 +200,8 @@ public actor FirestoreAccountStorage: AccountStorageProvider { // TODO: complete for removedKey in modifications.removedAccountKeys { keys.removeValue(forKey: ObjectIdentifier(removedKey)) } + + registeredKeys[accountId] = keys } let localCache = localCache diff --git a/Sources/SpeziFirebaseAccountStorage/Visitor/FirestoreDecodeVisitor.swift b/Sources/SpeziFirebaseAccountStorage/Visitor/FirestoreDecodeVisitor.swift deleted file mode 100644 index 774ab10..0000000 --- a/Sources/SpeziFirebaseAccountStorage/Visitor/FirestoreDecodeVisitor.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import FirebaseFirestore -import SpeziAccount - - -struct FirestoreDecodeVisitor: AccountKeyVisitor { - private let data: [String: Any] - private let reference: DocumentReference - - private var details = AccountDetails() - private(set) var errors: [(any AccountKey.Type, Error)] = [] - - - init(data: [String: Any], in reference: DocumentReference) { - self.data = data - self.reference = reference - } - - - mutating func visit(_ key: Key.Type) { - guard let dataValue = data[key.identifier] else { - return - } - - let decoder = Firestore.Decoder() - - do { - let value = try decoder.decode(Key.Value.self, from: dataValue, in: reference) - details.set(key, value: value) - } catch { - errors.append((key, error)) - } - } - - func final() -> AccountDetails { - details - } -} diff --git a/Sources/SpeziFirebaseAccountStorage/Visitor/FirestoreEncodeVisitor.swift b/Sources/SpeziFirebaseAccountStorage/Visitor/FirestoreEncodeVisitor.swift deleted file mode 100644 index 0bf292e..0000000 --- a/Sources/SpeziFirebaseAccountStorage/Visitor/FirestoreEncodeVisitor.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - - -import FirebaseFirestore -import OSLog -import SpeziAccount - - -private struct SingleKeyContainer: Codable { - let value: Value -} - - -class FirestoreEncodeVisitor: AccountValueVisitor { - typealias Data = [String: Any] - - private let logger = Logger(subsystem: "edu.stanford.spezi.firebase", category: "FirestoreEncode") - - private var values: Data = [:] - private var errors: [String: Error] = [:] - - init() {} - - func visit(_ key: Key.Type, _ value: Key.Value) { - let encoder = Firestore.Encoder() - - // the firestore encode method expects a container type! - let container = SingleKeyContainer(value: value) - - do { - let result = try encoder.encode(container) - guard let encoded = result["value"] else { - preconditionFailure("Layout of SingleKeyContainer changed. Does not contain value anymore: \(result)") - } - - values["\(Key.self)"] = encoded - } catch { - logger.error("Failed to encode \("\(value)") for key \(key): \(error)") - errors["\(Key.self)"] = error - } - } - - func final() -> Result { - if let first = errors.first { - // we just report the first error, like in a typical do-catch setup - return .failure(first.value) - } else { - return .success(values) - } - } -} diff --git a/Sources/SpeziFirestore/FirebaseEncorderAndDecoder+Sendable.swift b/Sources/SpeziFirestore/FirebaseEncorderAndDecoder+Sendable.swift index e1ea275..1e0f5da 100644 --- a/Sources/SpeziFirestore/FirebaseEncorderAndDecoder+Sendable.swift +++ b/Sources/SpeziFirestore/FirebaseEncorderAndDecoder+Sendable.swift @@ -9,6 +9,7 @@ import FirebaseFirestoreSwift +// TODO: lol, remove that! extension FirebaseFirestore.Firestore.Encoder: @unchecked Sendable {} diff --git a/Sources/SpeziFirestore/Firestore.swift b/Sources/SpeziFirestore/Firestore.swift index 4f0fca9..427196e 100644 --- a/Sources/SpeziFirestore/Firestore.swift +++ b/Sources/SpeziFirestore/Firestore.swift @@ -31,8 +31,9 @@ import SwiftUI /// - Note: We recommend using the [Firebase Firestore SDK as defined in the API documentation](https://firebase.google.com/docs/firestore/manage-data/add-data#swift) /// throughout the application. We **highly recommend using the async/await variants of the APIs** instead of the closure-based APIs the SDK provides. public class Firestore: Module, DefaultInitializable { - @Dependency private var configureFirebaseApp: ConfigureFirebaseApp - + @Dependency(ConfigureFirebaseApp.self) + private var configureFirebaseApp + private let settings: FirestoreSettings diff --git a/Sources/SpeziFirestore/FirestoreSettings+Emulator.swift b/Sources/SpeziFirestore/FirestoreSettings+Emulator.swift index 7f7047b..314906d 100644 --- a/Sources/SpeziFirestore/FirestoreSettings+Emulator.swift +++ b/Sources/SpeziFirestore/FirestoreSettings+Emulator.swift @@ -9,7 +9,8 @@ import FirebaseFirestore -extension FirestoreSettings: @unchecked Sendable { +// TODO: remove that? @unchecked Sendable? +extension FirestoreSettings { /// The emulator settings define the default settings when using the Firebase emulator suite as described at [Connect your app to the Cloud Firestore Emulator](https://firebase.google.com/docs/emulator-suite/connect_firestore). public static var emulator: FirestoreSettings { let settings = FirestoreSettings() diff --git a/Tests/UITests/TestApp/FirebaseAccountTests/FirebaseAccountTestsView.swift b/Tests/UITests/TestApp/FirebaseAccountTests/FirebaseAccountTestsView.swift index 5af999b..67ce590 100644 --- a/Tests/UITests/TestApp/FirebaseAccountTests/FirebaseAccountTestsView.swift +++ b/Tests/UITests/TestApp/FirebaseAccountTests/FirebaseAccountTestsView.swift @@ -22,7 +22,6 @@ struct FirebaseAccountTestsView: View { @State var showSetup = false @State var showOverview = false - @State var isEditing = false var body: some View { List { @@ -59,22 +58,17 @@ struct FirebaseAccountTestsView: View { } .sheet(isPresented: $showOverview) { NavigationStack { - AccountOverview(isEditing: $isEditing) - .toolbar { - toolbar(closing: $showOverview, isEditing: $isEditing) - } + AccountOverview(close: .showCloseButton) } } } @ToolbarContentBuilder - func toolbar(closing flag: Binding, isEditing: Binding = .constant(false)) -> some ToolbarContent { - if isEditing.wrappedValue == false { - ToolbarItemGroup(placement: .cancellationAction) { - Button("Close") { - flag.wrappedValue = false - } + func toolbar(closing flag: Binding) -> some ToolbarContent { + ToolbarItemGroup(placement: .cancellationAction) { + Button("Close") { + flag.wrappedValue = false } } } diff --git a/Tests/UITests/TestApp/FirebaseStorageTests/StorageMetadata+Sendable.swift b/Tests/UITests/TestApp/FirebaseStorageTests/StorageMetadata+Sendable.swift index f0a2a59..eaac611 100644 --- a/Tests/UITests/TestApp/FirebaseStorageTests/StorageMetadata+Sendable.swift +++ b/Tests/UITests/TestApp/FirebaseStorageTests/StorageMetadata+Sendable.swift @@ -9,4 +9,5 @@ import FirebaseStorage +// TODO: remove that? extension StorageMetadata: @unchecked Sendable {} diff --git a/Tests/UITests/TestAppUITests/FirebaseClient.swift b/Tests/UITests/TestAppUITests/FirebaseClient.swift index 019f415..6c6ebe8 100644 --- a/Tests/UITests/TestAppUITests/FirebaseClient.swift +++ b/Tests/UITests/TestAppUITests/FirebaseClient.swift @@ -51,7 +51,7 @@ struct FirestoreAccount: Decodable, Equatable { enum FirebaseClient { - private static let projectId = "spezifirebaseuitests" + private static let projectId = "nams-e43ed" // TODO: restore "spezifirebaseuitests" // curl -H "Authorization: Bearer owner" -X DELETE http://localhost:9099/emulator/v1/projects/spezifirebaseuitests/accounts static func deleteAllAccounts() async throws { diff --git a/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index 7188a30..0000000 --- a/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,284 +0,0 @@ -{ - "pins" : [ - { - "identity" : "abseil-cpp-binary", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/abseil-cpp-binary.git", - "state" : { - "revision" : "194a6706acbd25e4ef639bcaddea16e8758a3e27", - "version" : "1.2024011602.0" - } - }, - { - "identity" : "app-check", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/app-check.git", - "state" : { - "revision" : "3b62f154d00019ae29a71e9738800bb6f18b236d", - "version" : "10.19.2" - } - }, - { - "identity" : "collectionconcurrencykit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/JohnSundell/CollectionConcurrencyKit.git", - "state" : { - "revision" : "b4f23e24b5a1bff301efc5e70871083ca029ff95", - "version" : "0.2.0" - } - }, - { - "identity" : "cryptoswift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/krzyzanowskim/CryptoSwift.git", - "state" : { - "revision" : "c9c3df6ab812de32bae61fc0cd1bf6d45170ebf0", - "version" : "1.8.2" - } - }, - { - "identity" : "firebase-ios-sdk", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/firebase-ios-sdk", - "state" : { - "revision" : "eca84fd638116dd6adb633b5a3f31cc7befcbb7d", - "version" : "10.29.0" - } - }, - { - "identity" : "googleappmeasurement", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleAppMeasurement.git", - "state" : { - "revision" : "fe727587518729046fc1465625b9afd80b5ab361", - "version" : "10.28.0" - } - }, - { - "identity" : "googledatatransport", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleDataTransport.git", - "state" : { - "revision" : "a637d318ae7ae246b02d7305121275bc75ed5565", - "version" : "9.4.0" - } - }, - { - "identity" : "googleutilities", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleUtilities.git", - "state" : { - "revision" : "57a1d307f42df690fdef2637f3e5b776da02aad6", - "version" : "7.13.3" - } - }, - { - "identity" : "grpc-binary", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/grpc-binary.git", - "state" : { - "revision" : "e9fad491d0673bdda7063a0341fb6b47a30c5359", - "version" : "1.62.2" - } - }, - { - "identity" : "gtm-session-fetcher", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/gtm-session-fetcher.git", - "state" : { - "revision" : "a2ab612cb980066ee56d90d60d8462992c07f24b", - "version" : "3.5.0" - } - }, - { - "identity" : "interop-ios-for-google-sdks", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/interop-ios-for-google-sdks.git", - "state" : { - "revision" : "2d12673670417654f08f5f90fdd62926dc3a2648", - "version" : "100.0.0" - } - }, - { - "identity" : "leveldb", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/leveldb.git", - "state" : { - "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", - "version" : "1.22.5" - } - }, - { - "identity" : "nanopb", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/nanopb.git", - "state" : { - "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", - "version" : "2.30910.0" - } - }, - { - "identity" : "promises", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/promises.git", - "state" : { - "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", - "version" : "2.4.0" - } - }, - { - "identity" : "sourcekitten", - "kind" : "remoteSourceControl", - "location" : "https://github.com/jpsim/SourceKitten.git", - "state" : { - "revision" : "fd4df99170f5e9d7cf9aa8312aa8506e0e7a44e7", - "version" : "0.35.0" - } - }, - { - "identity" : "spezi", - "kind" : "remoteSourceControl", - "location" : "https://github.com/StanfordSpezi/Spezi", - "state" : { - "branch" : "feature/dependency-restructure", - "revision" : "48e239a2a8f7a7bb8a60cb4cdec3490e81e3cba4" - } - }, - { - "identity" : "speziaccount", - "kind" : "remoteSourceControl", - "location" : "https://github.com/StanfordSpezi/SpeziAccount", - "state" : { - "branch" : "feature/account-service-singleton", - "revision" : "bfe48d064f80e1e93b0852f4bcb8dff69e6a4443" - } - }, - { - "identity" : "spezifoundation", - "kind" : "remoteSourceControl", - "location" : "https://github.com/StanfordSpezi/SpeziFoundation", - "state" : { - "revision" : "4781d96a09587f3d47ac3f3e71d197149b288146", - "version" : "1.1.3" - } - }, - { - "identity" : "spezistorage", - "kind" : "remoteSourceControl", - "location" : "https://github.com/StanfordSpezi/SpeziStorage", - "state" : { - "branch" : "feature/sendable-modules", - "revision" : "772fe846a82616bf01133575198e589e05190446" - } - }, - { - "identity" : "speziviews", - "kind" : "remoteSourceControl", - "location" : "https://github.com/StanfordSpezi/SpeziViews.git", - "state" : { - "revision" : "ff61e6594677572df051b96905cc2a7a12cffd10", - "version" : "1.5.0" - } - }, - { - "identity" : "swift-argument-parser", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser.git", - "state" : { - "revision" : "41982a3656a71c768319979febd796c6fd111d5c", - "version" : "1.5.0" - } - }, - { - "identity" : "swift-atomics", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-atomics.git", - "state" : { - "revision" : "cd142fd2f64be2100422d658e7411e39489da985", - "version" : "1.2.0" - } - }, - { - "identity" : "swift-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-collections.git", - "state" : { - "revision" : "3d2dc41a01f9e49d84f0a3925fb858bed64f702d", - "version" : "1.1.2" - } - }, - { - "identity" : "swift-protobuf", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-protobuf.git", - "state" : { - "revision" : "e17d61f26df0f0e06f58f6977ba05a097a720106", - "version" : "1.27.1" - } - }, - { - "identity" : "swift-syntax", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-syntax", - "state" : { - "revision" : "2bc86522d115234d1f588efe2bcb4ce4be8f8b82", - "version" : "510.0.3" - } - }, - { - "identity" : "swiftlint", - "kind" : "remoteSourceControl", - "location" : "https://github.com/realm/SwiftLint.git", - "state" : { - "revision" : "b515723b16eba33f15c4677ee65f3fef2ce8c255", - "version" : "0.55.1" - } - }, - { - "identity" : "swiftytexttable", - "kind" : "remoteSourceControl", - "location" : "https://github.com/scottrhoyt/SwiftyTextTable.git", - "state" : { - "revision" : "c6df6cf533d120716bff38f8ff9885e1ce2a4ac3", - "version" : "0.9.0" - } - }, - { - "identity" : "swxmlhash", - "kind" : "remoteSourceControl", - "location" : "https://github.com/drmohundro/SWXMLHash.git", - "state" : { - "revision" : "a853604c9e9a83ad9954c7e3d2a565273982471f", - "version" : "7.0.2" - } - }, - { - "identity" : "xctestextensions", - "kind" : "remoteSourceControl", - "location" : "https://github.com/StanfordSpezi/XCTestExtensions", - "state" : { - "revision" : "cc2705fde81978eacd5496e14c9caf58909e2322", - "version" : "0.4.12" - } - }, - { - "identity" : "xctruntimeassertions", - "kind" : "remoteSourceControl", - "location" : "https://github.com/StanfordBDHG/XCTRuntimeAssertions", - "state" : { - "revision" : "7ce28015c0bee62b523a860e343e3d6b7ec40fda", - "version" : "1.1.1" - } - }, - { - "identity" : "yams", - "kind" : "remoteSourceControl", - "location" : "https://github.com/jpsim/Yams.git", - "state" : { - "revision" : "3036ba9d69cf1fd04d433527bc339dc0dc75433d", - "version" : "5.1.3" - } - } - ], - "version" : 2 -} From 54526a1c3565ae594ade500dd0c24c4d09d21545 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Fri, 9 Aug 2024 08:28:13 +0200 Subject: [PATCH 10/26] Some concurrency fixes in the UITests --- .../TestAppUITests/FirebaseStorageTests.swift | 12 +++--- .../FirestoreDataStorageTests.swift | 40 ++++++++++--------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/Tests/UITests/TestAppUITests/FirebaseStorageTests.swift b/Tests/UITests/TestAppUITests/FirebaseStorageTests.swift index 1638cee..f68a0f7 100644 --- a/Tests/UITests/TestAppUITests/FirebaseStorageTests.swift +++ b/Tests/UITests/TestAppUITests/FirebaseStorageTests.swift @@ -23,11 +23,9 @@ final class FirebaseStorageTests: XCTestCase { @MainActor override func setUp() async throws { - try await super.setUp() - continueAfterFailure = false - try await deleteAllFiles() + try await Self.deleteAllFiles() try await Task.sleep(for: .seconds(0.5)) } @@ -38,18 +36,18 @@ final class FirebaseStorageTests: XCTestCase { XCTAssert(app.buttons["FirebaseStorage"].waitForExistence(timeout: 2.0)) app.buttons["FirebaseStorage"].tap() - var documents = try await getAllFiles() + var documents = try await Self.getAllFiles() XCTAssert(documents.isEmpty) XCTAssert(app.buttons["Upload"].waitForExistence(timeout: 2.0)) app.buttons["Upload"].tap() try await Task.sleep(for: .seconds(2.0)) - documents = try await getAllFiles() + documents = try await Self.getAllFiles() XCTAssertEqual(documents.count, 1) } - private func getAllFiles() async throws -> [FirebaseStorageItem] { + private static func getAllFiles() async throws -> [FirebaseStorageItem] { let documentsURL = try XCTUnwrap( URL(string: "http://localhost:9199/v0/b/STORAGE_BUCKET/o") ) @@ -79,7 +77,7 @@ final class FirebaseStorageTests: XCTestCase { } } - private func deleteAllFiles() async throws { + private static func deleteAllFiles() async throws { for storageItem in try await getAllFiles() { let url = try XCTUnwrap( URL(string: "http://localhost:9199/v0/b/STORAGE_BUCKET/o/\(storageItem.name)") diff --git a/Tests/UITests/TestAppUITests/FirestoreDataStorageTests.swift b/Tests/UITests/TestAppUITests/FirestoreDataStorageTests.swift index 93f7a1c..4046556 100644 --- a/Tests/UITests/TestAppUITests/FirestoreDataStorageTests.swift +++ b/Tests/UITests/TestAppUITests/FirestoreDataStorageTests.swift @@ -48,11 +48,9 @@ final class FirestoreDataStorageTests: XCTestCase { @MainActor override func setUp() async throws { - try await super.setUp() - continueAfterFailure = false - try await deleteAllDocuments() + try await Self.deleteAllDocuments() try await Task.sleep(for: .seconds(0.5)) } @@ -63,13 +61,13 @@ final class FirestoreDataStorageTests: XCTestCase { app.launch() app.buttons["FirestoreDataStorage"].tap() - var documents = try await getAllDocuments() + var documents = try await Self.getAllDocuments() XCTAssert(documents.isEmpty) try add(id: "Identifier1", content: "1") try await Task.sleep(for: .seconds(0.5)) - documents = try await getAllDocuments() + documents = try await Self.getAllDocuments() XCTAssertEqual( documents.sorted(by: { $0.name < $1.name }), [ @@ -87,13 +85,13 @@ final class FirestoreDataStorageTests: XCTestCase { app.launch() app.buttons["FirestoreDataStorage"].tap() - var documents = try await getAllDocuments() + var documents = try await Self.getAllDocuments() XCTAssert(documents.isEmpty) try merge(id: "Identifier1", content: "1") try await Task.sleep(for: .seconds(0.5)) - documents = try await getAllDocuments() + documents = try await Self.getAllDocuments() XCTAssertEqual( documents.sorted(by: { $0.name < $1.name }), [ @@ -111,13 +109,13 @@ final class FirestoreDataStorageTests: XCTestCase { app.launch() app.buttons["FirestoreDataStorage"].tap() - var documents = try await getAllDocuments() + var documents = try await Self.getAllDocuments() XCTAssert(documents.isEmpty) try add(id: "Identifier1", content: "1") try await Task.sleep(for: .seconds(0.5)) - documents = try await getAllDocuments() + documents = try await Self.getAllDocuments() XCTAssertEqual( documents.sorted(by: { $0.name < $1.name }), [ @@ -131,7 +129,7 @@ final class FirestoreDataStorageTests: XCTestCase { try add(id: "Identifier1", content: "2") try await Task.sleep(for: .seconds(0.5)) - documents = try await getAllDocuments() + documents = try await Self.getAllDocuments() XCTAssertEqual( documents.sorted(by: { $0.name < $1.name }), [ @@ -150,13 +148,13 @@ final class FirestoreDataStorageTests: XCTestCase { app.launch() app.buttons["FirestoreDataStorage"].tap() - var documents = try await getAllDocuments() + var documents = try await Self.getAllDocuments() XCTAssert(documents.isEmpty) try add(id: "Identifier1", content: "1") try await Task.sleep(for: .seconds(0.5)) - documents = try await getAllDocuments() + documents = try await Self.getAllDocuments() XCTAssertEqual( documents.sorted(by: { $0.name < $1.name }), [ @@ -169,26 +167,30 @@ final class FirestoreDataStorageTests: XCTestCase { try remove(id: "Identifier1", content: "1") - documents = try await getAllDocuments() + documents = try await Self.getAllDocuments() XCTAssert(documents.isEmpty) } - + + @MainActor private func add(id: String, content: String) throws { try enterFirestoreElement(id: id, content: content) XCUIApplication().buttons["Upload Element"].tap() } - + + @MainActor private func merge(id: String, content: String) throws { try enterFirestoreElement(id: id, content: content) XCUIApplication().buttons["Merge Element"].tap() } - + + @MainActor private func remove(id: String, content: String) throws { try enterFirestoreElement(id: id, content: content) XCUIApplication().buttons["Delete Element"].tap() } - + + @MainActor private func enterFirestoreElement(id: String, content: String) throws { let app = XCUIApplication() @@ -201,7 +203,7 @@ final class FirestoreDataStorageTests: XCTestCase { try app.textFields[contentFieldIdentifier].enter(value: content) } - private func deleteAllDocuments() async throws { + private static func deleteAllDocuments() async throws { let emulatorDocumentsURL = try XCTUnwrap( URL(string: "http://localhost:8080/emulator/v1/projects/spezifirebaseuitests/databases/(default)/documents") ) @@ -224,7 +226,7 @@ final class FirestoreDataStorageTests: XCTestCase { } } - private func getAllDocuments() async throws -> [FirestoreElement] { + private static func getAllDocuments() async throws -> [FirestoreElement] { let documentsURL = try XCTUnwrap( URL(string: "http://localhost:8080/v1/projects/spezifirebaseuitests/databases/(default)/documents/") ) From dbc6ce8ea43fa4d0db39afba6e9d078ac6448d2f Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Fri, 9 Aug 2024 11:39:14 +0200 Subject: [PATCH 11/26] Complete documentation and todos --- Package.swift | 2 + .../FirebaseAccountService.swift | 262 ++++++++++++------ .../FirebaseAuthProviders.swift | 14 +- .../Models/FirebaseAccountError.swift | 6 + .../Resources/Localizable.xcstrings | 114 +++++++- .../SpeziFirebaseAccount.md | 42 ++- ...fier.swift => FirebaseSecurityAlert.swift} | 10 +- .../FirestoreAccountStorage.swift | 108 +++++--- .../SpeziFirebaseAccountStorage.md | 59 ++-- .../ConfigureFirebaseApp.swift | 16 +- .../FirebaseConfiguration.md | 14 +- .../FirebaseStorageConfiguration.swift | 24 +- .../SpeziFirebaseStorage.md | 2 +- .../DocumentReference+AsyncAwait.swift | 7 +- .../FirebaseEncorderAndDecoder+Sendable.swift | 16 -- Sources/SpeziFirestore/Firestore.swift | 7 +- ...reErrorCode.swift => FirestoreError.swift} | 0 .../FirestoreSettings+Emulator.swift | 6 +- .../SpeziFirestore.docc/SpeziFirestore.md | 16 +- .../StorageMetadata+Sendable.swift | 13 - .../TestAppUITests/FirebaseClient.swift | 2 +- 21 files changed, 483 insertions(+), 257 deletions(-) rename Sources/SpeziFirebaseAccount/Views/{ReauthenticationAlertModifier.swift => FirebaseSecurityAlert.swift} (88%) delete mode 100644 Sources/SpeziFirestore/FirebaseEncorderAndDecoder+Sendable.swift rename Sources/SpeziFirestore/{FirestoreErrorCode.swift => FirestoreError.swift} (100%) delete mode 100644 Tests/UITests/TestApp/FirebaseStorageTests/StorageMetadata+Sendable.swift diff --git a/Package.swift b/Package.swift index 89ba3b4..90bc479 100644 --- a/Package.swift +++ b/Package.swift @@ -33,6 +33,7 @@ let package = Package( .library(name: "SpeziFirebaseAccountStorage", targets: ["SpeziFirebaseAccountStorage"]) ], dependencies: [ + .package(url: "https://github.com/StanfordSpezi/SpeziFoundation.git", from: "1.1.3"), .package(url: "https://github.com/StanfordSpezi/Spezi", branch: "feature/dependency-restructure"), .package(url: "https://github.com/StanfordSpezi/SpeziViews.git", from: "1.5.0"), .package(url: "https://github.com/StanfordSpezi/SpeziAccount", branch: "feature/account-service-singleton"), @@ -43,6 +44,7 @@ let package = Package( name: "SpeziFirebaseAccount", dependencies: [ .target(name: "SpeziFirebaseConfiguration"), + .product(name: "SpeziFoundation", package: "SpeziFoundation"), .product(name: "Spezi", package: "Spezi"), .product(name: "SpeziValidation", package: "SpeziViews"), .product(name: "SpeziAccount", package: "SpeziAccount"), diff --git a/Sources/SpeziFirebaseAccount/FirebaseAccountService.swift b/Sources/SpeziFirebaseAccount/FirebaseAccountService.swift index 715942c..0f34a15 100644 --- a/Sources/SpeziFirebaseAccount/FirebaseAccountService.swift +++ b/Sources/SpeziFirebaseAccount/FirebaseAccountService.swift @@ -12,6 +12,7 @@ import OSLog import Spezi import SpeziAccount import SpeziFirebaseConfiguration +import SpeziFoundation import SpeziLocalStorage import SpeziSecureStorage import SpeziValidation @@ -30,19 +31,55 @@ private struct UserUpdate { } -/// Configures an `AccountService` to interact with Firebase Auth. +/// Configures an `AccountService` that interacts with Firebase Auth. +/// +/// +/// Configure the account service using the +/// [`AccountConfiguration`](https://swiftpackageindex.com/stanfordspezi/speziaccount/documentation/speziaccount/accountconfiguration). +/// +/// ```swift +/// import SpeziAccount +/// import SpeziFirebaseAccount /// -/// The `FirebaseAccountConfiguration` can, e.g., be used to to connect to the Firebase Auth emulator: -/// ``` /// class ExampleAppDelegate: SpeziAppDelegate { /// override var configuration: Configuration { /// Configuration { -/// FirebaseAccountConfiguration(emulatorSettings: (host: "localhost", port: 9099)) -/// // ... +/// AccountConfiguration( +/// service: FirebaseAccountService() +/// configuration: [/* ... */] +/// ) /// } /// } /// } /// ``` +/// +/// ## Topics +/// +/// ### Configuration +/// +/// - ``init(providers:emulatorSettings:passwordValidation:)`` +/// +/// ### Signup +/// - ``signUpAnonymously()`` +/// - ``signUp(with:)-6qeht`` +/// - ``signup(with:)-3bvwo`` +/// +/// ### Login +/// - ``login(userId:password:)`` +/// +/// ### Modifications +/// - ``updateAccountDetails(_:)`` +/// +/// ### Password Reset +/// - ``resetPassword(userId:)`` +/// +/// ### Logout & Deletion +/// - ``logout()`` +/// - ``delete()`` +/// +/// ### Presenting the security alert +/// - ``securityAlert`` +/// - ``FirebaseSecurityAlert`` public final class FirebaseAccountService: AccountService { // swiftlint:disable:this type_body_length private static let supportedAccountKeys = AccountKeyCollection { \.accountId @@ -51,7 +88,6 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable \.name } - // TODO: update all docs! @Application(\.logger) private var logger @@ -67,6 +103,7 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable private var externalStorage + @_documentation(visibility: internal) public let configuration: AccountServiceConfiguration private let emulatorSettings: (host: String, port: Int)? @@ -77,7 +114,11 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable @IdentityProvider(section: .singleSignOn) private var signInWithApple = FirebaseSignInWithAppleButton() - @SecurityRelatedModifier private var emailPasswordReauth = ReauthenticationAlertModifier() + /// Security alert to authorize security sensitive operations. + /// + /// This view modifier injects an alert into the view hierarchy that will present an alert, if the account service requests to re-authenticate the + /// user for security-sensitive operations. This modifier is automatically injected in SpeziAccount-related views. + @SecurityRelatedModifier public var securityAlert = FirebaseSecurityAlert() @Model private var firebaseModel = FirebaseAccountModel() @Modifier private var firebaseModifier = FirebaseAccountModifier() @@ -85,10 +126,10 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable @MainActor private var authStateDidChangeListenerHandle: AuthStateDidChangeListenerHandle? @MainActor private var lastNonce: String? - // dispatch of user updates - private var shouldQueue = false private var isConfiguring = false - private var queuedUpdate: UserUpdate? + private var shouldQueue = false + private var queuedUpdates: [UserUpdate] = [] + private var actionSemaphore = AsyncSemaphore() private var unsupportedKeys: AccountKeyCollection { @@ -132,6 +173,7 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable } } + @_documentation(visibility: internal) public func configure() { if let emulatorSettings { Auth.auth().useEmulator(withHost: emulatorSettings.host, port: emulatorSettings.port) @@ -144,44 +186,7 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable // get notified about changes of the User reference authStateDidChangeListenerHandle = Auth.auth().addStateDidChangeListener { [weak self] auth, user in - guard let self else { - return - } - - if isConfiguring { - // We can safely assume main actor isolation, see - // https://firebase.google.com/docs/reference/swift/firebaseauth/api/reference/Classes/Auth#/c:@M@FirebaseAuth@objc(cs)FIRAuth(im)addAuthStateDidChangeListener: - let skip = MainActor.assumeIsolated { - // Ensure that there are details associated as soon as possible. - // Mark them as incomplete if we know there might be account details that are stored externally, - // we update the details later anyways, even if we might be wrong. - if let user { - var details = self.buildUser(user, isNewUser: false) - details.isIncomplete = !self.unsupportedKeys.isEmpty - - self.logger.debug("Supply initial user details of associated Firebase account.") - self.account.supplyUserDetails(details) - return !details.isIncomplete - } else { - self.account.removeUserDetails() - return true - } - } - - guard !skip else { - // don't spin of the task below if we know it wouldn't change anything. - return - } - } - - - Task { - do { - try await self.stateDidChangeListener(auth: auth, user: user) - } catch { - self.logger.error("Failed to apply FirebaseAuth stateDidChangeListener: \(error)") - } - } + self?.handleStateDidChange(auth: auth, user: user) } // if there is a cached user, we refresh the authentication token @@ -193,8 +198,9 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable return // we make sure that we don't remove the account when we don't have network (e.g., flight mode) } - // TODO: can we assume main actor? - Task { + // guaranteed to be invoked on the main thread, see + // https://firebase.google.com/docs/reference/swift/firebaseauth/api/reference/Classes/User#getidtokenforcingrefresh_:completion: + MainActor.assumeIsolated { self.notifyUserRemoval() } } @@ -218,20 +224,12 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable } } - private func handleUpdatedDetailsFromExternalStorage(for accountId: String, details: AccountDetails) async { - guard let user = Auth.auth().currentUser else { - return - } - - // TODO: make sure we do not interrupt! anything? (e.g. shouldQueue is true?) - // TODO: semaphore on the withFirebaseAction? - - let details = buildUser(user, isNewUser: false, mergeWith: details) - logger.debug("Update user details due to updates in the externally stored account details.") - account.supplyUserDetails(details) - } - - public func login(userId: String, password: String) async throws { // TODO: do all docs + /// Login user with userId and password credentials. + /// - Parameters: + /// - userId: The user id. Typically the email used with the firebase account. + /// - password: The user's password. + /// - Throws: Throws an ``FirebaseAccountError`` if the operation fails. + public func login(userId: String, password: String) async throws { logger.debug("Received new login request...") try await dispatchFirebaseAuthAction { @MainActor in @@ -240,18 +238,23 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable } } - // TODO: expose methods for email verification? - + /// Sign in with an anonymous user account. + /// - Throws: Throws an ``FirebaseAccountError`` if the operation fails. public func signUpAnonymously() async throws { try await dispatchFirebaseAuthAction { try await Auth.auth().signInAnonymously() } } + /// Sign up with userId and password credentials and additional user details. + /// + /// - Parameter signupDetails: The `AccountDetails` that must contain a **userId** and a **password**. + /// - Throws: Trows an ``FirebaseAccountError`` if the operation fails. A ``FirebaseAccountError/invalidCredentials`` is thrown if + /// the `userId` or `password` keys are not present. public func signUp(with signupDetails: AccountDetails) async throws { logger.debug("Received new signup request...") - guard let password = signupDetails.password else { + guard let password = signupDetails.password, signupDetails.contains(AccountKeys.userId) else { throw FirebaseAccountError.invalidCredentials } @@ -287,6 +290,11 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable } } + /// Sign up with an O-Auth credential. + /// + /// Sign up with an O-Auth credential, like one received from Sign in with Apple. + /// - Parameter credential: The o-auth credential. + /// - Throws: Throws an ``FirebaseAccountError`` if the operation fails. public func signup(with credential: OAuthCredential) async throws { try await dispatchFirebaseAuthAction { @MainActor in if let currentUser = Auth.auth().currentUser, @@ -324,6 +332,9 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable } } + /// Logout the current user. + /// - Throws: Throws an ``FirebaseAccountError`` if the operation fails. A ``FirebaseAccountError/notSignedIn`` is thrown if logout + /// is called when no user was logged in. public func logout() async throws { guard Auth.auth().currentUser != nil else { if account.signedIn { @@ -341,6 +352,15 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable } } + /// Delete the current user and all associated data. + /// + /// This will notify external storage provider to delete the account data associated with the current user account. + /// - Important: A re-authentication is required, when requesting to delete the account. This is automatically done through the a Single-Sign-On provider if available. + /// Otherwise, an alert will be presented to enter the password credential. Make sure that the ``securityAlert`` modifier is injected from the point your are calling + /// this method. This is automatically done with native SpeziAccount views. + /// + /// - Throws: Throws an ``FirebaseAccountError`` if the operation fails. A ``FirebaseAccountError/notSignedIn`` is thrown if delete + /// is called when no user was logged in. public func delete() async throws { guard let currentUser = Auth.auth().currentUser else { if account.signedIn { @@ -394,6 +414,18 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable } } + /// Apply modifications to the current user account. + /// + /// This method applies modifications to the current user account details. Modifications will be automatically forwarded to the external storage provider for + /// keys not supported by Firebase Auth. + /// + /// - Important: A re-authentication is required, when changing security-sensitive account details (like userId or password). + /// This is automatically done through the a Single-Sign-On provider if available. + /// Otherwise, an alert will be presented to enter the password credential. Make sure that the ``securityAlert`` modifier is injected from the point your are calling + /// this method. This is automatically done with native SpeziAccount views. + /// + /// - Throws: Throws an ``FirebaseAccountError`` if the operation fails. A ``FirebaseAccountError/notSignedIn`` is thrown if delete + /// is called when no user was logged in. public func updateAccountDetails(_ modifications: AccountModifications) async throws { guard let currentUser = Auth.auth().currentUser else { if account.signedIn { @@ -448,8 +480,11 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable // we just prefer apple for simplicity, and because for the delete operation we need to token to revoke it if user.providerData.contains(where: { $0.providerID == "apple.com" }) { try await reauthenticateUserApple(user: user) - } else { + } else if user.providerData.contains(where: { $0.providerID == "password" }) { try await reauthenticateUserPassword(user: user) + } else { + logger.error("Tried to re-authenticate but couldn't find a supported provider, found: \(user.providerData)") + throw FirebaseAccountError.unsupportedProvider } } @@ -489,6 +524,67 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable } } +// MARK: - Listener and Handler + +extension FirebaseAccountService { + @MainActor + private func handleStateDidChange(auth: Auth, user: User?) { + if isConfiguring { + // We can safely assume main actor isolation, see + // https://firebase.google.com/docs/reference/swift/firebaseauth/api/reference/Classes/Auth#/c:@M@FirebaseAuth@objc(cs)FIRAuth(im)addAuthStateDidChangeListener: + let skip = MainActor.assumeIsolated { + // Ensure that there are details associated as soon as possible. + // Mark them as incomplete if we know there might be account details that are stored externally, + // we update the details later anyways, even if we might be wrong. + if let user { + var details = buildUser(user, isNewUser: false) + details.isIncomplete = !self.unsupportedKeys.isEmpty + + logger.debug("Supply initial user details of associated Firebase account.") + account.supplyUserDetails(details) + return !details.isIncomplete + } else { + account.removeUserDetails() + return true + } + } + + guard !skip else { + return // don't spin of the task below if we know it wouldn't change anything. + } + } + + + Task { + do { + try await handleUpdatedUserState(user: user) + } catch { + logger.error("Failed to handle update Firebase user state: \(error)") + } + } + } + + private func handleUpdatedDetailsFromExternalStorage(for accountId: String, details: AccountDetails) async { + guard let user = Auth.auth().currentUser else { + return + } + + do { + try await actionSemaphore.waitCheckingCancellation() + } catch { + return + } + + defer { + actionSemaphore.signal() + } + + let details = buildUser(user, isNewUser: false, mergeWith: details) + logger.debug("Update user details due to updates in the externally stored account details.") + account.supplyUserDetails(details) + } +} + // MARK: - Sign In With Apple @@ -609,6 +705,7 @@ extension FirebaseAccountService { } } +// MARK: - Infrastructure @MainActor extension FirebaseAccountService { @@ -627,9 +724,8 @@ extension FirebaseAccountService { // a overload that just returns void func dispatchFirebaseAuthAction( - action: @Sendable () async throws -> Void + action: () async throws -> Void ) async throws { - // TODO: use async semaphore here! try await self.dispatchFirebaseAuthAction { try await action() return nil @@ -647,13 +743,15 @@ extension FirebaseAccountService { /// we can forward additional information back to SpeziAccount. @_disfavoredOverload func dispatchFirebaseAuthAction( - action: @Sendable () async throws -> AuthDataResult? + action: () async throws -> AuthDataResult? ) async throws { defer { shouldQueue = false + actionSemaphore.signal() } shouldQueue = true + try await actionSemaphore.waitCheckingCancellation() do { let result = try await action() @@ -669,7 +767,7 @@ extension FirebaseAccountService { } - private func stateDidChangeListener(auth: Auth, user: User?) async throws { + private func handleUpdatedUserState(user: User?) async throws { // this is called by the FIRAuth framework. let change: UserChange @@ -683,7 +781,7 @@ extension FirebaseAccountService { if shouldQueue { logger.debug("Received FirebaseAuth stateDidChange that is queued to be dispatched in active call.") - self.queuedUpdate = update + queuedUpdates.append(update) } else { logger.debug("Received FirebaseAuth stateDidChange that that was triggered due to other reasons. Dispatching anonymously...") @@ -693,19 +791,19 @@ extension FirebaseAccountService { } private func dispatchQueuedChanges(result: AuthDataResult? = nil) async throws { - shouldQueue = false - - guard var queuedUpdate else { - return + defer { + shouldQueue = false } - self.queuedUpdate = nil + while var queuedUpdate = queuedUpdates.first { + queuedUpdates.removeFirst() - if let result { // patch the update before we apply it - queuedUpdate.authResult = result - } + if let result { // patch the update before we apply it + queuedUpdate.authResult = result + } - try await apply(update: queuedUpdate) + try await apply(update: queuedUpdate) + } } private func apply(update: UserUpdate) async throws { diff --git a/Sources/SpeziFirebaseAccount/FirebaseAuthProviders.swift b/Sources/SpeziFirebaseAccount/FirebaseAuthProviders.swift index 1e71b04..30c586a 100644 --- a/Sources/SpeziFirebaseAccount/FirebaseAuthProviders.swift +++ b/Sources/SpeziFirebaseAccount/FirebaseAuthProviders.swift @@ -7,13 +7,13 @@ // -/// Definition of the authentication methods supported by the FirebaseAccount module. +/// Authentication Providers supported by the `FirebaseAccountService`. public struct FirebaseAuthProviders: OptionSet, Codable, Sendable { - /// E-Mail and password-based authentication. - /// + /// E-Mail and Password-based authentication. + /// /// Please follow the necessary setup steps at [Password Authentication](https://firebase.google.com/docs/auth/ios/password-auth). public static let emailAndPassword = FirebaseAuthProviders(rawValue: 1 << 0) - /// Sign In With Apple Identity Provider. + /// Sign In With Apple. /// /// Please follow the necessary setup steps at [Sign in with Apple](https://firebase.google.com/docs/auth/ios/apple). public static let signInWithApple = FirebaseAuthProviders(rawValue: 1 << 1) @@ -22,10 +22,12 @@ public struct FirebaseAuthProviders: OptionSet, Codable, Sendable { @_spi(Internal) public static let anonymousButton = FirebaseAuthProviders(rawValue: 1 << 2) - + + @_documentation(visibility: internal) public let rawValue: Int - + + @_documentation(visibility: internal) public init(rawValue: Int) { self.rawValue = rawValue } diff --git a/Sources/SpeziFirebaseAccount/Models/FirebaseAccountError.swift b/Sources/SpeziFirebaseAccount/Models/FirebaseAccountError.swift index 8b5ad25..a3d1170 100644 --- a/Sources/SpeziFirebaseAccount/Models/FirebaseAccountError.swift +++ b/Sources/SpeziFirebaseAccount/Models/FirebaseAccountError.swift @@ -36,6 +36,8 @@ public enum FirebaseAccountError { case linkFailedDuplicate /// Linking the account failed as the credentials are already in use with a different account. case linkFailedAlreadyInUse + /// Encountered an unrecognized provider when trying to re-authenticate the user. + case unsupportedProvider /// Unrecognized Firebase account error. case unknown(AuthErrorCode.Code) @@ -88,6 +90,8 @@ extension FirebaseAccountError: LocalizedError { return "FIREBASE_ACCOUNT_SIGN_IN_ERROR" case .requireRecentLogin: return "FIREBASE_ACCOUNT_REQUIRE_RECENT_LOGIN_ERROR" + case .unsupportedProvider: + return "FIREBASE_ACCOUNT_UNSUPPORTED_PROVIDER_ERROR" case .appleFailed: return "FIREBASE_APPLE_FAILED" case .linkFailedDuplicate: @@ -121,6 +125,8 @@ extension FirebaseAccountError: LocalizedError { return "FIREBASE_ACCOUNT_SIGN_IN_ERROR_SUGGESTION" case .requireRecentLogin: return "FIREBASE_ACCOUNT_REQUIRE_RECENT_LOGIN_ERROR_SUGGESTION" + case .unsupportedProvider: + return "FIREBASE_ACCOUNT_UNSUPPORTED_PROVIDER_ERROR_SUGGESTION" case .appleFailed: return "FIREBASE_APPLE_FAILED_SUGGESTION" case .linkFailedDuplicate: diff --git a/Sources/SpeziFirebaseAccount/Resources/Localizable.xcstrings b/Sources/SpeziFirebaseAccount/Resources/Localizable.xcstrings index 00dbb78..8ce50e2 100644 --- a/Sources/SpeziFirebaseAccount/Resources/Localizable.xcstrings +++ b/Sources/SpeziFirebaseAccount/Resources/Localizable.xcstrings @@ -2,41 +2,49 @@ "sourceLanguage" : "en", "strings" : { "Anonymous Signup" : { - - }, - "Authentication Required" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anonym Registrieren" + } + }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Authentication Required" + "value" : "Anonymous Signup" } } } }, - "Cancel" : { + "Authentication Required" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bestätigung Erforderlich" + } + }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Cancel" + "value" : "Authentication Required" } } } }, - "E-Mail Verified" : { - "extractionState" : "stale", + "Cancel" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "E-Mail verifiziert" + "value" : "Abbrechen" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "E-Mail Verified" + "value" : "Cancel" } } } @@ -187,6 +195,12 @@ }, "FIREBASE_ACCOUNT_LINK_FAILED_ALREADY_IN_USE" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verbinden der Anmeldedaten fehlgeschlagen" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -197,6 +211,12 @@ }, "FIREBASE_ACCOUNT_LINK_FAILED_ALREADY_IN_USE_SUGGESTION" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Zugangsdaten sind bereits mit einem anderen Konto verbunden." + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -207,6 +227,12 @@ }, "FIREBASE_ACCOUNT_LINK_FAILED_DUPLICATE" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verbinden der Anmeldedaten fehlgeschlagen" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -217,6 +243,12 @@ }, "FIREBASE_ACCOUNT_LINK_FAILED_DUPLICATE_SUGGESTION" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Es sind bereits Zugangsdaten dieser Art mit dem Benutzerkonto verbunden." + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -353,6 +385,38 @@ } } }, + "FIREBASE_ACCOUNT_UNSUPPORTED_PROVIDER_ERROR" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anbieter nicht unterstützt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unsupported Provider" + } + } + } + }, + "FIREBASE_ACCOUNT_UNSUPPORTED_PROVIDER_ERROR_SUGGESTION" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Es wurde kein unterstützer Anbieter gefunden, um deine Identität zu bestätigen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Found an unsupported provider when trying to re-authenticate." + } + } + } + }, "FIREBASE_ACCOUNT_WEAK_PASSWORD" : { "localizations" : { "de" : { @@ -418,10 +482,36 @@ } }, "Login" : { - + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anmelden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Login" + } + } + } }, "Please enter your password for %@." : { - + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bitte bestätige dein Passwort für %@." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Please enter your password for %@." + } + } + } } }, "version" : "1.0" diff --git a/Sources/SpeziFirebaseAccount/SpeziFirebaseAccount.docc/SpeziFirebaseAccount.md b/Sources/SpeziFirebaseAccount/SpeziFirebaseAccount.docc/SpeziFirebaseAccount.md index addb392..740fb35 100644 --- a/Sources/SpeziFirebaseAccount/SpeziFirebaseAccount.docc/SpeziFirebaseAccount.md +++ b/Sources/SpeziFirebaseAccount/SpeziFirebaseAccount.docc/SpeziFirebaseAccount.md @@ -14,31 +14,47 @@ Firebase Auth support for SpeziAccount. ## Overview -This Module adds support for Firebase Auth for SpeziAccount by implementing a respective - [AccountService](https://swiftpackageindex.com/stanfordspezi/speziaccount/documentation/speziaccount/accountservice). +This Module adds support for Firebase Auth for SpeziAccount by implementing an + [`AccountService`](https://swiftpackageindex.com/stanfordspezi/speziaccount/documentation/speziaccount/accountservice). + +Configure the account service by supplying it to the + [`AccountConfiguration`](https://swiftpackageindex.com/stanfordspezi/speziaccount/documentation/speziaccount/accountconfiguration). + +> Note: For more information refer to the +[Account Configuration](https://swiftpackageindex.com/stanfordspezi/speziaccount/documentation/speziaccount/initial-setup#Account-Configuration) article. + +```swift +import SpeziAccount +import SpeziFirebaseAccount -The `FirebaseAccountConfiguration` can, e.g., be used to to connect to the Firebase Auth emulator: -``` class ExampleAppDelegate: SpeziAppDelegate { override var configuration: Configuration { Configuration { - FirebaseAccountConfiguration(emulatorSettings: (host: "localhost", port: 9099)) - // ... + AccountConfiguration( + service: FirebaseAccountService() + configuration: [/* ... */] + ) } } } ``` +> Note: Use the ``FirebaseAccountService/init(providers:emulatorSettings:passwordValidation:)`` to customize the enabled + ``FirebaseAuthProviders`` or supplying Firebase Auth emulator settings. + ## Topics -### Firebase Account +### Configuration + +- ``FirebaseAccountService`` +- ``FirebaseAuthProviders`` + +### Account Details -- ``FirebaseAccountConfiguration`` -- ``FirebaseAuthAuthenticationMethods`` +- ``SpeziAccount/AccountDetails/creationDate`` +- ``SpeziAccount/AccountDetails/lastSignInDate`` -### Account Keys +### Errors -- ``FirebaseEmailVerifiedKey`` -- ``SpeziAccount/AccountValues/isEmailVerified`` -- ``SpeziAccount/AccountKeys/isEmailVerified`` +- ``FirebaseAccountError`` diff --git a/Sources/SpeziFirebaseAccount/Views/ReauthenticationAlertModifier.swift b/Sources/SpeziFirebaseAccount/Views/FirebaseSecurityAlert.swift similarity index 88% rename from Sources/SpeziFirebaseAccount/Views/ReauthenticationAlertModifier.swift rename to Sources/SpeziFirebaseAccount/Views/FirebaseSecurityAlert.swift index e0dcf1d..b37afa9 100644 --- a/Sources/SpeziFirebaseAccount/Views/ReauthenticationAlertModifier.swift +++ b/Sources/SpeziFirebaseAccount/Views/FirebaseSecurityAlert.swift @@ -12,7 +12,11 @@ import SpeziViews import SwiftUI -struct ReauthenticationAlertModifier: ViewModifier { +/// An alert to authorize security-sensitive operations. +/// +/// The alert will request the user's password to authorize security-sensitive operations like account deletion or change of +/// sensitive account details. +public struct FirebaseSecurityAlert: ViewModifier { @Environment(FirebaseAccountModel.self) private var firebaseModel: FirebaseAccountModel @@ -38,7 +42,7 @@ struct ReauthenticationAlertModifier: ViewModifier { nonisolated init() {} - func body(content: Content) -> some View { + public func body(content: Content) -> some View { content .onAppear { isActive = true @@ -86,7 +90,7 @@ struct ReauthenticationAlertModifier: ViewModifier { let model = FirebaseAccountModel() return Text(verbatim: "") - .modifier(ReauthenticationAlertModifier()) + .modifier(FirebaseSecurityAlert()) .environment(model) .task { let password = await model.reauthenticateUser(userId: "lelandstandford@stanford.edu") diff --git a/Sources/SpeziFirebaseAccountStorage/FirestoreAccountStorage.swift b/Sources/SpeziFirebaseAccountStorage/FirestoreAccountStorage.swift index 2c357dc..5a2d199 100644 --- a/Sources/SpeziFirebaseAccountStorage/FirestoreAccountStorage.swift +++ b/Sources/SpeziFirebaseAccountStorage/FirestoreAccountStorage.swift @@ -18,74 +18,81 @@ import SpeziFirestore /// The `FirestoreAccountStorage` can be used to store additional account details, that are not supported out of the box by your account services, /// inside Firestore in a custom user collection. /// -/// - Note: The `FirestoreAccountStorage` relies on the primary [AccountId](https://swiftpackageindex.com/stanfordspezi/speziaccount/documentation/speziaccount/accountidkey) -/// as the document identifier. Fore Firebase-based account service, this is the primary, firebase user identifier. Make sure to configure your firestore security rules respectively. +/// - Important: The `FirestoreAccountStorage` uses the [`accountId`](https://swiftpackageindex.com/stanfordspezi/speziaccount/documentation/speziaccount/accountdetails/accountid) +/// of the user for the document identifier. When using the `FirebaseAccountService`, this is the primary, firebase user identifier. Make sure to configure your firestore security rules respectively. /// -/// Once you have [AccountConfiguration](https://swiftpackageindex.com/stanfordspezi/speziaccount/documentation/speziaccount/initial-setup#Account-Configuration) -/// and the [FirebaseAccountConfiguration](https://swiftpackageindex.com/stanfordspezi/spezifirebase/documentation/spezifirebaseaccount/firebaseaccountconfiguration) -/// set up, you can adopt the [AccountStorageConstraint](https://swiftpackageindex.com/stanfordspezi/speziaccount/documentation/speziaccount/accountstorageconstraint) -/// protocol to provide a custom storage for SpeziAccount. +/// To configure Firestore as your external storage provider, just supply the ``FirestoreAccountStorage`` as an argument to the `AccountConfiguration`. /// -/// - Important: In order to use the `FirestoreAccountStorage`, you must have [Firestore](https://swiftpackageindex.com/stanfordspezi/spezifirebase/main/documentation/spezifirestore/firestore) -/// configured in your app. Refer to the documentation page for more information. +/// - Note: For more information refer to the +/// [Account Configuration](https://swiftpackageindex.com/stanfordspezi/speziaccount/documentation/speziaccount/initial-setup#Account-Configuration) article. +/// +/// The example below illustrates a configuration example, setting up the `FirebaseAccountService` in combination with the `FirestoreAccountStorage` provider. /// /// ```swift -/// import FirebaseFirestore /// import Spezi /// import SpeziAccount +/// import SpeziFirebase +/// import SpeziFirebaseAccount /// import SpeziFirebaseAccountStorage /// -/// -/// actor ExampleStandard: Standard, AccountStorageConstraint { -/// // Define the collection where you want to store your additional user data, ... -/// static var collection: CollectionReference { -/// Firestore.firestore().collection("users") -/// } -/// -/// // ... define and initialize the `FirestoreAccountStorage` dependency ... -/// @Dependency private var accountStorage = FirestoreAccountStorage(storedIn: Self.collection) -/// -/// -/// // ... and forward all implementations of `AccountStorageConstraint` to the `FirestoreAccountStorage`. -/// -/// public func create(_ identifier: AdditionalRecordId, _ details: SignupDetails) async throws { -/// try await accountStorage.create(identifier, details) -/// } -/// -/// public func load(_ identifier: AdditionalRecordId, _ keys: [any AccountKey.Type]) async throws -> PartialAccountDetails { -/// try await accountStorage.load(identifier, keys) +/// class ExampleAppDelegate: SpeziAppDelegate { +/// override var configuration: Configuration { +/// Configuration { +/// AccountConfiguration( +/// service: FirebaseAccountService(), +/// storageProvider: FirestoreAccountStorage(storeIn: Firestore.firestore().collection("users")) +/// configuration: [/* ... */] +/// ) /// } +/// } +/// ``` /// -/// public func modify(_ identifier: AdditionalRecordId, _ modifications: AccountModifications) async throws { -/// try await accountStorage.modify(identifier, modifications) -/// } +/// - Important: In order to use the `FirestoreAccountStorage`, you must have [`Firestore`](https://swiftpackageindex.com/stanfordspezi/spezifirebase/main/documentation/spezifirestore/firestore) +/// configured in your app. Refer to the documentation page for more information. /// -/// public func clear(_ identifier: AdditionalRecordId) async { -/// await accountStorage.clear(identifier) -/// } +/// ## Topics /// -/// public func delete(_ identifier: AdditionalRecordId) async throws { -/// try await accountStorage.delete(identifier) -/// } -/// } -/// ``` +/// ### Configuration +/// - ``init(storeIn:mapping:)`` public actor FirestoreAccountStorage: AccountStorageProvider { - // TODO: completely restructure docs! @Application(\.logger) private var logger - @Dependency private var firestore = SpeziFirestore.Firestore() // ensure firestore is configured + @Dependency(Firestore.self) + private var firestore @Dependency(ExternalAccountStorage.self) private var externalStorage - @Dependency private var localCache = AccountDetailsCache() + @Dependency(AccountDetailsCache.self) + private var localCache private let collection: @Sendable () -> CollectionReference + private let identifierMapping: [String: any AccountKey.Type]? // swiftlint:disable:this discouraged_optional_collection private var listenerRegistrations: [String: ListenerRegistration] = [:] private var registeredKeys: [String: [ObjectIdentifier: any AccountKey.Type]] = [:] - public init(storeIn collection: @Sendable @autoclosure @escaping () -> CollectionReference) { - self.collection = collection + /// Configure the Firestore Account Storage provider. + /// + /// - Note: The `collection` parameter is passed as an auto-closure. At the time the closure is called the + /// [`Firestore`](https://swiftpackageindex.com/stanfordspezi/spezifirebase/main/documentation/spezifirestore/firestore) + /// Module has been configured and it is safe to access `Firestore.firestore()` to derive the collection reference. + /// + /// ### Custom Identifier Mapping + /// + /// By default, the [`identifier`](https://swiftpackageindex.com/stanfordspezi/speziaccount/1.2.4/documentation/speziaccount/accountkey/identifier) + /// provided by the account key is used as a field name. + /// + /// - Parameters: + /// - collection: The Firestore collection that all users records are stored in. The `accountId` is used for the name of + /// each user document. The field names are derived from the stable `AccountKey/identifier`. + /// - identifierMapping: An optional mapping of string identifiers to their `AccountKey`. Use that to customize the scheme used to store account keys + /// or provide backwards compatibility with details stored with SpeziAccount 1.0. + public init( + storeIn collection: @Sendable @autoclosure @escaping () -> CollectionReference, + mapping identifierMapping: [String: any AccountKey.Type]? = nil // swiftlint:disable:this discouraged_optional_collection + ) { + self.collection = collection // make it a auto-closure. Firestore.firstore() is only configured later on + self.identifierMapping = identifierMapping } @@ -148,6 +155,9 @@ public actor FirestoreAccountStorage: AccountStorageProvider { let decoder = Firestore.Decoder() decoder.userInfo[.accountDetailsKeys] = keys + if let identifierMapping { + decoder.userInfo[.accountKeyIdentifierMapping] = identifierMapping + } do { return try snapshot.data(as: AccountDetails.self, decoder: decoder) @@ -157,6 +167,7 @@ public actor FirestoreAccountStorage: AccountStorageProvider { } } + @_documentation(visibility: internal) public func load(_ accountId: String, _ keys: [any AccountKey.Type]) async throws -> AccountDetails? { let localCache = localCache let cached = await localCache.loadEntry(for: accountId, keys) @@ -168,12 +179,17 @@ public actor FirestoreAccountStorage: AccountStorageProvider { return cached } + @_documentation(visibility: internal) public func store(_ accountId: String, _ modifications: SpeziAccount.AccountModifications) async throws { let document = userDocument(for: accountId) if !modifications.modifiedDetails.isEmpty { do { - try await document.setData(from: modifications.modifiedDetails, merge: true) + let encoder = Firestore.Encoder() + if let identifierMapping { + encoder.userInfo[.accountKeyIdentifierMapping] = identifierMapping + } + try await document.setData(from: modifications.modifiedDetails, merge: true, encoder: encoder) } catch { throw FirestoreError(error) } @@ -208,6 +224,7 @@ public actor FirestoreAccountStorage: AccountStorageProvider { await localCache.communicateModifications(for: accountId, modifications) } + @_documentation(visibility: internal) public func disassociate(_ accountId: String) async { guard let registration = listenerRegistrations.removeValue(forKey: accountId) else { return @@ -219,6 +236,7 @@ public actor FirestoreAccountStorage: AccountStorageProvider { await localCache.clearEntry(for: accountId) } + @_documentation(visibility: internal) public func delete(_ accountId: String) async throws { await disassociate(accountId) diff --git a/Sources/SpeziFirebaseAccountStorage/SpeziFirebaseAccountStorage.docc/SpeziFirebaseAccountStorage.md b/Sources/SpeziFirebaseAccountStorage/SpeziFirebaseAccountStorage.docc/SpeziFirebaseAccountStorage.md index 1b27d50..0f30480 100644 --- a/Sources/SpeziFirebaseAccountStorage/SpeziFirebaseAccountStorage.docc/SpeziFirebaseAccountStorage.md +++ b/Sources/SpeziFirebaseAccountStorage/SpeziFirebaseAccountStorage.docc/SpeziFirebaseAccountStorage.md @@ -18,59 +18,40 @@ Certain account services, like the account services provided by Firebase, can on The ``FirestoreAccountStorage`` can be used to store additional account details, that are not supported out of the box by your account services, inside Firestore in a custom user collection. -For more detailed information, refer to the documentation of ``FirestoreAccountStorage``. +> Important: The `FirestoreAccountStorage` uses the [`accountId`](https://swiftpackageindex.com/stanfordspezi/speziaccount/documentation/speziaccount/accountdetails/accountid) + of the user for the document identifier. When using the `FirebaseAccountService`, this is the primary, firebase user identifier. Make sure to configure your firestore security rules respectively. -### Example +To configure Firestore as your external storage provider, just supply the ``FirestoreAccountStorage`` as an argument to the `AccountConfiguration`. -Once you have [AccountConfiguration](https://swiftpackageindex.com/stanfordspezi/speziaccount/documentation/speziaccount/initial-setup#Account-Configuration) -and the [FirebaseAccountConfiguration](https://swiftpackageindex.com/stanfordspezi/spezifirebase/documentation/spezifirebaseaccount/firebaseaccountconfiguration) -set up, you can adopt the [AccountStorageConstraint](https://swiftpackageindex.com/stanfordspezi/speziaccount/documentation/speziaccount/accountstorageconstraint) -protocol to provide a custom storage for SpeziAccount. +> Note: For more information refer to the + [Account Configuration](https://swiftpackageindex.com/stanfordspezi/speziaccount/documentation/speziaccount/initial-setup#Account-Configuration) article. +The example below illustrates a configuration example, setting up the `FirebaseAccountService` in combination with the `FirestoreAccountStorage` provider. ```swift -import FirebaseFirestore import Spezi import SpeziAccount +import SpeziFirebase +import SpeziFirebaseAccount import SpeziFirebaseAccountStorage - -actor ExampleStandard: Standard, AccountStorageConstraint { - // Define the collection where you want to store your additional user data, ... - static var collection: CollectionReference { - Firestore.firestore().collection("users") - } - - // ... define and initialize the `FirestoreAccountStorage` dependency ... - @Dependency private var accountStorage = FirestoreAccountStorage(storedIn: Self.collection) - - - // ... and forward all implementations of `AccountStorageConstraint` to the `FirestoreAccountStorage`. - - public func create(_ identifier: AdditionalRecordId, _ details: SignupDetails) async throws { - try await accountStorage.create(identifier, details) - } - - public func load(_ identifier: AdditionalRecordId, _ keys: [any AccountKey.Type]) async throws -> PartialAccountDetails { - try await accountStorage.load(identifier, keys) - } - - public func modify(_ identifier: AdditionalRecordId, _ modifications: AccountModifications) async throws { - try await accountStorage.modify(identifier, modifications) - } - - public func clear(_ identifier: AdditionalRecordId) async { - await accountStorage.clear(identifier) - } - - public func delete(_ identifier: AdditionalRecordId) async throws { - try await accountStorage.delete(identifier) +class ExampleAppDelegate: SpeziAppDelegate { +override var configuration: Configuration { + Configuration { + AccountConfiguration( + service: FirebaseAccountService(), + storageProvider: FirestoreAccountStorage(storeIn: Firestore.firestore().collection("users")) + configuration: [/* ... */] + ) } } ``` +> Important: In order to use the `FirestoreAccountStorage`, you must have [`Firestore`](https://swiftpackageindex.com/stanfordspezi/spezifirebase/main/documentation/spezifirestore/firestore) + configured in your app. Refer to the documentation page for more information. + ## Topics -### Storage +### Configuration - ``FirestoreAccountStorage`` diff --git a/Sources/SpeziFirebaseConfiguration/ConfigureFirebaseApp.swift b/Sources/SpeziFirebaseConfiguration/ConfigureFirebaseApp.swift index e562c2b..6cbc2e3 100644 --- a/Sources/SpeziFirebaseConfiguration/ConfigureFirebaseApp.swift +++ b/Sources/SpeziFirebaseConfiguration/ConfigureFirebaseApp.swift @@ -10,16 +10,20 @@ import FirebaseCore import Spezi -/// Module to configure the Firebase set of dependencies. +/// Configure the Firebase application. +/// +/// The `FirebaseApp.configure()` method will be called upon configuration of the `Module`. /// -/// The ``configure()`` method calls `FirebaseApp.configure()`. /// Use the `@Dependency` property wrapper to define a dependency on this module and ensure that `FirebaseApp.configure()` is called before any -/// other Firebase-related modules: +/// other Firebase-related modules and to ensure it is called exactly once. +/// /// ```swift -/// public final class YourFirebaseModule: Module { -/// @Dependency private var configureFirebaseApp: ConfigureFirebaseApp +/// import Spezi +/// import SpeziFirebaseConfiguration /// -/// // ... +/// public final class MyFirebaseModule: Module { +/// @Dependency(ConfigureFirebaseApp.self) +/// private var configureFirebaseApp /// } /// ``` public final class ConfigureFirebaseApp: Module, DefaultInitializable { diff --git a/Sources/SpeziFirebaseConfiguration/FirebaseConfiguration.docc/FirebaseConfiguration.md b/Sources/SpeziFirebaseConfiguration/FirebaseConfiguration.docc/FirebaseConfiguration.md index 408c243..70a6fb1 100644 --- a/Sources/SpeziFirebaseConfiguration/FirebaseConfiguration.docc/FirebaseConfiguration.md +++ b/Sources/SpeziFirebaseConfiguration/FirebaseConfiguration.docc/FirebaseConfiguration.md @@ -10,19 +10,21 @@ SPDX-License-Identifier: MIT --> -Module to configure the Firebase set of dependencies. +Configure the Firebase application. ## Overview -The ``ConfigureFirebaseApp/configure()`` method calls `FirebaseApp.configure()`. +The `FirebaseApp.configure()` method will be called upon configuration of the ``ConfigureFirebaseApp`` `Module`. Use the `@Dependency` property wrapper to define a dependency on this module and ensure that `FirebaseApp.configure()` is called before any -other Firebase-related modules: +other Firebase-related modules and to ensure it is called exactly once. ```swift -public final class YourFirebaseModule: Module { - @Dependency private var configureFirebaseApp: ConfigureFirebaseApp +import Spezi +import SpeziFirebaseConfiguration - // ... +public final class MyFirebaseModule: Module { + @Dependency(ConfigureFirebaseApp.self) + private var configureFirebaseApp } ``` diff --git a/Sources/SpeziFirebaseStorage/FirebaseStorageConfiguration.swift b/Sources/SpeziFirebaseStorage/FirebaseStorageConfiguration.swift index 7f82905..70e4db6 100644 --- a/Sources/SpeziFirebaseStorage/FirebaseStorageConfiguration.swift +++ b/Sources/SpeziFirebaseStorage/FirebaseStorageConfiguration.swift @@ -13,7 +13,7 @@ import SpeziFirebaseConfiguration /// Configures the Firebase Storage that can then be used within any application via `Storage.storage()`. /// -/// The ``FirebaseStorageConfiguration`` can be used to connect to the Firebase Storage emulator: +/// The `FirebaseStorageConfiguration` can be used to connect to the Firebase Storage emulator: /// ``` /// class ExampleAppDelegate: SpeziAppDelegate { /// override var configuration: Configuration { @@ -24,25 +24,35 @@ import SpeziFirebaseConfiguration /// } /// } /// ``` +/// +/// ## Topics +/// +/// ### Configuration +/// - ``init()`` +/// - ``init(emulatorSettings:)`` public final class FirebaseStorageConfiguration: Module, DefaultInitializable { - @Dependency private var configureFirebaseApp: ConfigureFirebaseApp - + @Dependency(ConfigureFirebaseApp.self) + private var configureFirebaseApp + private let emulatorSettings: (host: String, port: Int)? - + + /// Default configuration. public required convenience init() { self.init(emulatorSettings: nil) } + /// Configure with emulator settings. /// - Parameters: - /// - emulatorSettings: The emulator settings. The default value is `nil`, connecting the FirebaseStorage module to the FirebaseStorage cloud instance. + /// - emulatorSettings: The emulator settings. When using `nil`, FirebaseStorage module will connect to the FirebaseStorage cloud instance. public init( - emulatorSettings: (host: String, port: Int)? = nil + emulatorSettings: (host: String, port: Int)? ) { self.emulatorSettings = emulatorSettings } - + + @_documentation(visibility: internal) public func configure() { if let emulatorSettings { Storage.storage().useEmulator(withHost: emulatorSettings.host, port: emulatorSettings.port) diff --git a/Sources/SpeziFirebaseStorage/SpeziFirebaseStorage.docc/SpeziFirebaseStorage.md b/Sources/SpeziFirebaseStorage/SpeziFirebaseStorage.docc/SpeziFirebaseStorage.md index 169c883..1f65cc8 100644 --- a/Sources/SpeziFirebaseStorage/SpeziFirebaseStorage.docc/SpeziFirebaseStorage.md +++ b/Sources/SpeziFirebaseStorage/SpeziFirebaseStorage.docc/SpeziFirebaseStorage.md @@ -30,6 +30,6 @@ class ExampleAppDelegate: SpeziAppDelegate { ## Topics -### Firebase Storage +### Configuration - ``FirebaseStorageConfiguration`` diff --git a/Sources/SpeziFirestore/DocumentReference+AsyncAwait.swift b/Sources/SpeziFirestore/DocumentReference+AsyncAwait.swift index 026b876..801ef15 100644 --- a/Sources/SpeziFirestore/DocumentReference+AsyncAwait.swift +++ b/Sources/SpeziFirestore/DocumentReference+AsyncAwait.swift @@ -6,13 +6,14 @@ // SPDX-License-Identifier: MIT // - @_exported import FirebaseFirestore @_exported import FirebaseFirestoreSwift import Foundation extension DocumentReference { + /// Overwrite the data of a document with an encodable value. + /// /// Encodes an instance of `Encodable` and overwrites the encoded data /// to the document referred by this `DocumentReference`. If no document exists, /// it is created. If a document already exists, it is overwritten. @@ -37,6 +38,8 @@ extension DocumentReference { } } + /// Write the data of a document with an encodable value. + /// /// Encodes an instance of `Encodable` and overwrites the encoded data /// to the document referred by this `DocumentReference`. If no document exists, /// it is created. If a document already exists, it is overwritten. If you pass @@ -65,6 +68,8 @@ extension DocumentReference { } } + /// Write the data of a document by merging a set of fields. + /// /// Encodes an instance of `Encodable` and writes the encoded data to the document referred /// by this `DocumentReference` by only replacing the fields specified under `mergeFields`. /// Any field that is not specified in mergeFields is ignored and remains untouched. If the diff --git a/Sources/SpeziFirestore/FirebaseEncorderAndDecoder+Sendable.swift b/Sources/SpeziFirestore/FirebaseEncorderAndDecoder+Sendable.swift deleted file mode 100644 index 1e0f5da..0000000 --- a/Sources/SpeziFirestore/FirebaseEncorderAndDecoder+Sendable.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import FirebaseFirestoreSwift - - -// TODO: lol, remove that! -extension FirebaseFirestore.Firestore.Encoder: @unchecked Sendable {} - - -extension FirebaseFirestore.Firestore.Decoder: @unchecked Sendable {} diff --git a/Sources/SpeziFirestore/Firestore.swift b/Sources/SpeziFirestore/Firestore.swift index 427196e..9ab2cdf 100644 --- a/Sources/SpeziFirestore/Firestore.swift +++ b/Sources/SpeziFirestore/Firestore.swift @@ -14,10 +14,13 @@ import SpeziFirebaseConfiguration import SwiftUI -/// The ``Firestore`` module allows for easy configuration of Firebase Firestore. +/// Easy configuration of Firebase Firestore. /// -/// You can configure the ``Firestore`` module in the `SpeziAppDelegate`, e.g. the configure it using the Firebase emulator. +/// You can configure the `Firestore` module in the `SpeziAppDelegate`, e.g. the configure it using the Firebase emulator. /// ```swift +/// import Spezi +/// import SpeziFirestore +/// /// class FirestoreExampleDelegate: SpeziAppDelegate { /// override var configuration: Configuration { /// Configuration { diff --git a/Sources/SpeziFirestore/FirestoreErrorCode.swift b/Sources/SpeziFirestore/FirestoreError.swift similarity index 100% rename from Sources/SpeziFirestore/FirestoreErrorCode.swift rename to Sources/SpeziFirestore/FirestoreError.swift diff --git a/Sources/SpeziFirestore/FirestoreSettings+Emulator.swift b/Sources/SpeziFirestore/FirestoreSettings+Emulator.swift index 314906d..3fc54d4 100644 --- a/Sources/SpeziFirestore/FirestoreSettings+Emulator.swift +++ b/Sources/SpeziFirestore/FirestoreSettings+Emulator.swift @@ -9,9 +9,11 @@ import FirebaseFirestore -// TODO: remove that? @unchecked Sendable? extension FirestoreSettings { - /// The emulator settings define the default settings when using the Firebase emulator suite as described at [Connect your app to the Cloud Firestore Emulator](https://firebase.google.com/docs/emulator-suite/connect_firestore). + /// Firestore settings that specify emulator support. + /// + /// The emulator settings define the default settings when using the Firebase emulator suite as described at + /// [Connect your app to the Cloud Firestore Emulator](https://firebase.google.com/docs/emulator-suite/connect_firestore). public static var emulator: FirestoreSettings { let settings = FirestoreSettings() settings.host = "localhost:8080" diff --git a/Sources/SpeziFirestore/SpeziFirestore.docc/SpeziFirestore.md b/Sources/SpeziFirestore/SpeziFirestore.docc/SpeziFirestore.md index 358b388..7b27ad7 100644 --- a/Sources/SpeziFirestore/SpeziFirestore.docc/SpeziFirestore.md +++ b/Sources/SpeziFirestore/SpeziFirestore.docc/SpeziFirestore.md @@ -16,8 +16,10 @@ Easily configure and interact with Firebase Firestore. The ``Firestore`` module allows for easy configuration of Firebase Firestore. -You can configure the ``Firestore`` module in the `SpeziAppDelegate`, e.g. the configure it using the Firebase emulator. +You can configure the `Firestore` module in the `SpeziAppDelegate`, e.g. the configure it using the Firebase emulator. ```swift +import SpeziFirestore + class FirestoreExampleDelegate: SpeziAppDelegate { override var configuration: Configuration { Configuration { @@ -30,7 +32,17 @@ class FirestoreExampleDelegate: SpeziAppDelegate { ## Topics -### Firestore +### Configuration - ``Firestore`` +- ``FirebaseFirestoreInternal/FirestoreSettings/emulator`` + +### Document Reference + +- ``FirebaseFirestoreInternal/DocumentReference/setData(from:encoder:)`` +- ``FirebaseFirestoreInternal/DocumentReference/setData(from:merge:encoder:)`` +- ``FirebaseFirestoreInternal/DocumentReference/setData(from:mergeFields:encoder:)`` + +### Errors + - ``FirestoreError`` diff --git a/Tests/UITests/TestApp/FirebaseStorageTests/StorageMetadata+Sendable.swift b/Tests/UITests/TestApp/FirebaseStorageTests/StorageMetadata+Sendable.swift deleted file mode 100644 index eaac611..0000000 --- a/Tests/UITests/TestApp/FirebaseStorageTests/StorageMetadata+Sendable.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import FirebaseStorage - - -// TODO: remove that? -extension StorageMetadata: @unchecked Sendable {} diff --git a/Tests/UITests/TestAppUITests/FirebaseClient.swift b/Tests/UITests/TestAppUITests/FirebaseClient.swift index 6c6ebe8..019f415 100644 --- a/Tests/UITests/TestAppUITests/FirebaseClient.swift +++ b/Tests/UITests/TestAppUITests/FirebaseClient.swift @@ -51,7 +51,7 @@ struct FirestoreAccount: Decodable, Equatable { enum FirebaseClient { - private static let projectId = "nams-e43ed" // TODO: restore "spezifirebaseuitests" + private static let projectId = "spezifirebaseuitests" // curl -H "Authorization: Bearer owner" -X DELETE http://localhost:9099/emulator/v1/projects/spezifirebaseuitests/accounts static func deleteAllAccounts() async throws { From 37fd7ade542a904086d8560d5620890482289c51 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Fri, 9 Aug 2024 12:38:40 +0200 Subject: [PATCH 12/26] Fix some issues --- .../TestApp/Shared/TestAppDelegate.swift | 5 +++-- .../TestAppUITests/FirebaseAccountTests.swift | 2 +- .../TestAppUITests/FirebaseStorageTests.swift | 17 ++++++++++------- .../FirestoreDataStorageTests.swift | 13 ++++++++----- Tests/UITests/UITests.xcodeproj/project.pbxproj | 4 ---- 5 files changed, 22 insertions(+), 19 deletions(-) diff --git a/Tests/UITests/TestApp/Shared/TestAppDelegate.swift b/Tests/UITests/TestApp/Shared/TestAppDelegate.swift index 3017254..0f64fc7 100644 --- a/Tests/UITests/TestApp/Shared/TestAppDelegate.swift +++ b/Tests/UITests/TestApp/Shared/TestAppDelegate.swift @@ -9,7 +9,8 @@ import FirebaseFirestore import Spezi import SpeziAccount -@_spi(Internal) import SpeziFirebaseAccount +@_spi(Internal) +import SpeziFirebaseAccount import SpeziFirebaseAccountStorage import SpeziFirebaseStorage import SpeziFirestore @@ -31,7 +32,7 @@ class TestAppDelegate: SpeziAppDelegate { ] let service = FirebaseAccountService( - providers: [.emailAndPassword, .signInWithApple, .anonymousButton], // TODO: add anonymous button tests + providers: [.emailAndPassword, .signInWithApple, .anonymousButton], emulatorSettings: (host: "localhost", port: 9099) ) diff --git a/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift b/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift index c551142..5a2f423 100644 --- a/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift +++ b/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift @@ -14,7 +14,7 @@ import XCTestExtensions /// /// Refer to https://firebase.google.com/docs/emulator-suite/connect_auth about more information about the /// Firebase Local Emulator Suite. -final class FirebaseAccountTests: XCTestCase { // swiftlint:disable:this type_body_length2 +final class FirebaseAccountTests: XCTestCase { // swiftlint:disable:this type_body_length override func setUp() { continueAfterFailure = false } diff --git a/Tests/UITests/TestAppUITests/FirebaseStorageTests.swift b/Tests/UITests/TestAppUITests/FirebaseStorageTests.swift index f68a0f7..e841580 100644 --- a/Tests/UITests/TestAppUITests/FirebaseStorageTests.swift +++ b/Tests/UITests/TestAppUITests/FirebaseStorageTests.swift @@ -46,13 +46,16 @@ final class FirebaseStorageTests: XCTestCase { documents = try await Self.getAllFiles() XCTAssertEqual(documents.count, 1) } - +} + + +extension FirebaseStorageTests { private static func getAllFiles() async throws -> [FirebaseStorageItem] { let documentsURL = try XCTUnwrap( URL(string: "http://localhost:9199/v0/b/STORAGE_BUCKET/o") ) let (data, response) = try await URLSession.shared.data(from: documentsURL) - + guard let urlResponse = response as? HTTPURLResponse, 200...299 ~= urlResponse.statusCode else { print( @@ -65,18 +68,18 @@ final class FirebaseStorageTests: XCTestCase { ) throw URLError(.fileDoesNotExist) } - + struct ResponseWrapper: Decodable { let items: [FirebaseStorageItem] } - + do { return try JSONDecoder().decode(ResponseWrapper.self, from: data).items } catch { return [] } } - + private static func deleteAllFiles() async throws { for storageItem in try await getAllFiles() { let url = try XCTUnwrap( @@ -84,9 +87,9 @@ final class FirebaseStorageTests: XCTestCase { ) var request = URLRequest(url: url) request.httpMethod = "DELETE" - + let (_, response) = try await URLSession.shared.data(for: request) - + guard let urlResponse = response as? HTTPURLResponse, 200...299 ~= urlResponse.statusCode else { print( diff --git a/Tests/UITests/TestAppUITests/FirestoreDataStorageTests.swift b/Tests/UITests/TestAppUITests/FirestoreDataStorageTests.swift index 4046556..a81d474 100644 --- a/Tests/UITests/TestAppUITests/FirestoreDataStorageTests.swift +++ b/Tests/UITests/TestAppUITests/FirestoreDataStorageTests.swift @@ -202,7 +202,10 @@ final class FirestoreDataStorageTests: XCTestCase { try app.textFields[contentFieldIdentifier].delete(count: 100) try app.textFields[contentFieldIdentifier].enter(value: content) } - +} + + +extension FirestoreDataStorageTests { private static func deleteAllDocuments() async throws { let emulatorDocumentsURL = try XCTUnwrap( URL(string: "http://localhost:8080/emulator/v1/projects/spezifirebaseuitests/databases/(default)/documents") @@ -211,7 +214,7 @@ final class FirestoreDataStorageTests: XCTestCase { request.httpMethod = "DELETE" let (_, response) = try await URLSession.shared.data(for: request) - + guard let urlResponse = response as? HTTPURLResponse, 200...299 ~= urlResponse.statusCode else { print( @@ -231,7 +234,7 @@ final class FirestoreDataStorageTests: XCTestCase { URL(string: "http://localhost:8080/v1/projects/spezifirebaseuitests/databases/(default)/documents/") ) let (data, response) = try await URLSession.shared.data(from: documentsURL) - + guard let urlResponse = response as? HTTPURLResponse, 200...299 ~= urlResponse.statusCode else { print( @@ -244,11 +247,11 @@ final class FirestoreDataStorageTests: XCTestCase { ) throw URLError(.fileDoesNotExist) } - + struct ResponseWrapper: Decodable { let documents: [FirestoreElement] } - + do { return try JSONDecoder().decode(ResponseWrapper.self, from: data).documents } catch { diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index 7995a69..4dea23a 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -21,7 +21,6 @@ 2FB07597299DF96E00C0B37F /* SpeziFirestore in Frameworks */ = {isa = PBXBuildFile; productRef = 2FB07596299DF96E00C0B37F /* SpeziFirestore */; }; 2FE62C3D2966074F00FCBE7F /* FirestoreDataStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FE62C3C2966074F00FCBE7F /* FirestoreDataStorageTests.swift */; }; 97359F642ADB27500080CB11 /* FirebaseStorageTestsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97359F632ADB27500080CB11 /* FirebaseStorageTestsView.swift */; }; - 97359F662ADB286D0080CB11 /* StorageMetadata+Sendable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97359F652ADB286D0080CB11 /* StorageMetadata+Sendable.swift */; }; 978DFE922ADB1E1600E2B9B5 /* SpeziFirebaseStorage in Frameworks */ = {isa = PBXBuildFile; productRef = 978DFE912ADB1E1600E2B9B5 /* SpeziFirebaseStorage */; }; 978E198E2ADB40A300732324 /* FirebaseStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 978E198D2ADB40A300732324 /* FirebaseStorageTests.swift */; }; A95D60D02AA35E2200EB5968 /* FirebaseClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95D60CF2AA35E2200EB5968 /* FirebaseClient.swift */; }; @@ -59,7 +58,6 @@ 2FC42FD7290ADD5E00B08F18 /* SpeziFirebase */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = SpeziFirebase; path = ../..; sourceTree = ""; }; 2FE62C3C2966074F00FCBE7F /* FirestoreDataStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirestoreDataStorageTests.swift; sourceTree = ""; }; 97359F632ADB27500080CB11 /* FirebaseStorageTestsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FirebaseStorageTestsView.swift; sourceTree = ""; }; - 97359F652ADB286D0080CB11 /* StorageMetadata+Sendable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StorageMetadata+Sendable.swift"; sourceTree = ""; }; 978E198D2ADB40A300732324 /* FirebaseStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirebaseStorageTests.swift; sourceTree = ""; }; A95D60CF2AA35E2200EB5968 /* FirebaseClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirebaseClient.swift; sourceTree = ""; }; A9D83F982B0BDB13000D0C78 /* FeatureFlags.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeatureFlags.swift; sourceTree = ""; }; @@ -179,7 +177,6 @@ isa = PBXGroup; children = ( 97359F632ADB27500080CB11 /* FirebaseStorageTestsView.swift */, - 97359F652ADB286D0080CB11 /* StorageMetadata+Sendable.swift */, ); path = FirebaseStorageTests; sourceTree = ""; @@ -305,7 +302,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 97359F662ADB286D0080CB11 /* StorageMetadata+Sendable.swift in Sources */, 2F148C00298BB15900031B7F /* FirebaseAccountTestsView.swift in Sources */, 2FA7382C290ADFAA007ACEB9 /* TestApp.swift in Sources */, 2FE62C3D2966074F00FCBE7F /* FirestoreDataStorageTests.swift in Sources */, From 0d1d88166d90d1f93cb78e6370ef3cde4641b405 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Fri, 9 Aug 2024 15:25:28 +0200 Subject: [PATCH 13/26] Minor fixes in tests --- .../FirebaseAccountService.swift | 55 ++++---- .../TestApp/Shared/TestAppDelegate.swift | 25 ++++ .../FirebaseAccountStorageTests.swift | 5 - .../TestAppUITests/FirebaseAccountTests.swift | 124 +++++++----------- 4 files changed, 97 insertions(+), 112 deletions(-) diff --git a/Sources/SpeziFirebaseAccount/FirebaseAccountService.swift b/Sources/SpeziFirebaseAccount/FirebaseAccountService.swift index 0f34a15..d7f2e58 100644 --- a/Sources/SpeziFirebaseAccount/FirebaseAccountService.swift +++ b/Sources/SpeziFirebaseAccount/FirebaseAccountService.swift @@ -126,10 +126,10 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable @MainActor private var authStateDidChangeListenerHandle: AuthStateDidChangeListenerHandle? @MainActor private var lastNonce: String? - private var isConfiguring = false private var shouldQueue = false private var queuedUpdates: [UserUpdate] = [] private var actionSemaphore = AsyncSemaphore() + private var skipNextStateChange = false private var unsupportedKeys: AccountKeyCollection { @@ -179,13 +179,12 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable Auth.auth().useEmulator(withHost: emulatorSettings.host, port: emulatorSettings.port) } - isConfiguring = true - defer { - isConfiguring = false - } + checkForInitialUserAccount() // get notified about changes of the User reference authStateDidChangeListenerHandle = Auth.auth().addStateDidChangeListener { [weak self] auth, user in + // We could safely assume main actor isolation here, see + // https://firebase.google.com/docs/reference/swift/firebaseauth/api/reference/Classes/Auth#/c:@M@FirebaseAuth@objc(cs)FIRAuth(im)addAuthStateDidChangeListener: self?.handleStateDidChange(auth: auth, user: user) } @@ -528,32 +527,30 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable extension FirebaseAccountService { @MainActor - private func handleStateDidChange(auth: Auth, user: User?) { - if isConfiguring { - // We can safely assume main actor isolation, see - // https://firebase.google.com/docs/reference/swift/firebaseauth/api/reference/Classes/Auth#/c:@M@FirebaseAuth@objc(cs)FIRAuth(im)addAuthStateDidChangeListener: - let skip = MainActor.assumeIsolated { - // Ensure that there are details associated as soon as possible. - // Mark them as incomplete if we know there might be account details that are stored externally, - // we update the details later anyways, even if we might be wrong. - if let user { - var details = buildUser(user, isNewUser: false) - details.isIncomplete = !self.unsupportedKeys.isEmpty - - logger.debug("Supply initial user details of associated Firebase account.") - account.supplyUserDetails(details) - return !details.isIncomplete - } else { - account.removeUserDetails() - return true - } - } - - guard !skip else { - return // don't spin of the task below if we know it wouldn't change anything. - } + private func checkForInitialUserAccount() { + guard let user = Auth.auth().currentUser else { + skipNextStateChange = true + return } + // Ensure that there are details associated as soon as possible. + // Mark them as incomplete if we know there might be account details that are stored externally, + // we update the details later anyways, even if we might be wrong. + + var details = buildUser(user, isNewUser: false) + details.isIncomplete = !self.unsupportedKeys.isEmpty + + logger.debug("Supply initial user details of associated Firebase account.") + account.supplyUserDetails(details) + skipNextStateChange = !details.isIncomplete + } + + @MainActor + private func handleStateDidChange(auth: Auth, user: User?) { + if skipNextStateChange { + skipNextStateChange = false + return + } Task { do { diff --git a/Tests/UITests/TestApp/Shared/TestAppDelegate.swift b/Tests/UITests/TestApp/Shared/TestAppDelegate.swift index 0f64fc7..df4ba4d 100644 --- a/Tests/UITests/TestApp/Shared/TestAppDelegate.swift +++ b/Tests/UITests/TestApp/Shared/TestAppDelegate.swift @@ -18,6 +18,29 @@ import SwiftUI class TestAppDelegate: SpeziAppDelegate { + private class Logout: Module { + @Application(\.logger) + private var logger + + @Dependency(Account.self) + private var account + @Dependency(FirebaseAccountService.self) + private var service + + func configure() { + if account.signedIn { + Task { [logger, service] in + do { + logger.info("Performing initial logout!") + try await service.logout() + } catch { + logger.error("Failed initial logout") + } + } + } + } + } + override var configuration: Configuration { Configuration { let configuration: AccountValueConfiguration = FeatureFlags.accountStorageTests @@ -46,6 +69,8 @@ class TestAppDelegate: SpeziAppDelegate { AccountConfiguration(service: service, configuration: configuration) } + Logout() + Firestore(settings: .emulator) FirebaseStorageConfiguration(emulatorSettings: (host: "localhost", port: 9199)) } diff --git a/Tests/UITests/TestAppUITests/FirebaseAccountStorageTests.swift b/Tests/UITests/TestAppUITests/FirebaseAccountStorageTests.swift index ec09f9b..331d829 100644 --- a/Tests/UITests/TestAppUITests/FirebaseAccountStorageTests.swift +++ b/Tests/UITests/TestAppUITests/FirebaseAccountStorageTests.swift @@ -31,11 +31,6 @@ final class FirebaseAccountStorageTests: XCTestCase { XCTAssert(app.buttons["FirebaseAccount"].waitForExistence(timeout: 2.0)) app.buttons["FirebaseAccount"].tap() - if app.buttons["Logout"].waitForExistence(timeout: 2.0) && app.buttons["Logout"].isHittable { - app.buttons["Logout"].tap() - } - - try app.signup( username: "test@username1.edu", password: "TestPassword1", diff --git a/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift b/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift index 5a2f423..10ff52b 100644 --- a/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift +++ b/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift @@ -37,14 +37,10 @@ final class FirebaseAccountTests: XCTestCase { // swiftlint:disable:this type_bo var accounts = try await FirebaseClient.getAllAccounts() XCTAssert(accounts.isEmpty) - - if app.buttons["Logout"].waitForExistence(timeout: 5.0) && app.buttons["Logout"].isHittable { - app.buttons["Logout"].tap() - } try app.signup(username: "test@username1.edu", password: "TestPassword1", givenName: "Test1", familyName: "Username1") - XCTAssert(app.buttons["Logout"].waitForExistence(timeout: 10.0)) + XCTAssert(app.buttons["Logout"].waitForExistence(timeout: 2.0)) app.buttons["Logout"].tap() try app.signup(username: "test@username2.edu", password: "TestPassword2", givenName: "Test2", familyName: "Username2") @@ -60,7 +56,7 @@ final class FirebaseAccountTests: XCTestCase { // swiftlint:disable:this type_bo ] ) - XCTAssert(app.buttons["Logout"].waitForExistence(timeout: 10.0)) + XCTAssert(app.buttons["Logout"].waitForExistence(timeout: 2.0)) app.buttons["Logout"].tap() } @@ -83,23 +79,19 @@ final class FirebaseAccountTests: XCTestCase { // swiftlint:disable:this type_bo app.launchArguments = ["--firebaseAccount"] app.launch() - XCTAssert(app.buttons["FirebaseAccount"].waitForExistence(timeout: 10.0)) + XCTAssert(app.buttons["FirebaseAccount"].waitForExistence(timeout: 2.0)) app.buttons["FirebaseAccount"].tap() - - if app.buttons["Logout"].waitForExistence(timeout: 3.0) && app.buttons["Logout"].isHittable { - app.buttons["Logout"].tap() - } try app.login(username: "test@username1.edu", password: "TestPassword1") - XCTAssert(app.staticTexts["test@username1.edu"].waitForExistence(timeout: 10.0)) - - XCTAssert(app.buttons["Logout"].waitForExistence(timeout: 10.0)) + XCTAssert(app.staticTexts["test@username1.edu"].waitForExistence(timeout: 2.0)) + + XCTAssert(app.buttons["Logout"].exists) app.buttons["Logout"].tap() try app.login(username: "test@username2.edu", password: "TestPassword2") - XCTAssert(app.staticTexts["test@username2.edu"].waitForExistence(timeout: 10.0)) - - XCTAssert(app.buttons["Logout"].waitForExistence(timeout: 10.0)) + XCTAssert(app.staticTexts["test@username2.edu"].waitForExistence(timeout: 2.0)) + + XCTAssert(app.buttons["Logout"].exists) app.buttons["Logout"].tap() } @@ -117,10 +109,6 @@ final class FirebaseAccountTests: XCTestCase { // swiftlint:disable:this type_bo XCTAssert(app.buttons["FirebaseAccount"].waitForExistence(timeout: 10.0)) app.buttons["FirebaseAccount"].tap() - if app.buttons["Logout"].waitForExistence(timeout: 5.0) && app.buttons["Logout"].isHittable { - app.buttons["Logout"].tap() - } - try app.login(username: "test@username.edu", password: "TestPassword") XCTAssert(app.staticTexts["test@username.edu"].waitForExistence(timeout: 10.0)) @@ -139,9 +127,7 @@ final class FirebaseAccountTests: XCTestCase { // swiftlint:disable:this type_bo let accounts2 = try await FirebaseClient.getAllAccounts() XCTAssertEqual( accounts2.sorted(by: { $0.email < $1.email }), - [ - FirestoreAccount(email: "test@username.edu", displayName: "Test Username") - ] + [FirestoreAccount(email: "test@username.edu", displayName: "Test Username")] ) } @@ -161,10 +147,6 @@ final class FirebaseAccountTests: XCTestCase { // swiftlint:disable:this type_bo XCTAssert(app.buttons["FirebaseAccount"].waitForExistence(timeout: 10.0)) app.buttons["FirebaseAccount"].tap() - if app.buttons["Logout"].waitForExistence(timeout: 5.0) && app.buttons["Logout"].isHittable { - app.buttons["Logout"].tap() - } - try app.login(username: "test@username.edu", password: "TestPassword") XCTAssert(app.staticTexts["test@username.edu"].waitForExistence(timeout: 10.0)) @@ -212,10 +194,6 @@ final class FirebaseAccountTests: XCTestCase { // swiftlint:disable:this type_bo XCTAssert(app.buttons["FirebaseAccount"].waitForExistence(timeout: 2.0)) app.buttons["FirebaseAccount"].tap() - if app.buttons["Logout"].waitForExistence(timeout: 2.0) && app.buttons["Logout"].isHittable { - app.buttons["Logout"].tap() - } - try app.login(username: "test@username.edu", password: "TestPassword") XCTAssert(app.staticTexts["test@username.edu"].waitForExistence(timeout: 10.0)) @@ -233,8 +211,8 @@ final class FirebaseAccountTests: XCTestCase { // swiftlint:disable:this type_bo try app.textFields["enter last name"].enter(value: "Test1") app.buttons["Done"].tap() - sleep(3) - XCTAssertTrue(app.staticTexts["Username Test1"].waitForExistence(timeout: 5.0)) + XCTAssertTrue(app.navigationBars.staticTexts["Name, E-Mail Address"].waitForExistence(timeout: 4.0)) + XCTAssertTrue(app.staticTexts["Name, Username Test1"].exists) // CHANGE EMAIL ADDRESS app.buttons["E-Mail Address, test@username.edu"].tap() @@ -251,8 +229,8 @@ final class FirebaseAccountTests: XCTestCase { // swiftlint:disable:this type_bo XCTAssertTrue(app.alerts["Authentication Required"].buttons["Login"].waitForExistence(timeout: 0.5)) app.alerts["Authentication Required"].buttons["Login"].tap() - sleep(3) - XCTAssertTrue(app.staticTexts["test@username.de"].waitForExistence(timeout: 5.0)) + XCTAssertTrue(app.navigationBars.staticTexts["Name, E-Mail Address"].waitForExistence(timeout: 4.0)) + XCTAssertTrue(app.staticTexts["E-Mail Address, test@username.de"].exists) let newAccounts = try await FirebaseClient.getAllAccounts() @@ -270,25 +248,25 @@ final class FirebaseAccountTests: XCTestCase { // swiftlint:disable:this type_bo app.launchArguments = ["--firebaseAccount"] app.launch() - XCTAssert(app.buttons["FirebaseAccount"].waitForExistence(timeout: 10.0)) + XCTAssert(app.buttons["FirebaseAccount"].waitForExistence(timeout: 2.0)) app.buttons["FirebaseAccount"].tap() - if app.buttons["Logout"].waitForExistence(timeout: 5.0) && app.buttons["Logout"].isHittable { - app.buttons["Logout"].tap() - } - try app.login(username: "test@username.edu", password: "TestPassword") - XCTAssert(app.staticTexts["test@username.edu"].waitForExistence(timeout: 10.0)) + XCTAssert(app.staticTexts["test@username.edu"].waitForExistence(timeout: 2.0)) + XCTAssertTrue(app.buttons["Account Overview"].exists) app.buttons["Account Overview"].tap() - XCTAssertTrue(app.staticTexts["test@username.edu"].waitForExistence(timeout: 5.0)) + XCTAssertTrue(app.staticTexts["test@username.edu"].waitForExistence(timeout: 2.0)) + XCTAssertTrue(app.buttons["Sign-In & Security"].exists) app.buttons["Sign-In & Security"].tap() - XCTAssertTrue(app.navigationBars.staticTexts["Sign-In & Security"].waitForExistence(timeout: 10.0)) + XCTAssertTrue(app.navigationBars.staticTexts["Sign-In & Security"].waitForExistence(timeout: 2.0)) + XCTAssertTrue(app.buttons["Change Password"].exists) app.buttons["Change Password"].tap() - XCTAssertTrue(app.navigationBars.staticTexts["Change Password"].waitForExistence(timeout: 10.0)) - sleep(2) + + + XCTAssertTrue(app.navigationBars.staticTexts["Change Password"].waitForExistence(timeout: 2.0)) try app.secureTextFields["enter password"].enter(value: "1234567890") app.dismissKeyboard() @@ -312,11 +290,13 @@ final class FirebaseAccountTests: XCTestCase { // swiftlint:disable:this type_bo XCTAssertTrue(app.alerts["Authentication Required"].buttons["Login"].waitForExistence(timeout: 0.5)) app.alerts["Authentication Required"].buttons["Login"].tap() - sleep(1) + XCTAssertTrue(app.navigationBars.buttons["Account Overview"].waitForExistence(timeout: 2.0)) app.navigationBars.buttons["Account Overview"].tap() // back button - sleep(1) - app.buttons["Close"].tap() - sleep(1) + + XCTAssertTrue(app.navigationBars.buttons["Close"].waitForExistence(timeout: 2.0)) + app.navigationBars.buttons["Close"].tap() + + XCTAssertTrue(app.buttons["Logout"].waitForExistence(timeout: 2.0)) app.buttons["Logout"].tap() // we tap the custom button to be lest dependent on the other tests and not deal with the alert try app.login(username: "test@username.edu", password: "1234567890", close: false) @@ -351,11 +331,13 @@ final class FirebaseAccountTests: XCTestCase { // swiftlint:disable:this type_bo XCTAssertTrue(app.alerts["Authentication Required"].buttons["Cancel"].waitForExistence(timeout: 0.5)) app.alerts["Authentication Required"].buttons["Cancel"].tap() - sleep(1) + XCTAssertTrue(app.navigationBars.buttons["Account Overview"].waitForExistence(timeout: 2.0)) app.navigationBars.buttons["Account Overview"].tap() // back button - sleep(1) - app.buttons["Close"].tap() - sleep(1) + + XCTAssertTrue(app.navigationBars.buttons["Close"].waitForExistence(timeout: 2.0)) + app.navigationBars.buttons["Close"].tap() + + XCTAssertTrue(app.buttons["Logout"].waitForExistence(timeout: 2.0)) app.buttons["Logout"].tap() // we tap the custom button to be lest dependent on the other tests and not deal with the alert try app.login(username: "test@username.edu", password: "TestPassword", close: false) // login with previous password! @@ -371,10 +353,6 @@ final class FirebaseAccountTests: XCTestCase { // swiftlint:disable:this type_bo XCTAssert(app.buttons["FirebaseAccount"].waitForExistence(timeout: 10.0)) app.buttons["FirebaseAccount"].tap() - if app.buttons["Logout"].waitForExistence(timeout: 5.0) && app.buttons["Logout"].isHittable { - app.buttons["Logout"].tap() - } - app.buttons["Account Setup"].tap() XCTAssertTrue(app.buttons["Forgot Password?"].waitForExistence(timeout: 2.0)) @@ -403,18 +381,18 @@ final class FirebaseAccountTests: XCTestCase { // swiftlint:disable:this type_bo app.launchArguments = ["--firebaseAccount"] app.launch() - XCTAssert(app.buttons["FirebaseAccount"].waitForExistence(timeout: 10.0)) + XCTAssert(app.buttons["FirebaseAccount"].waitForExistence(timeout: 2.0)) app.buttons["FirebaseAccount"].tap() - if app.buttons["Logout"].waitForExistence(timeout: 5.0) && app.buttons["Logout"].isHittable { - app.buttons["Logout"].tap() - } - try app.login(username: "unknown@example.de", password: "HelloWorld", close: false) - XCTAssertTrue(app.alerts["Invalid Credentials"].waitForExistence(timeout: 6.0)) - app.alerts["Invalid Credentials"].scrollViews.otherElements.buttons["OK"].tap() + XCTAssertTrue(app.alerts["Invalid Credentials"].waitForExistence(timeout: 3.0)) + XCTAssertTrue(app.alerts["Invalid Credentials"].scrollViews.buttons["OK"].exists) + app.alerts["Invalid Credentials"].scrollViews.buttons["OK"].tap() + + XCTAssertTrue(app.buttons["Close"].exists) app.buttons["Close"].tap() - sleep(2) + + XCTAssertTrue(app.buttons["Account Setup"].waitForExistence(timeout: 2.0)) // signing in with unknown credentials or credentials with a incorrect password are two different errors // that should, nonetheless, be treated equally in UI. @@ -432,10 +410,6 @@ final class FirebaseAccountTests: XCTestCase { // swiftlint:disable:this type_bo XCTAssert(app.buttons["FirebaseAccount"].waitForExistence(timeout: 10.0)) app.buttons["FirebaseAccount"].tap() - if app.buttons["Logout"].waitForExistence(timeout: 3.0) && app.buttons["Logout"].isHittable { - app.buttons["Logout"].tap() - } - app.buttons["Account Setup"].tap() addUIInterruptionMonitor(withDescription: "Apple Sign In") { element in @@ -461,10 +435,6 @@ final class FirebaseAccountTests: XCTestCase { // swiftlint:disable:this type_bo XCTAssert(app.buttons["FirebaseAccount"].waitForExistence(timeout: 2.0)) app.buttons["FirebaseAccount"].tap() - if app.buttons["Logout"].waitForExistence(timeout: 2.0) && app.buttons["Logout"].isHittable { - app.buttons["Logout"].tap() - } - XCTAssertTrue(app.buttons["Account Setup"].exists) app.buttons["Account Setup"].tap() @@ -493,13 +463,12 @@ extension XCUIApplication { try textFields["E-Mail Address"].enter(value: username) try secureTextFields["Password"].enter(value: password) - - swipeUp() scrollViews.buttons["Login"].tap() + if close { - sleep(3) // TODO: remove all sleeps! + XCTAssertTrue(staticTexts[username].waitForExistence(timeout: 5.0)) self.buttons["Close"].tap() } } @@ -529,4 +498,3 @@ extension XCUIApplication { buttons["Close"].tap() } } -// swiftlint:disable:this file_length From 3dc47ecba50e167e5467c569c71730b718435576 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Mon, 12 Aug 2024 16:00:06 +0200 Subject: [PATCH 14/26] Upgrade Firebase SDK and inherit isolation for setData methods --- Package.swift | 8 +-- .../FirebaseAccountService.swift | 50 +++++++++++-------- .../Models/FirebaseAccountError.swift | 8 +-- .../Views/FirebaseAccountModifier.swift | 2 +- .../DocumentReference+AsyncAwait.swift | 14 +++--- 5 files changed, 45 insertions(+), 37 deletions(-) diff --git a/Package.swift b/Package.swift index 90bc479..a4d18ec 100644 --- a/Package.swift +++ b/Package.swift @@ -37,7 +37,7 @@ let package = Package( .package(url: "https://github.com/StanfordSpezi/Spezi", branch: "feature/dependency-restructure"), .package(url: "https://github.com/StanfordSpezi/SpeziViews.git", from: "1.5.0"), .package(url: "https://github.com/StanfordSpezi/SpeziAccount", branch: "feature/account-service-singleton"), - .package(url: "https://github.com/firebase/firebase-ios-sdk", from: "10.29.0") + .package(url: "https://github.com/firebase/firebase-ios-sdk", from: "11.0.0") ] + swiftLintPackage(), targets: [ .target( @@ -71,8 +71,7 @@ let package = Package( dependencies: [ .target(name: "SpeziFirebaseConfiguration"), .product(name: "Spezi", package: "Spezi"), - .product(name: "FirebaseFirestore", package: "firebase-ios-sdk"), - .product(name: "FirebaseFirestoreSwift", package: "firebase-ios-sdk") + .product(name: "FirebaseFirestore", package: "firebase-ios-sdk") ], swiftSettings: [ swiftConcurrency @@ -109,7 +108,8 @@ let package = Package( dependencies: [ .target(name: "SpeziFirebaseAccount"), .target(name: "SpeziFirebaseConfiguration"), - .target(name: "SpeziFirestore") + .target(name: "SpeziFirestore"), + .product(name: "XCTSpezi", package: "Spezi"), ], swiftSettings: [ swiftConcurrency diff --git a/Sources/SpeziFirebaseAccount/FirebaseAccountService.swift b/Sources/SpeziFirebaseAccount/FirebaseAccountService.swift index d7f2e58..d9bc3e8 100644 --- a/Sources/SpeziFirebaseAccount/FirebaseAccountService.swift +++ b/Sources/SpeziFirebaseAccount/FirebaseAccountService.swift @@ -62,7 +62,7 @@ private struct UserUpdate { /// ### Signup /// - ``signUpAnonymously()`` /// - ``signUp(with:)-6qeht`` -/// - ``signup(with:)-3bvwo`` +/// - ``signUp(with:)-rpy`` /// /// ### Login /// - ``login(userId:password:)`` @@ -191,9 +191,7 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable // if there is a cached user, we refresh the authentication token Auth.auth().currentUser?.getIDTokenForcingRefresh(true) { _, error in if let error { - let code = AuthErrorCode(_nsError: error as NSError) - - guard code.code != .networkError else { + guard (error as NSError).code != AuthErrorCode.networkError.rawValue else { return // we make sure that we don't remove the account when we don't have network (e.g., flight mode) } @@ -294,7 +292,7 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable /// Sign up with an O-Auth credential, like one received from Sign in with Apple. /// - Parameter credential: The o-auth credential. /// - Throws: Throws an ``FirebaseAccountError`` if the operation fails. - public func signup(with credential: OAuthCredential) async throws { + public func signUp(with credential: OAuthCredential) async throws { try await dispatchFirebaseAuthAction { @MainActor in if let currentUser = Auth.auth().currentUser, currentUser.isAnonymous { @@ -319,14 +317,18 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable do { try await Auth.auth().sendPasswordReset(withEmail: userId) logger.debug("sendPasswordReset(withEmail:) for user.") - } catch let error as NSError { - let firebaseError = FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) - if case .invalidCredentials = firebaseError { - return // make sure we don't leak any information - } else { - throw firebaseError - } } catch { + let nsError = error as NSError + if nsError.domain == AuthErrors.domain, + let code = AuthErrorCode(rawValue: nsError.code) { + let accountError = FirebaseAccountError(authErrorCode: code) + + if case .invalidCredentials = accountError { + return // make sure we don't leak any information + } else { + throw accountError + } + } throw FirebaseAccountError.unknown(.internalError) } } @@ -374,7 +376,7 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable let result = try await reauthenticateUser(user: currentUser) // delete requires a recent sign in guard case .success = result else { logger.debug("Re-authentication was cancelled by user. Not deleting the account.") - return // cancelled + return// cancelled } if let credential = result.credential { @@ -397,7 +399,7 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable // token revocation for Sign in with Apple is currently unsupported for Firebase // see https://github.com/firebase/firebase-tools/issues/6028 // and https://github.com/firebase/firebase-tools/pull/6050 - if AuthErrorCode(_nsError: error).code != .invalidCredential { + if error.code != AuthErrorCode.invalidCredential.rawValue { throw error } #else @@ -466,11 +468,13 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable // None of the above requests will trigger our state change listener, therefore, we just call it manually. try await notifyUserSignIn(user: currentUser) - } catch let error as NSError { - logger.error("Received NSError on firebase dispatch: \(error)") - throw FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) } catch { logger.error("Received error on firebase dispatch: \(error)") + let nsError = error as NSError + if nsError.domain == AuthErrors.domain, + let code = AuthErrorCode(rawValue: nsError.code) { + throw FirebaseAccountError(authErrorCode: code) + } throw FirebaseAccountError.unknown(.internalError) } } @@ -540,7 +544,7 @@ extension FirebaseAccountService { var details = buildUser(user, isNewUser: false) details.isIncomplete = !self.unsupportedKeys.isEmpty - logger.debug("Supply initial user details of associated Firebase account.") + logger.debug("Found existing Firebase account. Supplying initial user details of associated Firebase account.") account.supplyUserDetails(details) skipNextStateChange = !details.isIncomplete } @@ -619,7 +623,7 @@ extension FirebaseAccountService { logger.info("onAppleSignInCompletion creating firebase apple credential from authorization credential") - try await signup(with: credential) + try await signUp(with: credential) case let .failure(error): guard let authorizationError = error as? ASAuthorizationError else { logger.error("onAppleSignInCompletion received unknown error: \(error)") @@ -754,11 +758,13 @@ extension FirebaseAccountService { let result = try await action() try await dispatchQueuedChanges(result: result) - } catch let error as NSError { - logger.error("Received NSError on firebase dispatch: \(error)") - throw FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) } catch { logger.error("Received error on firebase dispatch: \(error)") + let nsError = error as NSError + if nsError.domain == AuthErrors.domain, + let code = AuthErrorCode(rawValue: nsError.code) { + throw FirebaseAccountError(authErrorCode: code) + } throw FirebaseAccountError.unknown(.internalError) } } diff --git a/Sources/SpeziFirebaseAccount/Models/FirebaseAccountError.swift b/Sources/SpeziFirebaseAccount/Models/FirebaseAccountError.swift index a3d1170..1ea5d04 100644 --- a/Sources/SpeziFirebaseAccount/Models/FirebaseAccountError.swift +++ b/Sources/SpeziFirebaseAccount/Models/FirebaseAccountError.swift @@ -6,7 +6,7 @@ // SPDX-License-Identifier: MIT // -import FirebaseAuth +@preconcurrency import FirebaseAuth import Foundation @@ -39,13 +39,13 @@ public enum FirebaseAccountError { /// Encountered an unrecognized provider when trying to re-authenticate the user. case unsupportedProvider /// Unrecognized Firebase account error. - case unknown(AuthErrorCode.Code) + case unknown(AuthErrorCode) /// Derive the error from the Firebase `AuthErrorCode`. /// - Parameter authErrorCode: The error code from the NSError reported by Firebase Auth. public init(authErrorCode: AuthErrorCode) { - switch authErrorCode.code { + switch authErrorCode { case .invalidEmail, .invalidRecipientEmail: self = .invalidEmail case .emailAlreadyInUse: @@ -65,7 +65,7 @@ public enum FirebaseAccountError { case .credentialAlreadyInUse: self = .linkFailedAlreadyInUse default: - self = .unknown(authErrorCode.code) + self = .unknown(authErrorCode) } } } diff --git a/Sources/SpeziFirebaseAccount/Views/FirebaseAccountModifier.swift b/Sources/SpeziFirebaseAccount/Views/FirebaseAccountModifier.swift index ee5fc7b..f427a6d 100644 --- a/Sources/SpeziFirebaseAccount/Views/FirebaseAccountModifier.swift +++ b/Sources/SpeziFirebaseAccount/Views/FirebaseAccountModifier.swift @@ -28,7 +28,7 @@ struct FirebaseAccountModifier: ViewModifier { content .task { firebaseModel.authorizationController = authorizationController - Self.logger.debug("Retrieved the authorization controller from the environment!") + Self.logger.debug("Retrieved the Sign in With Apple authorization controller from the SwiftUI environment!") } } } diff --git a/Sources/SpeziFirestore/DocumentReference+AsyncAwait.swift b/Sources/SpeziFirestore/DocumentReference+AsyncAwait.swift index 801ef15..3d3ea03 100644 --- a/Sources/SpeziFirestore/DocumentReference+AsyncAwait.swift +++ b/Sources/SpeziFirestore/DocumentReference+AsyncAwait.swift @@ -6,8 +6,7 @@ // SPDX-License-Identifier: MIT // -@_exported import FirebaseFirestore -@_exported import FirebaseFirestoreSwift +@preconcurrency import FirebaseFirestore import Foundation @@ -26,7 +25,8 @@ extension DocumentReference { /// - Parameters: /// - value: An instance of `Encodable` to be encoded to a document. /// - encoder: An encoder instance to use to run the encoding. - public func setData( + public func setData( // swiftlint:disable:this function_default_parameter_at_end + isolation: isolated (any Actor)? = #isolation, from value: T, encoder: FirebaseFirestore.Firestore.Encoder = FirebaseFirestore.Firestore.Encoder() ) async throws { @@ -37,7 +37,7 @@ extension DocumentReference { throw FirestoreError(error) } } - + /// Write the data of a document with an encodable value. /// /// Encodes an instance of `Encodable` and overwrites the encoded data @@ -55,7 +55,8 @@ extension DocumentReference { /// - merge: Whether to merge the provided `Encodable` into any existing /// document. /// - encoder: An encoder instance to use to run the encoding. - public func setData( + public func setData( // swiftlint:disable:this function_default_parameter_at_end + isolation: isolated (any Actor)? = #isolation, from value: T, merge: Bool, encoder: FirebaseFirestore.Firestore.Encoder = FirebaseFirestore.Firestore.Encoder() @@ -89,7 +90,8 @@ extension DocumentReference { /// merge. Fields can contain dots to reference nested fields within the /// document. /// - encoder: An encoder instance to use to run the encoding. - public func setData( + public func setData( // swiftlint:disable:this function_default_parameter_at_end + isolation: isolated (any Actor)? = #isolation, from value: T, mergeFields: [Any], encoder: FirebaseFirestore.Firestore.Encoder = FirebaseFirestore.Firestore.Encoder() From c834dafc20982c45ed55cde30d445dfb5ad4a9c9 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Tue, 13 Aug 2024 12:23:46 +0200 Subject: [PATCH 15/26] Upgrade dependencies --- Package.swift | 8 ++++---- Sources/SpeziFirestore/DocumentReference+AsyncAwait.swift | 2 +- Sources/SpeziFirestore/Firestore.swift | 1 - Sources/SpeziFirestore/FirestoreError.swift | 1 - 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/Package.swift b/Package.swift index a4d18ec..f7a4841 100644 --- a/Package.swift +++ b/Package.swift @@ -33,9 +33,9 @@ let package = Package( .library(name: "SpeziFirebaseAccountStorage", targets: ["SpeziFirebaseAccountStorage"]) ], dependencies: [ - .package(url: "https://github.com/StanfordSpezi/SpeziFoundation.git", from: "1.1.3"), - .package(url: "https://github.com/StanfordSpezi/Spezi", branch: "feature/dependency-restructure"), - .package(url: "https://github.com/StanfordSpezi/SpeziViews.git", from: "1.5.0"), + .package(url: "https://github.com/StanfordSpezi/SpeziFoundation.git", from: "2.0.0-beta.1"), + .package(url: "https://github.com/StanfordSpezi/Spezi", branch: "feature/upgrade-spezi"), + .package(url: "https://github.com/StanfordSpezi/SpeziViews.git", from: "1.6.0"), .package(url: "https://github.com/StanfordSpezi/SpeziAccount", branch: "feature/account-service-singleton"), .package(url: "https://github.com/firebase/firebase-ios-sdk", from: "11.0.0") ] + swiftLintPackage(), @@ -131,7 +131,7 @@ func swiftLintPlugin() -> [Target.PluginUsage] { func swiftLintPackage() -> [PackageDescription.Package.Dependency] { if ProcessInfo.processInfo.environment["SPEZI_DEVELOPMENT_SWIFTLINT"] != nil { - [.package(url: "https://github.com/realm/SwiftLint.git", .upToNextMinor(from: "0.55.1"))] + [.package(url: "https://github.com/realm/SwiftLint.git", from: "0.55.1")] } else { [] } diff --git a/Sources/SpeziFirestore/DocumentReference+AsyncAwait.swift b/Sources/SpeziFirestore/DocumentReference+AsyncAwait.swift index 3d3ea03..5573ad0 100644 --- a/Sources/SpeziFirestore/DocumentReference+AsyncAwait.swift +++ b/Sources/SpeziFirestore/DocumentReference+AsyncAwait.swift @@ -6,7 +6,7 @@ // SPDX-License-Identifier: MIT // -@preconcurrency import FirebaseFirestore +import FirebaseFirestore import Foundation diff --git a/Sources/SpeziFirestore/Firestore.swift b/Sources/SpeziFirestore/Firestore.swift index 9ab2cdf..cf3b592 100644 --- a/Sources/SpeziFirestore/Firestore.swift +++ b/Sources/SpeziFirestore/Firestore.swift @@ -8,7 +8,6 @@ import FirebaseCore import FirebaseFirestore -import FirebaseFirestoreSwift import Spezi import SpeziFirebaseConfiguration import SwiftUI diff --git a/Sources/SpeziFirestore/FirestoreError.swift b/Sources/SpeziFirestore/FirestoreError.swift index 12ed60c..0ec6d3f 100644 --- a/Sources/SpeziFirestore/FirestoreError.swift +++ b/Sources/SpeziFirestore/FirestoreError.swift @@ -7,7 +7,6 @@ // import FirebaseFirestore -import FirebaseFirestoreSwift import Foundation From 9d50c68185634b8ca46a4a73b62701a24a433b7a Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Wed, 14 Aug 2024 08:29:46 +0200 Subject: [PATCH 16/26] Try with continatuions again for sendability --- Package.swift | 4 +- .../DocumentReference+AsyncAwait.swift | 119 +++++++++++++++++- 2 files changed, 118 insertions(+), 5 deletions(-) diff --git a/Package.swift b/Package.swift index f7a4841..4c398f0 100644 --- a/Package.swift +++ b/Package.swift @@ -34,7 +34,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/StanfordSpezi/SpeziFoundation.git", from: "2.0.0-beta.1"), - .package(url: "https://github.com/StanfordSpezi/Spezi", branch: "feature/upgrade-spezi"), + .package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.7.1"), .package(url: "https://github.com/StanfordSpezi/SpeziViews.git", from: "1.6.0"), .package(url: "https://github.com/StanfordSpezi/SpeziAccount", branch: "feature/account-service-singleton"), .package(url: "https://github.com/firebase/firebase-ios-sdk", from: "11.0.0") @@ -109,7 +109,7 @@ let package = Package( .target(name: "SpeziFirebaseAccount"), .target(name: "SpeziFirebaseConfiguration"), .target(name: "SpeziFirestore"), - .product(name: "XCTSpezi", package: "Spezi"), + .product(name: "XCTSpezi", package: "Spezi") ], swiftSettings: [ swiftConcurrency diff --git a/Sources/SpeziFirestore/DocumentReference+AsyncAwait.swift b/Sources/SpeziFirestore/DocumentReference+AsyncAwait.swift index 5573ad0..f2e87bb 100644 --- a/Sources/SpeziFirestore/DocumentReference+AsyncAwait.swift +++ b/Sources/SpeziFirestore/DocumentReference+AsyncAwait.swift @@ -11,6 +11,7 @@ import Foundation extension DocumentReference { +#if compiler(>=6) /// Overwrite the data of a document with an encodable value. /// /// Encodes an instance of `Encodable` and overwrites the encoded data @@ -29,6 +30,119 @@ extension DocumentReference { isolation: isolated (any Actor)? = #isolation, from value: T, encoder: FirebaseFirestore.Firestore.Encoder = FirebaseFirestore.Firestore.Encoder() + ) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + do { + try setData(from: value, encoder: encoder) { error in + if let error { + continuation.resume(throwing: FirestoreError(error)) + } else { + continuation.resume() + } + } + } catch { + continuation.resume(throwing: FirestoreError(error)) + } + } + } + + /// Write the data of a document with an encodable value. + /// + /// Encodes an instance of `Encodable` and overwrites the encoded data + /// to the document referred by this `DocumentReference`. If no document exists, + /// it is created. If a document already exists, it is overwritten. If you pass + /// merge:true, the provided `Encodable` will be merged into any existing document. + /// + /// See `Firestore.Encoder` for more details about the encoding process. + /// + /// Returns once the document has been successfully written to the server. + /// Due to the Firebase SDK, it will not return when the client is offline, though local changes will be visible immediately. + /// + /// - Parameters: + /// - value: An instance of `Encodable` to be encoded to a document. + /// - merge: Whether to merge the provided `Encodable` into any existing + /// document. + /// - encoder: An encoder instance to use to run the encoding. + public func setData( // swiftlint:disable:this function_default_parameter_at_end + isolation: isolated (any Actor)? = #isolation, + from value: T, + merge: Bool, + encoder: FirebaseFirestore.Firestore.Encoder = FirebaseFirestore.Firestore.Encoder() + ) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + do { + try setData(from: value, merge: merge, encoder: encoder) { error in + if let error { + continuation.resume(throwing: FirestoreError(error)) + } else { + continuation.resume() + } + } + } catch { + continuation.resume(throwing: FirestoreError(error)) + } + } + } + + /// Write the data of a document by merging a set of fields. + /// + /// Encodes an instance of `Encodable` and writes the encoded data to the document referred + /// by this `DocumentReference` by only replacing the fields specified under `mergeFields`. + /// Any field that is not specified in mergeFields is ignored and remains untouched. If the + /// document doesn’t yet exist, this method creates it and then sets the data. + /// + /// It is an error to include a field in `mergeFields` that does not have a corresponding + /// field in the `Encodable`. + /// + /// See `Firestore.Encoder` for more details about the encoding process. + /// + /// Returns once the document has been successfully written to the server. + /// Due to the Firebase SDK, it will not return when the client is offline, though local changes will be visible immediately. + /// + /// - Parameters: + /// - value: An instance of `Encodable` to be encoded to a document. + /// - mergeFields: Array of `String` or `FieldPath` elements specifying which fields to + /// merge. Fields can contain dots to reference nested fields within the + /// document. + /// - encoder: An encoder instance to use to run the encoding. + public func setData( // swiftlint:disable:this function_default_parameter_at_end + isolation: isolated (any Actor)? = #isolation, + from value: T, + mergeFields: [Any], + encoder: FirebaseFirestore.Firestore.Encoder = FirebaseFirestore.Firestore.Encoder() + ) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + do { + try setData(from: value, mergeFields: mergeFields, encoder: encoder) { error in + if let error { + continuation.resume(throwing: FirestoreError(error)) + } else { + continuation.resume() + } + } + } catch { + continuation.resume(throwing: FirestoreError(error)) + } + } + } +#else + /// Overwrite the data of a document with an encodable value. + /// + /// Encodes an instance of `Encodable` and overwrites the encoded data + /// to the document referred by this `DocumentReference`. If no document exists, + /// it is created. If a document already exists, it is overwritten. + /// + /// See `Firestore.Encoder` for more details about the encoding process. + /// + /// Returns once the document has been successfully written to the server. + /// Due to the Firebase SDK, it will not return when the client is offline, though local changes will be visible immediately. + /// + /// - Parameters: + /// - value: An instance of `Encodable` to be encoded to a document. + /// - encoder: An encoder instance to use to run the encoding. + public func setData( // swiftlint:disable:this function_default_parameter_at_end + from value: T, + encoder: FirebaseFirestore.Firestore.Encoder = FirebaseFirestore.Firestore.Encoder() ) async throws { do { let encoded = try encoder.encode(value) @@ -56,7 +170,6 @@ extension DocumentReference { /// document. /// - encoder: An encoder instance to use to run the encoding. public func setData( // swiftlint:disable:this function_default_parameter_at_end - isolation: isolated (any Actor)? = #isolation, from value: T, merge: Bool, encoder: FirebaseFirestore.Firestore.Encoder = FirebaseFirestore.Firestore.Encoder() @@ -68,7 +181,7 @@ extension DocumentReference { throw FirestoreError(error) } } - + /// Write the data of a document by merging a set of fields. /// /// Encodes an instance of `Encodable` and writes the encoded data to the document referred @@ -91,7 +204,6 @@ extension DocumentReference { /// document. /// - encoder: An encoder instance to use to run the encoding. public func setData( // swiftlint:disable:this function_default_parameter_at_end - isolation: isolated (any Actor)? = #isolation, from value: T, mergeFields: [Any], encoder: FirebaseFirestore.Firestore.Encoder = FirebaseFirestore.Firestore.Encoder() @@ -103,4 +215,5 @@ extension DocumentReference { throw FirestoreError(error) } } +#endif } From a19e42bdfed947d155565f91b2e4b90935fe0e49 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 15 Aug 2024 20:23:57 +0200 Subject: [PATCH 17/26] Minor adjustments --- Package.swift | 2 +- Sources/SpeziFirestore/DocumentReference+AsyncAwait.swift | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Package.swift b/Package.swift index 4c398f0..0476a5d 100644 --- a/Package.swift +++ b/Package.swift @@ -36,7 +36,7 @@ let package = Package( .package(url: "https://github.com/StanfordSpezi/SpeziFoundation.git", from: "2.0.0-beta.1"), .package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.7.1"), .package(url: "https://github.com/StanfordSpezi/SpeziViews.git", from: "1.6.0"), - .package(url: "https://github.com/StanfordSpezi/SpeziAccount", branch: "feature/account-service-singleton"), + .package(url: "https://github.com/StanfordSpezi/SpeziAccount", from: "2.0.0-beta.2"), .package(url: "https://github.com/firebase/firebase-ios-sdk", from: "11.0.0") ] + swiftLintPackage(), targets: [ diff --git a/Sources/SpeziFirestore/DocumentReference+AsyncAwait.swift b/Sources/SpeziFirestore/DocumentReference+AsyncAwait.swift index f2e87bb..dcbd5d0 100644 --- a/Sources/SpeziFirestore/DocumentReference+AsyncAwait.swift +++ b/Sources/SpeziFirestore/DocumentReference+AsyncAwait.swift @@ -140,7 +140,7 @@ extension DocumentReference { /// - Parameters: /// - value: An instance of `Encodable` to be encoded to a document. /// - encoder: An encoder instance to use to run the encoding. - public func setData( // swiftlint:disable:this function_default_parameter_at_end + public func setData( from value: T, encoder: FirebaseFirestore.Firestore.Encoder = FirebaseFirestore.Firestore.Encoder() ) async throws { @@ -169,7 +169,7 @@ extension DocumentReference { /// - merge: Whether to merge the provided `Encodable` into any existing /// document. /// - encoder: An encoder instance to use to run the encoding. - public func setData( // swiftlint:disable:this function_default_parameter_at_end + public func setData( from value: T, merge: Bool, encoder: FirebaseFirestore.Firestore.Encoder = FirebaseFirestore.Firestore.Encoder() @@ -203,7 +203,7 @@ extension DocumentReference { /// merge. Fields can contain dots to reference nested fields within the /// document. /// - encoder: An encoder instance to use to run the encoding. - public func setData( // swiftlint:disable:this function_default_parameter_at_end + public func setData( from value: T, mergeFields: [Any], encoder: FirebaseFirestore.Firestore.Encoder = FirebaseFirestore.Firestore.Encoder() From 1505ea9bd0418cc21726b6d42270a320abdb90f6 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Fri, 16 Aug 2024 09:48:23 +0200 Subject: [PATCH 18/26] Make firebase data functions more safe --- Package.swift | 13 +-- .../DocumentReference+AsyncAwait.swift | 95 ++++++++++++------- .../TestAppUITests/FirebaseClient.swift | 2 +- .../xcshareddata/xcschemes/TestApp.xcscheme | 2 +- 4 files changed, 71 insertions(+), 41 deletions(-) diff --git a/Package.swift b/Package.swift index 0476a5d..c282769 100644 --- a/Package.swift +++ b/Package.swift @@ -33,11 +33,12 @@ let package = Package( .library(name: "SpeziFirebaseAccountStorage", targets: ["SpeziFirebaseAccountStorage"]) ], dependencies: [ - .package(url: "https://github.com/StanfordSpezi/SpeziFoundation.git", from: "2.0.0-beta.1"), + .package(url: "https://github.com/StanfordSpezi/SpeziFoundation", from: "2.0.0-beta.1"), .package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.7.1"), - .package(url: "https://github.com/StanfordSpezi/SpeziViews.git", from: "1.6.0"), + .package(url: "https://github.com/StanfordSpezi/SpeziViews", from: "1.6.0"), .package(url: "https://github.com/StanfordSpezi/SpeziAccount", from: "2.0.0-beta.2"), - .package(url: "https://github.com/firebase/firebase-ios-sdk", from: "11.0.0") + .package(url: "https://github.com/firebase/firebase-ios-sdk", from: "11.0.0"), + .package(url: "https://github.com/apple/swift-atomics.git", from: "1.2.0") ] + swiftLintPackage(), targets: [ .target( @@ -71,7 +72,8 @@ let package = Package( dependencies: [ .target(name: "SpeziFirebaseConfiguration"), .product(name: "Spezi", package: "Spezi"), - .product(name: "FirebaseFirestore", package: "firebase-ios-sdk") + .product(name: "FirebaseFirestore", package: "firebase-ios-sdk"), + .product(name: "Atomics", package: "swift-atomics") ], swiftSettings: [ swiftConcurrency @@ -108,8 +110,7 @@ let package = Package( dependencies: [ .target(name: "SpeziFirebaseAccount"), .target(name: "SpeziFirebaseConfiguration"), - .target(name: "SpeziFirestore"), - .product(name: "XCTSpezi", package: "Spezi") + .target(name: "SpeziFirestore") ], swiftSettings: [ swiftConcurrency diff --git a/Sources/SpeziFirestore/DocumentReference+AsyncAwait.swift b/Sources/SpeziFirestore/DocumentReference+AsyncAwait.swift index dcbd5d0..5f2788e 100644 --- a/Sources/SpeziFirestore/DocumentReference+AsyncAwait.swift +++ b/Sources/SpeziFirestore/DocumentReference+AsyncAwait.swift @@ -6,8 +6,61 @@ // SPDX-License-Identifier: MIT // +import Atomics import FirebaseFirestore import Foundation +import OSLog + + +#if compiler(>=6) +private struct FirestoreCompletion: Sendable { + private static var logger: Logger { + Logger(subsystem: "edu.stanford.spezi.firebase", category: "FirestoreCompletion") + } + + private let continuation: UnsafeContinuation + private let resumed: ManagedAtomic + + private init(continuation: UnsafeContinuation) { + self.continuation = continuation + self.resumed = ManagedAtomic(false) + } + + static func perform( + isolation: isolated (any Actor)? = #isolation, + file: StaticString = #filePath, + line: Int = #line, + action: (FirestoreCompletion) throws -> Void + ) async throws { + try await withUnsafeThrowingContinuation { continuation in + let completion = FirestoreCompletion(continuation: continuation) + do { + try action(completion) + } catch { + completion.complete(with: error, file: file, line: line) + } + } + } + + func complete( + with error: Error?, + file: StaticString = #filePath, + line: Int = #line + ) { + let (exchanged, _) = resumed.compareExchange(expected: false, desired: true, ordering: .relaxed) + if !exchanged { + Self.logger.warning("\(file):\(line): Firestore completion handler completed twice. This time with: \(error)") + return + } + + if let error { + continuation.resume(throwing: FirestoreError(error)) + } else { + continuation.resume() + } + } +} +#endif extension DocumentReference { @@ -31,17 +84,9 @@ extension DocumentReference { from value: T, encoder: FirebaseFirestore.Firestore.Encoder = FirebaseFirestore.Firestore.Encoder() ) async throws { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - do { - try setData(from: value, encoder: encoder) { error in - if let error { - continuation.resume(throwing: FirestoreError(error)) - } else { - continuation.resume() - } - } - } catch { - continuation.resume(throwing: FirestoreError(error)) + try await FirestoreCompletion.perform { completion in + try setData(from: value, encoder: encoder) { error in + completion.complete(with: error) } } } @@ -69,17 +114,9 @@ extension DocumentReference { merge: Bool, encoder: FirebaseFirestore.Firestore.Encoder = FirebaseFirestore.Firestore.Encoder() ) async throws { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - do { - try setData(from: value, merge: merge, encoder: encoder) { error in - if let error { - continuation.resume(throwing: FirestoreError(error)) - } else { - continuation.resume() - } - } - } catch { - continuation.resume(throwing: FirestoreError(error)) + try await FirestoreCompletion.perform { completion in + try setData(from: value, merge: merge, encoder: encoder) { error in + completion.complete(with: error) } } } @@ -111,17 +148,9 @@ extension DocumentReference { mergeFields: [Any], encoder: FirebaseFirestore.Firestore.Encoder = FirebaseFirestore.Firestore.Encoder() ) async throws { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - do { - try setData(from: value, mergeFields: mergeFields, encoder: encoder) { error in - if let error { - continuation.resume(throwing: FirestoreError(error)) - } else { - continuation.resume() - } - } - } catch { - continuation.resume(throwing: FirestoreError(error)) + try await FirestoreCompletion.perform { completion in + try setData(from: value, mergeFields: mergeFields, encoder: encoder) { error in + completion.complete(with: error) } } } diff --git a/Tests/UITests/TestAppUITests/FirebaseClient.swift b/Tests/UITests/TestAppUITests/FirebaseClient.swift index 019f415..6c6ebe8 100644 --- a/Tests/UITests/TestAppUITests/FirebaseClient.swift +++ b/Tests/UITests/TestAppUITests/FirebaseClient.swift @@ -51,7 +51,7 @@ struct FirestoreAccount: Decodable, Equatable { enum FirebaseClient { - private static let projectId = "spezifirebaseuitests" + private static let projectId = "nams-e43ed" // TODO: restore "spezifirebaseuitests" // curl -H "Authorization: Bearer owner" -X DELETE http://localhost:9099/emulator/v1/projects/spezifirebaseuitests/accounts static func deleteAllAccounts() async throws { diff --git a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme index 46472a0..4fa87bf 100644 --- a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme +++ b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme @@ -140,7 +140,7 @@ + isEnabled = "YES"> Date: Fri, 16 Aug 2024 09:54:41 +0200 Subject: [PATCH 19/26] Restore projectid --- Tests/UITests/TestAppUITests/FirebaseClient.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/UITests/TestAppUITests/FirebaseClient.swift b/Tests/UITests/TestAppUITests/FirebaseClient.swift index 6c6ebe8..019f415 100644 --- a/Tests/UITests/TestAppUITests/FirebaseClient.swift +++ b/Tests/UITests/TestAppUITests/FirebaseClient.swift @@ -51,7 +51,7 @@ struct FirestoreAccount: Decodable, Equatable { enum FirebaseClient { - private static let projectId = "nams-e43ed" // TODO: restore "spezifirebaseuitests" + private static let projectId = "spezifirebaseuitests" // curl -H "Authorization: Bearer owner" -X DELETE http://localhost:9099/emulator/v1/projects/spezifirebaseuitests/accounts static func deleteAllAccounts() async throws { From e96ca974cc96dcd79da64f132463c1107484d1a2 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Fri, 16 Aug 2024 12:13:59 +0200 Subject: [PATCH 20/26] Can we rerun? --- Tests/UITests/TestAppUITests/FirebaseAccountStorageTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Tests/UITests/TestAppUITests/FirebaseAccountStorageTests.swift b/Tests/UITests/TestAppUITests/FirebaseAccountStorageTests.swift index 331d829..58c80e6 100644 --- a/Tests/UITests/TestAppUITests/FirebaseAccountStorageTests.swift +++ b/Tests/UITests/TestAppUITests/FirebaseAccountStorageTests.swift @@ -20,6 +20,7 @@ final class FirebaseAccountStorageTests: XCTestCase { try await Task.sleep(for: .seconds(0.5)) } + @MainActor func testAdditionalAccountStorage() async throws { let app = XCUIApplication() From 0dd0cf8963e242468de7460b9e425a94468e1d0a Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Fri, 16 Aug 2024 20:51:07 +0200 Subject: [PATCH 21/26] Require latest beta --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index c282769..75a4977 100644 --- a/Package.swift +++ b/Package.swift @@ -36,7 +36,7 @@ let package = Package( .package(url: "https://github.com/StanfordSpezi/SpeziFoundation", from: "2.0.0-beta.1"), .package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.7.1"), .package(url: "https://github.com/StanfordSpezi/SpeziViews", from: "1.6.0"), - .package(url: "https://github.com/StanfordSpezi/SpeziAccount", from: "2.0.0-beta.2"), + .package(url: "https://github.com/StanfordSpezi/SpeziAccount", from: "2.0.0-beta.3"), .package(url: "https://github.com/firebase/firebase-ios-sdk", from: "11.0.0"), .package(url: "https://github.com/apple/swift-atomics.git", from: "1.2.0") ] + swiftLintPackage(), From 4195dd6d1e0b83fb1b490e6cc5ae1c2c0058f9d7 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Fri, 16 Aug 2024 21:21:02 +0200 Subject: [PATCH 22/26] Rerun so Codecov might be able to report coverage this time? --- Package.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Package.swift b/Package.swift index 75a4977..9670e86 100644 --- a/Package.swift +++ b/Package.swift @@ -130,6 +130,7 @@ func swiftLintPlugin() -> [Target.PluginUsage] { } } + func swiftLintPackage() -> [PackageDescription.Package.Dependency] { if ProcessInfo.processInfo.environment["SPEZI_DEVELOPMENT_SWIFTLINT"] != nil { [.package(url: "https://github.com/realm/SwiftLint.git", from: "0.55.1")] From d2fa8965e7f98db2bfa0b0b108abbf12220264c5 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Mon, 19 Aug 2024 10:25:37 +0200 Subject: [PATCH 23/26] Use new test extensions --- Tests/UITests/UITests.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index 4dea23a..298dcaf 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -730,8 +730,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/StanfordSpezi/XCTestExtensions"; requirement = { - kind = upToNextMinorVersion; - minimumVersion = 0.4.10; + branch = "feature/improved-text-entry"; + kind = branch; }; }; /* End XCRemoteSwiftPackageReference section */ From 8920a3566fad3527ce1a274e70e33895ea429869 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Mon, 19 Aug 2024 10:43:57 +0200 Subject: [PATCH 24/26] Fix compatibility --- .../UITests/TestAppUITests/FirebaseAccountTests.swift | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift b/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift index 10ff52b..59dabe4 100644 --- a/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift +++ b/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift @@ -207,8 +207,8 @@ final class FirebaseAccountTests: XCTestCase { // swiftlint:disable:this type_bo app.buttons["Name, Username Test"].tap() XCTAssertTrue(app.navigationBars.staticTexts["Name"].waitForExistence(timeout: 10.0)) - try app.textFields["enter last name"].delete(count: 4) - try app.textFields["enter last name"].enter(value: "Test1") + try app.textFields["enter last name"].delete(count: 4, options: .disableKeyboardDismiss) + try app.textFields["enter last name"].enter(value: "Test1", options: .skipTextFieldSelection) app.buttons["Done"].tap() XCTAssertTrue(app.navigationBars.staticTexts["Name, E-Mail Address"].waitForExistence(timeout: 4.0)) @@ -218,8 +218,8 @@ final class FirebaseAccountTests: XCTestCase { // swiftlint:disable:this type_bo app.buttons["E-Mail Address, test@username.edu"].tap() XCTAssertTrue(app.navigationBars.staticTexts["E-Mail Address"].waitForExistence(timeout: 10.0)) - try app.textFields["E-Mail Address"].delete(count: 3) - try app.textFields["E-Mail Address"].enter(value: "de", checkIfTextWasEnteredCorrectly: false) + try app.textFields["E-Mail Address"].delete(count: 3, options: .disableKeyboardDismiss) + try app.textFields["E-Mail Address"].enter(value: "de", options: .skipTextFieldSelection) app.buttons["Done"].tap() @@ -269,10 +269,7 @@ final class FirebaseAccountTests: XCTestCase { // swiftlint:disable:this type_bo XCTAssertTrue(app.navigationBars.staticTexts["Change Password"].waitForExistence(timeout: 2.0)) try app.secureTextFields["enter password"].enter(value: "1234567890") - app.dismissKeyboard() - try app.secureTextFields["re-enter password"].enter(value: "1234567890") - app.dismissKeyboard() app.buttons["Done"].tap() } From 1ae1854c155ff215f453127c105e91f82163ba1a Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Mon, 19 Aug 2024 20:24:27 +0200 Subject: [PATCH 25/26] Versions --- Package.swift | 2 +- Tests/UITests/UITests.xcodeproj/project.pbxproj | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Package.swift b/Package.swift index 9670e86..f750475 100644 --- a/Package.swift +++ b/Package.swift @@ -36,7 +36,7 @@ let package = Package( .package(url: "https://github.com/StanfordSpezi/SpeziFoundation", from: "2.0.0-beta.1"), .package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.7.1"), .package(url: "https://github.com/StanfordSpezi/SpeziViews", from: "1.6.0"), - .package(url: "https://github.com/StanfordSpezi/SpeziAccount", from: "2.0.0-beta.3"), + .package(url: "https://github.com/StanfordSpezi/SpeziAccount", exact: "2.0.0-beta.3"), .package(url: "https://github.com/firebase/firebase-ios-sdk", from: "11.0.0"), .package(url: "https://github.com/apple/swift-atomics.git", from: "1.2.0") ] + swiftLintPackage(), diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index 298dcaf..0dead7b 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -730,8 +730,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/StanfordSpezi/XCTestExtensions"; requirement = { - branch = "feature/improved-text-entry"; - kind = branch; + kind = upToNextMajorVersion; + minimumVersion = 1.0.0; }; }; /* End XCRemoteSwiftPackageReference section */ From ce646300174180d7e00c08a6b0bb1609fb60e6f6 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Tue, 20 Aug 2024 09:14:41 +0200 Subject: [PATCH 26/26] Use latest beta to fix test focus issues --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index f750475..f983c82 100644 --- a/Package.swift +++ b/Package.swift @@ -36,7 +36,7 @@ let package = Package( .package(url: "https://github.com/StanfordSpezi/SpeziFoundation", from: "2.0.0-beta.1"), .package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.7.1"), .package(url: "https://github.com/StanfordSpezi/SpeziViews", from: "1.6.0"), - .package(url: "https://github.com/StanfordSpezi/SpeziAccount", exact: "2.0.0-beta.3"), + .package(url: "https://github.com/StanfordSpezi/SpeziAccount", exact: "2.0.0-beta.4"), .package(url: "https://github.com/firebase/firebase-ios-sdk", from: "11.0.0"), .package(url: "https://github.com/apple/swift-atomics.git", from: "1.2.0") ] + swiftLintPackage(),