0

This is what I am doing:

-> Login/Signup to Firebase using FirebaseAuthentification

-> Listining to AuthStateDidChangeListenerHandle

-> I store extra user information in Firestore, therefore I check if the user exists in Firestore

-> If the user does not exist I create an empty user

-> If everything was successful I return a Future Publisher via callback (I want to change that as well)

This is the checkLoginState function:

func checkLoginState(completion: @escaping (AnyPublisher<AccountDetails,Error>) -> Void) {
    self.handler = Auth.auth().addStateDidChangeListener { [weak self] auth, user in
        guard let safeSelf = self else { return }
        completion(Future<AccountDetails,Error> { promise in
            if let user = user {
                print(user)
                print(auth)

                safeSelf.checkIfUserIsInDatabase(user: user.uid) { result in
                    switch result {
                    case .success(let isAvailable):
                        if isAvailable {
                             promise(.success(AccountDetails(userUID: user.uid,name: user.displayName, loggedIn: true, premiumUser: false)))
                        } else {
                            safeSelf.createEmptyUser(user: user.uid,email: user.email) { result in
                                switch result {
                                case .success(_):
                                    promise(.success(AccountDetails(userUID: user.uid,name: user.displayName, loggedIn: true, premiumUser: false)))
                                case .failure(let error):
                                    print(error)
                                }
                            }
                        }
                    case .failure(let error):
                        print(error)
                    }
                }
            } else {
                promise(.success(AccountDetails(userUID: nil, loggedIn: false, premiumUser: false)))
            }
            }.eraseToAnyPublisher()
        )
    }
}

These are my current functions:

private func checkIfUserIsInDatabase(user id: String, completion: @escaping (Result<Bool,Error>) -> Void)

private func createEmptyUser(user id: String, email:String?, completion: @escaping (Result<Bool,Error>) -> Void)

Thats what I want to use:

private func checkIfUserIsInDatabase(user id: String) -> AnyPublisher<Bool,Error>

private func createEmptyUser(user id: String) -> AnyPublisher<Bool,Error>

func checkLoginState() -> AnyPublisher<AccountDetails,Error>

I had something like that, but it does not work, also looks confusing:

func checkLoginState(completion: @escaping (AnyPublisher<AccountDetails,Error>) -> Void) {
    self.handler = Auth.auth().addStateDidChangeListener { [weak self] auth, user in
        guard let safeSelf = self else { return }
        completion(Future<AccountDetails,Error> { promise in
            if let user = user {
                print(user)
                print(auth)

                safeSelf.checkIfUserIsInDatabase(user: user.uid)
                    .sinkToResult { value in
                        switch value {
                        case .success(let isUserInDatabase):
                            if isUserInDatabase {
                                promise(.success(AccountDetails(userUID: user.uid,name: user.displayName, loggedIn: true, premiumUser: false)))
                            } else {
                                safeSelf.createEmptyUser(user: user.uid)
                                    .sinkToResult { value in
                                        switch value {
                                        case .success( _):
                                            promise(.success(AccountDetails(userUID: user.uid,name: user.displayName, loggedIn: true, premiumUser: false)))
                                        case .failure(let error):
                                            print(error)
                                        }
                                }
                            }
                        case .failure(let error):
                            print(error)
                        }

                }
            } else {
                promise(.success(AccountDetails(userUID: nil, loggedIn: false, premiumUser: false)))
            }
        }.eraseToAnyPublisher()
        )
    }
}
PaFi
  • 888
  • 1
  • 9
  • 24
  • What’s the question? Is it that you don’t know how sequentialize three asynchronous operations with Combine? Or is it that you don’t know how to express an asynchronous operation as a Future? Or is it the if/then choice that your pipeline needs to make? – matt Mar 07 '20 at 15:09
  • Yes basically I tried to use combineLatest, but I do not want them to happen at the same time, but with the result of the others – PaFi Mar 07 '20 at 15:12
  • 1
    Ok cool! You’re looking for `flatMap`. Take a look at my explanation here: http://www.apeth.com/UnderstandingCombine/operators/operatorsflatmap.html#SECSerializingAsynchronicity – matt Mar 07 '20 at 15:13
  • 1
    See also https://stackoverflow.com/a/60418000/341994 – matt Mar 07 '20 at 15:20
  • What is the declaration of `addStateDidChangeListener`? Does it call its closure multiple times? – rob mayoff Mar 08 '20 at 06:57
  • Its declaration is: " - (nonnull FIRAuthStateDidChangeListenerHandle)addAuthStateDidChangeListener: (nonnull FIRAuthStateDidChangeListenerBlock)listener; " it is part of the Firebase framework. It gets called whenever: " + The block is registered as a listener, + A user with a different UID from the current user has signed in, or + The current user has signed out. " – PaFi Mar 08 '20 at 11:00
  • Can you also edit your question to include the definitions of `checkIfUserIsInDatabase` and `createEmptyUser`? – rob mayoff Mar 08 '20 at 18:57

2 Answers2

5

So you have some AccountDetails type:

import Combine
import FirebaseAuth

struct AccountDetails {
    var userId: String
    var name: String?
    var isLoggedIn: Bool
    var isPremiumUser: Bool
}

Let's extend it with an init that takes a User, because it will simplify things later:

extension AccountDetails {
    init(user: User) {
        self.userId = user.uid
        self.name = user.displayName
        self.isLoggedIn = true
        self.isPremiumUser = false
    }
}

I think your end goal is a Publisher that emits AccountDetails. But since there isn't always a logged-in user, it should really emit Optional<AccountDetails>, so that it can emit nil when the user logs out.

Let's start by wrapping the addStateDidChangeListener API in a Publisher. We can't use a Future for this, because a Future emits at most one output, but addStateDidChangeListener can emit multiple events. So we'll use a CurrentValueSubject instead. That means we need a place to store the subject and the AuthStateDidChangeListenerHandle. You could store them as globals, or in your AppDelegate, or wherever you feel is appropriate. For this answer, let's create a Demo class to hold them:

class Demo {
    static let shared = Demo()

    let userPublisher: AnyPublisher<User?, Error>

    private let userSubject = CurrentValueSubject<User?, Error>(nil)
    private var tickets: [AnyCancellable] = []

    private init() {
        userPublisher = userSubject.eraseToAnyPublisher()
        let handle = Auth.auth().addStateDidChangeListener { [userSubject] (_, user) in
            userSubject.send(user)
        }
        AnyCancellable { Auth.auth().removeStateDidChangeListener(handle) }
            .store(in: &tickets)
    }
}

So now you can get a Publisher of the logged-in user (or nil if no user is logged in) like this:

let loggedInUserPublisher: AnyPublisher<User?, Error> = Demo.shared.userPublisher

But you really want an AccountDetails? publisher, not a User? publisher, like this:

let accountDetailsPublisher: AnyPublisher<AccountDetails?, Error> = Demo.shared
    .accountDetailsPublisher()

So we need to write an accountDetailsPublisher method that maps the User? to an AccountDetails?.

If the User? is nil, we just want to emit nil. But if the User? is .some(user), we need to do more asynchronous actions: we need to check whether the user is in the database, and add the user if not. The flatMap operator lets you chain asynchronous actions, but there's some complexity because we need to take different actions depending on the output of the upstream publisher.

We'd really like to hide the complexity away and just write this:

extension Demo {
    func loggedInAccountDetailsPublisher() -> AnyPublisher<AccountDetails?, Error> {
        return userPublisher
            .flatMap(
                ifSome: { $0.accountDetailsPublisher().map { Optional.some($0) } },
                ifNone: { Just(nil).setFailureType(to: Error.self) })
            .eraseToAnyPublisher()
    }
}

But then we need to write flatMap(ifSome:ifNone:). Here it is:

extension Publisher {
    func flatMap<Wrapped, Some: Publisher, None: Publisher>(
        ifSome: @escaping (Wrapped) -> Some,
        ifNone: @escaping () -> None
    ) -> AnyPublisher<Some.Output, Failure>
        where Output == Optional<Wrapped>, Some.Output == None.Output, Some.Failure == Failure, None.Failure == Failure
    {
        return self
            .flatMap { $0.map { ifSome($0).eraseToAnyPublisher() } ?? ifNone().eraseToAnyPublisher() }
            .eraseToAnyPublisher()
    }
}

Now we need to implement accountDetailsPublisher in a User extension. What does this method need to do? It needs to check whether the User is in the database (an asynchronous action) and, if not, add the User (another asynchronous action). Since we need to chain asynchronous actions, we again need flatMap. But we'd really like to just write this:

extension User {
    func accountDetailsPublisher() -> AnyPublisher<AccountDetails, Error> {
        return isInDatabasePublisher()
            .flatMap(
                ifTrue: { Just(AccountDetails(user: self)).setFailureType(to: Error.self) },
                ifFalse: { self.addToDatabase().map { AccountDetails(user: self) } })
    }
}

Here is flatMap(ifTrue:ifFalse:):

extension Publisher where Output == Bool {
    func flatMap<True: Publisher, False: Publisher>(
        ifTrue: @escaping () -> True,
        ifFalse: @escaping () -> False
    ) -> AnyPublisher<True.Output, Failure>
        where True.Output == False.Output, True.Failure == Failure, False.Failure == Failure
    {
        return self
            .flatMap { return $0 ? ifTrue().eraseToAnyPublisher() : ifFalse().eraseToAnyPublisher() }
            .eraseToAnyPublisher()
    }
}

Now we need to write isInDatabasePublisher and addToDatabase methods on User. I don't have the source code to your checkIfUserIsInDatabase and createEmptyUser functions, so I can't convert them to publishers directly. But we can wrap them using Future:

extension User {
    func isInDatabasePublisher() -> AnyPublisher<Bool, Error> {
        return Future { promise in
            checkIfUserIsInDatabase(user: self.uid, completion: promise)
        }.eraseToAnyPublisher()
    }

    func addToDatabase() -> AnyPublisher<Void, Error> {
        return Future { promise in
            createEmptyUser(user: self.uid, email: self.email, completion: promise)
        } //
            .map { _ in } // convert Bool to Void
            .eraseToAnyPublisher()
    }
}

Note that, since your example code ignores the Bool output of createEmptyUser, I wrote addToDatabase to output Void instead.

rob mayoff
  • 375,296
  • 67
  • 796
  • 848
0

Thats what I came up with:

Reference by matt: http://www.apeth.com/UnderstandingCombine/operators/operatorsflatmap.html#SECSerializingAsynchronicity https://stackoverflow.com/a/60418000/341994

 var handler: AuthStateDidChangeListenerHandle?
 var storage = Set<AnyCancellable>()

    func checkLoginState(completion: @escaping (AnyPublisher<AccountDetails,Error>) -> Void) {
        self.handler = Auth.auth().addStateDidChangeListener { [weak self] auth, user in
            guard let safeSelf = self else { return }
            completion(Future<AccountDetails,Error> { promise in
                if let user = user {
                    safeSelf.handleUserInDatabase(user: user.uid)
                    .sink(receiveCompletion: { completion in
                        if let error = completion.error  {
                            print(error.localizedDescription)
                            promise(.failure(error))
                        }
                    }, receiveValue: { result in
                        if result {
                            promise(.success(AccountDetails(userUID: user.uid,name: user.displayName, loggedIn: true, premiumUser: false)))
                        }
                    }).store(in: &safeSelf.storage)
                } else {
                    promise(.success(AccountDetails(userUID: nil, loggedIn: false, premiumUser: false)))
                }
            }.eraseToAnyPublisher())
        }
    }


    /// Checks if User exists in Firestore, if not creates an empty User and returns true
    private func handleUserInDatabase(user: String) -> AnyPublisher<Bool,Error> {
        return Future<Bool,Error>( { [weak self] promise in
            guard let safeSelf = self else { return }
            safeSelf.checkIfUserIsInDatabase(user: user)
                .flatMap { result -> AnyPublisher<Bool,Error> in
                    if result == false {
                        return safeSelf.createEmptyUser(user: user).eraseToAnyPublisher()
                    } else {
                        promise(.success(true))
                        return Empty<Bool,Error>(completeImmediately: true).eraseToAnyPublisher()
                    }}
                .sink(receiveCompletion: { completion in
                    if let error = completion.error {
                        promise(.failure(error))
                    }}, receiveValue: {promise(.success($0))})
                .store(in:&safeSelf.storage)
            }
        ).eraseToAnyPublisher()
    }
PaFi
  • 888
  • 1
  • 9
  • 24