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/Package.swift b/Package.swift index bf2fe69..f983c82 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", @@ -25,31 +33,39 @@ 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", from: "1.0.0"), - .package(url: "https://github.com/StanfordSpezi/SpeziAccount", from: "1.2.2"), - .package(url: "https://github.com/firebase/firebase-ios-sdk", from: "10.13.0") - ], + .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.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(), targets: [ .target( 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"), - .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", @@ -57,8 +73,12 @@ let package = Package( .target(name: "SpeziFirebaseConfiguration"), .product(name: "Spezi", package: "Spezi"), .product(name: "FirebaseFirestore", package: "firebase-ios-sdk"), - .product(name: "FirebaseFirestoreSwift", package: "firebase-ios-sdk") - ] + .product(name: "Atomics", package: "swift-atomics") + ], + 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,30 @@ 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", from: "0.55.1")] + } else { + [] + } +} 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/AccountValues/FirebaseEmailVerifiedKey.swift b/Sources/SpeziFirebaseAccount/AccountValues/FirebaseEmailVerifiedKey.swift deleted file mode 100644 index 7d32861..0000000 --- a/Sources/SpeziFirebaseAccount/AccountValues/FirebaseEmailVerifiedKey.swift +++ /dev/null @@ -1,51 +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 - - -/// 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 var name: LocalizedStringResource = "E-Mail Verified" // not translated as never shown - public static var category: AccountKeyCategory = .other - public static var initialValue: InitialValue = .default(false) -} - - -extension AccountKeys { - /// The email-verified ``FirebaseEmailVerifiedKey`` metatype. - public var isEmailVerified: FirebaseEmailVerifiedKey.Type { - FirebaseEmailVerifiedKey.self - } -} - - -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 - - public var body: some View { - Text(verbatim: "The FirebaseEmailVerifiedKey cannot be set!") - } - - public init(_ value: Binding) {} - } -} diff --git a/Sources/SpeziFirebaseAccount/AccountValues/FirebaseOAuthCredential.swift b/Sources/SpeziFirebaseAccount/AccountValues/FirebaseOAuthCredential.swift deleted file mode 100644 index 0fda2a4..0000000 --- a/Sources/SpeziFirebaseAccount/AccountValues/FirebaseOAuthCredential.swift +++ /dev/null @@ -1,77 +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 -} - - -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/FirebaseAccountConfiguration.swift deleted file mode 100644 index 1661daf..0000000 --- a/Sources/SpeziFirebaseAccount/FirebaseAccountConfiguration.swift +++ /dev/null @@ -1,99 +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 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 - - -/// Configures an `AccountService` to interact with Firebase Auth. -/// -/// 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)) -/// // ... -/// } -/// } -/// } -/// ``` -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? - - @Provide private var accountServices: [any AccountService] - - @Model private var accountModel = FirebaseAccountModel() - @Modifier private var firebaseModifier = FirebaseAccountModifier() - - private let emulatorSettings: (host: String, port: Int)? - private let authenticationMethods: FirebaseAuthAuthenticationMethods - - - /// 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. - public init( - authenticationMethods: FirebaseAuthAuthenticationMethods, - emulatorSettings: (host: String, port: Int)? = nil - ) { - self.emulatorSettings = emulatorSettings - self.authenticationMethods = authenticationMethods - self.accountServices = [] - - if authenticationMethods.contains(.emailAndPassword) { - self.accountServices.append(FirebaseEmailPasswordAccountService(accountModel)) - } - if authenticationMethods.contains(.signInWithApple) { - self.accountServices.append(FirebaseIdentityProviderAccountService(accountModel)) - } - } - - public func configure() { - if let emulatorSettings { - Auth.auth().useEmulator(withHost: emulatorSettings.host, port: emulatorSettings.port) - } - - - 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 - """) - } - - Task { - let context = FirebaseContext(local: localStorage, secure: secureStorage) - let firebaseServices = accountServices.compactMap { service in - service as? any FirebaseAccountService - } - - for service in firebaseServices { - await service.configure(with: context) - } - - await context.setup(firebaseServices) - self.context = context // we inject as weak, so ensure to keep the reference here! - } - } -} diff --git a/Sources/SpeziFirebaseAccount/FirebaseAccountService.swift b/Sources/SpeziFirebaseAccount/FirebaseAccountService.swift new file mode 100644 index 0000000..d9bc3e8 --- /dev/null +++ b/Sources/SpeziFirebaseAccount/FirebaseAccountService.swift @@ -0,0 +1,890 @@ +// +// 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 AuthenticationServices +@preconcurrency import FirebaseAuth +import OSLog +import Spezi +import SpeziAccount +import SpeziFirebaseConfiguration +import SpeziFoundation +import SpeziLocalStorage +import SpeziSecureStorage +import SpeziValidation +import SwiftUI + + +private enum UserChange { + case user(_ user: User) + case removed +} + + +private struct UserUpdate { + let change: UserChange + var authResult: AuthDataResult? +} + + +/// 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 +/// +/// class ExampleAppDelegate: SpeziAppDelegate { +/// override var configuration: Configuration { +/// Configuration { +/// AccountConfiguration( +/// service: FirebaseAccountService() +/// configuration: [/* ... */] +/// ) +/// } +/// } +/// } +/// ``` +/// +/// ## Topics +/// +/// ### Configuration +/// +/// - ``init(providers:emulatorSettings:passwordValidation:)`` +/// +/// ### Signup +/// - ``signUpAnonymously()`` +/// - ``signUp(with:)-6qeht`` +/// - ``signUp(with:)-rpy`` +/// +/// ### 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 + \.userId + \.password + \.name + } + + @Application(\.logger) + private var logger + + @Dependency private var configureFirebaseApp = ConfigureFirebaseApp() + @Dependency private var localStorage = LocalStorage() + @Dependency private var secureStorage = SecureStorage() + + @Dependency(Account.self) + private var account + @Dependency(AccountNotifications.self) + private var notifications + @Dependency(ExternalAccountStorage.self) + private var externalStorage + + + @_documentation(visibility: internal) + 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() + + /// 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() + + @MainActor private var authStateDidChangeListenerHandle: AuthStateDidChangeListenerHandle? + @MainActor private var lastNonce: String? + + private var shouldQueue = false + private var queuedUpdates: [UserUpdate] = [] + private var actionSemaphore = AsyncSemaphore() + private var skipNextStateChange = false + + + 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, + passwordValidation: [ValidationRule]? = nil // swiftlint:disable:this discouraged_optional_collection + ) { + self.emulatorSettings = emulatorSettings + + self.configuration = AccountServiceConfiguration(supportedKeys: .exactly(Self.supportedAccountKeys)) { + RequiredAccountKeys { + \.userId + if providers.contains(.emailAndPassword) { + \.password + } + } + + UserIdConfiguration.emailAddress + FieldValidationRules(for: \.userId, rules: .minimalEmail) + FieldValidationRules(for: \.password, rules: passwordValidation ?? [.minimumFirebasePassword]) + } + + if !providers.contains(.emailAndPassword) { + $loginWithPassword.isEnabled = false + } + if !providers.contains(.signInWithApple) { + $signInWithApple.isEnabled = false + } + if providers.contains(.anonymousButton) { + $anonymousSignup.isEnabled = true + } + } + + @_documentation(visibility: internal) + public func configure() { + if let emulatorSettings { + Auth.auth().useEmulator(withHost: emulatorSettings.host, port: emulatorSettings.port) + } + + 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) + } + + // if there is a cached user, we refresh the authentication token + Auth.auth().currentUser?.getIDTokenForcingRefresh(true) { _, error in + if let error { + 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) + } + + // 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() + } + } + } + + 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 { + guard let self else { + return + } + + await handleUpdatedDetailsFromExternalStorage(for: updatedDetails.accountId, details: updatedDetails.details) + } + } + } + + /// 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 + try await Auth.auth().signIn(withEmail: userId, password: password) + logger.debug("signIn(withEmail:password:)") + } + } + + /// 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, signupDetails.contains(AccountKeys.userId) else { + throw FirebaseAccountError.invalidCredentials + } + + 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 { + try await updateDisplayName(of: result.user, displayName) + } + + 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) + logger.debug("createUser(withEmail:password:) for user.") + + logger.debug("Sending email verification link now...") + try await authResult.user.sendEmailVerification() + + if let displayName = signupDetails.name { + try await updateDisplayName(of: authResult.user, displayName) + } + + try await requestExternalStorage(for: authResult.user.uid, details: signupDetails) + + return authResult + } + } + + /// 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, + currentUser.isAnonymous { + logger.debug("Linking oauth credentials with current anonymous user account ...") + let result = try await currentUser.link(with: credential) + + try await notifyUserSignIn(user: currentUser, isNewUser: true) + + return result + } + + let authResult = try await Auth.auth().signIn(with: credential) + logger.debug("signIn(with:) credential for user.") + + // nothing to store externally + + return authResult + } + } + + public func resetPassword(userId: String) async throws { + do { + try await Auth.auth().sendPasswordReset(withEmail: userId) + logger.debug("sendPasswordReset(withEmail:) for user.") + } 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) + } + } + + /// 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 { + notifyUserRemoval() + return + } else { + throw FirebaseAccountError.notSignedIn + } + } + + try await dispatchFirebaseAuthAction { @MainActor in + try Auth.auth().signOut() + try await Task.sleep(for: .milliseconds(10)) + logger.debug("signOut() for user.") + } + } + + /// 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 { + notifyUserRemoval() + } + throw FirebaseAccountError.notSignedIn + } + + try await notifications.reportEvent(.deletingAccount(currentUser.uid)) + + 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.") + return// cancelled + } + + 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 + } + + guard let authorizationCodeString = String(data: authorizationCode, encoding: .utf8) else { + logger.error("Unable to serialize authorizationCode to utf8 string.") + throw FirebaseAccountError.setupError + } + + 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 error.code != AuthErrorCode.invalidCredential.rawValue { + throw error + } +#else + throw error +#endif + } catch { + throw error + } + } + + try await currentUser.delete() + logger.debug("delete() for user.") + } + } + + /// 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 { + notifyUserRemoval() + } + throw FirebaseAccountError.notSignedIn + } + + do { + // if we modify sensitive credentials and require a recent login + 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.") + return // got cancelled! + } + } + + if modifications.modifiedDetails.contains(AccountKeys.userId) { + logger.debug("updateEmail(to:) for user.") + try await currentUser.updateEmail(to: modifications.modifiedDetails.userId) + } + + if let password = modifications.modifiedDetails.password { + logger.debug("updatePassword(to:) for user.") + try await currentUser.updatePassword(to: password) + } + + if let name = modifications.modifiedDetails.name { + try await updateDisplayName(of: currentUser, name) + } + + 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) + } 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) + } + } + + private func reauthenticateUser(user: User) async throws -> ReauthenticationOperation { + // 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 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 + } + } + + 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 { + 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: - Listener and Handler + +extension FirebaseAccountService { + @MainActor + 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("Found existing Firebase account. Supplying 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 { + 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 + +@MainActor +extension FirebaseAccountService { + 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.name?.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 + } + + 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 { + logger.error("Unable to obtain credential as ASAuthorizationAppleIDCredential") + throw FirebaseAccountError.setupError + } + + let credential = try oAuthCredential(from: appleIdCredential) + + logger.info("onAppleSignInCompletion creating firebase apple credential from authorization credential") + + try await signUp(with: credential) + case let .failure(error): + guard let authorizationError = error as? ASAuthorizationError else { + logger.error("onAppleSignInCompletion received unknown error: \(error)") + throw error + } + + 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? { + 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 { + 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 { + 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 + } + + private func oAuthCredential(from credential: ASAuthorizationAppleIDCredential) throws -> OAuthCredential { + guard let lastNonce else { + logger.error("AppleIdCredential was received though no login request was found.") + throw FirebaseAccountError.setupError + } + + guard let identityToken = credential.identityToken else { + logger.error("Unable to fetch identityToken from ASAuthorizationAppleIDCredential.") + throw FirebaseAccountError.setupError + } + + guard let identityTokenString = String(data: identityToken, encoding: .utf8) else { + 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 + ) + } +} + +// MARK: - Infrastructure + +@MainActor +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: () 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: () async throws -> AuthDataResult? + ) async throws { + defer { + shouldQueue = false + actionSemaphore.signal() + } + + shouldQueue = true + try await actionSemaphore.waitCheckingCancellation() + + do { + let result = try await action() + + try await dispatchQueuedChanges(result: result) + } 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) + } + } + + + private func handleUpdatedUserState(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.") + queuedUpdates.append(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 { + defer { + shouldQueue = false + } + + while var queuedUpdate = queuedUpdates.first { + queuedUpdates.removeFirst() + + 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() + } + } + + 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 + } + + // 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 + details.name = nameComponents + } + + if let additionalDetails { + details.add(contentsOf: additionalDetails) + } + + 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) + } + + 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/FirebaseAuthAuthenticationMethods.swift deleted file mode 100644 index f456ddf..0000000 --- a/Sources/SpeziFirebaseAccount/FirebaseAuthAuthenticationMethods.swift +++ /dev/null @@ -1,28 +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 -// - - -/// Definition of the authentication methods supported by the FirebaseAccount module. -public struct FirebaseAuthAuthenticationMethods: 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) - /// 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 let rawValue: Int - - - public init(rawValue: Int) { - self.rawValue = rawValue - } -} diff --git a/Sources/SpeziFirebaseAccount/FirebaseAuthProviders.swift b/Sources/SpeziFirebaseAccount/FirebaseAuthProviders.swift new file mode 100644 index 0000000..30c586a --- /dev/null +++ b/Sources/SpeziFirebaseAccount/FirebaseAuthProviders.swift @@ -0,0 +1,34 @@ +// +// 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 +// + + +/// Authentication Providers supported by the `FirebaseAccountService`. +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 = FirebaseAuthProviders(rawValue: 1 << 0) + /// 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) + + /// Sign in anonymously using a button press. + @_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/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/Account Services/FirebaseAccountError.swift b/Sources/SpeziFirebaseAccount/Models/FirebaseAccountError.swift similarity index 68% rename from Sources/SpeziFirebaseAccount/Account Services/FirebaseAccountError.swift rename to Sources/SpeziFirebaseAccount/Models/FirebaseAccountError.swift index bfccbed..1ea5d04 100644 --- a/Sources/SpeziFirebaseAccount/Account Services/FirebaseAccountError.swift +++ b/Sources/SpeziFirebaseAccount/Models/FirebaseAccountError.swift @@ -6,25 +6,72 @@ // SPDX-License-Identifier: MIT // -import FirebaseAuth +@preconcurrency import FirebaseAuth import Foundation -enum FirebaseAccountError: LocalizedError { +/// 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 - case unknown(AuthErrorCode.Code) - - + /// Encountered an unrecognized provider when trying to re-authenticate the user. + case unsupportedProvider + /// Unrecognized Firebase account error. + 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 { + 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) + } + } +} + + +extension FirebaseAccountError: LocalizedError { private var errorDescriptionValue: String.LocalizationValue { switch self { case .invalidEmail: @@ -43,6 +90,8 @@ enum 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: @@ -54,10 +103,10 @@ enum FirebaseAccountError: LocalizedError { } } - var errorDescription: String? { + public var errorDescription: String? { .init(localized: errorDescriptionValue, bundle: .module) } - + private var recoverySuggestionValue: String.LocalizationValue { switch self { case .invalidEmail: @@ -76,6 +125,8 @@ enum 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: @@ -87,35 +138,7 @@ enum FirebaseAccountError: LocalizedError { } } - var recoverySuggestion: String? { + public var recoverySuggestion: String? { .init(localized: recoverySuggestionValue, bundle: .module) } - - - init(authErrorCode: AuthErrorCode) { - FirebaseEmailPasswordAccountService.logger.debug("Received authError with code \(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) - } - } } 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 1b7d91a..f574b06 100644 --- a/Sources/SpeziFirebaseAccount/Models/FirebaseContext.swift +++ b/Sources/SpeziFirebaseAccount/Models/FirebaseContext.swift @@ -6,8 +6,9 @@ // SPDX-License-Identifier: MIT // -import FirebaseAuth +@preconcurrency import FirebaseAuth import OSLog +import Spezi import SpeziAccount import SpeziLocalStorage import SpeziSecureStorage @@ -19,304 +20,19 @@ private enum UserChange { } private struct UserUpdate { - let service: (any FirebaseAccountService)? let change: UserChange var authResult: AuthDataResult? } -actor FirebaseContext { +final class 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(Account.self) + private var account - private var authStateDidChangeListenerHandle: AuthStateDidChangeListenerHandle? - private var lastActiveAccountServiceId: String? - private var lastActiveAccountService: (any FirebaseAccountService)? - - // 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)") - } - - // get notified about changes of the User reference - authStateDidChangeListenerHandle = Auth.auth().addStateDidChangeListener(stateDidChangeListener) - - // 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(for: self.lastActiveAccountService) - } - } - } - } - - // a overload that just returns void - func dispatchFirebaseAuthAction( - on service: Service, - action: () async throws -> Void - ) async throws { - try await self.dispatchFirebaseAuthAction(on: service) { - 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( - on service: Service, - action: () async throws -> AuthDataResult? - ) async throws { - defer { - cleanupQueuedChanges() - } - - shouldQueue = true - setActiveAccountService(to: service) - - 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 nonisolated func removeCredentials(userId: String, server: String) { - 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 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 { - // 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(service: lastActiveAccountService, change: change) - - 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 - } - - - self.queuedUpdate = nil - anonymouslyDispatch(update: queuedUpdate) - } - - 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 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): - 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. - return - } - - 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 - } - - try await notifyUserSignIn(user: user, for: service, isNewUser: isNewUser) - case .removed: - try await notifyUserRemoval(for: update.service) - } - } - - func notifyUserSignIn(user: User, for service: any FirebaseAccountService, 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 - } - - 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 = builder.build(owner: service) - - // 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) - - try await account.supplyUserDetails(details, isNewUser: isNewUser) - } - - func notifyUserRemoval(for service: (any FirebaseAccountService)?) async throws { - Self.logger.debug("Notifying SpeziAccount of removed user details.") - - await account.removeUserDetails() - - resetActiveAccountService() - } + init() {} } diff --git a/Sources/SpeziFirebaseAccount/Models/ReauthenticationOperationResult.swift b/Sources/SpeziFirebaseAccount/Models/ReauthenticationOperationResult.swift new file mode 100644 index 0000000..186457b --- /dev/null +++ b/Sources/SpeziFirebaseAccount/Models/ReauthenticationOperationResult.swift @@ -0,0 +1,45 @@ +// +// 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 + + +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/Models/ValidationRule+FirebasePassword.swift b/Sources/SpeziFirebaseAccount/Models/ValidationRule+FirebasePassword.swift new file mode 100644 index 0000000..f252636 --- /dev/null +++ b/Sources/SpeziFirebaseAccount/Models/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 { + 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/Resources/Localizable.xcstrings b/Sources/SpeziFirebaseAccount/Resources/Localizable.xcstrings index a6cdf84..8ce50e2 100644 --- a/Sources/SpeziFirebaseAccount/Resources/Localizable.xcstrings +++ b/Sources/SpeziFirebaseAccount/Resources/Localizable.xcstrings @@ -1,38 +1,50 @@ { "sourceLanguage" : "en", "strings" : { - "Authentication Required" : { + "Anonymous Signup" : { "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" : { + "Cancel" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "E-Mail verifiziert" + "value" : "Abbrechen" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "E-Mail Verified" + "value" : "Cancel" } } } @@ -183,6 +195,12 @@ }, "FIREBASE_ACCOUNT_LINK_FAILED_ALREADY_IN_USE" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verbinden der Anmeldedaten fehlgeschlagen" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -193,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", @@ -203,6 +227,12 @@ }, "FIREBASE_ACCOUNT_LINK_FAILED_DUPLICATE" : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verbinden der Anmeldedaten fehlgeschlagen" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -213,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", @@ -349,123 +385,133 @@ } } }, - "FIREBASE_ACCOUNT_WEAK_PASSWORD" : { + "FIREBASE_ACCOUNT_UNSUPPORTED_PROVIDER_ERROR" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Schwaches Passwort" + "value" : "Anbieter nicht unterstützt" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Weak Password" + "value" : "Unsupported Provider" } } } }, - "FIREBASE_ACCOUNT_WEAK_PASSWORD_SUGGESTION" : { + "FIREBASE_ACCOUNT_UNSUPPORTED_PROVIDER_ERROR_SUGGESTION" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Bitte gebe ein stärkeres Password ein." + "value" : "Es wurde kein unterstützer Anbieter gefunden, um deine Identität zu bestätigen." } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Please choose a safer password." + "value" : "Found an unsupported provider when trying to re-authenticate." } } } }, - "FIREBASE_APPLE_FAILED" : { + "FIREBASE_ACCOUNT_WEAK_PASSWORD" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Mit Apple anmelden ist fehlgeschlagen" + "value" : "Schwaches Passwort" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Sign in with Apple failed" + "value" : "Weak Password" } } } }, - "FIREBASE_APPLE_FAILED_SUGGESTION" : { + "FIREBASE_ACCOUNT_WEAK_PASSWORD_SUGGESTION" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Wir hatten Probleme dich mit Apple anzumelden. Bitte versuche es später erneut." + "value" : "Bitte gebe ein stärkeres Password ein." } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "We had issues completing your Sign in with Apple request. Please try again later." + "value" : "Please choose a safer password." } } } }, - "FIREBASE_EMAIL_AND_PASSWORD" : { + "FIREBASE_APPLE_FAILED" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "E-Mail und Password" + "value" : "Mit Apple anmelden ist fehlgeschlagen" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "E-Mail and Password" + "value" : "Sign in with Apple failed" } } } }, - "FIREBASE_IDENTITY_PROVIDER" : { + "FIREBASE_APPLE_FAILED_SUGGESTION" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Single Sign-On" + "value" : "Wir hatten Probleme dich mit Apple anzumelden. Bitte versuche es später erneut." } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Single Sign-On" + "value" : "We had issues completing your Sign in with Apple request. Please try again later." } } } }, "Login" : { - - }, - "OAuth Credential" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "OAuth Berechtigung" + "value" : "Anmelden" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "OAuth Credential" + "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/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/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 new file mode 100644 index 0000000..429c0f4 --- /dev/null +++ b/Sources/SpeziFirebaseAccount/Views/FirebaseLoginView.swift @@ -0,0 +1,30 @@ +// +// 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(FirebaseAccountService.self) + private var service + + + var body: some View { + // 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(with: details) + } resetPassword: { userId in + try await service.resetPassword(userId: userId) + } + } + + nonisolated init() {} +} diff --git a/Sources/SpeziFirebaseAccount/Views/ReauthenticationAlertModifier.swift b/Sources/SpeziFirebaseAccount/Views/FirebaseSecurityAlert.swift similarity index 67% rename from Sources/SpeziFirebaseAccount/Views/ReauthenticationAlertModifier.swift rename to Sources/SpeziFirebaseAccount/Views/FirebaseSecurityAlert.swift index 2eeaca6..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 @@ -23,7 +27,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,12 +35,14 @@ struct ReauthenticationAlertModifier: ViewModifier { } } - private var context: ReauthenticationContext? { + @MainActor private var context: ReauthenticationContext? { firebaseModel.reauthenticationContext } + nonisolated init() {} - func body(content: Content) -> some View { + + public func body(content: Content) -> some View { content .onAppear { isActive = true @@ -48,28 +54,28 @@ struct ReauthenticationAlertModifier: ViewModifier { SecureField(text: $password) { Text(PasswordFieldType.password.localizedStringResource) } - .textContentType(.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 @@ -84,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/SpeziFirebaseAccount/Views/FirebaseSignInWithAppleButton.swift b/Sources/SpeziFirebaseAccount/Views/FirebaseSignInWithAppleButton.swift index edd551a..a206b91 100644 --- a/Sources/SpeziFirebaseAccount/Views/FirebaseSignInWithAppleButton.swift +++ b/Sources/SpeziFirebaseAccount/Views/FirebaseSignInWithAppleButton.swift @@ -6,47 +6,29 @@ // SPDX-License-Identifier: MIT // -import AuthenticationServices +import SpeziAccount import SpeziViews import SwiftUI struct FirebaseSignInWithAppleButton: View { - private let accountService: FirebaseIdentityProviderAccountService + @Environment(FirebaseAccountService.self) + private var service @Environment(\.colorScheme) private var colorScheme - @Environment(\.defaultErrorDescription) - private var defaultErrorDescription @State private var viewState: ViewState = .idle var body: some View { - SignInWithAppleButton(onRequest: { request in - accountService.onAppleSignInRequest(request: request) - }, onCompletion: { result in - Task { - do { - try await accountService.onAppleSignInCompletion(result: result) - } catch { - if let localizedError = error as? LocalizedError { - viewState = .error(localizedError) - } else { - viewState = .error(AnyLocalizedError( - error: error, - defaultErrorDescription: defaultErrorDescription - )) - } - } - } - }) + SignInWithAppleButton(state: $viewState) { request in + service.onAppleSignInRequest(request: request) + } onCompletion: { result in + try await service.onAppleSignInCompletion(result: result) + } .frame(height: 55) - .signInWithAppleButtonStyle(colorScheme == .light ? .black : .white) .viewStateAlert(state: $viewState) } - - init(service: FirebaseIdentityProviderAccountService) { - self.accountService = service - } + nonisolated init() {} } diff --git a/Sources/SpeziFirebaseAccountStorage/FirestoreAccountStorage.swift b/Sources/SpeziFirebaseAccountStorage/FirestoreAccountStorage.swift index 852634f..5a2d199 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 @@ -18,152 +18,230 @@ 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) -/// } -/// } -/// ``` -public actor FirestoreAccountStorage: Module, AccountStorageConstraint { - @Dependency private var firestore: SpeziFirestore.Firestore // ensure firestore is configured - - private let collection: () -> CollectionReference - - - public init(storeIn collection: @Sendable @autoclosure @escaping () -> CollectionReference) { - self.collection = collection +/// ### Configuration +/// - ``init(storeIn:mapping:)`` +public actor FirestoreAccountStorage: AccountStorageProvider { + @Application(\.logger) + private var logger + + @Dependency(Firestore.self) + private var firestore + @Dependency(ExternalAccountStorage.self) + private var externalStorage + @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]] = [:] + + /// 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 } - 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): - guard !data.isEmpty else { + 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 + } + + 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 } - - try await userDocument(for: identifier.accountId) - .setData(data, merge: true) - case let .failure(error): - throw error + + await self.processUpdatedSnapshot(for: accountId, snapshot) } - } catch { - throw FirestoreError(error) } } - public func load(_ identifier: AdditionalRecordId, _ keys: [any AccountKey.Type]) async throws -> PartialAccountDetails { - let builder = PartialAccountDetails.Builder() + 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)) + + 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 snapshot.exists else { + return AccountDetails() + } - let document = userDocument(for: identifier.accountId) + let decoder = Firestore.Decoder() + decoder.userInfo[.accountDetailsKeys] = keys + if let identifierMapping { + decoder.userInfo[.accountKeyIdentifierMapping] = identifierMapping + } 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 - } - } - } + return try snapshot.data(as: AccountDetails.self, decoder: decoder) } catch { - throw FirestoreError(error) + logger.error("Failed to decode account details from firestore snapshot: \(error)") + return AccountDetails() + } + } + + @_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) + + if listenerRegistrations[accountId] != nil { // check that there is a snapshot listener in place + snapshotListener(for: accountId, with: keys) } - return builder.build() + return cached } - public func modify(_ identifier: AdditionalRecordId, _ modifications: AccountModifications) async throws { - let result = modifications.modifiedDetails.acceptAll(FirestoreEncodeVisitor()) + @_documentation(visibility: internal) + public func store(_ accountId: String, _ modifications: SpeziAccount.AccountModifications) async throws { + let document = userDocument(for: accountId) - do { - switch result { - case let .success(data): - try await userDocument(for: identifier.accountId) - .updateData(data) - case let .failure(error): - throw error + if !modifications.modifiedDetails.isEmpty { + do { + 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) } + } + + 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 { + do { + try await document.updateData(removedFields) + } catch { + throw FirestoreError(error) } + } - try await userDocument(for: identifier.accountId) - .updateData(removedFields) - } 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)) + } + + for removedKey in modifications.removedAccountKeys { + keys.removeValue(forKey: ObjectIdentifier(removedKey)) + } + + registeredKeys[accountId] = keys } + + let localCache = localCache + await localCache.communicateModifications(for: accountId, modifications) } - public func clear(_ identifier: AdditionalRecordId) async { - // nothing we can do ... + @_documentation(visibility: internal) + public func disassociate(_ accountId: String) async { + guard let registration = listenerRegistrations.removeValue(forKey: accountId) else { + return + } + registration.remove() + registeredKeys.removeValue(forKey: accountId) + + let localCache = localCache + await localCache.clearEntry(for: accountId) } - public func delete(_ identifier: AdditionalRecordId) async throws { + @_documentation(visibility: internal) + public func delete(_ accountId: String) async throws { + await disassociate(accountId) + do { - try await userDocument(for: identifier.accountId) + try await userDocument(for: accountId) .delete() } catch { throw FirestoreError(error) 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/SpeziFirebaseAccountStorage/Visitor/FirestoreDecodeVisitor.swift b/Sources/SpeziFirebaseAccountStorage/Visitor/FirestoreDecodeVisitor.swift deleted file mode 100644 index 48339e9..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 - - -class FirestoreDecodeVisitor: AccountKeyVisitor { - private let builder: PartialAccountDetails.Builder - private let value: Any - private let reference: DocumentReference - - private var error: Error? - - - init(value: Any, builder: PartialAccountDetails.Builder, in reference: DocumentReference) { - self.value = value - self.builder = builder - self.reference = reference - } - - - func visit(_ key: Key.Type) { - let decoder = Firestore.Decoder() - - do { - try builder.set(key, value: decoder.decode(Key.Value.self, from: value, in: reference)) - } catch { - self.error = error - } - } - - func final() -> Result { - if let error { - return .failure(error) - } else { - return .success(()) - } - } -} 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/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..5f2788e 100644 --- a/Sources/SpeziFirestore/DocumentReference+AsyncAwait.swift +++ b/Sources/SpeziFirestore/DocumentReference+AsyncAwait.swift @@ -6,13 +6,157 @@ // SPDX-License-Identifier: MIT // - -@_exported import FirebaseFirestore -@_exported import FirebaseFirestoreSwift +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 { +#if compiler(>=6) + /// 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 + isolation: isolated (any Actor)? = #isolation, + from value: T, + encoder: FirebaseFirestore.Firestore.Encoder = FirebaseFirestore.Firestore.Encoder() + ) async throws { + try await FirestoreCompletion.perform { completion in + try setData(from: value, encoder: encoder) { error in + completion.complete(with: 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 FirestoreCompletion.perform { completion in + try setData(from: value, merge: merge, encoder: encoder) { error in + completion.complete(with: 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 FirestoreCompletion.perform { completion in + try setData(from: value, mergeFields: mergeFields, encoder: encoder) { error in + completion.complete(with: 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. @@ -36,7 +180,9 @@ 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 /// 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 @@ -64,7 +210,9 @@ 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 /// 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 @@ -96,4 +244,5 @@ extension DocumentReference { throw FirestoreError(error) } } +#endif } diff --git a/Sources/SpeziFirestore/FirebaseEncorderAndDecoder+Sendable.swift b/Sources/SpeziFirestore/FirebaseEncorderAndDecoder+Sendable.swift deleted file mode 100644 index e1ea275..0000000 --- a/Sources/SpeziFirestore/FirebaseEncorderAndDecoder+Sendable.swift +++ /dev/null @@ -1,15 +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 - - -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 4f0fca9..cf3b592 100644 --- a/Sources/SpeziFirestore/Firestore.swift +++ b/Sources/SpeziFirestore/Firestore.swift @@ -8,16 +8,18 @@ import FirebaseCore import FirebaseFirestore -import FirebaseFirestoreSwift import Spezi 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 { @@ -31,8 +33,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/FirestoreErrorCode.swift b/Sources/SpeziFirestore/FirestoreError.swift similarity index 99% rename from Sources/SpeziFirestore/FirestoreErrorCode.swift rename to Sources/SpeziFirestore/FirestoreError.swift index 12ed60c..0ec6d3f 100644 --- a/Sources/SpeziFirestore/FirestoreErrorCode.swift +++ b/Sources/SpeziFirestore/FirestoreError.swift @@ -7,7 +7,6 @@ // import FirebaseFirestore -import FirebaseFirestoreSwift import Foundation diff --git a/Sources/SpeziFirestore/FirestoreSettings+Emulator.swift b/Sources/SpeziFirestore/FirestoreSettings+Emulator.swift index 7f7047b..3fc54d4 100644 --- a/Sources/SpeziFirestore/FirestoreSettings+Emulator.swift +++ b/Sources/SpeziFirestore/FirestoreSettings+Emulator.swift @@ -9,8 +9,11 @@ import FirebaseFirestore -extension FirestoreSettings: @unchecked Sendable { - /// 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). +extension FirestoreSettings { + /// 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/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 74dfc6e..67ce590 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 @@ -23,28 +22,23 @@ struct FirebaseAccountTestsView: View { @State var showSetup = false @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 details.accountService.logout() + try await account.accountService.logout() } } Button("Account Setup") { @@ -53,15 +47,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 { @@ -73,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 deleted file mode 100644 index f0a2a59..0000000 --- a/Tests/UITests/TestApp/FirebaseStorageTests/StorageMetadata+Sendable.swift +++ /dev/null @@ -1,12 +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 - - -extension StorageMetadata: @unchecked Sendable {} diff --git a/Tests/UITests/TestApp/Shared/TestAppDelegate.swift b/Tests/UITests/TestApp/Shared/TestAppDelegate.swift index ea6e81c..df4ba4d 100644 --- a/Tests/UITests/TestApp/Shared/TestAppDelegate.swift +++ b/Tests/UITests/TestApp/Shared/TestAppDelegate.swift @@ -6,47 +6,73 @@ // SPDX-License-Identifier: MIT // +import FirebaseFirestore import Spezi import SpeziAccount +@_spi(Internal) import SpeziFirebaseAccount +import SpeziFirebaseAccountStorage import SpeziFirebaseStorage import SpeziFirestore import SwiftUI class TestAppDelegate: SpeziAppDelegate { - override var configuration: Configuration { - if FeatureFlags.accountStorageTests { - return Configuration(standard: AccountStorageTestStandard(), configurationsClosure) - } else { - return Configuration(configurationsClosure) - } - } + private class Logout: Module { + @Application(\.logger) + private var logger - var configurationsClosure: () -> ModuleCollection { - { - self.configurations + @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") + } + } + } } } - @ModuleBuilder var configurations: ModuleCollection { - if FeatureFlags.accountStorageTests { - AccountConfiguration(configuration: [ + override var configuration: Configuration { + Configuration { + let configuration: AccountValueConfiguration = FeatureFlags.accountStorageTests + ? [ .requires(\.userId), .requires(\.name), .requires(\.biography) - ]) - } else { - AccountConfiguration(configuration: [ + ] + : [ .requires(\.userId), .collects(\.name) - ]) + ] + + let service = FirebaseAccountService( + providers: [.emailAndPassword, .signInWithApple, .anonymousButton], + emulatorSettings: (host: "localhost", port: 9099) + ) + + if FeatureFlags.accountStorageTests { + AccountConfiguration( + service: service, + storageProvider: FirestoreAccountStorage(storeIn: Firestore.firestore().collection("users")), + configuration: configuration + ) + } else { + AccountConfiguration(service: service, configuration: configuration) + } + + Logout() + + Firestore(settings: .emulator) + FirebaseStorageConfiguration(emulatorSettings: (host: "localhost", port: 9199)) } - Firestore(settings: .emulator) - FirebaseAccountConfiguration( - authenticationMethods: [.emailAndPassword, .signInWithApple], - emulatorSettings: (host: "localhost", port: 9099) - ) - FirebaseStorageConfiguration(emulatorSettings: (host: "localhost", port: 9199)) } } diff --git a/Tests/UITests/TestAppUITests/FirebaseAccountStorageTests.swift b/Tests/UITests/TestAppUITests/FirebaseAccountStorageTests.swift index 94176b2..58c80e6 100644 --- a/Tests/UITests/TestAppUITests/FirebaseAccountStorageTests.swift +++ b/Tests/UITests/TestAppUITests/FirebaseAccountStorageTests.swift @@ -11,29 +11,26 @@ 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)) } + @MainActor func testAdditionalAccountStorage() async throws { let app = XCUIApplication() app.launchArguments = ["--account-storage"] app.launch() - 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() - } + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 2.0)) + XCTAssert(app.buttons["FirebaseAccount"].waitForExistence(timeout: 2.0)) + app.buttons["FirebaseAccount"].tap() try app.signup( username: "test@username1.edu", diff --git a/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift b/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift index c50ef98..59dabe4 100644 --- a/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift +++ b/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift @@ -15,36 +15,32 @@ 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() - + 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() 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")] ) } @@ -156,13 +142,11 @@ 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() - 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)) @@ -205,12 +189,10 @@ final class FirebaseAccountTests: XCTestCase { // swiftlint:disable:this type_bo app.launchArguments = ["--firebaseAccount"] app.launch() - XCTAssert(app.buttons["FirebaseAccount"].waitForExistence(timeout: 10.0)) - app.buttons["FirebaseAccount"].tap() + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 4.0)) - if app.buttons["Logout"].waitForExistence(timeout: 5.0) && app.buttons["Logout"].isHittable { - app.buttons["Logout"].tap() - } + XCTAssert(app.buttons["FirebaseAccount"].waitForExistence(timeout: 2.0)) + app.buttons["FirebaseAccount"].tap() try app.login(username: "test@username.edu", password: "TestPassword") XCTAssert(app.staticTexts["test@username.edu"].waitForExistence(timeout: 10.0)) @@ -225,19 +207,19 @@ 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() - 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() 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() @@ -247,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() @@ -266,31 +248,28 @@ 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) - try app.secureTextFields["enter password"].enter(value: "1234567890") - app.dismissKeyboard() + XCTAssertTrue(app.navigationBars.staticTexts["Change Password"].waitForExistence(timeout: 2.0)) + + try app.secureTextFields["enter password"].enter(value: "1234567890") try app.secureTextFields["re-enter password"].enter(value: "1234567890") - app.dismissKeyboard() app.buttons["Done"].tap() } @@ -308,11 +287,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) @@ -347,11 +328,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! @@ -367,10 +350,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)) @@ -399,18 +378,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. @@ -428,10 +407,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 @@ -446,22 +421,27 @@ 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 { - app.buttons["Logout"].tap() - } + XCTAssertTrue(app.buttons["Account Setup"].exists) + app.buttons["Account Setup"].tap() - XCTAssertTrue(app.buttons["Login Anonymously"].waitForExistence(timeout: 2.0)) - app.buttons["Login Anonymously"].tap() + XCTAssertTrue(app.buttons["Anonymous Signup"].waitForExistence(timeout: 4.0)) + app.buttons["Anonymous Signup"].tap() - XCTAssertTrue(app.staticTexts["User, Anonymous"].waitForExistence(timeout: 5.0)) + XCTAssertTrue(app.buttons["Close"].exists) + app.buttons["Close"].tap() + + 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,48 +454,44 @@ 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)) try textFields["E-Mail Address"].enter(value: username) try secureTextFields["Password"].enter(value: password) - - swipeUp() scrollViews.buttons["Login"].tap() + if close { - sleep(3) + XCTAssertTrue(staticTexts[username].waitForExistence(timeout: 5.0)) 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() } } -// swiftlint:disable:this file_length diff --git a/Tests/UITests/TestAppUITests/FirebaseStorageTests.swift b/Tests/UITests/TestAppUITests/FirebaseStorageTests.swift index 1638cee..e841580 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,23 +36,26 @@ 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] { +} + + +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( @@ -67,28 +68,28 @@ 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 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)") ) 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 93f7a1c..a81d474 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() @@ -200,8 +202,11 @@ final class FirestoreDataStorageTests: XCTestCase { try app.textFields[contentFieldIdentifier].delete(count: 100) try app.textFields[contentFieldIdentifier].enter(value: content) } - - private func deleteAllDocuments() async throws { +} + + +extension FirestoreDataStorageTests { + private static func deleteAllDocuments() async throws { let emulatorDocumentsURL = try XCTUnwrap( URL(string: "http://localhost:8080/emulator/v1/projects/spezifirebaseuitests/databases/(default)/documents") ) @@ -209,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( @@ -224,12 +229,12 @@ 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/") ) let (data, response) = try await URLSession.shared.data(from: documentsURL) - + guard let urlResponse = response as? HTTPURLResponse, 200...299 ~= urlResponse.statusCode else { print( @@ -242,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 6cbb278..0dead7b 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 */; }; @@ -29,7 +28,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 */ @@ -60,13 +58,11 @@ 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 = ""; }; 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 */ @@ -181,7 +177,6 @@ isa = PBXGroup; children = ( 97359F632ADB27500080CB11 /* FirebaseStorageTestsView.swift */, - 97359F652ADB286D0080CB11 /* StorageMetadata+Sendable.swift */, ); path = FirebaseStorageTests; sourceTree = ""; @@ -190,7 +185,6 @@ isa = PBXGroup; children = ( A9D83F9A2B0BDB1D000D0C78 /* BiographyKey.swift */, - A9D83FA12B0BE048000D0C78 /* AccountStorageTestStandard.swift */, ); path = FirebaseAccountStorage; sourceTree = ""; @@ -308,11 +302,9 @@ 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 */, - A9D83FA22B0BE048000D0C78 /* AccountStorageTestStandard.swift in Sources */, 2F8A431729130BBC005D2B8F /* TestAppType.swift in Sources */, 2F9F07F129090B0500CDC598 /* TestAppDelegate.swift in Sources */, A9D83F992B0BDB13000D0C78 /* FeatureFlags.swift in Sources */, @@ -402,6 +394,7 @@ SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = complete; }; name = Debug; }; @@ -456,6 +449,7 @@ SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_STRICT_CONCURRENCY = complete; VALIDATE_PRODUCT = YES; }; name = Release; @@ -634,6 +628,7 @@ SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = TEST; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = complete; }; name = Test; }; @@ -735,8 +730,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/StanfordSpezi/XCTestExtensions"; requirement = { - kind = upToNextMinorVersion; - minimumVersion = 0.4.10; + kind = upToNextMajorVersion; + minimumVersion = 1.0.0; }; }; /* End XCRemoteSwiftPackageReference section */ 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 e260efd..0000000 --- a/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,194 +0,0 @@ -{ - "pins" : [ - { - "identity" : "abseil-cpp-binary", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/abseil-cpp-binary.git", - "state" : { - "revision" : "748c7837511d0e6a507737353af268484e1745e2", - "version" : "1.2024011601.1" - } - }, - { - "identity" : "app-check", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/app-check.git", - "state" : { - "revision" : "076b241a625e25eac22f8849be256dfb960fcdfe", - "version" : "10.19.1" - } - }, - { - "identity" : "firebase-ios-sdk", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/firebase-ios-sdk", - "state" : { - "revision" : "8bcaf973b1d84e119b7c7c119abad72ed460979f", - "version" : "10.27.0" - } - }, - { - "identity" : "googleappmeasurement", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleAppMeasurement.git", - "state" : { - "revision" : "70df02431e216bed98dd461e0c4665889245ba70", - "version" : "10.27.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" : "0382ca27f22fb3494cf657d8dc356dc282cd1193", - "version" : "3.4.1" - } - }, - { - "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" : "spezi", - "kind" : "remoteSourceControl", - "location" : "https://github.com/StanfordSpezi/Spezi", - "state" : { - "revision" : "734f90c19422a4196762b0e1dd055471066e89ee", - "version" : "1.3.0" - } - }, - { - "identity" : "speziaccount", - "kind" : "remoteSourceControl", - "location" : "https://github.com/StanfordSpezi/SpeziAccount", - "state" : { - "revision" : "2de07209430fe7b13c44790eab948b30482fcb9d", - "version" : "1.2.4" - } - }, - { - "identity" : "spezifoundation", - "kind" : "remoteSourceControl", - "location" : "https://github.com/StanfordSpezi/SpeziFoundation", - "state" : { - "revision" : "01af5b91a54f30ddd121258e81aff2ddc2a99ff9", - "version" : "1.0.4" - } - }, - { - "identity" : "spezistorage", - "kind" : "remoteSourceControl", - "location" : "https://github.com/StanfordSpezi/SpeziStorage", - "state" : { - "revision" : "b958df9b31f24800388a7bfc28f457ce7b82556c", - "version" : "1.0.2" - } - }, - { - "identity" : "speziviews", - "kind" : "remoteSourceControl", - "location" : "https://github.com/StanfordSpezi/SpeziViews.git", - "state" : { - "revision" : "4d2a724d97c8f19ac7de7aa2c046b1cb3ef7b279", - "version" : "1.3.1" - } - }, - { - "identity" : "swift-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-collections.git", - "state" : { - "revision" : "ee97538f5b81ae89698fd95938896dec5217b148", - "version" : "1.1.1" - } - }, - { - "identity" : "swift-protobuf", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-protobuf.git", - "state" : { - "revision" : "9f0c76544701845ad98716f3f6a774a892152bcb", - "version" : "1.26.0" - } - }, - { - "identity" : "xctestextensions", - "kind" : "remoteSourceControl", - "location" : "https://github.com/StanfordSpezi/XCTestExtensions", - "state" : { - "revision" : "1fe9b8e76aeb7a132af37bfa0892160c9b662dcc", - "version" : "0.4.10" - } - }, - { - "identity" : "xctruntimeassertions", - "kind" : "remoteSourceControl", - "location" : "https://github.com/StanfordBDHG/XCTRuntimeAssertions", - "state" : { - "revision" : "51da3403f128b120705571ce61e0fe190f8889e6", - "version" : "1.0.1" - } - } - ], - "version" : 2 -} 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">