From d54978f593d9ffc92bc3a54aef98b8cae08104cd Mon Sep 17 00:00:00 2001 From: Daniel Gultsch Date: Sun, 26 Feb 2023 16:39:20 +0100 Subject: [PATCH] store connection settings after pressing submit in hostname fragment --- .../android/database/dao/AccountDao.java | 55 ++++++++++------ .../android/database/model/Connection.java | 11 ++++ .../android/repository/AccountRepository.java | 17 ++++- .../android/ui/activity/SetupActivity.java | 11 ++++ .../android/ui/model/SetupViewModel.java | 65 ++++++++++++++++++- .../android/util/ConnectionStates.java | 64 ++++++++++++++++++ .../android/xmpp/XmppConnection.java | 27 +++----- app/src/main/res/layout/fragment_hostname.xml | 9 +-- app/src/main/res/values/strings.xml | 1 + 9 files changed, 216 insertions(+), 44 deletions(-) create mode 100644 app/src/main/java/im/conversations/android/util/ConnectionStates.java 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 c75d3c9cc..5ccf9eb7c 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 @@ -4,6 +4,7 @@ import androidx.lifecycle.LiveData; import androidx.room.Dao; import androidx.room.Insert; import androidx.room.Query; +import androidx.room.Transaction; import com.google.common.util.concurrent.ListenableFuture; import im.conversations.android.database.entity.AccountEntity; import im.conversations.android.database.model.Account; @@ -13,64 +14,80 @@ import java.util.List; import org.jxmpp.jid.BareJid; @Dao -public interface AccountDao { +public abstract class AccountDao { @Query("SELECT EXISTS (SELECT id FROM account WHERE address=:address)") - boolean hasAccount(BareJid address); + public abstract boolean hasAccount(BareJid address); @Query("SELECT NOT EXISTS (SELECT id FROM account)") - LiveData hasNoAccounts(); + public abstract LiveData hasNoAccounts(); @Insert - long insert(final AccountEntity account); + public abstract long insert(final AccountEntity account); @Query("SELECT id,address,randomSeed FROM account WHERE enabled = 1") - ListenableFuture> getEnabledAccounts(); + public abstract ListenableFuture> getEnabledAccounts(); @Query("SELECT id,address,randomSeed FROM account WHERE address=:address AND enabled=1") - ListenableFuture getEnabledAccount(BareJid address); + public abstract ListenableFuture getEnabledAccount(BareJid address); @Query("SELECT id,address,randomSeed FROM account WHERE id=:id AND enabled=1") - ListenableFuture getEnabledAccount(long id); + public abstract ListenableFuture getEnabledAccount(long id); @Query("SELECT id,address FROM account") - LiveData> getAccounts(); + public abstract LiveData> getAccounts(); - @Query("SELECT hostname,port,directTls FROM account WHERE id=:id AND hostname != null") - Connection getConnectionSettings(long id); + @Query("SELECT hostname,port,directTls FROM account WHERE id=:id AND hostname IS NOT null") + public abstract Connection getConnectionSettings(long id); @Query("SELECT resource FROM account WHERE id=:id") - String getResource(long id); + public abstract String getResource(long id); @Query("SELECT rosterVersion FROM account WHERE id=:id") - String getRosterVersion(long id); + public abstract String getRosterVersion(long id); @Query("SELECT quickStartAvailable FROM account where id=:id") - boolean quickStartAvailable(long id); + public abstract boolean quickStartAvailable(long id); @Query("SELECT loginAndBind FROM account where id=:id") - boolean loginAndBind(long id); + public abstract boolean loginAndBind(long id); @Query( "UPDATE account set quickStartAvailable=:available WHERE id=:id AND" + " quickStartAvailable != :available") - void setQuickStartAvailable(long id, boolean available); + public abstract void setQuickStartAvailable(long id, boolean available); @Query( "UPDATE account set loginAndBind=:loginAndBind WHERE id=:id AND" + " loginAndBind != :loginAndBind") - void setLoginAndBind(long id, boolean loginAndBind); + public abstract void setLoginAndBind(long id, boolean loginAndBind); @Query( "UPDATE account set showErrorNotification=:showErrorNotification WHERE id=:id AND" + " showErrorNotification != :showErrorNotification") - int setShowErrorNotification(long id, boolean showErrorNotification); + public abstract int setShowErrorNotification(long id, boolean showErrorNotification); @Query("UPDATE account set resource=:resource WHERE id=:id") - void setResource(long id, String resource); + public abstract void setResource(long id, String resource); @Query("DELETE FROM account WHERE id=:id") - int delete(long id); + public abstract int delete(long id); + + @Query( + "UPDATE account SET hostname=:hostname, port=:port, directTls=:directTls WHERE" + + " id=:account") + protected abstract int setConnection( + long account, String hostname, int port, boolean directTls); + + @Transaction + public void setConnection(final Account account, final Connection connection) { + final var count = + setConnection( + account.id, connection.hostname, connection.port, connection.directTls); + if (count != 1) { + throw new IllegalStateException("Could not update account"); + } + } // TODO on disable set resource to null } diff --git a/app/src/main/java/im/conversations/android/database/model/Connection.java b/app/src/main/java/im/conversations/android/database/model/Connection.java index 23b461062..501ab606c 100644 --- a/app/src/main/java/im/conversations/android/database/model/Connection.java +++ b/app/src/main/java/im/conversations/android/database/model/Connection.java @@ -1,5 +1,7 @@ package im.conversations.android.database.model; +import com.google.common.base.MoreObjects; + public class Connection { public final String hostname; @@ -11,4 +13,13 @@ public class Connection { this.port = port; this.directTls = directTls; } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("hostname", hostname) + .add("port", port) + .add("directTls", directTls) + .toString(); + } } 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 04b1e6afe..70bddc1f2 100644 --- a/app/src/main/java/im/conversations/android/repository/AccountRepository.java +++ b/app/src/main/java/im/conversations/android/repository/AccountRepository.java @@ -11,6 +11,7 @@ import im.conversations.android.database.CredentialStore; import im.conversations.android.database.entity.AccountEntity; import im.conversations.android.database.model.Account; import im.conversations.android.database.model.AccountIdentifier; +import im.conversations.android.database.model.Connection; import im.conversations.android.xmpp.ConnectionPool; import im.conversations.android.xmpp.XmppConnection; import im.conversations.android.xmpp.manager.RegistrationManager; @@ -74,7 +75,7 @@ public class AccountRepository extends AbstractRepository { } public ListenableFuture deleteAccountAsync(@NonNull Account account) { - return Futures.submit(() -> deleteAccount(account), IO_EXECUTOR); + return Futures.submit(() -> deleteAccount(account), database.getQueryExecutor()); } private Void deleteAccount(@NonNull Account account) { @@ -86,7 +87,7 @@ public class AccountRepository extends AbstractRepository { public ListenableFuture getConnectedFuture(@NonNull final Account account) { final var optional = ConnectionPool.getInstance(context).get(account); if (optional.isPresent()) { - return optional.get().asConnectedFuture(); + return optional.get().asConnectedFuture(false); } else { return Futures.immediateFailedFuture( new IllegalStateException( @@ -106,6 +107,18 @@ public class AccountRepository extends AbstractRepository { return account; } + public ListenableFuture setConnectionAsync( + final Account account, final Connection connection) { + return Futures.submit( + () -> setConnection(account, connection), database.getQueryExecutor()); + } + + public Account setConnection(final Account account, final Connection connection) { + database.accountDao().setConnection(account, connection); + ConnectionPool.getInstance(context).reconnect(account); + return account; + } + public void reconnect(final Account account) { ConnectionPool.getInstance(context).reconnect(account); } 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 f4255eb19..84b12bc33 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 @@ -5,6 +5,7 @@ import android.os.Bundle; import androidx.databinding.DataBindingUtil; import androidx.lifecycle.ViewModelProvider; import androidx.navigation.NavController; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; import im.conversations.android.R; import im.conversations.android.SetupNavigationDirections; import im.conversations.android.databinding.ActivitySetupBinding; @@ -30,9 +31,19 @@ public class SetupActivity extends BaseActivity { new ViewModelProvider(this, getDefaultViewModelProviderFactory()); this.setupViewModel = viewModelProvider.get(SetupViewModel.class); this.setupViewModel.getRedirection().observe(this, this::onRedirectionEvent); + this.setupViewModel.getGenericErrorEvent().observe(this, this::onGenericErrorEvent); Activities.setStatusAndNavigationBarColors(this, binding.getRoot()); } + private void onGenericErrorEvent(final Event errorEvent) { + if (errorEvent.isConsumable()) { + new MaterialAlertDialogBuilder(this) + .setMessage(errorEvent.consume()) + .setPositiveButton(R.string.ok, null) + .show(); + } + } + private void onRedirectionEvent(final Event targetEvent) { if (targetEvent.isConsumable()) { final NavController navController = getNavController(); 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 ab1588dcd..b8c94c2ca 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,19 +6,24 @@ import androidx.lifecycle.AndroidViewModel; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.Transformations; +import com.google.common.base.CharMatcher; import com.google.common.base.Strings; +import com.google.common.primitives.Ints; 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.database.model.Connection; import im.conversations.android.repository.AccountRepository; import im.conversations.android.ui.Event; +import im.conversations.android.util.ConnectionStates; import im.conversations.android.xmpp.ConnectionException; import im.conversations.android.xmpp.ConnectionState; import im.conversations.android.xmpp.XmppConnection; import java.util.Arrays; +import java.util.Locale; import org.jetbrains.annotations.NotNull; import org.jxmpp.jid.BareJid; import org.jxmpp.jid.impl.JidCreate; @@ -35,8 +40,12 @@ public class SetupViewModel extends AndroidViewModel { private final MutableLiveData password = new MutableLiveData<>(); private final MutableLiveData passwordError = new MutableLiveData<>(); private final MutableLiveData hostname = new MutableLiveData<>(); + private final MutableLiveData hostnameError = new MutableLiveData<>(); private final MutableLiveData port = new MutableLiveData<>(); + private final MutableLiveData portError = new MutableLiveData<>(); private final MutableLiveData opportunisticTls = new MutableLiveData<>(); + + private final MutableLiveData> genericErrorEvent = new MutableLiveData<>(); private final MutableLiveData loading = new MutableLiveData<>(false); private final MutableLiveData> redirection = new MutableLiveData<>(); @@ -54,6 +63,9 @@ public class SetupViewModel extends AndroidViewModel { .observeForever(s -> xmppAddressError.postValue(null)); Transformations.distinctUntilChanged(password) .observeForever(s -> passwordError.postValue(null)); + Transformations.distinctUntilChanged(port).observeForever(s -> portError.postValue(null)); + Transformations.distinctUntilChanged(hostname) + .observeForever(s -> hostnameError.postValue(null)); } public LiveData isLoading() { @@ -76,10 +88,18 @@ public class SetupViewModel extends AndroidViewModel { return hostname; } + public LiveData getHostnameError() { + return this.hostnameError; + } + public MutableLiveData getPort() { return port; } + public LiveData getPortError() { + return this.portError; + } + public MutableLiveData getOpportunisticTls() { return this.opportunisticTls; } @@ -88,6 +108,10 @@ public class SetupViewModel extends AndroidViewModel { return Transformations.distinctUntilChanged(this.passwordError); } + public LiveData> getGenericErrorEvent() { + return this.genericErrorEvent; + } + public boolean submitXmppAddress() { final var account = this.account; final var userInput = Strings.nullToEmpty(this.xmppAddress.getValue()).trim(); @@ -111,6 +135,14 @@ public class SetupViewModel extends AndroidViewModel { return true; } else { this.account = null; + + // when the XMPP address changes we want to reset connection info too + // this is partially to indicate that Conversations might not actually use those + // connection settings if the connection works without them + this.hostname.setValue(null); + this.port.setValue(null); + this.opportunisticTls.setValue(false); + this.accountRepository.deleteAccountAsync(account); } } @@ -210,7 +242,8 @@ public class SetupViewModel extends AndroidViewModel { return; } } - // TODO show generic error + this.genericErrorEvent.postValue( + new Event<>(getApplication().getString(ConnectionStates.toStringRes(state)))); } private boolean redirectIfNecessary(final Target current, final Target next) { @@ -231,7 +264,37 @@ public class SetupViewModel extends AndroidViewModel { this.redirectIfNecessary(Target.ENTER_HOSTNAME, Target.ENTER_ADDRESS); return true; } + final String hostname = + Strings.nullToEmpty(this.hostname.getValue()).trim().toLowerCase(Locale.ROOT); + if (hostname.isEmpty() || CharMatcher.whitespace().matchesAnyOf(hostname)) { + this.hostnameError.postValue(getApplication().getString(R.string.not_valid_hostname)); + return true; + } + final Integer port = Ints.tryParse(Strings.nullToEmpty(this.port.getValue())); + if (port == null || port < 0 || port > 65535) { + this.portError.postValue(getApplication().getString(R.string.invalid)); + return true; + } + final boolean directTls = Boolean.FALSE.equals(this.opportunisticTls.getValue()); + final var connection = new Connection(hostname, port, directTls); + final var setConnectionFuture = + this.accountRepository.setConnectionAsync(account, connection); + this.setCurrentOperation(setConnectionFuture); + Futures.addCallback( + setConnectionFuture, + new FutureCallback<>() { + @Override + public void onSuccess(final Account result) { + decideNextStep(Target.ENTER_HOSTNAME, account); + } + @Override + public void onFailure(@NonNull final Throwable throwable) { + loading.postValue(false); + // TODO error message?! + } + }, + MoreExecutors.directExecutor()); return true; } diff --git a/app/src/main/java/im/conversations/android/util/ConnectionStates.java b/app/src/main/java/im/conversations/android/util/ConnectionStates.java new file mode 100644 index 000000000..5499916cf --- /dev/null +++ b/app/src/main/java/im/conversations/android/util/ConnectionStates.java @@ -0,0 +1,64 @@ +package im.conversations.android.util; + +import androidx.annotation.StringRes; +import im.conversations.android.R; +import im.conversations.android.xmpp.ConnectionState; + +public final class ConnectionStates { + + private ConnectionStates() { + throw new IllegalStateException("Do not instantiate me"); + } + + @StringRes + public static int toStringRes(final ConnectionState state) { + switch (state) { + case ONLINE: + return R.string.account_status_online; + case CONNECTING: + return R.string.account_status_connecting; + case OFFLINE: + return R.string.account_status_offline; + case UNAUTHORIZED: + return R.string.account_status_unauthorized; + case SERVER_NOT_FOUND: + return R.string.account_status_not_found; + case TLS_ERROR: + return R.string.account_status_tls_error; + case TLS_ERROR_DOMAIN: + return R.string.account_status_tls_error_domain; + case INCOMPATIBLE_SERVER: + return R.string.account_status_incompatible_server; + case INCOMPATIBLE_CLIENT: + return R.string.account_status_incompatible_client; + case TOR_NOT_AVAILABLE: + return R.string.account_status_tor_unavailable; + case BIND_FAILURE: + return R.string.account_status_bind_failure; + case SESSION_FAILURE: + return R.string.session_failure; + case DOWNGRADE_ATTACK: + return R.string.sasl_downgrade; + case HOST_UNKNOWN: + return R.string.account_status_host_unknown; + case POLICY_VIOLATION: + return R.string.account_status_policy_violation; + case REGISTRATION_PLEASE_WAIT: + return R.string.registration_please_wait; + case REGISTRATION_PASSWORD_TOO_WEAK: + return R.string.registration_password_too_weak; + case STREAM_ERROR: + return R.string.account_status_stream_error; + case STREAM_OPENING_ERROR: + return R.string.account_status_stream_opening_error; + case PAYMENT_REQUIRED: + return R.string.payment_required; + case MISSING_INTERNET_PERMISSION: + return R.string.missing_internet_permission; + case TEMPORARY_AUTH_FAILURE: + return R.string.account_status_temporary_auth_failure; + default: + throw new IllegalStateException(String.format("no string res for %s", state)); + } + } +} 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 223c9e4ea..c0e16f3fe 100644 --- a/app/src/main/java/im/conversations/android/xmpp/XmppConnection.java +++ b/app/src/main/java/im/conversations/android/xmpp/XmppConnection.java @@ -78,6 +78,7 @@ import java.security.Principal; import java.security.PrivateKey; import java.security.cert.X509Certificate; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.Hashtable; import java.util.Iterator; @@ -97,7 +98,6 @@ import javax.net.ssl.SSLSocket; import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.X509KeyManager; import javax.net.ssl.X509TrustManager; -import okhttp3.HttpUrl; import org.jxmpp.jid.Jid; import org.jxmpp.jid.impl.JidCreate; import org.jxmpp.stringprep.XmppStringprepException; @@ -151,7 +151,6 @@ public class XmppConnection implements Runnable { private final PendingItem> connectedFuture = new PendingItem<>(); private SaslMechanism saslMechanism; private HashedToken.Mechanism hashTokenRequest; - private HttpUrl redirectionUrl = null; private String verifiedHostname = null; private volatile Thread mThread; private CountDownLatch mStreamCountDownLatch; @@ -1392,26 +1391,12 @@ public class XmppConnection implements Runnable { return bind; } - private void setAccountCreationFailed(final String url) { - final HttpUrl httpUrl = url == null ? null : HttpUrl.parse(url); - if (httpUrl != null && httpUrl.isHttps()) { - this.redirectionUrl = httpUrl; - throw new StateChangingError(ConnectionState.REGISTRATION_WEB); - } - throw new StateChangingError(ConnectionState.REGISTRATION_FAILED); - } - - public HttpUrl getRedirectionUrl() { - return this.redirectionUrl; - } - public void resetEverything() { resetAttemptCount(true); resetStreamId(); clearIqCallbacks(); this.stanzasSent = 0; mStanzaQueue.clear(); - this.redirectionUrl = null; this.saslMechanism = null; } @@ -1892,13 +1877,19 @@ public class XmppConnection implements Runnable { this.statusListener = listener; } - public ListenableFuture asConnectedFuture() { + public ListenableFuture asConnectedFuture(final boolean waitOnError) { synchronized (this) { + final var state = this.connectionState; // TODO some more permanent errors like 'unauthorized' should also return immediate if (this.connectionState == ConnectionState.ONLINE) { return Futures.immediateFuture(this); + } else if (Arrays.asList(ConnectionState.OFFLINE, ConnectionState.CONNECTING) + .contains(state) + || waitOnError) { + return this.connectedFuture.peekOrCreate(SettableFuture::create); + } else { + return Futures.immediateFailedFuture(new ConnectionException(state)); } - return this.connectedFuture.peekOrCreate(SettableFuture::create); } } diff --git a/app/src/main/res/layout/fragment_hostname.xml b/app/src/main/res/layout/fragment_hostname.xml index ef3294482..e271922c7 100644 --- a/app/src/main/res/layout/fragment_hostname.xml +++ b/app/src/main/res/layout/fragment_hostname.xml @@ -102,10 +102,10 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginHorizontal="4dp" - android:layout_weight="3" + android:layout_weight="5" android:enabled="@{!setupViewModel.isLoading()}" android:hint="@string/account_settings_hostname" - app:errorText="@{setupViewModel.xmppAddressError}"> + app:errorText="@{setupViewModel.hostnameError}"> + app:errorText="@{setupViewModel.portError}"> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8c81ca07f..08f82c9b8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1040,5 +1040,6 @@ Receiving Opportunistic TLS (STARTTLS) Info required + Invalid!