3
\$\begingroup\$

I'm new to MVVM architecture. I want to have authentication with email + password, google and Facebook. I'm not sure if there should be separate view models for each authentication way:

EmailPasswordAuthViewModel GoogleAuthViewModel FacebookAuthViewModel

or one view model for authentication with credentials and one with email and password authentication?

CredentialsAuthViewModel EmailPasswordAuthViewModel

or the way how I've got it working now, a single view model for everything AuthViewModel.

I had to remove imports, because I wasn't able to post the question. Please find it here: https://gitlab.com/SC5Shout/PackIT/-/tree/Login?ref_type=heads

This is how the code looks like:

AuthRepository

public class AuthRepository {
    private static volatile AuthRepository instance;
    private final FirebaseAuth firebaseAuth = FirebaseAuth.getInstance();
    private final FirebaseFirestore rootRef = FirebaseFirestore.getInstance();
    private final CollectionReference usersRef = rootRef.collection("users");

    // private constructor : singleton access
    private AuthRepository() {}

    public static AuthRepository getInstance() {
        if (instance == null) {
            instance = new AuthRepository();
        }
        return instance;
    }

    MutableLiveData<SignInResult> signInWithCredentials(AuthCredential authCredential) {
        MutableLiveData<SignInResult> authenticationResultMutableLiveData = new MutableLiveData<>();
        firebaseAuth.signInWithCredential(authCredential).addOnCompleteListener(authTask -> {
            if (authTask.isSuccessful()) {
                boolean isNewUser = Objects.requireNonNull(authTask.getResult().getAdditionalUserInfo()).isNewUser();
                FirebaseUser firebaseUser = firebaseAuth.getCurrentUser();
                if (firebaseUser != null) {
                    String uid = firebaseUser.getUid();
                    String name = firebaseUser.getDisplayName();
                    String email = firebaseUser.getEmail();
                    User user = new User(uid, name, email);
                    user.isNew = isNewUser;
                    authenticationResultMutableLiveData.postValue(new SignInResult(user));
                }
            } else {
                authenticationResultMutableLiveData.postValue(new SignInResult(Objects.requireNonNull(authTask.getException()).getMessage()));
                logErrorMessage(Objects.requireNonNull(authTask.getException()).getMessage());
            }
        });
        return authenticationResultMutableLiveData;
    }

    MutableLiveData<SignInResult> signInWithEmailPassword(String email, String password) {
        MutableLiveData<SignInResult> authenticatedUserMutableLiveData = new MutableLiveData<>();
        firebaseAuth.signInWithEmailAndPassword(email, password).addOnCompleteListener(authTask -> {
            if (authTask.isSuccessful()) {
                boolean isNewUser = Objects.requireNonNull(authTask.getResult().getAdditionalUserInfo()).isNewUser();
                FirebaseUser firebaseUser = firebaseAuth.getCurrentUser();
                if (firebaseUser != null) {
                    User user = new User(firebaseUser.getUid(), firebaseUser.getDisplayName(), firebaseUser.getEmail());
                    user.isNew = isNewUser;
                    authenticatedUserMutableLiveData.postValue(new SignInResult(user));
                }
            } else {
                authenticatedUserMutableLiveData.postValue(new SignInResult(Objects.requireNonNull(authTask.getException()).getMessage()));
                logErrorMessage(Objects.requireNonNull(authTask.getException()).getMessage());
            }
        });
        return authenticatedUserMutableLiveData;
    }

    MutableLiveData<SignInResult> signUpWithEmailPassword(String name, String email, String password) {
        MutableLiveData<SignInResult> authenticationResultMutableLiveData = new MutableLiveData<>();
        firebaseAuth.createUserWithEmailAndPassword(email, password).addOnCompleteListener(authTask -> {
            if (authTask.isSuccessful()) {
                boolean isNewUser = Objects.requireNonNull(authTask.getResult().getAdditionalUserInfo()).isNewUser();
                FirebaseUser firebaseUser = firebaseAuth.getCurrentUser();
                if (firebaseUser != null) {
                    firebaseUser.updateProfile(new UserProfileChangeRequest.Builder().setDisplayName(name).build());

                    User user = new User(firebaseUser.getUid(), name, firebaseUser.getEmail());
                    user.isNew = isNewUser;
                    authenticationResultMutableLiveData.postValue(new SignInResult(user));
                }
            } else {
                authenticationResultMutableLiveData.postValue(new SignInResult(Objects.requireNonNull(authTask.getException()).getMessage()));
                logErrorMessage(Objects.requireNonNull(authTask.getException()).getMessage());
            }
        });
        return authenticationResultMutableLiveData;
    }

    MutableLiveData<User> createUserInFirestoreIfNotExists(User authenticatedUser) {
        MutableLiveData<User> newUserMutableLiveData = new MutableLiveData<>();
        DocumentReference uidRef = usersRef.document(authenticatedUser.uid);
        uidRef.get().addOnCompleteListener(uidTask -> {
            if (uidTask.isSuccessful()) {
                DocumentSnapshot document = uidTask.getResult();
                if (!document.exists()) {
                    uidRef.set(authenticatedUser).addOnCompleteListener(userCreationTask -> {
                        if (userCreationTask.isSuccessful()) {
                            authenticatedUser.isCreated = true;
                            newUserMutableLiveData.postValue(authenticatedUser);
                        } else {
                            logErrorMessage(Objects.requireNonNull(userCreationTask.getException()).getMessage());
                        }
                    });
                } else {
                    newUserMutableLiveData.postValue(authenticatedUser);
                }
            } else {
                logErrorMessage(Objects.requireNonNull(uidTask.getException()).getMessage());
            }
        });
        return newUserMutableLiveData;
    }

    public void logErrorMessage(String errorMessage) {
        Log.d("AuthRepository", errorMessage);
    }
}

SignInResult

public class SignInResult {
    @Nullable
    private User success;

    @Nullable
    private String errorMessage;

    SignInResult(@Nullable String errorMessage) {
        this.errorMessage = errorMessage;
    }
    SignInResult(@Nullable User success) {
        this.success = success;
    }

    @Nullable
    User getSuccess() {
        return success;
    }
    @Nullable
    String getErrorMessage() {
        return errorMessage;
    }
}

User

public class User implements Serializable {
    public String uid;
    public String name;
    @SuppressWarnings("WeakerAccess")
    public String email;
    @Exclude
    public boolean isAuthenticated;
    @Exclude
    public
    boolean isNew;
    @Exclude
    public
    boolean isCreated;

    public User() {}

    public User(String uid, String name, String email) {
        this.uid = uid;
        this.name = name;
        this.email = email;
    }
}

AuthViewModel

public class AuthViewModel extends ViewModel {
    private final AuthRepository authRepository;
    MutableLiveData<SignInResult> authenticationResult = new MutableLiveData<>();
    MutableLiveData<User> createdUserLiveData = new MutableLiveData<>();

    public AuthViewModel(AuthRepository authRepository) {
        this.authRepository = authRepository;
    }

    void signInWithCredentials(AuthCredential googleAuthCredential) {
        authenticationResult = authRepository.signInWithCredentials(googleAuthCredential);
    }

    void signInWithEmailPassword(String email, String password) {
        authenticationResult = authRepository.signInWithEmailPassword(email, password);
    }

    void signUpWithEmailPassword(String name, String email, String password) {
        authenticationResult = authRepository.signUpWithEmailPassword(name, email, password);
    }

    void createUser(User authenticatedUser) {
        createdUserLiveData = authRepository.createUserInFirestoreIfNotExists(authenticatedUser);
    }
}

AuthViewModelFactory

public class AuthViewModelFactory implements ViewModelProvider.Factory {
    @NonNull
    @Override
    @SuppressWarnings("unchecked")
    public <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
        if (modelClass.isAssignableFrom(AuthViewModel.class)) {
            return (T) new AuthViewModel(AuthRepository.getInstance());
        } else {
            throw new IllegalArgumentException("Unknown ViewModel class");
        }
    }
}

A user can authenticate with google and facebook with just one button click, login and registration with email + password require a new fragment to be displayed.

EmailPasswordLoginFragment

public class EmailPasswordLoginFragment extends Fragment {
    FragmentEmailPasswordLoginBinding binding;
    AuthViewModel authViewModel;

    public EmailPasswordLoginFragment() {}

    @Override
    public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
        binding = FragmentEmailPasswordLoginBinding.inflate(inflater, container, false);
        return binding.getRoot();
    }

    public void onViewCreated(@NonNull View view, Bundle savedInstanceState) {
        super.onViewCreated(view, savedInstanceState);

        authViewModel = new ViewModelProvider(this, new AuthViewModelFactory()).get(AuthViewModel.class);

        var backButton = binding.backButton;
        backButton.setOnClickListener(v -> {
            NavHostFragment.findNavController(EmailPasswordLoginFragment.this).navigate(R.id.nav_to_auth_menu);
        });

        var loginProgress = binding.loginProgress;
        var emailText = binding.email;
        var passwordText = binding.password;
        var recoverPasswordButton = binding.recoverPassword;

        var loginButton = binding.login;
        loginButton.setOnClickListener(v -> {
            loginProgress.setVisibility(View.VISIBLE);
            authViewModel.signInWithEmailPassword(emailText.getText().toString(), passwordText.getText().toString());
            authViewModel.authenticationResult.observe(getViewLifecycleOwner(), authenticationResult -> {
                loginProgress.setVisibility(View.GONE);

                if(authenticationResult.getErrorMessage() != null ) {
                    showLoginFailed(authenticationResult.getErrorMessage());
                    return;
                }

                var authenticatedUser = authenticationResult.getSuccess();
                if (authenticatedUser.isNew) {
                    createNewUser(authenticatedUser);
                } else {
                    goToMainActivity(authenticatedUser);
                }
            });
        });

        passwordText.setOnEditorActionListener((v, actionId, event) -> {
            if (actionId == EditorInfo.IME_ACTION_DONE) {
                authViewModel.signInWithEmailPassword(emailText.getText().toString(), passwordText.getText().toString());
                Toast.makeText(getActivity(), "Successful login", Toast.LENGTH_SHORT).show();
            }
            return false;
        });

        TextWatcher afterTextChangedListener = new TextWatcher() {
            @Override
            public void afterTextChanged(Editable s) {
                loginButton.setEnabled(false);
                if (!isEmailValid(emailText.getText().toString())) {
                    emailText.setError(getString(R.string.invalid_email));
                } else if (!isPasswordValid(passwordText.getText().toString())) {
                    passwordText.setError(getString(R.string.invalid_password));
                } else {
                    loginButton.setEnabled(true);
                }
            }

            //empty
            @Override
            public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
            @Override
            public void onTextChanged(CharSequence s, int start, int before, int count) {}
        };
        emailText.addTextChangedListener(afterTextChangedListener);
        passwordText.addTextChangedListener(afterTextChangedListener);
    }

    @Override
    public void onDestroyView() {
        super.onDestroyView();
        binding = null;
    }

    private boolean isEmailValid(String email) {
        if (email == null) {
            return false;
        }
        if (email.contains("@")) {
            return Patterns.EMAIL_ADDRESS.matcher(email).matches();
        } else {
            return !email.trim().isEmpty();
        }
    }

    // A placeholder password validation check
    private boolean isPasswordValid(String password) {
        return password != null && password.trim().length() > 5;
    }

    private void createNewUser(User authenticatedUser) {
        authViewModel.createUser(authenticatedUser);
        authViewModel.createdUserLiveData.observe(getViewLifecycleOwner(), user -> {
            if (user.isCreated) {
                updateUiWithUser(user);
            }
            goToMainActivity(user);
        });
    }

    //There's no main activity yet, so just display a toast message
    private void goToMainActivity(User user) {
        updateUiWithUser(user);
    }

    private void updateUiWithUser(User model) {
        String welcome = getString(R.string.welcome) + " " + model.name;
        // TODO : initiate successful logged in experience
        Toast.makeText(getActivity(), welcome, Toast.LENGTH_LONG).show();
    }

    private void showLoginFailed(String errorString) {
        Toast.makeText(getActivity(), errorString, Toast.LENGTH_SHORT).show();
    }
}

EmailPasswordRegistrationFragment

public class EmailPasswordRegistrationFragment extends Fragment {
    FragmentEmailPasswordRegistrationBinding binding;
    AuthViewModel authViewModel;

    public EmailPasswordRegistrationFragment() {}

    @Override
    public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
        binding = FragmentEmailPasswordRegistrationBinding.inflate(inflater, container, false);
        return binding.getRoot();
    }

    public void onViewCreated(@NonNull View view, Bundle savedInstanceState) {
        super.onViewCreated(view, savedInstanceState);

        authViewModel = new ViewModelProvider(this, new AuthViewModelFactory()).get(AuthViewModel.class);

        var backButton = binding.backButton;
        backButton.setOnClickListener(v -> {
            NavHostFragment.findNavController(EmailPasswordRegistrationFragment.this).navigate(R.id.nav_to_auth_menu);
        });

        var registerProgress = binding.registerProgress;
        var nameText = binding.name;
        var emailText = binding.email;
        var passwordText = binding.password;

        var registerButton = binding.register;
        registerButton.setOnClickListener(v -> {
            registerProgress.setVisibility(View.VISIBLE);
            authViewModel.signUpWithEmailPassword(nameText.getText().toString(), emailText.getText().toString(), passwordText.getText().toString());
            authViewModel.authenticationResult.observe(getViewLifecycleOwner(), authenticationResult -> {
                registerProgress.setVisibility(View.GONE);

                if(authenticationResult.getErrorMessage() != null ) {
                    showLoginFailed(authenticationResult.getErrorMessage());
                    return;
                }

                var authenticatedUser = authenticationResult.getSuccess();
                if (authenticatedUser.isNew) {
                    createNewUser(authenticatedUser);
                } else {
                    goToMainActivity(authenticatedUser);
                }
            });
        });

        TextWatcher afterTextChangedListener = new TextWatcher() {
            @Override
            public void afterTextChanged(Editable s) {
                registerButton.setEnabled(false);
                if (!isEmailValid(emailText.getText().toString())) {
                    emailText.setError(getString(R.string.invalid_email));
                } else if (!isPasswordValid(passwordText.getText().toString())) {
                    passwordText.setError(getString(R.string.invalid_password));
                } else {
                    registerButton.setEnabled(true);
                }
            }

            //empty
            @Override
            public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
            @Override
            public void onTextChanged(CharSequence s, int start, int before, int count) {}
        };
        emailText.addTextChangedListener(afterTextChangedListener);
        passwordText.addTextChangedListener(afterTextChangedListener);
    }

    @Override
    public void onDestroyView() {
        super.onDestroyView();
        binding = null;
    }

    // A placeholder email validation check
    private boolean isEmailValid(String email) {
        if (email == null) {
            return false;
        }
        if (email.contains("@")) {
            return Patterns.EMAIL_ADDRESS.matcher(email).matches();
        } else {
            return !email.trim().isEmpty();
        }
    }

    // A placeholder password validation check
    private boolean isPasswordValid(String password) {
        return password != null && password.trim().length() > 5;
    }


    private void createNewUser(User authenticatedUser) {
        authViewModel.createUser(authenticatedUser);
        authViewModel.createdUserLiveData.observe(getViewLifecycleOwner(), user -> {
            if (user.isCreated) {
                updateUiWithUser(user);
            }
            goToMainActivity(user);
        });
    }

    //There's no main activity yet, so just display a toast message
    private void goToMainActivity(User user) {
        updateUiWithUser(user);
    }

    private void updateUiWithUser(User model) {
        String welcome = getString(R.string.welcome) + " " + model.name;
        // TODO : initiate successful logged in experience
        Toast.makeText(getActivity(), welcome, Toast.LENGTH_LONG).show();
    }

    private void showLoginFailed(String errorString) {
        Toast.makeText(getActivity(), errorString, Toast.LENGTH_SHORT).show();
    }
}

As I said before google and facebook authentication is handled with just one button click, so it's in a main authentication fragment: AuthFragment

public class AuthFragment extends Fragment {
    private FragmentNewAuthBinding binding;
    private AuthViewModel authViewModel;
    private GoogleSignInClient googleSignInClient;
    private BeginSignInRequest oneTapSignInRequest;
    private SignInClient oneTapClient;

    public AuthFragment() {
        // Required empty public constructor
    }

    @Override
    public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
        binding = FragmentNewAuthBinding.inflate(inflater, container, false);
        return binding.getRoot();
    }

    @Override
    public void onDestroyView() {
        super.onDestroyView();
        binding = null;
    }

    public void onViewCreated(@NonNull View view, Bundle savedInstanceState) {
        super.onViewCreated(view, savedInstanceState);

        var joinNowButton = binding.joinNowButton;
        var loginButton = binding.loginButton;

        joinNowButton.setOnClickListener(v ->{
            NavHostFragment.findNavController(AuthFragment.this).navigate(R.id.nav_to_registration);
        });

        loginButton.setOnClickListener(v -> {
            NavHostFragment.findNavController(AuthFragment.this).navigate(R.id.nav_to_login);
        });

        authViewModel = new ViewModelProvider(this, new AuthViewModelFactory()).get(AuthViewModel.class);

        HandleGoogleSignIn();
        HandleFacebookLogin();
    }

    private void HandleGoogleSignIn()
    {
        ActivityResultLauncher<Intent> googleAuthWithDeviceLauncher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(),
            result -> {
                if (result.getResultCode() == Activity.RESULT_OK) {
                    Intent data = result.getData();
                    var task = GoogleSignIn.getSignedInAccountFromIntent(data);
                    try {
                        var googleSignInAccount = task.getResult(ApiException.class);
                        if (googleSignInAccount != null) {
                            String googleTokenId = googleSignInAccount.getIdToken();
                            var authCredential = GoogleAuthProvider.getCredential(googleTokenId, null);
                            signInWithAuthCredential(authCredential);
                        }
                    } catch (ApiException e) {
                        showLoginFailed(e.getMessage());
                    }
                }
            });

        ActivityResultLauncher<IntentSenderRequest> oneTapLauncher = registerForActivityResult(new ActivityResultContracts.StartIntentSenderForResult(),
            result -> {
                if (result.getResultCode() == Activity.RESULT_OK) {
                    Intent data = result.getData();
                    try {
                        var signInCredential = oneTapClient.getSignInCredentialFromIntent(data);
                        String idToken = signInCredential.getGoogleIdToken();
                        if(idToken != null) {
                            AuthCredential credential = GoogleAuthProvider.getCredential(idToken, null);
                            signInWithAuthCredential(credential);
                        } else {
                            Log.d("idToken", "is null");
                        }
                    } catch (ApiException e) {
                        throw new RuntimeException(e);
                    }
                }
            }
        );

        var googleSignInOptions = new GoogleSignInOptions
                .Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
                .requestIdToken(getString(R.string.default_web_client_id))
                .requestEmail()
                .build();

        googleSignInClient = GoogleSignIn.getClient(requireActivity(), googleSignInOptions);

        oneTapClient = Identity.getSignInClient(requireActivity());
        oneTapSignInRequest = BeginSignInRequest.builder()
                .setGoogleIdTokenRequestOptions(BeginSignInRequest.GoogleIdTokenRequestOptions.builder()
                        .setSupported(true)
                        // Your server's client ID, not your Android client ID.
                        .setServerClientId(getString(R.string.default_web_client_id))
                        // Only show accounts previously used to sign in.
                        .setFilterByAuthorizedAccounts(false)
                        .build())
                .build();

        var googleLoginButton = binding.googleLoginButton;
        googleLoginButton.setOnClickListener(v -> {
            oneTapClient.beginSignIn(oneTapSignInRequest).
                addOnSuccessListener(requireActivity(), result -> {
                    var intentSenderRequest = new IntentSenderRequest
                        .Builder(result.getPendingIntent().getIntentSender())
                        .build();
                    oneTapLauncher.launch(intentSenderRequest); }).
                addOnFailureListener(requireActivity(), e -> {
                    //TODO: check more cases

                    //this may be called if the device is not authenticated with any google account
                    //if this happens, launch an activity that will authenticate a google account with the device
                    Intent signInIntent = googleSignInClient.getSignInIntent();
                    googleAuthWithDeviceLauncher.launch(signInIntent);
            });
        });
    }

    private void HandleFacebookLogin() {
        var callbackManager = CallbackManager.Factory.create();
        var facebookLoginButton = binding.facebookLoginButton;
        facebookLoginButton.setOnClickListener(v -> {
            var loginManager = LoginManager.getInstance();
            loginManager.logInWithReadPermissions(this, callbackManager, Arrays.asList("email", "public_profile"));
            loginManager.registerCallback(callbackManager, new FacebookCallback<>() {
                @Override
                public void onSuccess(LoginResult loginResult) {
                    var authCredential = FacebookAuthProvider.getCredential(loginResult.getAccessToken().getToken());
                    signInWithAuthCredential(authCredential);
                }

                @Override
                public void onCancel() {
                    //TODO(Michal): better onCancel handling
                }

                @Override
                public void onError(@NonNull FacebookException e) {
                    //TODO(Michal): better onError handling
                    showLoginFailed(e.getMessage());
                }
            });
        });
    }

    //General functions for signing in / up
    private void signInWithAuthCredential(AuthCredential authCredential) {
        authViewModel.signInWithCredentials(authCredential);
        authViewModel.authenticationResult.observe(getViewLifecycleOwner(), authenticationResult -> {
            if(authenticationResult.getErrorMessage() != null ) {
                showLoginFailed(authenticationResult.getErrorMessage());
                return;
            }

            var authenticatedUser = authenticationResult.getSuccess();
            if (authenticatedUser.isNew) {
                createNewUser(authenticatedUser);
            } else {
                goToMainActivity(authenticatedUser);
            }
        });
    }

    private void createNewUser(User authenticatedUser) {
        authViewModel.createUser(authenticatedUser);
        authViewModel.createdUserLiveData.observe(getViewLifecycleOwner(), user -> {
            if (user.isCreated) {
                updateUiWithUser(user);
            }
            goToMainActivity(user);
        });
    }


    //There's no main activity yet, so just display a toast message
    private void goToMainActivity(User user) {
        updateUiWithUser(user);
    }

    private void updateUiWithUser(User model) {
        String welcome = getString(R.string.welcome) + " " + model.name;
        // TODO : initiate successful logged in experience
        Toast.makeText(getActivity(), welcome, Toast.LENGTH_LONG).show();
    }

    private void showLoginFailed(String errorString) {
        Toast.makeText(getActivity(), errorString, Toast.LENGTH_SHORT).show();
    }
}

I know that fragments have a few duplicated functions (createNewUser, etc.), and have to deal with it later.

Could you review this code, please? As I said in the beginning, I'm not sure if one view model is enough for this. There are probably more mistakes.

\$\endgroup\$
0

0