From bca253faa4ea85e8c852e03dcec471043c9ea469 Mon Sep 17 00:00:00 2001 From: Daniel Gultsch Date: Fri, 17 Feb 2023 21:50:06 +0100 Subject: [PATCH] navigate from start to password to done in Setup --- .../conversations/android/Conversations.java | 5 +- .../android/database/dao/AccountDao.java | 9 +- .../android/database/model/Account.java | 12 +- .../android/repository/AccountRepository.java | 51 +++++- .../android/ui/activity/SetupActivity.java | 23 ++- .../android/ui/model/SetupViewModel.java | 173 ++++++++++++++++-- .../android/xmpp/ConnectionException.java | 4 + .../android/xmpp/ConnectionPool.java | 20 +- .../android/xmpp/XmppConnection.java | 1 + .../xmpp/processor/PresenceProcessor.java | 6 +- 10 files changed, 260 insertions(+), 44 deletions(-) diff --git a/app/src/main/java/im/conversations/android/Conversations.java b/app/src/main/java/im/conversations/android/Conversations.java index 16005816d..d11f705b8 100644 --- a/app/src/main/java/im/conversations/android/Conversations.java +++ b/app/src/main/java/im/conversations/android/Conversations.java @@ -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); } } diff --git a/app/src/main/java/im/conversations/android/database/dao/AccountDao.java b/app/src/main/java/im/conversations/android/database/dao/AccountDao.java index 9ca569824..09a6ae25d 100644 --- a/app/src/main/java/im/conversations/android/database/dao/AccountDao.java +++ b/app/src/main/java/im/conversations/android/database/dao/AccountDao.java @@ -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 } diff --git a/app/src/main/java/im/conversations/android/database/model/Account.java b/app/src/main/java/im/conversations/android/database/model/Account.java index 5b1cc61d3..a534eb8d0 100644 --- a/app/src/main/java/im/conversations/android/database/model/Account.java +++ b/app/src/main/java/im/conversations/android/database/model/Account.java @@ -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 diff --git a/app/src/main/java/im/conversations/android/repository/AccountRepository.java b/app/src/main/java/im/conversations/android/repository/AccountRepository.java index dc90b2760..4a4556ec6 100644 --- a/app/src/main/java/im/conversations/android/repository/AccountRepository.java +++ b/app/src/main/java/im/conversations/android/repository/AccountRepository.java @@ -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 getRegistration( - final Account account) { - final ListenableFuture connectedFuture = - ConnectionPool.getInstance(context).reconfigure(account).asConnectedFuture(); + @NonNull final Account account) { + final ListenableFuture connectedFuture = getConnectedFuture(account); return Futures.transformAsync( connectedFuture, xc -> xc.getManager(RegistrationManager.class).getRegistration(), MoreExecutors.directExecutor()); } + + public ListenableFuture 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 getConnectedFuture(@NonNull final Account account) { + return ConnectionPool.getInstance(context).get(account).asConnectedFuture(); + } + + public ListenableFuture 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)); + } + } } diff --git a/app/src/main/java/im/conversations/android/ui/activity/SetupActivity.java b/app/src/main/java/im/conversations/android/ui/activity/SetupActivity.java index 6ecc943bc..cb0565513 100644 --- a/app/src/main/java/im/conversations/android/ui/activity/SetupActivity.java +++ b/app/src/main/java/im/conversations/android/ui/activity/SetupActivity.java @@ -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(); + } } diff --git a/app/src/main/java/im/conversations/android/ui/model/SetupViewModel.java b/app/src/main/java/im/conversations/android/ui/model/SetupViewModel.java index 2ae78b295..12fc7d48e 100644 --- a/app/src/main/java/im/conversations/android/ui/model/SetupViewModel.java +++ b/app/src/main/java/im/conversations/android/ui/model/SetupViewModel.java @@ -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 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() { + 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 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> getRedirection() { return this.redirection; } public enum Target { + ENTER_ADDRESS, ENTER_PASSWORD, - ENTER_HOSTNAME + ENTER_HOSTNAME, + DONE } } diff --git a/app/src/main/java/im/conversations/android/xmpp/ConnectionException.java b/app/src/main/java/im/conversations/android/xmpp/ConnectionException.java index 608463c7a..e3481101c 100644 --- a/app/src/main/java/im/conversations/android/xmpp/ConnectionException.java +++ b/app/src/main/java/im/conversations/android/xmpp/ConnectionException.java @@ -7,4 +7,8 @@ public class ConnectionException extends Exception { public ConnectionException(ConnectionState state) { this.connectionState = state; } + + public ConnectionState getConnectionState() { + return this.connectionState; + } } diff --git a/app/src/main/java/im/conversations/android/xmpp/ConnectionPool.java b/app/src/main/java/im/conversations/android/xmpp/ConnectionPool.java index 41b1bbd32..ed6a5c609 100644 --- a/app/src/main/java/im/conversations/android/xmpp/ConnectionPool.java +++ b/app/src/main/java/im/conversations/android/xmpp/ConnectionPool.java @@ -62,7 +62,7 @@ public class ConnectionPool { reconfigurationExecutor); } - public synchronized XmppConnection reconfigure(final Account account) { + public synchronized XmppConnection get(final Account account) { final Optional 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 xmppConnectionOptional = + Iterables.tryFind(this.connections, c -> c.getAccount().equals(account)); + if (xmppConnectionOptional.isPresent()) { + reconnectAccount(xmppConnectionOptional.get()); + } else { + setupXmppConnection(context, account); + } + } + public synchronized ListenableFuture 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 getConnections() { - return ImmutableList.copyOf(this.connections); - } private synchronized Void reconfigure(final Set accounts) { final Set 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); diff --git a/app/src/main/java/im/conversations/android/xmpp/XmppConnection.java b/app/src/main/java/im/conversations/android/xmpp/XmppConnection.java index fe6f46d4d..99ad0320a 100644 --- a/app/src/main/java/im/conversations/android/xmpp/XmppConnection.java +++ b/app/src/main/java/im/conversations/android/xmpp/XmppConnection.java @@ -1884,6 +1884,7 @@ public class XmppConnection implements Runnable { public ListenableFuture asConnectedFuture() { synchronized (this) { + // TODO some more permanent errors like 'unauthorized' should also return immediate if (this.connectionState == ConnectionState.ONLINE) { return Futures.immediateFuture(this); } diff --git a/app/src/main/java/im/conversations/android/xmpp/processor/PresenceProcessor.java b/app/src/main/java/im/conversations/android/xmpp/processor/PresenceProcessor.java index 2cd7d5f25..09f120c5a 100644 --- a/app/src/main/java/im/conversations/android/xmpp/processor/PresenceProcessor.java +++ b/app/src/main/java/im/conversations/android/xmpp/processor/PresenceProcessor.java @@ -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 {