store connection settings after pressing submit in hostname fragment

This commit is contained in:
Daniel Gultsch 2023-02-26 16:39:20 +01:00
parent 99c11fba17
commit d54978f593
No known key found for this signature in database
GPG key ID: F43D18AD2A0982C2
9 changed files with 216 additions and 44 deletions

View file

@ -4,6 +4,7 @@ import androidx.lifecycle.LiveData;
import androidx.room.Dao; import androidx.room.Dao;
import androidx.room.Insert; import androidx.room.Insert;
import androidx.room.Query; import androidx.room.Query;
import androidx.room.Transaction;
import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListenableFuture;
import im.conversations.android.database.entity.AccountEntity; import im.conversations.android.database.entity.AccountEntity;
import im.conversations.android.database.model.Account; import im.conversations.android.database.model.Account;
@ -13,64 +14,80 @@ import java.util.List;
import org.jxmpp.jid.BareJid; import org.jxmpp.jid.BareJid;
@Dao @Dao
public interface AccountDao { public abstract class AccountDao {
@Query("SELECT EXISTS (SELECT id FROM account WHERE address=:address)") @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)") @Query("SELECT NOT EXISTS (SELECT id FROM account)")
LiveData<Boolean> hasNoAccounts(); public abstract LiveData<Boolean> hasNoAccounts();
@Insert @Insert
long insert(final AccountEntity account); public abstract long insert(final AccountEntity account);
@Query("SELECT id,address,randomSeed FROM account WHERE enabled = 1") @Query("SELECT id,address,randomSeed FROM account WHERE enabled = 1")
ListenableFuture<List<Account>> getEnabledAccounts(); public abstract ListenableFuture<List<Account>> getEnabledAccounts();
@Query("SELECT id,address,randomSeed FROM account WHERE address=:address AND enabled=1") @Query("SELECT id,address,randomSeed FROM account WHERE address=:address AND enabled=1")
ListenableFuture<Account> getEnabledAccount(BareJid address); public abstract ListenableFuture<Account> getEnabledAccount(BareJid address);
@Query("SELECT id,address,randomSeed FROM account WHERE id=:id AND enabled=1") @Query("SELECT id,address,randomSeed FROM account WHERE id=:id AND enabled=1")
ListenableFuture<Account> getEnabledAccount(long id); public abstract ListenableFuture<Account> getEnabledAccount(long id);
@Query("SELECT id,address FROM account") @Query("SELECT id,address FROM account")
LiveData<List<AccountIdentifier>> getAccounts(); public abstract LiveData<List<AccountIdentifier>> getAccounts();
@Query("SELECT hostname,port,directTls FROM account WHERE id=:id AND hostname != null") @Query("SELECT hostname,port,directTls FROM account WHERE id=:id AND hostname IS NOT null")
Connection getConnectionSettings(long id); public abstract Connection getConnectionSettings(long id);
@Query("SELECT resource FROM account WHERE id=: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") @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") @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") @Query("SELECT loginAndBind FROM account where id=:id")
boolean loginAndBind(long id); public abstract boolean loginAndBind(long id);
@Query( @Query(
"UPDATE account set quickStartAvailable=:available WHERE id=:id AND" "UPDATE account set quickStartAvailable=:available WHERE id=:id AND"
+ " quickStartAvailable != :available") + " quickStartAvailable != :available")
void setQuickStartAvailable(long id, boolean available); public abstract void setQuickStartAvailable(long id, boolean available);
@Query( @Query(
"UPDATE account set loginAndBind=:loginAndBind WHERE id=:id AND" "UPDATE account set loginAndBind=:loginAndBind WHERE id=:id AND"
+ " loginAndBind != :loginAndBind") + " loginAndBind != :loginAndBind")
void setLoginAndBind(long id, boolean loginAndBind); public abstract void setLoginAndBind(long id, boolean loginAndBind);
@Query( @Query(
"UPDATE account set showErrorNotification=:showErrorNotification WHERE id=:id AND" "UPDATE account set showErrorNotification=:showErrorNotification WHERE id=:id AND"
+ " showErrorNotification != :showErrorNotification") + " 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") @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") @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 // TODO on disable set resource to null
} }

View file

@ -1,5 +1,7 @@
package im.conversations.android.database.model; package im.conversations.android.database.model;
import com.google.common.base.MoreObjects;
public class Connection { public class Connection {
public final String hostname; public final String hostname;
@ -11,4 +13,13 @@ public class Connection {
this.port = port; this.port = port;
this.directTls = directTls; this.directTls = directTls;
} }
@Override
public String toString() {
return MoreObjects.toStringHelper(this)
.add("hostname", hostname)
.add("port", port)
.add("directTls", directTls)
.toString();
}
} }

View file

@ -11,6 +11,7 @@ import im.conversations.android.database.CredentialStore;
import im.conversations.android.database.entity.AccountEntity; import im.conversations.android.database.entity.AccountEntity;
import im.conversations.android.database.model.Account; import im.conversations.android.database.model.Account;
import im.conversations.android.database.model.AccountIdentifier; 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.ConnectionPool;
import im.conversations.android.xmpp.XmppConnection; import im.conversations.android.xmpp.XmppConnection;
import im.conversations.android.xmpp.manager.RegistrationManager; import im.conversations.android.xmpp.manager.RegistrationManager;
@ -74,7 +75,7 @@ public class AccountRepository extends AbstractRepository {
} }
public ListenableFuture<Void> deleteAccountAsync(@NonNull Account account) { public ListenableFuture<Void> deleteAccountAsync(@NonNull Account account) {
return Futures.submit(() -> deleteAccount(account), IO_EXECUTOR); return Futures.submit(() -> deleteAccount(account), database.getQueryExecutor());
} }
private Void deleteAccount(@NonNull Account account) { private Void deleteAccount(@NonNull Account account) {
@ -86,7 +87,7 @@ public class AccountRepository extends AbstractRepository {
public ListenableFuture<XmppConnection> getConnectedFuture(@NonNull final Account account) { public ListenableFuture<XmppConnection> getConnectedFuture(@NonNull final Account account) {
final var optional = ConnectionPool.getInstance(context).get(account); final var optional = ConnectionPool.getInstance(context).get(account);
if (optional.isPresent()) { if (optional.isPresent()) {
return optional.get().asConnectedFuture(); return optional.get().asConnectedFuture(false);
} else { } else {
return Futures.immediateFailedFuture( return Futures.immediateFailedFuture(
new IllegalStateException( new IllegalStateException(
@ -106,6 +107,18 @@ public class AccountRepository extends AbstractRepository {
return account; return account;
} }
public ListenableFuture<Account> 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) { public void reconnect(final Account account) {
ConnectionPool.getInstance(context).reconnect(account); ConnectionPool.getInstance(context).reconnect(account);
} }

View file

@ -5,6 +5,7 @@ import android.os.Bundle;
import androidx.databinding.DataBindingUtil; import androidx.databinding.DataBindingUtil;
import androidx.lifecycle.ViewModelProvider; import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.NavController; import androidx.navigation.NavController;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import im.conversations.android.R; import im.conversations.android.R;
import im.conversations.android.SetupNavigationDirections; import im.conversations.android.SetupNavigationDirections;
import im.conversations.android.databinding.ActivitySetupBinding; import im.conversations.android.databinding.ActivitySetupBinding;
@ -30,9 +31,19 @@ public class SetupActivity extends BaseActivity {
new ViewModelProvider(this, getDefaultViewModelProviderFactory()); new ViewModelProvider(this, getDefaultViewModelProviderFactory());
this.setupViewModel = viewModelProvider.get(SetupViewModel.class); this.setupViewModel = viewModelProvider.get(SetupViewModel.class);
this.setupViewModel.getRedirection().observe(this, this::onRedirectionEvent); this.setupViewModel.getRedirection().observe(this, this::onRedirectionEvent);
this.setupViewModel.getGenericErrorEvent().observe(this, this::onGenericErrorEvent);
Activities.setStatusAndNavigationBarColors(this, binding.getRoot()); Activities.setStatusAndNavigationBarColors(this, binding.getRoot());
} }
private void onGenericErrorEvent(final Event<String> errorEvent) {
if (errorEvent.isConsumable()) {
new MaterialAlertDialogBuilder(this)
.setMessage(errorEvent.consume())
.setPositiveButton(R.string.ok, null)
.show();
}
}
private void onRedirectionEvent(final Event<SetupViewModel.Target> targetEvent) { private void onRedirectionEvent(final Event<SetupViewModel.Target> targetEvent) {
if (targetEvent.isConsumable()) { if (targetEvent.isConsumable()) {
final NavController navController = getNavController(); final NavController navController = getNavController();

View file

@ -6,19 +6,24 @@ import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LiveData; import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Transformations; import androidx.lifecycle.Transformations;
import com.google.common.base.CharMatcher;
import com.google.common.base.Strings; 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.FutureCallback;
import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors; import com.google.common.util.concurrent.MoreExecutors;
import im.conversations.android.R; import im.conversations.android.R;
import im.conversations.android.database.model.Account; import im.conversations.android.database.model.Account;
import im.conversations.android.database.model.Connection;
import im.conversations.android.repository.AccountRepository; import im.conversations.android.repository.AccountRepository;
import im.conversations.android.ui.Event; import im.conversations.android.ui.Event;
import im.conversations.android.util.ConnectionStates;
import im.conversations.android.xmpp.ConnectionException; import im.conversations.android.xmpp.ConnectionException;
import im.conversations.android.xmpp.ConnectionState; import im.conversations.android.xmpp.ConnectionState;
import im.conversations.android.xmpp.XmppConnection; import im.conversations.android.xmpp.XmppConnection;
import java.util.Arrays; import java.util.Arrays;
import java.util.Locale;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import org.jxmpp.jid.BareJid; import org.jxmpp.jid.BareJid;
import org.jxmpp.jid.impl.JidCreate; import org.jxmpp.jid.impl.JidCreate;
@ -35,8 +40,12 @@ public class SetupViewModel extends AndroidViewModel {
private final MutableLiveData<String> password = new MutableLiveData<>(); private final MutableLiveData<String> password = new MutableLiveData<>();
private final MutableLiveData<String> passwordError = new MutableLiveData<>(); private final MutableLiveData<String> passwordError = new MutableLiveData<>();
private final MutableLiveData<String> hostname = new MutableLiveData<>(); private final MutableLiveData<String> hostname = new MutableLiveData<>();
private final MutableLiveData<String> hostnameError = new MutableLiveData<>();
private final MutableLiveData<String> port = new MutableLiveData<>(); private final MutableLiveData<String> port = new MutableLiveData<>();
private final MutableLiveData<String> portError = new MutableLiveData<>();
private final MutableLiveData<Boolean> opportunisticTls = new MutableLiveData<>(); private final MutableLiveData<Boolean> opportunisticTls = new MutableLiveData<>();
private final MutableLiveData<Event<String>> genericErrorEvent = new MutableLiveData<>();
private final MutableLiveData<Boolean> loading = new MutableLiveData<>(false); private final MutableLiveData<Boolean> loading = new MutableLiveData<>(false);
private final MutableLiveData<Event<Target>> redirection = new MutableLiveData<>(); private final MutableLiveData<Event<Target>> redirection = new MutableLiveData<>();
@ -54,6 +63,9 @@ public class SetupViewModel extends AndroidViewModel {
.observeForever(s -> xmppAddressError.postValue(null)); .observeForever(s -> xmppAddressError.postValue(null));
Transformations.distinctUntilChanged(password) Transformations.distinctUntilChanged(password)
.observeForever(s -> passwordError.postValue(null)); .observeForever(s -> passwordError.postValue(null));
Transformations.distinctUntilChanged(port).observeForever(s -> portError.postValue(null));
Transformations.distinctUntilChanged(hostname)
.observeForever(s -> hostnameError.postValue(null));
} }
public LiveData<Boolean> isLoading() { public LiveData<Boolean> isLoading() {
@ -76,10 +88,18 @@ public class SetupViewModel extends AndroidViewModel {
return hostname; return hostname;
} }
public LiveData<String> getHostnameError() {
return this.hostnameError;
}
public MutableLiveData<String> getPort() { public MutableLiveData<String> getPort() {
return port; return port;
} }
public LiveData<String> getPortError() {
return this.portError;
}
public MutableLiveData<Boolean> getOpportunisticTls() { public MutableLiveData<Boolean> getOpportunisticTls() {
return this.opportunisticTls; return this.opportunisticTls;
} }
@ -88,6 +108,10 @@ public class SetupViewModel extends AndroidViewModel {
return Transformations.distinctUntilChanged(this.passwordError); return Transformations.distinctUntilChanged(this.passwordError);
} }
public LiveData<Event<String>> getGenericErrorEvent() {
return this.genericErrorEvent;
}
public boolean submitXmppAddress() { public boolean submitXmppAddress() {
final var account = this.account; final var account = this.account;
final var userInput = Strings.nullToEmpty(this.xmppAddress.getValue()).trim(); final var userInput = Strings.nullToEmpty(this.xmppAddress.getValue()).trim();
@ -111,6 +135,14 @@ public class SetupViewModel extends AndroidViewModel {
return true; return true;
} else { } else {
this.account = null; 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); this.accountRepository.deleteAccountAsync(account);
} }
} }
@ -210,7 +242,8 @@ public class SetupViewModel extends AndroidViewModel {
return; return;
} }
} }
// TODO show generic error this.genericErrorEvent.postValue(
new Event<>(getApplication().getString(ConnectionStates.toStringRes(state))));
} }
private boolean redirectIfNecessary(final Target current, final Target next) { 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); this.redirectIfNecessary(Target.ENTER_HOSTNAME, Target.ENTER_ADDRESS);
return true; 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; return true;
} }

View file

@ -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));
}
}
}

View file

@ -78,6 +78,7 @@ import java.security.Principal;
import java.security.PrivateKey; import java.security.PrivateKey;
import java.security.cert.X509Certificate; import java.security.cert.X509Certificate;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection; import java.util.Collection;
import java.util.Hashtable; import java.util.Hashtable;
import java.util.Iterator; import java.util.Iterator;
@ -97,7 +98,6 @@ import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.X509KeyManager; import javax.net.ssl.X509KeyManager;
import javax.net.ssl.X509TrustManager; import javax.net.ssl.X509TrustManager;
import okhttp3.HttpUrl;
import org.jxmpp.jid.Jid; import org.jxmpp.jid.Jid;
import org.jxmpp.jid.impl.JidCreate; import org.jxmpp.jid.impl.JidCreate;
import org.jxmpp.stringprep.XmppStringprepException; import org.jxmpp.stringprep.XmppStringprepException;
@ -151,7 +151,6 @@ public class XmppConnection implements Runnable {
private final PendingItem<SettableFuture<XmppConnection>> connectedFuture = new PendingItem<>(); private final PendingItem<SettableFuture<XmppConnection>> connectedFuture = new PendingItem<>();
private SaslMechanism saslMechanism; private SaslMechanism saslMechanism;
private HashedToken.Mechanism hashTokenRequest; private HashedToken.Mechanism hashTokenRequest;
private HttpUrl redirectionUrl = null;
private String verifiedHostname = null; private String verifiedHostname = null;
private volatile Thread mThread; private volatile Thread mThread;
private CountDownLatch mStreamCountDownLatch; private CountDownLatch mStreamCountDownLatch;
@ -1392,26 +1391,12 @@ public class XmppConnection implements Runnable {
return bind; 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() { public void resetEverything() {
resetAttemptCount(true); resetAttemptCount(true);
resetStreamId(); resetStreamId();
clearIqCallbacks(); clearIqCallbacks();
this.stanzasSent = 0; this.stanzasSent = 0;
mStanzaQueue.clear(); mStanzaQueue.clear();
this.redirectionUrl = null;
this.saslMechanism = null; this.saslMechanism = null;
} }
@ -1892,13 +1877,19 @@ public class XmppConnection implements Runnable {
this.statusListener = listener; this.statusListener = listener;
} }
public ListenableFuture<XmppConnection> asConnectedFuture() { public ListenableFuture<XmppConnection> asConnectedFuture(final boolean waitOnError) {
synchronized (this) { synchronized (this) {
final var state = this.connectionState;
// TODO some more permanent errors like 'unauthorized' should also return immediate // TODO some more permanent errors like 'unauthorized' should also return immediate
if (this.connectionState == ConnectionState.ONLINE) { if (this.connectionState == ConnectionState.ONLINE) {
return Futures.immediateFuture(this); 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);
} }
} }

View file

@ -102,10 +102,10 @@
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginHorizontal="4dp" android:layout_marginHorizontal="4dp"
android:layout_weight="3" android:layout_weight="5"
android:enabled="@{!setupViewModel.isLoading()}" android:enabled="@{!setupViewModel.isLoading()}"
android:hint="@string/account_settings_hostname" android:hint="@string/account_settings_hostname"
app:errorText="@{setupViewModel.xmppAddressError}"> app:errorText="@{setupViewModel.hostnameError}">
<com.google.android.material.textfield.TextInputEditText <com.google.android.material.textfield.TextInputEditText
android:id="@+id/hostname" android:id="@+id/hostname"
@ -124,10 +124,10 @@
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginHorizontal="4dp" android:layout_marginHorizontal="4dp"
android:layout_weight="1" android:layout_weight="2"
android:enabled="@{!setupViewModel.isLoading()}" android:enabled="@{!setupViewModel.isLoading()}"
android:hint="@string/account_settings_port" android:hint="@string/account_settings_port"
app:errorText="@{setupViewModel.xmppAddressError}"> app:errorText="@{setupViewModel.portError}">
<com.google.android.material.textfield.TextInputEditText <com.google.android.material.textfield.TextInputEditText
android:id="@+id/port" android:id="@+id/port"
@ -136,6 +136,7 @@
android:imeOptions="flagNoExtractUi|actionNext" android:imeOptions="flagNoExtractUi|actionNext"
android:inputType="number" android:inputType="number"
android:maxLines="1" android:maxLines="1"
android:maxLength="5"
android:text="@={setupViewModel.port}" android:text="@={setupViewModel.port}"
app:editorAction="@{()->setupViewModel.submitHostname()}" /> app:editorAction="@{()->setupViewModel.submitHostname()}" />
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>

View file

@ -1040,5 +1040,6 @@
<string name="pref_category_receiving">Receiving</string> <string name="pref_category_receiving">Receiving</string>
<string name="use_opportunistic_tls">Opportunistic TLS (STARTTLS)</string> <string name="use_opportunistic_tls">Opportunistic TLS (STARTTLS)</string>
<string name="info_required">Info required</string> <string name="info_required">Info required</string>
<string name="invalid">Invalid!</string>
</resources> </resources>