Account Management: Move ViewModel logic mostly to domain

This commit is contained in:
Paul Schaub 2020-06-06 16:45:20 +02:00
parent 91084ad2a7
commit 9ed9c36fa3
Signed by: vanitasvitae
GPG key ID: 62BEE9264BF17311
21 changed files with 667 additions and 444 deletions

View file

@ -32,9 +32,6 @@
<activity
android:name=".ui.settings.SettingsActivity"
android:label="@string/title_activity_settings" />
<activity
android:name=".ui.account.LoginActivity"
android:label="@string/title_activity_login" />
<activity android:name=".ui.roster.contacts.detail.ContactDetailActivity" />
<service android:name=".service.MercuryConnectionService" />

View file

@ -4,20 +4,22 @@ import org.mercury_im.messenger.MercuryImApplication;
import org.mercury_im.messenger.data.di.RepositoryModule;
import org.mercury_im.messenger.di.module.AndroidPersistenceModule;
import org.mercury_im.messenger.di.module.AppModule;
import org.mercury_im.messenger.di.module.ViewModelModule;
import org.mercury_im.messenger.service.MercuryConnectionService;
import org.mercury_im.messenger.store.MercuryEntityCapsStore;
import org.mercury_im.messenger.ui.MainActivity;
import org.mercury_im.messenger.ui.account.AndroidAccountsViewModel;
import org.mercury_im.messenger.ui.account.AndroidLoginViewModel;
import org.mercury_im.messenger.ui.chat.ChatActivity;
import org.mercury_im.messenger.ui.chat.ChatInputFragment;
import org.mercury_im.messenger.ui.chat.ChatInputViewModel;
import org.mercury_im.messenger.ui.chat.ChatViewModel;
import org.mercury_im.messenger.ui.chatlist.ChatListViewModel;
import org.mercury_im.messenger.ui.account.AccountsViewModel;
import org.mercury_im.messenger.ui.account.LoginActivity;
import org.mercury_im.messenger.ui.account.LoginViewModel;
import org.mercury_im.messenger.ui.roster.contacts.ContactListViewModel;
import org.mercury_im.messenger.ui.roster.contacts.detail.ContactDetailActivity;
import org.mercury_im.messenger.ui.roster.contacts.detail.ContactDetailViewModel;
import org.mercury_im.messenger.viewmodel.accounts.AccountsViewModel;
import org.mercury_im.messenger.viewmodel.accounts.LoginViewModel;
import javax.inject.Singleton;
@ -32,7 +34,8 @@ import dagger.Component;
modules = {
AppModule.class,
AndroidPersistenceModule.class,
RepositoryModule.class
RepositoryModule.class,
ViewModelModule.class
})
public interface AppComponent {
@ -45,8 +48,6 @@ public interface AppComponent {
void inject(MainActivity mainActivity);
void inject(LoginActivity loginActivity);
void inject(ChatActivity chatActivity);
void inject(ChatInputFragment chatInputFragment);
@ -63,15 +64,21 @@ public interface AppComponent {
void inject(ChatInputViewModel chatInputViewModel);
void inject(LoginViewModel loginViewModel);
void inject(AndroidLoginViewModel androidLoginViewModel);
void inject(AccountsViewModel accountsViewModel);
void inject(AndroidAccountsViewModel androidAccountsViewModel);
void inject(ChatListViewModel chatListViewModel);
void inject(ContactDetailViewModel contactDetailViewModel);
// Common VMs
void inject(LoginViewModel loginViewModel);
void inject(AccountsViewModel accountsViewModel);
// Services
void inject(MercuryConnectionService service);

View file

@ -0,0 +1,18 @@
package org.mercury_im.messenger.ui;
import org.mercury_im.messenger.viewmodel.MercuryViewModel;
import io.reactivex.disposables.Disposable;
public interface MercuryAndroidViewModel<VM extends MercuryViewModel> {
VM getCommonViewModel();
default void dispose() {
getCommonViewModel().dispose();
}
default void addDisposable(Disposable disposable) {
getCommonViewModel().addDisposable(disposable);
}
}

View file

@ -1,7 +1,6 @@
package org.mercury_im.messenger.ui.account;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
@ -16,12 +15,10 @@ import com.google.android.material.floatingactionbutton.ExtendedFloatingActionBu
import org.mercury_im.messenger.R;
import org.mercury_im.messenger.entity.Account;
import org.mercury_im.messenger.xmpp.MercuryConnection;
import org.mercury_im.messenger.xmpp.state.ConnectionState;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;
import butterknife.BindView;
import butterknife.ButterKnife;
@ -36,7 +33,7 @@ public class AccountsFragment extends Fragment {
private OnAccountListItemClickListener accountClickListener;
AccountsViewModel viewModel;
AndroidAccountsViewModel viewModel;
private AccountsRecyclerViewAdapter adapter;
@ -53,7 +50,7 @@ public class AccountsFragment extends Fragment {
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
viewModel = new ViewModelProvider(this).get(AccountsViewModel.class);
viewModel = new ViewModelProvider(this).get(AndroidAccountsViewModel.class);
this.adapter = new AccountsRecyclerViewAdapter(viewModel, accountClickListener);
}
@ -61,6 +58,7 @@ public class AccountsFragment extends Fragment {
public void onResume() {
super.onResume();
observeViewModel();
fab.setOnClickListener(v -> displayAddAccountDialog());
}
private void observeViewModel() {
@ -75,7 +73,6 @@ public class AccountsFragment extends Fragment {
ButterKnife.bind(this, view);
Context context = view.getContext();
fab.setOnClickListener(v -> startActivity(new Intent(context, LoginActivity.class)));
recyclerView.setLayoutManager(new LinearLayoutManager(context));
recyclerView.setAdapter(adapter);
@ -115,4 +112,10 @@ public class AccountsFragment extends Fragment {
void onAccountListItemLongClick(Account item);
}
private void displayAddAccountDialog() {
Logger.getAnonymousLogger().log(Level.INFO, "DISPLAY FRAGMENT!!!");
AddAccountDialogFragment addAccountDialogFragment = new AddAccountDialogFragment();
addAccountDialogFragment.show(getParentFragmentManager(), "addAccount");
}
}

View file

@ -26,9 +26,9 @@ public class AccountsRecyclerViewAdapter extends RecyclerView.Adapter<AccountsRe
private final List<ConnectionState> connectionStates = new ArrayList<>();
private final OnAccountListItemClickListener onAccountClickListener;
private final AccountsViewModel viewModel;
private final AndroidAccountsViewModel viewModel;
public AccountsRecyclerViewAdapter(AccountsViewModel viewModel, OnAccountListItemClickListener listener) {
public AccountsRecyclerViewAdapter(AndroidAccountsViewModel viewModel, OnAccountListItemClickListener listener) {
onAccountClickListener = listener;
this.viewModel = viewModel;
}

View file

@ -1,67 +0,0 @@
package org.mercury_im.messenger.ui.account;
import android.app.Application;
import android.util.Log;
import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import org.mercury_im.messenger.MercuryImApplication;
import org.mercury_im.messenger.Messenger;
import org.mercury_im.messenger.data.repository.AccountRepository;
import org.mercury_im.messenger.entity.Account;
import org.mercury_im.messenger.xmpp.MercuryConnection;
import org.mercury_im.messenger.xmpp.state.ConnectionPoolState;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.inject.Inject;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.disposables.CompositeDisposable;
import io.reactivex.schedulers.Schedulers;
public class AccountsViewModel extends AndroidViewModel {
@Inject
AccountRepository repository;
@Inject
Messenger messenger;
private static final Logger LOGGER = Logger.getLogger(AccountsViewModel.class.getName());
private final MutableLiveData<ConnectionPoolState> connectionPool = new MutableLiveData<>();
private final CompositeDisposable compositeDisposable = new CompositeDisposable();
@Inject
public AccountsViewModel(Application application) {
super(application);
MercuryImApplication.getApplication().getAppComponent().inject(this);
compositeDisposable.add(messenger.getConnectionManager()
.observeConnectionPool()
.subscribe(connectionPool::postValue,
error -> LOGGER.log(Level.SEVERE, "Error observing connections", error)));
}
@Override
protected void onCleared() {
super.onCleared();
compositeDisposable.clear();
}
public LiveData<ConnectionPoolState> getConnectionPool() {
return connectionPool;
}
public void setAccountEnabled(Account accountModel, boolean enabled) {
accountModel.setEnabled(enabled);
repository.upsertAccount(accountModel)
.subscribe(
success -> LOGGER.log(Level.FINER, "Account " + accountModel.getAddress() + " " + (enabled ? "enabled" : "disabled")),
error -> LOGGER.log(Level.SEVERE, "Account " + accountModel.getAddress() + " cannot be " + (enabled ? "enabled" : "disabled"), error)
);
}
}

View file

@ -0,0 +1,123 @@
package org.mercury_im.messenger.ui.account;
import android.app.AlertDialog;
import android.app.Dialog;
import android.content.DialogInterface;
import android.os.Bundle;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.View;
import android.view.inputmethod.EditorInfo;
import android.widget.Button;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatDialogFragment;
import com.google.android.material.textfield.TextInputEditText;
import org.mercury_im.messenger.MercuryImApplication;
import org.mercury_im.messenger.R;
import butterknife.BindView;
import butterknife.ButterKnife;
public class AddAccountDialogFragment extends AppCompatDialogFragment implements TextView.OnEditorActionListener {
@BindView(R.id.username)
TextInputEditText addressView;
@BindView(R.id.password)
TextInputEditText passwordView;
private AndroidLoginViewModel viewModel;
@NonNull
@Override
public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
LayoutInflater inflater = requireActivity().getLayoutInflater();
View dialogView = inflater.inflate(R.layout.dialog_login, null);
ButterKnife.bind(this, dialogView);
AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
builder.setTitle(R.string.action_add_account)
.setView(dialogView)
.setCancelable(false)
.setPositiveButton(R.string.action_sign_in, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
// We later overwrite in onResume, so that the dialog does not automatically
// dismiss when button is clicked.
}
})
.setNegativeButton(R.string.button_cancel, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
AddAccountDialogFragment.this.onCancel(dialog);
}
});
return builder.create();
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
}
@Override
public void onResume() {
super.onResume();
final AlertDialog d = (AlertDialog)getDialog();
if(d == null) {
return;
}
viewModel = new AndroidLoginViewModel(MercuryImApplication.getApplication());
Button positiveButton = d.getButton(Dialog.BUTTON_POSITIVE);
positiveButton.setOnClickListener(v -> viewModel.onLoginButtonClicked());
viewModel.getLoginUsernameError().observe(this, error -> addressView.setError(error));
viewModel.getLoginPasswordError().observe(this, error -> passwordView.setError(error));
viewModel.isLoginButtonEnabled().observe(this, positiveButton::setEnabled);
viewModel.isLoginFinished().observe(this, finished -> {
if (finished) {
dismiss();
}
});
addressView.setOnEditorActionListener(this);
passwordView.setOnEditorActionListener(this);
addressView.addTextChangedListener(viewModel.getUsernameTextChangedListener());
passwordView.addTextChangedListener(viewModel.getPasswordTextChangedListener());
}
@Override
public void onDestroy() {
super.onDestroy();
}
@Override
public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
switch (v.getId()) {
case R.id.username:
if (actionId == EditorInfo.IME_ACTION_NEXT) {
passwordView.requestFocus();
return true;
}
break;
case R.id.password:
if (actionId == EditorInfo.IME_ACTION_DONE || actionId == EditorInfo.IME_NULL) {
viewModel.getCommonViewModel().login();
return true;
}
}
return false;
}
}

View file

@ -0,0 +1,52 @@
package org.mercury_im.messenger.ui.account;
import android.app.Application;
import androidx.annotation.NonNull;
import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import org.mercury_im.messenger.MercuryImApplication;
import org.mercury_im.messenger.entity.Account;
import org.mercury_im.messenger.ui.MercuryAndroidViewModel;
import org.mercury_im.messenger.viewmodel.accounts.AccountsViewModel;
import org.mercury_im.messenger.xmpp.state.ConnectionPoolState;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.inject.Inject;
public class AndroidAccountsViewModel extends AndroidViewModel implements MercuryAndroidViewModel<AccountsViewModel> {
private static final Logger LOGGER = Logger.getLogger(AndroidAccountsViewModel.class.getName());
private final MutableLiveData<ConnectionPoolState> connectionPool = new MutableLiveData<>();
@Inject
AccountsViewModel viewModel;
public AndroidAccountsViewModel(@NonNull Application application) {
super(application);
MercuryImApplication.getApplication().getAppComponent().inject(this);
addDisposable(getCommonViewModel().getConnectionPool()
.subscribe(connectionPool::postValue,
error -> LOGGER.log(Level.SEVERE, "Error observing connections", error)));
}
public LiveData<ConnectionPoolState> getConnectionPool() {
return connectionPool;
}
public void setAccountEnabled(Account accountModel, boolean enabled) {
getCommonViewModel().onToggleAccountEnabled(accountModel, enabled);
}
@Override
public AccountsViewModel getCommonViewModel() {
return viewModel;
}
}

View file

@ -0,0 +1,155 @@
package org.mercury_im.messenger.ui.account;
import android.app.Application;
import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import org.mercury_im.messenger.MercuryImApplication;
import org.mercury_im.messenger.R;
import org.mercury_im.messenger.account.error.PasswordError;
import org.mercury_im.messenger.account.error.UsernameError;
import org.mercury_im.messenger.ui.MercuryAndroidViewModel;
import org.mercury_im.messenger.util.Optional;
import org.mercury_im.messenger.util.TextChangedListener;
import org.mercury_im.messenger.viewmodel.accounts.LoginViewModel;
import java.util.logging.Logger;
import javax.inject.Inject;
import io.reactivex.Observable;
public class AndroidLoginViewModel extends AndroidViewModel implements MercuryAndroidViewModel<LoginViewModel> {
private static final Logger LOGGER = Logger.getLogger(AndroidLoginViewModel.class.getName());
private final MutableLiveData<String> loginUsernameError = new MutableLiveData<>();
private final MutableLiveData<String> loginPasswordError = new MutableLiveData<>();
private final MutableLiveData<Boolean> loginButtonEnabled = new MutableLiveData<>(true);
private final MutableLiveData<Boolean> loginFinished = new MutableLiveData<>(false);
@Inject
LoginViewModel commonViewModel;
@Inject
public AndroidLoginViewModel(Application application) {
super(application);
reset();
}
public void reset() {
MercuryImApplication.getApplication().getAppComponent().inject(this);
commonViewModel.reset();
addDisposable(mapUsernameErrorCodeToMessage(getCommonViewModel().getLoginUsernameError())
.subscribe(e -> loginUsernameError.setValue(e.isPresent() ? e.getItem() : null)));
addDisposable(mapPasswordErrorCodeToMessage(getCommonViewModel().getLoginPasswordError())
.subscribe(e -> loginPasswordError.setValue(e.isPresent() ? e.getItem() : null)));
addDisposable(getCommonViewModel().isLoginPossible()
.subscribe(loginButtonEnabled::setValue));
addDisposable(getCommonViewModel().isLoginSuccessful()
.subscribe(loginFinished::setValue));
}
private Observable<Optional<String>> mapUsernameErrorCodeToMessage(Observable<Optional<UsernameError>> errorCodeObservable) {
return errorCodeObservable.map(optional -> {
if (!optional.isPresent()){
return new Optional<>(null);
}
UsernameError error = optional.getItem();
int resourceId;
switch (error) {
case emptyUsername:
resourceId = R.string.error_field_required;
break;
case invalidUsername:
resourceId = R.string.error_invalid_username;
break;
case unreachableServer:
resourceId = R.string.error_unreachable_server;
break;
default:
resourceId = R.string.error_uknown_error;
}
return new Optional<>(getApplication().getResources().getString(resourceId));
});
}
private Observable<Optional<String>> mapPasswordErrorCodeToMessage(Observable<Optional<PasswordError>> errorCodeObservable) {
return errorCodeObservable.map(optional -> {
if (!optional.isPresent()){
return new Optional<>(null);
}
PasswordError error = optional.getItem();
int resourceId;
switch (error) {
case emptyPassword:
resourceId = R.string.error_field_required;
break;
case incorrectPassword:
resourceId = R.string.error_incorrect_password;
break;
default:
resourceId = R.string.error_uknown_error;
}
return new Optional<>(getApplication().getResources().getString(resourceId));
});
}
@Override
protected void onCleared() {
super.onCleared();
getCommonViewModel().dispose();
}
public void onLoginButtonClicked() {
getCommonViewModel().login();
}
public LiveData<String> getLoginUsernameError() {
return loginUsernameError;
}
public LiveData<String> getLoginPasswordError() {
return loginPasswordError;
}
public LiveData<Boolean> isLoginButtonEnabled() {
return loginButtonEnabled;
}
public LiveData<Boolean> isLoginFinished() {
return loginFinished;
}
@Override
public LoginViewModel getCommonViewModel() {
return commonViewModel;
}
public TextChangedListener getUsernameTextChangedListener() {
return usernameTextChangedListener;
}
public TextChangedListener getPasswordTextChangedListener() {
return passwordTextChangedListener;
}
private final TextChangedListener usernameTextChangedListener = new TextChangedListener() {
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
getCommonViewModel().onLoginUsernameChanged(s.toString());
}
};
private final TextChangedListener passwordTextChangedListener = new TextChangedListener() {
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
getCommonViewModel().onLoginPasswordChanged(s.toString());
}
};
}

View file

@ -1,158 +0,0 @@
package org.mercury_im.messenger.ui.account;
import android.os.Bundle;
import android.view.KeyEvent;
import android.view.inputmethod.EditorInfo;
import android.widget.Button;
import android.widget.TextView;
import androidx.appcompat.app.AppCompatActivity;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.ViewModelProvider;
import com.google.android.material.textfield.TextInputEditText;
import org.mercury_im.messenger.MercuryImApplication;
import org.mercury_im.messenger.R;
import org.mercury_im.messenger.account.error.PasswordError;
import org.mercury_im.messenger.account.error.UsernameError;
import org.mercury_im.messenger.entity.Account;
import org.mercury_im.messenger.util.Optional;
import org.mercury_im.messenger.util.TextChangedListener;
import butterknife.BindView;
import butterknife.ButterKnife;
/**
* A login screen that offers login via email/password.
*/
public class LoginActivity extends AppCompatActivity implements TextView.OnEditorActionListener {
@BindView(R.id.username)
TextInputEditText addressView;
@BindView(R.id.password)
TextInputEditText passwordView;
@BindView(R.id.sign_in_button)
Button loginButton;
private LoginViewModel viewModel;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_login);
ButterKnife.bind(this);
MercuryImApplication.getApplication().getAppComponent().inject(this);
viewModel = new ViewModelProvider(this).get(LoginViewModel.class);
observeViewModel();
setupTextInputListeners();
loginButton.setOnClickListener(view -> viewModel.login());
}
private void observeViewModel() {
observeUsernameError();
observePasswordError();
finishOnceLoginWasSuccessful();
enableLoginButtonOncePossible();
}
private void observeUsernameError() {
viewModel.getUsernameError().observe(this, usernameError -> {
addressView.setError(usernameError.isError() ? usernameError.getErrorMessage() : null);
});
}
private void observePasswordError() {
viewModel.getPasswordError().observe(this, passwordError -> {
passwordView.setError(passwordError.isError() ? passwordError.getErrorMessage() : null);
});
}
private void finishOnceLoginWasSuccessful() {
viewModel.isLoginSuccessful().observe(this, successful -> {
if (Boolean.TRUE.equals(successful)) {
finish();
}
});
}
private void enableLoginButtonOncePossible() {
viewModel.isLoginEnabled().observe(this, isEnabled -> {
loginButton.setEnabled(isEnabled);
});
}
private Optional<String> getUsernameError(UsernameError usernameError) {
switch (usernameError) {
case none:
return new Optional<>(null);
case emptyUsername:
return new Optional<>(getResources().getString(R.string.error_field_required));
case invalidUsername:
return new Optional<>(getResources().getString(R.string.error_invalid_username));
case unknownUsername:
return new Optional<>("Unknown Username!");
default:
throw new AssertionError("Unknown UsernameError enum value.");
}
}
private Optional<String> getPasswordError(PasswordError passwordError) {
switch (passwordError) {
case none:
return new Optional<>(null);
case emptyPassword:
return new Optional<>(getResources().getString(R.string.error_field_required));
case invalidPassword:
return new Optional<>(getResources().getString(R.string.error_invalid_password));
case incorrectPassword:
return new Optional<>(getResources().getString(R.string.error_incorrect_password));
default:
throw new AssertionError("Unknown PasswordError enum value.");
}
}
private void setupTextInputListeners() {
addressView.setOnEditorActionListener(this);
passwordView.setOnEditorActionListener(this);
addressView.addTextChangedListener(new TextChangedListener() {
@Override
public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) {
viewModel.setUsername(charSequence.toString());
}
});
passwordView.addTextChangedListener(new TextChangedListener() {
@Override
public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) {
viewModel.setPassword(charSequence.toString());
}
});
}
@Override
public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
switch (v.getId()) {
case R.id.username:
if (actionId == EditorInfo.IME_ACTION_NEXT) {
passwordView.requestFocus();
return true;
}
break;
case R.id.password:
if (actionId == EditorInfo.IME_ACTION_DONE || actionId == EditorInfo.IME_NULL) {
//viewModel.login();
return true;
}
}
return false;
}
}

View file

@ -1,167 +0,0 @@
package org.mercury_im.messenger.ui.account;
import android.app.Application;
import android.util.Log;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import org.jivesoftware.smack.sasl.SASLErrorException;
import org.jxmpp.jid.EntityBareJid;
import org.jxmpp.jid.impl.JidCreate;
import org.jxmpp.stringprep.XmppStringprepException;
import org.mercury_im.messenger.MercuryImApplication;
import org.mercury_im.messenger.Messenger;
import org.mercury_im.messenger.R;
import org.mercury_im.messenger.data.repository.AccountRepository;
import org.mercury_im.messenger.entity.Account;
import org.mercury_im.messenger.xmpp.MercuryConnection;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.inject.Inject;
import io.reactivex.Completable;
import io.reactivex.Scheduler;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.disposables.CompositeDisposable;
import io.reactivex.schedulers.Schedulers;
public class LoginViewModel extends AndroidViewModel {
private MutableLiveData<Error> usernameError = new MutableLiveData<>(new Error());
private MutableLiveData<Error> passwordError = new MutableLiveData<>(new Error());
private MutableLiveData<Boolean> loginButtonEnabled = new MutableLiveData<>(false);
private MutableLiveData<Boolean> loginCompleted = new MutableLiveData<>(false);
private final CompositeDisposable disposable = new CompositeDisposable();
private EntityBareJid username;
private String password;
@Inject
Messenger messenger;
@Inject
AccountRepository accountRepository;
public LoginViewModel(@NonNull Application application) {
super(application);
((MercuryImApplication) application).getAppComponent().inject(this);
}
public void setUsername(String username) {
if (username == null || username.isEmpty()) {
this.username = null;
usernameError.setValue(new Error(getApplication().getResources().getString(R.string.error_field_required)));
} else {
try {
this.username = JidCreate.entityBareFrom(username);
} catch (XmppStringprepException e) {
this.username = null;
usernameError.setValue(new Error(getApplication().getResources().getString(R.string.error_invalid_username)));
}
}
updateLoginButtonState();
}
public void setPassword(String password) {
if (password == null || password.isEmpty()) {
this.password = null;
passwordError.setValue(new Error(getApplication().getResources().getString(R.string.error_field_required)));
} else {
this.password = password;
}
updateLoginButtonState();
}
private void updateLoginButtonState() {
loginButtonEnabled.setValue(username != null && !(password == null || password.isEmpty()));
}
public synchronized void login() {
Boolean loginEnabled = loginButtonEnabled.getValue();
if (loginEnabled != null && !loginEnabled) {
// Prevent race condition where account would be logged in twice
return;
}
loginButtonEnabled.setValue(false);
Account account = new Account();
account.setAddress(username.asUnescapedString());
account.setPassword(password);
account.setEnabled(true);
MercuryConnection connection = messenger.getConnectionManager().createConnection(account);
disposable.add(connection.connect()
.andThen(connection.login())
.andThen(Completable.fromAction(() -> messenger.getConnectionManager().registerConnection(connection)))
.andThen(accountRepository.insertAccount(account).ignoreElement())
.subscribeOn(Schedulers.newThread())
.observeOn(AndroidSchedulers.mainThread())
.doOnComplete(this::signalLoginSuccessful)
.doOnError(this::signalLoginError)
.subscribe(() -> Log.d("Mercury-IM", "Login successful for " + account.getAddress()),
e -> Log.e("Mercury-IM", "Login failed for " + account.getAddress(), e)));
}
private void signalLoginSuccessful() {
Logger.getAnonymousLogger().log(Level.INFO, "Signal Login Successful");
loginCompleted.setValue(true);
}
private void signalLoginError(Throwable error) {
if (error instanceof SASLErrorException) {
passwordError.setValue(new Error(getApplication().getResources().getString(R.string.error_incorrect_password)));
loginButtonEnabled.setValue(true);
} else {
Toast.makeText(getApplication(), "A connection error occurred", Toast.LENGTH_LONG).show();
loginButtonEnabled.setValue(true);
}
}
public LiveData<Error> getPasswordError() {
return passwordError;
}
public LiveData<Error> getUsernameError() {
return usernameError;
}
public LiveData<Boolean> isLoginSuccessful() {
return loginCompleted;
}
public LiveData<Boolean> isLoginEnabled() {
return loginButtonEnabled;
}
@Override
protected void onCleared() {
super.onCleared();
disposable.clear();
}
public static class Error {
private String message;
public Error() {
}
public Error(String errorMessage) {
this.message = errorMessage;
}
public boolean isError() {
return message != null;
}
public String getErrorMessage() {
return message;
}
}
}

View file

@ -9,8 +9,7 @@
android:paddingLeft="@dimen/activity_horizontal_margin"
android:paddingTop="@dimen/activity_vertical_margin"
android:paddingRight="@dimen/activity_horizontal_margin"
android:paddingBottom="@dimen/activity_vertical_margin"
tools:context=".ui.account.LoginActivity">
android:paddingBottom="@dimen/activity_vertical_margin">
<LinearLayout
android:id="@+id/jid_login_form"
@ -55,13 +54,5 @@
</com.google.android.material.textfield.TextInputLayout>
<Button
android:id="@+id/sign_in_button"
style="@style/Widget.MaterialComponents.Button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/action_sign_in" />
</LinearLayout>
</ScrollView>

View file

@ -138,4 +138,6 @@
<string name="error_account_not_connected">Account not connected</string>
<string name="error_contact_already_added">Contact already added</string>
<string name="error_invalid_address">Invalid address</string>
<string name="error_unreachable_server">Server unreachable</string>
<string name="error_uknown_error">Unknown error</string>
</resources>

View file

@ -3,16 +3,13 @@ package org.mercury_im.messenger;
import org.jivesoftware.smack.SmackConfiguration;
import org.jivesoftware.smack.SmackException;
import org.jivesoftware.smack.XMPPException;
import org.jivesoftware.smack.chat2.Chat;
import org.jivesoftware.smack.chat2.ChatManager;
import org.jivesoftware.smack.roster.Roster;
import org.jivesoftware.smack.roster.RosterEntry;
import org.jxmpp.jid.BareJid;
import org.jxmpp.jid.EntityBareJid;
import org.jxmpp.jid.impl.JidCreate;
import org.jxmpp.stringprep.XmppStringprepException;
import org.mercury_im.messenger.data.repository.Repositories;
import org.mercury_im.messenger.entity.Account;
import org.mercury_im.messenger.entity.contact.Peer;
import org.mercury_im.messenger.exception.ConnectionNotFoundException;
import org.mercury_im.messenger.exception.ContactAlreadyAddedException;
@ -27,7 +24,6 @@ import javax.inject.Inject;
import javax.inject.Singleton;
import io.reactivex.Completable;
import io.reactivex.Single;
import io.reactivex.disposables.CompositeDisposable;
import io.reactivex.schedulers.Schedulers;
@ -61,17 +57,9 @@ public class Messenger {
LOGGER.log(Level.INFO, "Perform initial login.");
disposable.add(repositories.getAccountRepository().observeAllAccounts().firstOrError()
.subscribeOn(Schedulers.io())
.subscribe(connectionManager::registerConnections));
.subscribe(connectionManager::doRegisterConnections));
}
public Account createAccount(String username, String service, String password) {
Account account = new Account();
account.setAddress(username + "@" + service);
account.setPassword(password);
return account;
}
public Completable addContact(UUID accountId, String contactAddress) {
return Completable.fromAction(() -> doAddContact(accountId, contactAddress));
}

View file

@ -1,8 +1,6 @@
package org.mercury_im.messenger.account.error;
public enum PasswordError {
none,
emptyPassword,
invalidPassword,
incorrectPassword
}

View file

@ -1,8 +1,7 @@
package org.mercury_im.messenger.account.error;
public enum UsernameError {
none,
emptyUsername,
invalidUsername,
unknownUsername
unreachableServer
}

View file

@ -0,0 +1,36 @@
package org.mercury_im.messenger.di.module;
import org.mercury_im.messenger.data.repository.AccountRepository;
import org.mercury_im.messenger.util.ThreadUtils;
import org.mercury_im.messenger.viewmodel.accounts.AccountsViewModel;
import org.mercury_im.messenger.viewmodel.accounts.LoginViewModel;
import org.mercury_im.messenger.xmpp.MercuryConnectionManager;
import javax.inject.Named;
import javax.inject.Singleton;
import dagger.Module;
import dagger.Provides;
import io.reactivex.Scheduler;
@Module
public class ViewModelModule {
@Provides
@Singleton
static LoginViewModel provideLoginViewModel(MercuryConnectionManager connectionManager,
AccountRepository accountRepository,
@Named(ThreadUtils.SCHEDULER_IO) Scheduler ioScheduler,
@Named(ThreadUtils.SCHEDULER_UI) Scheduler uiScheduler) {
return new LoginViewModel(connectionManager, accountRepository, ioScheduler, uiScheduler);
}
@Provides
@Singleton
static AccountsViewModel provideAccountsViewModel(MercuryConnectionManager connectionManager,
AccountRepository accountRepository,
@Named(ThreadUtils.SCHEDULER_IO) Scheduler ioScheduler,
@Named(ThreadUtils.SCHEDULER_UI) Scheduler uiScheduler) {
return new AccountsViewModel(connectionManager, accountRepository, ioScheduler, uiScheduler);
}
}

View file

@ -0,0 +1,17 @@
package org.mercury_im.messenger.viewmodel;
import io.reactivex.disposables.CompositeDisposable;
import io.reactivex.disposables.Disposable;
public interface MercuryViewModel {
CompositeDisposable compositeDisposable = new CompositeDisposable();
default void dispose() {
compositeDisposable.dispose();
}
default void addDisposable(Disposable disposable) {
compositeDisposable.add(disposable);
}
}

View file

@ -0,0 +1,62 @@
package org.mercury_im.messenger.viewmodel.accounts;
import org.mercury_im.messenger.data.repository.AccountRepository;
import org.mercury_im.messenger.entity.Account;
import org.mercury_im.messenger.util.ThreadUtils;
import org.mercury_im.messenger.viewmodel.MercuryViewModel;
import org.mercury_im.messenger.xmpp.MercuryConnectionManager;
import org.mercury_im.messenger.xmpp.state.ConnectionPoolState;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.inject.Named;
import io.reactivex.Observable;
import io.reactivex.Scheduler;
public class AccountsViewModel implements MercuryViewModel {
private static final Logger LOGGER = Logger.getLogger(AccountsViewModel.class.getName());
private final MercuryConnectionManager connectionManager;
private final AccountRepository accountRepository;
private final Scheduler ioScheduler;
private final Scheduler uiScheduler;
public AccountsViewModel(MercuryConnectionManager connectionManager,
AccountRepository accountRepository,
@Named(ThreadUtils.SCHEDULER_IO) Scheduler ioScheduler,
@Named(ThreadUtils.SCHEDULER_UI) Scheduler uiScheduler) {
this.connectionManager = connectionManager;
this.accountRepository = accountRepository;
this.ioScheduler = ioScheduler;
this.uiScheduler = uiScheduler;
}
public Observable<ConnectionPoolState> getConnectionPool() {
return connectionManager.observeConnectionPool();
}
public void onToggleAccountEnabled(Account account, boolean enabled) {
account.setEnabled(enabled);
addDisposable(accountRepository.upsertAccount(account)
.subscribeOn(ioScheduler)
.observeOn(uiScheduler)
.subscribe(
success -> logAccountToggledSuccess(success, enabled),
error -> logAccountToggleError(account, enabled, error)
)
);
}
private void logAccountToggledSuccess(Account account, boolean enabled) {
LOGGER.log(Level.FINER, "Account " + account.getAddress() + (enabled ? " enabled" : " disabled"));
}
private void logAccountToggleError(Account account, boolean enabled, Throwable error) {
LOGGER.log(Level.SEVERE, "Account " + account.getAddress() + " could not be " + (enabled ? "enabled" : "disabled"), error);
}
}

View file

@ -0,0 +1,162 @@
package org.mercury_im.messenger.viewmodel.accounts;
import org.jivesoftware.smack.util.StringUtils;
import org.jxmpp.jid.EntityBareJid;
import org.jxmpp.jid.impl.JidCreate;
import org.jxmpp.stringprep.XmppStringprepException;
import org.mercury_im.messenger.account.error.PasswordError;
import org.mercury_im.messenger.account.error.UsernameError;
import org.mercury_im.messenger.data.repository.AccountRepository;
import org.mercury_im.messenger.entity.Account;
import org.mercury_im.messenger.util.Optional;
import org.mercury_im.messenger.util.ThreadUtils;
import org.mercury_im.messenger.viewmodel.MercuryViewModel;
import org.mercury_im.messenger.xmpp.MercuryConnection;
import org.mercury_im.messenger.xmpp.MercuryConnectionManager;
import org.mercury_im.messenger.xmpp.exception.InvalidCredentialsException;
import org.mercury_im.messenger.xmpp.exception.ServerUnreachableException;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.inject.Inject;
import javax.inject.Named;
import io.reactivex.Observable;
import io.reactivex.Scheduler;
import io.reactivex.disposables.CompositeDisposable;
import io.reactivex.schedulers.Schedulers;
import io.reactivex.subjects.BehaviorSubject;
public class LoginViewModel implements MercuryViewModel {
private static final Logger LOGGER = Logger.getLogger(LoginViewModel.class.getName());
private final MercuryConnectionManager connectionManager;
private final AccountRepository accountRepository;
private final Scheduler ioScheduler;
private final Scheduler uiScheduler;
private BehaviorSubject<Optional<UsernameError>> loginUsernameError;
private BehaviorSubject<Optional<PasswordError>> loginPasswordError;
private BehaviorSubject<Boolean> isLoginPossible;
private BehaviorSubject<Boolean> isLoginSuccessful;
private EntityBareJid loginUsernameValue;
private String loginPasswordValue;
@Inject
public LoginViewModel(MercuryConnectionManager connectionManager,
AccountRepository accountRepository,
@Named(ThreadUtils.SCHEDULER_IO) Scheduler ioScheduler,
@Named(ThreadUtils.SCHEDULER_UI) Scheduler uiScheduler) {
this.connectionManager = connectionManager;
this.accountRepository = accountRepository;
this.ioScheduler = ioScheduler;
this.uiScheduler = uiScheduler;
}
public void reset() {
loginUsernameError = BehaviorSubject.createDefault(new Optional<>());
loginPasswordError = BehaviorSubject.createDefault(new Optional<>());
isLoginPossible = BehaviorSubject.createDefault(false);
isLoginSuccessful = BehaviorSubject.createDefault(false);
loginUsernameValue = null;
loginPasswordValue = null;
}
public Observable<Optional<UsernameError>> getLoginUsernameError() {
return loginUsernameError;
}
public Observable<Optional<PasswordError>> getLoginPasswordError() {
return loginPasswordError;
}
public Observable<Boolean> isLoginPossible() {
return isLoginPossible;
}
public Observable<Boolean> isLoginSuccessful() {
return isLoginSuccessful;
}
public synchronized void onLoginUsernameChanged(String username) {
LOGGER.log(Level.INFO, "USERNAME CHANGED: " + username);
if (StringUtils.isNullOrEmpty(username)) {
loginUsernameValue = null;
loginUsernameError.onNext(new Optional<>(UsernameError.emptyUsername));
} else {
try {
loginUsernameValue = JidCreate.entityBareFrom(username);
loginUsernameError.onNext(new Optional<>());
} catch (XmppStringprepException e) {
loginUsernameValue = null;
loginUsernameError.onNext(new Optional<>(UsernameError.invalidUsername));
}
}
updateLoginPossible();
}
public synchronized void onLoginPasswordChanged(String password) {
if (StringUtils.isNullOrEmpty(password)) {
loginPasswordValue = null;
loginPasswordError.onNext(new Optional<>(PasswordError.emptyPassword));
} else {
loginPasswordValue = password;
loginPasswordError.onNext(new Optional<>());
}
updateLoginPossible();
}
private synchronized void updateLoginPossible() {
isLoginPossible.onNext(loginUsernameValue != null && loginPasswordValue != null);
}
public synchronized void login() {
if (!isLoginPossible.getValue()) {
// Prevent race condition where account would be logged in twice
return;
}
isLoginPossible.onNext(false);
Account account = createAccountEntity();
MercuryConnection connection = connectionManager.createConnection(account);
addDisposable(connection.connect()
.andThen(connection.login())
.andThen(connectionManager.registerConnection(connection))
.andThen(accountRepository.insertAccount(account))
.subscribeOn(Schedulers.newThread())
.observeOn(uiScheduler)
.subscribe(
this::onLoginSuccessful,
this::onLoginFailed
));
}
private Account createAccountEntity() {
Account account = new Account();
account.setAddress(loginUsernameValue.asEntityBareJidString());
account.setPassword(loginPasswordValue);
account.setEnabled(true);
return account;
}
private void onLoginSuccessful(Account account) {
LOGGER.log(Level.FINER, "Successfully added new account " + account);
isLoginSuccessful.onNext(true);
}
private void onLoginFailed(Throwable error) {
isLoginPossible.onNext(true);
if (error instanceof InvalidCredentialsException) {
loginPasswordError.onNext(new Optional<>(PasswordError.incorrectPassword));
} else if (error instanceof ServerUnreachableException) {
loginUsernameError.onNext(new Optional<>(UsernameError.unreachableServer));
}
}
}

View file

@ -26,6 +26,7 @@ import java.util.logging.Logger;
import javax.inject.Inject;
import javax.inject.Singleton;
import io.reactivex.Completable;
import io.reactivex.Observable;
import io.reactivex.disposables.CompositeDisposable;
import io.reactivex.disposables.Disposable;
@ -73,7 +74,7 @@ public class MercuryConnectionManager {
public void start() {
accountRepository.observeAllAccounts()
.subscribe(accounts -> registerConnections(accounts));
.subscribe(accounts -> doRegisterConnections(accounts));
}
public List<MercuryConnection> getConnections() {
@ -97,16 +98,20 @@ public class MercuryConnectionManager {
return new MercuryConnection(connectionFactory.createConnection(account), account);
}
public void registerConnections(List<Account> accounts) {
public void doRegisterConnections(List<Account> accounts) {
for (Account account : accounts) {
if (!connectionsMap.containsKey(account.getId())) {
MercuryConnection connection = createConnection(account);
registerConnection(connection);
doRegisterConnection(connection);
}
}
}
public void registerConnection(MercuryConnection connection) {
public Completable registerConnection(MercuryConnection connection) {
return Completable.fromAction(() -> doRegisterConnection(connection));
}
public void doRegisterConnection(MercuryConnection connection) {
LOGGER.log(Level.INFO, "Register Connection " + connection.getAccountId());
putConnection(connection);
disposable.add(accountRepository