navigate from start to password to done in Setup

This commit is contained in:
Daniel Gultsch 2023-02-17 21:50:06 +01:00
parent 68e9f25da2
commit bca253faa4
No known key found for this signature in database
GPG key ID: F43D18AD2A0982C2
10 changed files with 260 additions and 44 deletions

View file

@ -1,9 +1,7 @@
package im.conversations.android;
import android.app.Application;
import androidx.appcompat.app.AppCompatDelegate;
import com.google.android.material.color.DynamicColors;
import im.conversations.android.dns.Resolver;
import im.conversations.android.xmpp.ConnectionPool;
@ -29,7 +27,8 @@ public class Conversations extends Application {
}
Resolver.init(this);
ConnectionPool.getInstance(this).reconfigure();
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM); //For night mode theme
AppCompatDelegate.setDefaultNightMode(
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM); // For night mode theme
DynamicColors.applyToActivitiesIfAvailable(this);
}
}

View file

@ -2,7 +2,6 @@ package im.conversations.android.database.dao;
import androidx.room.Dao;
import androidx.room.Insert;
import androidx.room.OnConflictStrategy;
import androidx.room.Query;
import com.google.common.util.concurrent.ListenableFuture;
import im.conversations.android.database.entity.AccountEntity;
@ -14,7 +13,10 @@ import org.jxmpp.jid.BareJid;
@Dao
public interface AccountDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
@Query("SELECT EXISTS (SELECT id FROM account WHERE address=:address)")
boolean hasAccount(BareJid address);
@Insert
long insert(final AccountEntity account);
@Query("SELECT id,address,randomSeed FROM account WHERE enabled = 1")
@ -59,5 +61,8 @@ public interface AccountDao {
@Query("UPDATE account set resource=:resource WHERE id=:id")
void setResource(long id, String resource);
@Query("DELETE FROM account WHERE id=:id")
int delete(long id);
// TODO on disable set resource to null
}

View file

@ -8,6 +8,7 @@ import com.google.common.io.ByteSource;
import com.google.common.primitives.Ints;
import im.conversations.android.IDs;
import java.io.IOException;
import java.util.Arrays;
import java.util.UUID;
import org.jxmpp.jid.BareJid;
@ -17,6 +18,15 @@ public class Account {
@NonNull public final BareJid address;
@NonNull public final byte[] randomSeed;
@Override
public String toString() {
return "Account{" +
"id=" + id +
", address=" + address +
", randomSeed=" + Arrays.toString(randomSeed) +
'}';
}
public Account(final long id, @NonNull final BareJid address, @NonNull byte[] randomSeed) {
Preconditions.checkNotNull(address, "Account can not be instantiated without an address");
Preconditions.checkArgument(
@ -33,7 +43,7 @@ public class Account {
Account account = (Account) o;
return id == account.id
&& Objects.equal(address, account.address)
&& Objects.equal(randomSeed, account.randomSeed);
&& Arrays.equals(randomSeed, account.randomSeed);
}
@Override

View file

@ -2,7 +2,6 @@ package im.conversations.android.repository;
import android.content.Context;
import androidx.annotation.NonNull;
import com.google.common.base.Preconditions;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
@ -13,6 +12,8 @@ import im.conversations.android.database.model.Account;
import im.conversations.android.xmpp.ConnectionPool;
import im.conversations.android.xmpp.XmppConnection;
import im.conversations.android.xmpp.manager.RegistrationManager;
import java.io.IOException;
import java.security.GeneralSecurityException;
import org.jxmpp.jid.BareJid;
public class AccountRepository extends AbstractRepository {
@ -22,8 +23,11 @@ public class AccountRepository extends AbstractRepository {
}
private Account createAccount(
@NonNull final BareJid address, final String password, final boolean loginAndBind) {
Preconditions.checkArgument(password != null, "Missing password");
@NonNull final BareJid address, final String password, final boolean loginAndBind)
throws GeneralSecurityException, IOException {
if (database.accountDao().hasAccount(address)) {
throw new AccountAlreadyExistsException(address);
}
final byte[] randomSeed = IDs.seed();
final var entity = new AccountEntity();
entity.address = address;
@ -32,12 +36,10 @@ public class AccountRepository extends AbstractRepository {
entity.randomSeed = randomSeed;
final long id = database.accountDao().insert(entity);
final var account = new Account(id, address, entity.randomSeed);
try {
if (password != null) {
CredentialStore.getInstance(context).setPassword(account, password);
} catch (final Exception e) {
throw new IllegalStateException("Could not store password", e);
}
ConnectionPool.getInstance(context).reconfigure(account);
ConnectionPool.getInstance(context).reconfigure();
return account;
}
@ -52,12 +54,41 @@ public class AccountRepository extends AbstractRepository {
}
public ListenableFuture<RegistrationManager.Registration> getRegistration(
final Account account) {
final ListenableFuture<XmppConnection> connectedFuture =
ConnectionPool.getInstance(context).reconfigure(account).asConnectedFuture();
@NonNull final Account account) {
final ListenableFuture<XmppConnection> connectedFuture = getConnectedFuture(account);
return Futures.transformAsync(
connectedFuture,
xc -> xc.getManager(RegistrationManager.class).getRegistration(),
MoreExecutors.directExecutor());
}
public ListenableFuture<Boolean> deleteAccountAsync(@NonNull Account account) {
return Futures.submit(() -> deleteAccount(account), IO_EXECUTOR);
}
private Boolean deleteAccount(@NonNull Account account) {
return database.accountDao().delete(account.id) > 0;
}
public ListenableFuture<XmppConnection> getConnectedFuture(@NonNull final Account account) {
return ConnectionPool.getInstance(context).get(account).asConnectedFuture();
}
public ListenableFuture<Account> setPasswordAsync(
@NonNull Account account, @NonNull String password) {
return Futures.submit(() -> setPassword(account, password), IO_EXECUTOR);
}
private Account setPassword(@NonNull Account account, @NonNull String password)
throws GeneralSecurityException, IOException {
CredentialStore.getInstance(context).setPassword(account, password);
ConnectionPool.getInstance(context).reconnect(account);
return account;
}
public static class AccountAlreadyExistsException extends IllegalStateException {
public AccountAlreadyExistsException(BareJid address) {
super(String.format("The account %s has already been setup", address));
}
}
}

View file

@ -2,7 +2,6 @@ package im.conversations.android.ui.activity;
import android.os.Bundle;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.app.AppCompatDelegate;
import androidx.databinding.DataBindingUtil;
import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.NavController;
@ -13,9 +12,13 @@ import im.conversations.android.ui.Activities;
import im.conversations.android.ui.Event;
import im.conversations.android.ui.NavControllers;
import im.conversations.android.ui.model.SetupViewModel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class SetupActivity extends AppCompatActivity {
private static final Logger LOGGER = LoggerFactory.getLogger(SetupActivity.class);
private SetupViewModel setupViewModel;
@Override
@ -38,6 +41,10 @@ public class SetupActivity extends AppCompatActivity {
case ENTER_PASSWORD:
navController.navigate(SetupNavigationDirections.enterPassword());
break;
case DONE:
// TODO open MainActivity
finish();
break;
default:
throw new IllegalStateException(
String.format("Unable to navigate to target %s", target));
@ -48,4 +55,18 @@ public class SetupActivity extends AppCompatActivity {
private NavController getNavController() {
return NavControllers.findNavController(this, R.id.nav_host_fragment);
}
@Override
public void onBackPressed() {
if (this.setupViewModel.cancelCurrentOperation()) {
return;
}
final var navController = getNavController();
final var destination = navController.getCurrentDestination();
if (destination != null && destination.getId() == R.id.signIn) {
LOGGER.info("User pressed back in signIn. Cancel setup");
this.setupViewModel.cancelSetup();
}
super.onBackPressed();
}
}

View file

@ -6,12 +6,19 @@ import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Transformations;
import com.google.common.base.Strings;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
import im.conversations.android.R;
import im.conversations.android.database.model.Account;
import im.conversations.android.repository.AccountRepository;
import im.conversations.android.ui.Event;
import im.conversations.android.xmpp.ConnectionException;
import im.conversations.android.xmpp.ConnectionState;
import im.conversations.android.xmpp.XmppConnection;
import java.util.Arrays;
import org.jetbrains.annotations.NotNull;
import org.jxmpp.jid.BareJid;
import org.jxmpp.jid.impl.JidCreate;
@ -33,9 +40,17 @@ public class SetupViewModel extends AndroidViewModel {
private final AccountRepository accountRepository;
private Account account;
private ListenableFuture<?> currentOperation;
public SetupViewModel(@NonNull @NotNull Application application) {
super(application);
this.accountRepository = new AccountRepository(application);
// this clears the error if the user starts typing again
Transformations.distinctUntilChanged(xmppAddress)
.observeForever(s -> xmppAddressError.postValue(null));
Transformations.distinctUntilChanged(password)
.observeForever(s -> passwordError.postValue(null));
}
public LiveData<Boolean> isLoading() {
@ -59,44 +74,168 @@ public class SetupViewModel extends AndroidViewModel {
}
public boolean submitXmppAddress() {
redirection.postValue(new Event<>(Target.ENTER_PASSWORD));
return true;
}
public boolean submitPassword() {
final BareJid address;
try {
address = JidCreate.bareFrom(this.xmppAddress.getValue());
} catch (final XmppStringprepException e) {
xmppAddressError.postValue("Not a valid jid");
final var userInput = Strings.nullToEmpty(this.xmppAddress.getValue()).trim();
if (userInput.isEmpty()) {
this.xmppAddressError.postValue(
getApplication().getString(R.string.please_enter_xmpp_address));
return true;
}
final BareJid address;
try {
address = JidCreate.bareFrom(userInput);
} catch (final XmppStringprepException e) {
this.xmppAddressError.postValue(getApplication().getString(R.string.invalid_jid));
return true;
}
// TODO do we already have an account in this viewModel? is it the same? if so go to that
// one with the next step
final String password = this.password.getValue();
final var creationFuture =
this.accountRepository.createAccountAsync(address, password, true);
// post parsed/normalized jid back into UI
this.xmppAddress.postValue(address.toString());
this.loading.postValue(true);
final var creationFuture = this.accountRepository.createAccountAsync(address, password);
Futures.addCallback(
creationFuture,
new FutureCallback<Account>() {
new FutureCallback<>() {
@Override
public void onSuccess(final Account account) {
LOGGER.info("Successfully created account {}", account.address);
setAccount(account);
}
@Override
public void onFailure(@NonNull final Throwable t) {
LOGGER.warn("Could not create account", t);
public void onFailure(@NonNull final Throwable throwable) {
loading.postValue(false);
if (throwable instanceof AccountRepository.AccountAlreadyExistsException) {
xmppAddressError.postValue(
getApplication().getString(R.string.account_already_setup));
return;
}
LOGGER.warn("Could not create account", throwable);
}
},
MoreExecutors.directExecutor());
return true;
}
private void setAccount(@NonNull final Account account) {
this.account = account;
this.decideNextStep(Target.ENTER_ADDRESS, account);
}
private void decideNextStep(final Target current, @NonNull final Account account) {
LOGGER.info("Get connected future for {}", account.address);
final ListenableFuture<XmppConnection> connectedFuture =
this.accountRepository.getConnectedFuture(account);
Futures.addCallback(
connectedFuture,
new FutureCallback<>() {
@Override
public void onSuccess(final XmppConnection result) {
// TODO only when configured for loginAndBind
LOGGER.info("Account setup successful");
}
@Override
public void onFailure(@NonNull final Throwable throwable) {
loading.postValue(false);
if (throwable instanceof ConnectionException) {
decideNextStep(current, ((ConnectionException) throwable));
} else {
LOGGER.error("Something went wrong bad", throwable);
// something went wrong bad. display dialog with error message or
// something
}
}
},
MoreExecutors.directExecutor());
}
private void decideNextStep(
final Target current, final ConnectionException connectionException) {
final var state = connectionException.getConnectionState();
LOGGER.info("Deciding next step for {}", state);
if (Arrays.asList(ConnectionState.UNAUTHORIZED, ConnectionState.TEMPORARY_AUTH_FAILURE)
.contains(state)) {
if (this.redirectIfNecessary(current, Target.ENTER_PASSWORD)) {
return;
}
passwordError.postValue(
getApplication().getString(R.string.account_status_unauthorized));
return;
}
if (Arrays.asList(
ConnectionState.HOST_UNKNOWN,
ConnectionState.STREAM_OPENING_ERROR,
ConnectionState.SERVER_NOT_FOUND)
.contains(state)) {
if (this.redirectIfNecessary(current, Target.ENTER_HOSTNAME)) {
return;
}
}
// TODO show generic error
}
private boolean redirectIfNecessary(final Target current, final Target next) {
if (current == next) {
return false;
}
return redirect(next);
}
private boolean redirect(final Target next) {
this.redirection.postValue(new Event<>(next));
return true;
}
public boolean submitPassword() {
final var account = this.account;
if (account == null) {
this.redirectIfNecessary(Target.ENTER_PASSWORD, Target.ENTER_ADDRESS);
return true;
}
final String password = Strings.nullToEmpty(this.password.getValue());
final var setPasswordFuture = this.accountRepository.setPasswordAsync(account, password);
this.loading.postValue(true);
Futures.addCallback(setPasswordFuture, new FutureCallback<>() {
@Override
public void onSuccess(final Account account) {
decideNextStep(Target.ENTER_PASSWORD, account);
}
@Override
public void onFailure(@NonNull Throwable throwable) {
// TODO show some sort of error message
loading.postValue(false);
}
},MoreExecutors.directExecutor());
return true;
}
public boolean cancelCurrentOperation() {
final var currentFuture = this.currentOperation;
if (currentFuture == null || currentFuture.isDone()) {
return false;
}
return currentFuture.cancel(true);
}
public void cancelSetup() {
final var account = this.account;
if (account != null) {
this.accountRepository.deleteAccountAsync(account);
}
}
public LiveData<Event<Target>> getRedirection() {
return this.redirection;
}
public enum Target {
ENTER_ADDRESS,
ENTER_PASSWORD,
ENTER_HOSTNAME
ENTER_HOSTNAME,
DONE
}
}

View file

@ -7,4 +7,8 @@ public class ConnectionException extends Exception {
public ConnectionException(ConnectionState state) {
this.connectionState = state;
}
public ConnectionState getConnectionState() {
return this.connectionState;
}
}

View file

@ -62,7 +62,7 @@ public class ConnectionPool {
reconfigurationExecutor);
}
public synchronized XmppConnection reconfigure(final Account account) {
public synchronized XmppConnection get(final Account account) {
final Optional<XmppConnection> xmppConnectionOptional =
Iterables.tryFind(this.connections, c -> c.getAccount().equals(account));
if (xmppConnectionOptional.isPresent()) {
@ -71,6 +71,16 @@ public class ConnectionPool {
return setupXmppConnection(context, account);
}
public synchronized void reconnect(final Account account) {
final Optional<XmppConnection> xmppConnectionOptional =
Iterables.tryFind(this.connections, c -> c.getAccount().equals(account));
if (xmppConnectionOptional.isPresent()) {
reconnectAccount(xmppConnectionOptional.get());
} else {
setupXmppConnection(context, account);
}
}
public synchronized ListenableFuture<XmppConnection> get(final BareJid address) {
final var configured =
Iterables.tryFind(this.connections, c -> address.equals(c.getAccount().address));
@ -85,7 +95,7 @@ public class ConnectionPool {
String.format(
"No enabled account with address %s", address.toString()));
}
return reconfigure(account);
return get(account);
},
reconfigurationExecutor);
}
@ -102,7 +112,7 @@ public class ConnectionPool {
throw new IllegalStateException(
String.format("No enabled account with id %d", id));
}
return reconfigure(account);
return get(account);
},
reconfigurationExecutor);
}
@ -111,9 +121,6 @@ public class ConnectionPool {
return Iterables.any(this.connections, c -> id == c.getAccount().id);
}
public synchronized List<XmppConnection> getConnections() {
return ImmutableList.copyOf(this.connections);
}
private synchronized Void reconfigure(final Set<Account> accounts) {
final Set<Account> current = getAccounts();
@ -349,6 +356,7 @@ public class ConnectionPool {
}
private XmppConnection setupXmppConnection(final Context context, final Account account) {
LOGGER.info("Setting up XMPP connection for {}",account.address);
final XmppConnection xmppConnection = new XmppConnection(context, account);
this.connections.add(xmppConnection);
xmppConnection.setOnStatusChangedListener(this::onStatusChanged);

View file

@ -1884,6 +1884,7 @@ public class XmppConnection implements Runnable {
public ListenableFuture<XmppConnection> asConnectedFuture() {
synchronized (this) {
// TODO some more permanent errors like 'unauthorized' should also return immediate
if (this.connectionState == ConnectionState.ONLINE) {
return Futures.immediateFuture(this);
}

View file

@ -1,10 +1,6 @@
package im.conversations.android.xmpp.processor;
import android.content.Context;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import im.conversations.android.database.model.PresenceShow;
import im.conversations.android.database.model.PresenceType;
import im.conversations.android.xmpp.Entity;
@ -12,6 +8,8 @@ import im.conversations.android.xmpp.XmppConnection;
import im.conversations.android.xmpp.manager.DiscoManager;
import im.conversations.android.xmpp.model.stanza.Presence;
import java.util.function.Consumer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class PresenceProcessor extends XmppConnection.Delegate implements Consumer<Presence> {