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.