navigate from start to password to done in Setup
This commit is contained in:
parent
68e9f25da2
commit
bca253faa4
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
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;
|
||||
}
|
||||
|
||||
public boolean submitPassword() {
|
||||
final BareJid address;
|
||||
try {
|
||||
address = JidCreate.bareFrom(this.xmppAddress.getValue());
|
||||
address = JidCreate.bareFrom(userInput);
|
||||
} catch (final XmppStringprepException e) {
|
||||
xmppAddressError.postValue("Not a valid jid");
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,4 +7,8 @@ public class ConnectionException extends Exception {
|
|||
public ConnectionException(ConnectionState state) {
|
||||
this.connectionState = state;
|
||||
}
|
||||
|
||||
public ConnectionState getConnectionState() {
|
||||
return this.connectionState;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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> {
|
||||
|
||||
|
|
Loading…
Reference in a new issue