move registration code into manager

Daniel Gultsch created

Change summary

src/main/java/eu/siacs/conversations/generator/IqGenerator.java                       |  10 
src/main/java/eu/siacs/conversations/services/XmppConnectionService.java              |  91 
src/main/java/eu/siacs/conversations/ui/ChangePasswordActivity.java                   | 221 
src/main/java/eu/siacs/conversations/ui/EditAccountActivity.java                      |  43 
src/main/java/eu/siacs/conversations/ui/XmppActivity.java                             | 162 
src/main/java/eu/siacs/conversations/xml/Namespace.java                               |   4 
src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java                         | 299 
src/main/java/eu/siacs/conversations/xmpp/manager/RegistrationManager.java            | 180 
src/main/java/im/conversations/android/xmpp/model/bob/Data.java                       |  27 
src/main/java/im/conversations/android/xmpp/model/bob/package-info.java               |   5 
src/main/java/im/conversations/android/xmpp/model/data/Field.java                     |   5 
src/main/java/im/conversations/android/xmpp/model/media/Media.java                    |  25 
src/main/java/im/conversations/android/xmpp/model/media/Uri.java                      |  12 
src/main/java/im/conversations/android/xmpp/model/media/package-info.java             |   5 
src/main/java/im/conversations/android/xmpp/model/register/Instructions.java          |   2 
src/main/java/im/conversations/android/xmpp/model/register/Password.java              |   2 
src/main/java/im/conversations/android/xmpp/model/register/RegisterStreamFeature.java |  13 
src/main/java/im/conversations/android/xmpp/model/register/Remove.java                |   2 
src/main/java/im/conversations/android/xmpp/model/streams/Features.java               |  16 
src/main/java/im/conversations/android/xmpp/model/token/Register.java                 |  12 
src/main/java/im/conversations/android/xmpp/model/token/package-info.java             |   5 
21 files changed, 652 insertions(+), 489 deletions(-)

Detailed changes

src/main/java/eu/siacs/conversations/generator/IqGenerator.java 🔗

@@ -188,16 +188,6 @@ public class IqGenerator extends AbstractGenerator {
         return packet;
     }
 
-    public Iq generateSetPassword(final Account account, final String newPassword) {
-        final Iq packet = new Iq(Iq.Type.SET);
-        packet.setTo(account.getDomain());
-        final Element query = packet.addChild("query", Namespace.REGISTER);
-        final Jid jid = account.getJid();
-        query.addChild("username").setContent(jid.getLocal());
-        query.addChild("password").setContent(newPassword);
-        return packet;
-    }
-
     public Iq changeAffiliation(Conversation conference, Jid jid, String affiliation) {
         List<Jid> jids = new ArrayList<>();
         jids.add(jid);

src/main/java/eu/siacs/conversations/services/XmppConnectionService.java 🔗

@@ -39,7 +39,6 @@ import android.preference.PreferenceManager;
 import android.provider.ContactsContract;
 import android.security.KeyChain;
 import android.text.TextUtils;
-import android.util.DisplayMetrics;
 import android.util.Log;
 import android.util.LruCache;
 import android.util.Pair;
@@ -141,6 +140,7 @@ import eu.siacs.conversations.xmpp.manager.MessageDisplayedSynchronizationManage
 import eu.siacs.conversations.xmpp.manager.NickManager;
 import eu.siacs.conversations.xmpp.manager.PresenceManager;
 import eu.siacs.conversations.xmpp.manager.PrivateStorageManager;
+import eu.siacs.conversations.xmpp.manager.RegistrationManager;
 import eu.siacs.conversations.xmpp.manager.RosterManager;
 import eu.siacs.conversations.xmpp.manager.VCardManager;
 import eu.siacs.conversations.xmpp.pep.Avatar;
@@ -2809,41 +2809,10 @@ public class XmppConnectionService extends Service {
         }
     }
 
-    public void updateAccountPasswordOnServer(
-            final Account account,
-            final String newPassword,
-            final OnAccountPasswordChanged callback) {
-        final Iq iq = getIqGenerator().generateSetPassword(account, newPassword);
-        sendIqPacket(
-                account,
-                iq,
-                (packet) -> {
-                    if (packet.getType() == Iq.Type.RESULT) {
-                        account.setPassword(newPassword);
-                        account.setOption(Account.OPTION_MAGIC_CREATE, false);
-                        databaseBackend.updateAccount(account);
-                        callback.onPasswordChangeSucceeded();
-                    } else {
-                        callback.onPasswordChangeFailed();
-                    }
-                });
-    }
-
-    public void unregisterAccount(final Account account, final Consumer<Boolean> callback) {
-        final Iq iqPacket = new Iq(Iq.Type.SET);
-        final Element query = iqPacket.addChild("query", Namespace.REGISTER);
-        query.addChild("remove");
-        sendIqPacket(
-                account,
-                iqPacket,
-                (response) -> {
-                    if (response.getType() == Iq.Type.RESULT) {
-                        deleteAccount(account);
-                        callback.accept(true);
-                    } else {
-                        callback.accept(false);
-                    }
-                });
+    public ListenableFuture<Void> updateAccountPasswordOnServer(
+            final Account account, final String newPassword) {
+        final var connection = account.getXmppConnection();
+        return connection.getManager(RegistrationManager.class).setPassword(newPassword);
     }
 
     public void deleteAccount(final Account account) {
@@ -4664,21 +4633,24 @@ public class XmppConnectionService extends Service {
         }
     }
 
-    public boolean displayCaptchaRequest(Account account, String id, Data data, Bitmap captcha) {
-        if (mOnCaptchaRequested.size() > 0) {
-            DisplayMetrics metrics = getApplicationContext().getResources().getDisplayMetrics();
-            Bitmap scaled =
-                    Bitmap.createScaledBitmap(
-                            captcha,
-                            (int) (captcha.getWidth() * metrics.scaledDensity),
-                            (int) (captcha.getHeight() * metrics.scaledDensity),
-                            false);
-            for (OnCaptchaRequested listener : threadSafeList(this.mOnCaptchaRequested)) {
-                listener.onCaptchaRequested(account, id, data, scaled);
-            }
-            return true;
+    public boolean displayCaptchaRequest(
+            final Account account,
+            final im.conversations.android.xmpp.model.data.Data data,
+            final Bitmap captcha) {
+        if (mOnCaptchaRequested.isEmpty()) {
+            return false;
         }
-        return false;
+        final var metrics = getApplicationContext().getResources().getDisplayMetrics();
+        Bitmap scaled =
+                Bitmap.createScaledBitmap(
+                        captcha,
+                        (int) (captcha.getWidth() * metrics.scaledDensity),
+                        (int) (captcha.getHeight() * metrics.scaledDensity),
+                        false);
+        for (final OnCaptchaRequested listener : threadSafeList(this.mOnCaptchaRequested)) {
+            listener.onCaptchaRequested(account, data, scaled);
+        }
+        return true;
     }
 
     public void updateBlocklistUi(final OnUpdateBlocklist.Status status) {
@@ -5027,14 +4999,6 @@ public class XmppConnectionService extends Service {
         }
     }
 
-    public void sendCreateAccountWithCaptchaPacket(Account account, String id, Data data) {
-        final XmppConnection connection = account.getXmppConnection();
-        if (connection == null) {
-            return;
-        }
-        connection.sendCreateAccountWithCaptchaPacket(id, data);
-    }
-
     public ListenableFuture<Iq> sendIqPacket(final Account account, final Iq request) {
         final XmppConnection connection = account.getXmppConnection();
         if (connection == null) {
@@ -5401,12 +5365,6 @@ public class XmppConnectionService extends Service {
         void informUser(int r);
     }
 
-    public interface OnAccountPasswordChanged {
-        void onPasswordChangeSucceeded();
-
-        void onPasswordChangeFailed();
-    }
-
     public interface OnRoomDestroy {
         void onRoomDestroySucceeded();
 
@@ -5440,7 +5398,10 @@ public class XmppConnectionService extends Service {
     }
 
     public interface OnCaptchaRequested {
-        void onCaptchaRequested(Account account, String id, Data data, Bitmap captcha);
+        void onCaptchaRequested(
+                Account account,
+                im.conversations.android.xmpp.model.data.Data data,
+                Bitmap captcha);
     }
 
     public interface OnRosterUpdate {

src/main/java/eu/siacs/conversations/ui/ChangePasswordActivity.java 🔗

@@ -2,119 +2,130 @@ package eu.siacs.conversations.ui;
 
 import android.content.Intent;
 import android.os.Bundle;
+import android.util.Log;
 import android.view.View;
 import android.widget.Toast;
-
+import androidx.annotation.NonNull;
+import androidx.core.content.ContextCompat;
 import androidx.databinding.DataBindingUtil;
-
 import com.google.android.material.textfield.TextInputLayout;
-
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import eu.siacs.conversations.Config;
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.databinding.ActivityChangePasswordBinding;
 import eu.siacs.conversations.entities.Account;
-import eu.siacs.conversations.services.XmppConnectionService;
 import eu.siacs.conversations.ui.widget.DisabledActionModeCallback;
 
-public class ChangePasswordActivity extends XmppActivity implements XmppConnectionService.OnAccountPasswordChanged {
-
-	private ActivityChangePasswordBinding binding;
-
-	private final View.OnClickListener mOnChangePasswordButtonClicked = new View.OnClickListener() {
-		@Override
-		public void onClick(final View view) {
-			final var account = mAccount;
-			if (account == null) {
-				return;
-			}
-				final String currentPassword = binding.currentPassword.getText().toString();
-				final String newPassword = binding.newPassword.getText().toString();
-				if (!account.isOptionSet(Account.OPTION_MAGIC_CREATE) && !currentPassword.equals(account.getPassword())) {
-					binding.currentPassword.requestFocus();
-					binding.currentPasswordLayout.setError(getString(R.string.account_status_unauthorized));
-					removeErrorsOnAllBut(binding.currentPasswordLayout);
-				} else if (newPassword.trim().isEmpty()) {
-					binding.newPassword.requestFocus();
-					binding.newPasswordLayout.setError(getString(R.string.password_should_not_be_empty));
-					removeErrorsOnAllBut(binding.newPasswordLayout);
-				} else {
-					binding.currentPasswordLayout.setError(null);
-					binding.newPasswordLayout.setError(null);
-					xmppConnectionService.updateAccountPasswordOnServer(account, newPassword, ChangePasswordActivity.this);
-					binding.changePasswordButton.setEnabled(false);
-					binding.changePasswordButton.setText(R.string.updating);
-				}
-		}
-	};
-
-
-
-	private Account mAccount;
-
-	@Override
+public class ChangePasswordActivity extends XmppActivity {
+
+    private ActivityChangePasswordBinding binding;
+
+    private final FutureCallback<? super Void> passwordChangedCallback =
+            new FutureCallback<>() {
+                @Override
+                public void onSuccess(Void result) {
+                    Toast.makeText(
+                                    ChangePasswordActivity.this,
+                                    R.string.password_changed,
+                                    Toast.LENGTH_LONG)
+                            .show();
+                    finish();
+                }
+
+                @Override
+                public void onFailure(@NonNull Throwable t) {
+                    Log.d(Config.LOGTAG, "could not change password", t);
+                    binding.newPasswordLayout.setError(
+                            getString(R.string.could_not_change_password));
+                    binding.changePasswordButton.setEnabled(true);
+                    binding.changePasswordButton.setText(R.string.change_password);
+                }
+            };
+    private final View.OnClickListener mOnChangePasswordButtonClicked =
+            new View.OnClickListener() {
+                @Override
+                public void onClick(final View view) {
+                    final var account = mAccount;
+                    if (account == null) {
+                        return;
+                    }
+                    final String currentPassword = binding.currentPassword.getText().toString();
+                    final String newPassword = binding.newPassword.getText().toString();
+                    if (!account.isOptionSet(Account.OPTION_MAGIC_CREATE)
+                            && !currentPassword.equals(account.getPassword())) {
+                        binding.currentPassword.requestFocus();
+                        binding.currentPasswordLayout.setError(
+                                getString(R.string.account_status_unauthorized));
+                        removeErrorsOnAllBut(binding.currentPasswordLayout);
+                    } else if (newPassword.trim().isEmpty()) {
+                        binding.newPassword.requestFocus();
+                        binding.newPasswordLayout.setError(
+                                getString(R.string.password_should_not_be_empty));
+                        removeErrorsOnAllBut(binding.newPasswordLayout);
+                    } else {
+                        binding.currentPasswordLayout.setError(null);
+                        binding.newPasswordLayout.setError(null);
+                        final var future =
+                                xmppConnectionService.updateAccountPasswordOnServer(
+                                        account, newPassword);
+                        Futures.addCallback(
+                                future,
+                                ChangePasswordActivity.this.passwordChangedCallback,
+                                ContextCompat.getMainExecutor(getApplication()));
+                        binding.changePasswordButton.setEnabled(false);
+                        binding.changePasswordButton.setText(R.string.updating);
+                    }
+                }
+            };
+
+    private Account mAccount;
+
+    @Override
     protected void onBackendConnected() {
-		this.mAccount = extractAccount(getIntent());
-		if (this.mAccount != null && this.mAccount.isOptionSet(Account.OPTION_MAGIC_CREATE)) {
-			this.binding.currentPasswordLayout.setVisibility(View.GONE);
-		} else {
-			this.binding.currentPasswordLayout.setVisibility(View.VISIBLE);
-		}
-	}
-
-	@Override
-	protected void onCreate(final Bundle savedInstanceState) {
-		super.onCreate(savedInstanceState);
-		this.binding = DataBindingUtil.setContentView(this, R.layout.activity_change_password);
-		Activities.setStatusAndNavigationBarColors(this, binding.getRoot());
-		setSupportActionBar(binding.toolbar);
-		configureActionBar(getSupportActionBar());
-		binding.cancelButton.setOnClickListener(view -> finish());
-		binding.changePasswordButton.setOnClickListener(this.mOnChangePasswordButtonClicked);
-		binding.currentPassword.setCustomSelectionActionModeCallback(new DisabledActionModeCallback());
-		binding.newPassword.setCustomSelectionActionModeCallback(new DisabledActionModeCallback());
-	}
-
-	@Override
-	public void onStart() {
-		super.onStart();
-		Intent intent = getIntent();
-		String password = intent != null ? intent.getStringExtra("password") : null;
-		if (password != null) {
-			binding.newPassword.getEditableText().clear();
-			binding.newPassword.getEditableText().append(password);
-		}
-	}
-
-	@Override
-	public void onPasswordChangeSucceeded() {
-		runOnUiThread(() -> {
-			Toast.makeText(ChangePasswordActivity.this,R.string.password_changed,Toast.LENGTH_LONG).show();
-			finish();
-		});
-	}
-
-	@Override
-	public void onPasswordChangeFailed() {
-		runOnUiThread(() -> {
-			binding.newPasswordLayout.setError(getString(R.string.could_not_change_password));
-			binding.changePasswordButton.setEnabled(true);
-			binding.changePasswordButton.setText(R.string.change_password);
-		});
-
-	}
-
-	private void removeErrorsOnAllBut(TextInputLayout exception) {
-		if (this.binding.currentPasswordLayout != exception) {
-			this.binding.currentPasswordLayout.setErrorEnabled(false);
-			this.binding.currentPasswordLayout.setError(null);
-		}
-		if (this.binding.newPasswordLayout != exception) {
-			this.binding.newPasswordLayout.setErrorEnabled(false);
-			this.binding.newPasswordLayout.setError(null);
-		}
-
-	}
-
-	public void refreshUiReal() {
-
-	}
+        this.mAccount = extractAccount(getIntent());
+        if (this.mAccount != null && this.mAccount.isOptionSet(Account.OPTION_MAGIC_CREATE)) {
+            this.binding.currentPasswordLayout.setVisibility(View.GONE);
+        } else {
+            this.binding.currentPasswordLayout.setVisibility(View.VISIBLE);
+        }
+    }
+
+    @Override
+    protected void onCreate(final Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        this.binding = DataBindingUtil.setContentView(this, R.layout.activity_change_password);
+        Activities.setStatusAndNavigationBarColors(this, binding.getRoot());
+        setSupportActionBar(binding.toolbar);
+        configureActionBar(getSupportActionBar());
+        binding.cancelButton.setOnClickListener(view -> finish());
+        binding.changePasswordButton.setOnClickListener(this.mOnChangePasswordButtonClicked);
+        binding.currentPassword.setCustomSelectionActionModeCallback(
+                new DisabledActionModeCallback());
+        binding.newPassword.setCustomSelectionActionModeCallback(new DisabledActionModeCallback());
+    }
+
+    @Override
+    public void onStart() {
+        super.onStart();
+        Intent intent = getIntent();
+        String password = intent != null ? intent.getStringExtra("password") : null;
+        if (password != null) {
+            binding.newPassword.getEditableText().clear();
+            binding.newPassword.getEditableText().append(password);
+        }
+    }
+
+    private void removeErrorsOnAllBut(TextInputLayout exception) {
+        if (this.binding.currentPasswordLayout != exception) {
+            this.binding.currentPasswordLayout.setErrorEnabled(false);
+            this.binding.currentPasswordLayout.setError(null);
+        }
+        if (this.binding.newPasswordLayout != exception) {
+            this.binding.newPasswordLayout.setErrorEnabled(false);
+            this.binding.newPasswordLayout.setError(null);
+        }
+    }
+
+    public void refreshUiReal() {}
 }

src/main/java/eu/siacs/conversations/ui/EditAccountActivity.java 🔗

@@ -80,8 +80,9 @@ import eu.siacs.conversations.xmpp.OnKeyStatusUpdated;
 import eu.siacs.conversations.xmpp.OnUpdateBlocklist;
 import eu.siacs.conversations.xmpp.XmppConnection;
 import eu.siacs.conversations.xmpp.XmppConnection.Features;
-import eu.siacs.conversations.xmpp.forms.Data;
 import eu.siacs.conversations.xmpp.manager.CarbonsManager;
+import eu.siacs.conversations.xmpp.manager.RegistrationManager;
+import im.conversations.android.xmpp.model.data.Data;
 import im.conversations.android.xmpp.model.stanza.Presence;
 import java.util.Arrays;
 import java.util.List;
@@ -788,10 +789,10 @@ public class EditAccountActivity extends OmemoActivity
                 showBlocklist.setVisible(false);
             }
 
-            if (!mAccount.getXmppConnection().getFeatures().register()) {
-                changePassword.setVisible(false);
-                deleteAccount.setVisible(false);
-            }
+            final var registration =
+                    mAccount.getXmppConnection().getManager(RegistrationManager.class).hasFeature();
+            changePassword.setVisible(registration);
+            deleteAccount.setVisible(registration);
             mamPrefs.setVisible(mAccount.getXmppConnection().getFeatures().mam());
             changePresence.setVisible(!mInitMode);
         } else {
@@ -1601,8 +1602,7 @@ public class EditAccountActivity extends OmemoActivity
     }
 
     @Override
-    public void onCaptchaRequested(
-            final Account account, final String id, final Data data, final Bitmap captcha) {
+    public void onCaptchaRequested(final Account account, final Data data, final Bitmap captcha) {
         runOnUiThread(
                 () -> {
                     if (mCaptchaDialog != null && mCaptchaDialog.isShowing()) {
@@ -1624,34 +1624,15 @@ public class EditAccountActivity extends OmemoActivity
 
                     builder.setPositiveButton(
                             getString(R.string.ok),
-                            (dialog, which) -> {
-                                String rc = input.getText().toString();
-                                data.put("username", account.getUsername());
-                                data.put("password", account.getPassword());
-                                data.put("ocr", rc);
-                                data.submit();
-
-                                if (xmppConnectionServiceBound) {
-                                    xmppConnectionService.sendCreateAccountWithCaptchaPacket(
-                                            account, id, data);
-                                }
-                            });
+                            (dialog, which) ->
+                                    account.getXmppConnection()
+                                            .register(data, input.getText().toString()));
                     builder.setNegativeButton(
                             getString(R.string.cancel),
-                            (dialog, which) -> {
-                                if (xmppConnectionService != null) {
-                                    xmppConnectionService.sendCreateAccountWithCaptchaPacket(
-                                            account, null, null);
-                                }
-                            });
+                            (dialog, which) -> account.getXmppConnection().cancelRegistration());
 
                     builder.setOnCancelListener(
-                            dialog -> {
-                                if (xmppConnectionService != null) {
-                                    xmppConnectionService.sendCreateAccountWithCaptchaPacket(
-                                            account, null, null);
-                                }
-                            });
+                            dialog -> account.getXmppConnection().cancelRegistration());
                     mCaptchaDialog = builder.create();
                     mCaptchaDialog.show();
                     input.requestFocus();

src/main/java/eu/siacs/conversations/ui/XmppActivity.java 🔗

@@ -56,6 +56,9 @@ import com.google.android.material.color.MaterialColors;
 import com.google.android.material.dialog.MaterialAlertDialogBuilder;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.MoreExecutors;
 import eu.siacs.conversations.AppSettings;
 import eu.siacs.conversations.BuildConfig;
 import eu.siacs.conversations.Config;
@@ -86,6 +89,7 @@ import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.OnKeyStatusUpdated;
 import eu.siacs.conversations.xmpp.OnUpdateBlocklist;
 import eu.siacs.conversations.xmpp.manager.PresenceManager;
+import eu.siacs.conversations.xmpp.manager.RegistrationManager;
 import java.io.IOException;
 import java.lang.ref.WeakReference;
 import java.util.ArrayList;
@@ -390,92 +394,96 @@ public abstract class XmppActivity extends ActionBarActivity {
     protected void deleteAccount(final Account account, final Runnable postDelete) {
         final MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this);
         final View dialogView = getLayoutInflater().inflate(R.layout.dialog_delete_account, null);
-        final CheckBox deleteFromServer = dialogView.findViewById(R.id.delete_from_server);
         builder.setView(dialogView);
         builder.setTitle(R.string.mgmt_account_delete);
         builder.setPositiveButton(getString(R.string.delete), null);
         builder.setNegativeButton(getString(R.string.cancel), null);
         final AlertDialog dialog = builder.create();
         dialog.setOnShowListener(
-                dialogInterface -> {
-                    final Button button = dialog.getButton(AlertDialog.BUTTON_POSITIVE);
-                    button.setOnClickListener(
-                            v -> {
-                                final boolean unregister = deleteFromServer.isChecked();
-                                if (unregister) {
-                                    if (account.isOnlineAndConnected()) {
-                                        deleteFromServer.setEnabled(false);
-                                        button.setText(R.string.please_wait);
-                                        button.setEnabled(false);
-                                        xmppConnectionService.unregisterAccount(
-                                                account,
-                                                result -> {
-                                                    runOnUiThread(
-                                                            () -> {
-                                                                if (result) {
-                                                                    dialog.dismiss();
-                                                                    if (postDelete != null) {
-                                                                        postDelete.run();
-                                                                    }
-                                                                    if (xmppConnectionService
-                                                                                            .getAccounts()
-                                                                                            .size()
-                                                                                    == 0
-                                                                            && Config
-                                                                                            .MAGIC_CREATE_DOMAIN
-                                                                                    != null) {
-                                                                        final Intent intent =
-                                                                                SignupUtils
-                                                                                        .getSignUpIntent(
-                                                                                                this);
-                                                                        intent.setFlags(
-                                                                                Intent
-                                                                                                .FLAG_ACTIVITY_NEW_TASK
-                                                                                        | Intent
-                                                                                                .FLAG_ACTIVITY_CLEAR_TASK);
-                                                                        startActivity(intent);
-                                                                    }
-                                                                } else {
-                                                                    deleteFromServer.setEnabled(
-                                                                            true);
-                                                                    button.setText(R.string.delete);
-                                                                    button.setEnabled(true);
-                                                                    Toast.makeText(
-                                                                                    this,
-                                                                                    R.string
-                                                                                            .could_not_delete_account_from_server,
-                                                                                    Toast
-                                                                                            .LENGTH_LONG)
-                                                                            .show();
-                                                                }
-                                                            });
-                                                });
-                                    } else {
-                                        Toast.makeText(
-                                                        this,
-                                                        R.string.not_connected_try_again,
-                                                        Toast.LENGTH_LONG)
-                                                .show();
-                                    }
-                                } else {
-                                    xmppConnectionService.deleteAccount(account);
-                                    dialog.dismiss();
-                                    if (xmppConnectionService.getAccounts().size() == 0
-                                            && Config.MAGIC_CREATE_DOMAIN != null) {
-                                        final Intent intent = SignupUtils.getSignUpIntent(this);
-                                        intent.setFlags(
-                                                Intent.FLAG_ACTIVITY_NEW_TASK
-                                                        | Intent.FLAG_ACTIVITY_CLEAR_TASK);
-                                        startActivity(intent);
-                                    } else if (postDelete != null) {
-                                        postDelete.run();
-                                    }
-                                }
-                            });
-                });
+                dialogInterface -> onShowDeleteDialog(dialogInterface, account, postDelete));
         dialog.show();
     }
 
+    private void onShowDeleteDialog(
+            final DialogInterface dialogInterface,
+            final Account account,
+            final Runnable postDelete) {
+        final AlertDialog alertDialog;
+        if (dialogInterface instanceof AlertDialog dialog) {
+            alertDialog = dialog;
+        } else {
+            throw new IllegalStateException("DialogInterface was not of type AlertDialog");
+        }
+        final var button = dialog.getButton(AlertDialog.BUTTON_POSITIVE);
+        button.setOnClickListener(
+                v -> onDeleteDialogButtonClicked(alertDialog, account, postDelete));
+    }
+
+    private void onDeleteDialogButtonClicked(
+            final AlertDialog dialog, final Account account, final Runnable postDelete) {
+        final CheckBox deleteFromServer = dialog.findViewById(R.id.delete_from_server);
+        if (deleteFromServer == null) {
+            throw new IllegalStateException("AlertDialog did not have button");
+        }
+        final var button = dialog.getButton(AlertDialog.BUTTON_POSITIVE);
+        final boolean unregister = deleteFromServer.isChecked();
+        if (unregister) {
+            if (account.isOnlineAndConnected()) {
+                final var connection = account.getXmppConnection();
+                deleteFromServer.setEnabled(false);
+                button.setText(R.string.please_wait);
+                button.setEnabled(false);
+                final var future = connection.getManager(RegistrationManager.class).unregister();
+                Futures.addCallback(
+                        future,
+                        new FutureCallback<>() {
+                            @Override
+                            public void onSuccess(Void result) {
+                                runOnUiThread(
+                                        () -> onAccountDeletedSuccess(account, dialog, postDelete));
+                            }
+
+                            @Override
+                            public void onFailure(@NonNull Throwable t) {
+                                Log.d(Config.LOGTAG, "could not unregister account", t);
+                                runOnUiThread(() -> onAccountDeletionFailure(dialog, postDelete));
+                            }
+                        },
+                        MoreExecutors.directExecutor());
+            } else {
+                Toast.makeText(this, R.string.not_connected_try_again, Toast.LENGTH_LONG).show();
+            }
+        } else {
+            onAccountDeletedSuccess(account, dialog, postDelete);
+        }
+    }
+
+    private void onAccountDeletedSuccess(
+            final Account account, final AlertDialog dialog, final Runnable postDelete) {
+        xmppConnectionService.deleteAccount(account);
+        dialog.dismiss();
+        if (xmppConnectionService.getAccounts().isEmpty() && Config.MAGIC_CREATE_DOMAIN != null) {
+            final Intent intent = SignupUtils.getSignUpIntent(this);
+            intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
+            startActivity(intent);
+        } else if (postDelete != null) {
+            postDelete.run();
+        }
+    }
+
+    private void onAccountDeletionFailure(final AlertDialog dialog, final Runnable postDelete) {
+        final CheckBox deleteFromServer = dialog.findViewById(R.id.delete_from_server);
+        if (deleteFromServer == null) {
+            throw new IllegalStateException("AlertDialog did not have button");
+        }
+        final var button = dialog.getButton(AlertDialog.BUTTON_POSITIVE);
+        deleteFromServer.setEnabled(true);
+        button.setText(R.string.delete);
+        button.setEnabled(true);
+        Toast.makeText(this, R.string.could_not_delete_account_from_server, Toast.LENGTH_LONG)
+                .show();
+    }
+
     protected abstract void onBackendConnected();
 
     protected void registerListeners() {

src/main/java/eu/siacs/conversations/xml/Namespace.java 🔗

@@ -3,6 +3,7 @@ package eu.siacs.conversations.xml;
 public final class Namespace {
     public static final String ADDRESSING = "http://jabber.org/protocol/address";
     public static final String AXOLOTL = "eu.siacs.conversations.axolotl";
+    public static final String BOB = "urn:xmpp:bob";
     public static final String PGP_SIGNED = "jabber:x:signed";
     public static final String PGP_ENCRYPTED = "jabber:x:encrypted";
     public static final String AXOLOTL_BUNDLES = AXOLOTL + ".bundles";
@@ -98,7 +99,7 @@ public final class Namespace {
     public static final String MUC_USER = "http://jabber.org/protocol/muc#user";
     public static final String BOOKMARKS2 = "urn:xmpp:bookmarks:1";
     public static final String BOOKMARKS2_COMPAT = BOOKMARKS2 + "#compat";
-    public static final String INVITE = "urn:xmpp:invite";
+    public static final String PRE_AUTHENTICATED_IN_BAND_REGISTRATION = "urn:xmpp:ibr-token:0";
     public static final String PARS = "urn:xmpp:pars:0";
     public static final String EASY_ONBOARDING_INVITE = "urn:xmpp:invite#invite";
     public static final String OMEMO_DTLS_SRTP_VERIFICATION =
@@ -110,6 +111,7 @@ public final class Namespace {
     public static final String REPORTING_REASON_SPAM = "urn:xmpp:reporting:spam";
     public static final String SDP_OFFER_ANSWER = "urn:ietf:rfc:3264";
     public static final String HASHES = "urn:xmpp:hashes:2";
+    public static final String MEDIA_ELEMENT = "urn:xmpp:media-element";
     public static final String MDS_DISPLAYED = "urn:xmpp:mds:displayed:0";
     public static final String MDS_SERVER_ASSIST = "urn:xmpp:mds:server-assist:0";
 

src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java 🔗

@@ -3,8 +3,6 @@ package eu.siacs.conversations.xmpp;
 import static eu.siacs.conversations.utils.Random.SECURE_RANDOM;
 
 import android.content.Context;
-import android.graphics.Bitmap;
-import android.graphics.BitmapFactory;
 import android.os.Build;
 import android.os.SystemClock;
 import android.security.KeyChain;
@@ -44,8 +42,6 @@ import eu.siacs.conversations.crypto.sasl.SaslMechanism;
 import eu.siacs.conversations.crypto.sasl.ScramMechanism;
 import eu.siacs.conversations.entities.Account;
 import eu.siacs.conversations.entities.Message;
-import eu.siacs.conversations.generator.IqGenerator;
-import eu.siacs.conversations.http.HttpConnectionManager;
 import eu.siacs.conversations.parser.IqParser;
 import eu.siacs.conversations.parser.MessageParser;
 import eu.siacs.conversations.parser.PresenceParser;
@@ -70,13 +66,13 @@ import eu.siacs.conversations.xml.Tag;
 import eu.siacs.conversations.xml.TagWriter;
 import eu.siacs.conversations.xml.XmlReader;
 import eu.siacs.conversations.xmpp.bind.Bind2;
-import eu.siacs.conversations.xmpp.forms.Data;
 import eu.siacs.conversations.xmpp.jingle.OnJinglePacketReceived;
 import eu.siacs.conversations.xmpp.manager.AbstractManager;
 import eu.siacs.conversations.xmpp.manager.BlockingManager;
 import eu.siacs.conversations.xmpp.manager.CarbonsManager;
 import eu.siacs.conversations.xmpp.manager.DiscoManager;
 import eu.siacs.conversations.xmpp.manager.PingManager;
+import eu.siacs.conversations.xmpp.manager.RegistrationManager;
 import im.conversations.android.xmpp.Entity;
 import im.conversations.android.xmpp.IqErrorException;
 import im.conversations.android.xmpp.model.AuthenticationFailure;
@@ -120,9 +116,7 @@ import im.conversations.android.xmpp.model.tls.StartTls;
 import im.conversations.android.xmpp.processor.AccountStateProcessor;
 import im.conversations.android.xmpp.processor.BindProcessor;
 import im.conversations.android.xmpp.processor.MessageAcknowledgedProcessor;
-import java.io.ByteArrayInputStream;
 import java.io.IOException;
-import java.io.InputStream;
 import java.net.ConnectException;
 import java.net.IDN;
 import java.net.InetAddress;
@@ -135,7 +129,6 @@ 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.Collections;
 import java.util.HashMap;
@@ -260,9 +253,13 @@ public class XmppConnection implements Runnable {
         }
     }
 
-    private void changeStatus(final Account.State nextStatus) {
+    private void changeState(final Account.State nextStatus) {
+        this.changeState(nextStatus, true);
+    }
+
+    private void changeState(final Account.State nextStatus, final boolean skipOnInterrupt) {
         synchronized (this) {
-            if (Thread.currentThread().isInterrupted()) {
+            if (skipOnInterrupt && Thread.currentThread().isInterrupted()) {
                 Log.d(
                         Config.LOGTAG,
                         account.getJid().asBareJid()
@@ -290,6 +287,14 @@ public class XmppConnection implements Runnable {
         this.accountStateProcessor.accept(nextStatus);
     }
 
+    private void changeStateTerminal(final Account.State state) {
+        // interrupt needs to be called before status change; otherwise we interrupt the newly
+        // created thread
+        this.interrupt();
+        this.forceCloseSocket();
+        this.changeState(state, false);
+    }
+
     public Jid getJidForCommand(final String node) {
         synchronized (this.commands) {
             return this.commands.get(node);
@@ -301,7 +306,7 @@ public class XmppConnection implements Runnable {
         this.lastPingSent = SystemClock.elapsedRealtime();
         this.lastDiscoStarted = Long.MAX_VALUE;
         this.mWaitingForSmCatchup.set(false);
-        this.changeStatus(Account.State.CONNECTING);
+        this.changeState(Account.State.CONNECTING);
     }
 
     public boolean isWaitingForSmCatchup() {
@@ -331,7 +336,7 @@ public class XmppConnection implements Runnable {
         try {
             Socket localSocket;
             shouldAuthenticate = !account.isOptionSet(Account.OPTION_REGISTER);
-            this.changeStatus(Account.State.CONNECTING);
+            this.changeState(Account.State.CONNECTING);
             final boolean useTorSetting = appSettings.isUseTor();
             final boolean extended = appSettings.isExtendedConnectionOptions();
             final boolean useTor = useTorSetting || account.isOnion();
@@ -533,27 +538,27 @@ public class XmppConnection implements Runnable {
             }
             processStream();
         } catch (final SecurityException e) {
-            this.changeStatus(Account.State.MISSING_INTERNET_PERMISSION);
+            this.changeState(Account.State.MISSING_INTERNET_PERMISSION);
         } catch (final StateChangingException e) {
-            this.changeStatus(e.state);
+            this.changeState(e.state);
         } catch (final UnknownHostException
                 | ConnectException
                 | SocksSocketFactory.HostNotFoundException e) {
-            this.changeStatus(Account.State.SERVER_NOT_FOUND);
+            this.changeState(Account.State.SERVER_NOT_FOUND);
         } catch (final SocksSocketFactory.SocksProxyNotFoundException e) {
-            this.changeStatus(Account.State.TOR_NOT_AVAILABLE);
+            this.changeState(Account.State.TOR_NOT_AVAILABLE);
         } catch (final IOException | XmlPullParserException e) {
             Log.d(Config.LOGTAG, account.getJid().asBareJid().toString() + ": " + e.getMessage());
-            this.changeStatus(Account.State.OFFLINE);
+            this.changeState(Account.State.OFFLINE);
             this.attempt = Math.max(0, this.attempt - 1);
         } finally {
-            if (!Thread.currentThread().isInterrupted()) {
-                forceCloseSocket();
-            } else {
+            if (Thread.currentThread().isInterrupted()) {
                 Log.d(
                         Config.LOGTAG,
                         account.getJid().asBareJid()
                                 + ": not force closing socket because thread was interrupted");
+            } else {
+                forceCloseSocket();
             }
         }
     }
@@ -1189,7 +1194,7 @@ public class XmppConnection implements Runnable {
             Log.d(
                     Config.LOGTAG,
                     account.getJid().asBareJid() + ": awaiting disco results after resume");
-            changeStatus(Account.State.CONNECTING);
+            changeState(Account.State.CONNECTING);
         } else {
             changeStatusToOnline();
         }
@@ -1199,7 +1204,7 @@ public class XmppConnection implements Runnable {
         Log.d(
                 Config.LOGTAG,
                 account.getJid().asBareJid() + ": online with resource " + account.getResource());
-        changeStatus(Account.State.ONLINE);
+        changeState(Account.State.ONLINE);
     }
 
     private void processFailed(final Failed failed, final boolean sendBindRequest) {
@@ -1539,12 +1544,12 @@ public class XmppConnection implements Runnable {
             mXmppConnectionService.databaseBackend.updateAccount(account);
             throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER);
         }
-        if (streamFeatures.hasChild("register", Namespace.REGISTER_STREAM_FEATURE)
-                && account.isOptionSet(Account.OPTION_REGISTER)) {
-            register();
-        } else if (!streamFeatures.hasChild("register", Namespace.REGISTER_STREAM_FEATURE)
-                && account.isOptionSet(Account.OPTION_REGISTER)) {
-            throw new StateChangingException(Account.State.REGISTRATION_NOT_SUPPORTED);
+        if (account.isOptionSet(Account.OPTION_REGISTER)) {
+            if (this.streamFeatures.register()) {
+                this.register();
+            } else {
+                throw new StateChangingException(Account.State.REGISTRATION_NOT_SUPPORTED);
+            }
         } else if (streamFeatures.hasStreamFeature(Authentication.class)
                 && shouldAuthenticate
                 && this.loginInfo == null) {
@@ -1847,167 +1852,95 @@ public class XmppConnection implements Runnable {
     }
 
     private void register() {
-        final String preAuth = account.getKey(Account.KEY_PRE_AUTH_REGISTRATION_TOKEN);
-        if (preAuth != null && features.invite()) {
-            final Iq preAuthRequest = new Iq(Iq.Type.SET);
-            preAuthRequest.addChild("preauth", Namespace.PARS).setAttribute("token", preAuth);
-            sendUnmodifiedIqPacket(
-                    preAuthRequest,
-                    (response) -> {
-                        if (response.getType() == Iq.Type.RESULT) {
-                            sendRegistryRequest();
+        final String preAuthToken =
+                Strings.emptyToNull(account.getKey(Account.KEY_PRE_AUTH_REGISTRATION_TOKEN));
+        final ListenableFuture<RegistrationManager.Registration> registrationFuture;
+        if (preAuthToken != null && streamFeatures.preAuthenticatedInBandRegistration()) {
+            registrationFuture =
+                    getManager(RegistrationManager.class).getRegistration(preAuthToken);
+        } else {
+            registrationFuture = getManager(RegistrationManager.class).getRegistration();
+        }
+        // TODO should we store this future and cancel it during disconnect or something
+        Futures.addCallback(
+                registrationFuture,
+                new FutureCallback<>() {
+                    @Override
+                    public void onSuccess(final RegistrationManager.Registration registration) {
+                        if (registration instanceof RegistrationManager.SimpleRegistration) {
+                            final var future = getManager(RegistrationManager.class).register();
+                            awaitRegistrationResponse(future);
+                        } else if (registration
+                                instanceof RegistrationManager.ExtendedRegistration er) {
+                            mXmppConnectionService.displayCaptchaRequest(
+                                    account, er.getData(), er.getCaptcha());
+                        } else if (registration
+                                instanceof
+                                RegistrationManager.RedirectRegistration redirectRegistration) {
+                            XmppConnection.this.redirectionUrl = redirectRegistration.getURL();
+                            changeStateTerminal(Account.State.REGISTRATION_WEB);
                         } else {
-                            final String error = response.getErrorCondition();
                             Log.d(
                                     Config.LOGTAG,
-                                    account.getJid().asBareJid()
-                                            + ": failed to pre auth. "
-                                            + error);
-                            throw new StateChangingError(Account.State.REGISTRATION_INVALID_TOKEN);
+                                    "got registration: " + registration.getClass().getName());
+                            changeStateTerminal(Account.State.REGISTRATION_NOT_SUPPORTED);
                         }
-                    },
-                    true);
-        } else {
-            sendRegistryRequest();
-        }
-    }
-
-    private void sendRegistryRequest() {
-        final Iq register = new Iq(Iq.Type.GET);
-        register.query(Namespace.REGISTER);
-        register.setTo(account.getDomain());
-        sendUnmodifiedIqPacket(
-                register,
-                (packet) -> {
-                    if (packet.getType() == Iq.Type.TIMEOUT) {
-                        return;
-                    }
-                    if (packet.getType() == Iq.Type.ERROR) {
-                        throw new StateChangingError(Account.State.REGISTRATION_FAILED);
                     }
-                    final Element query = packet.query(Namespace.REGISTER);
-                    if (query.hasChild("username") && (query.hasChild("password"))) {
-                        final Iq register1 = new Iq(Iq.Type.SET);
-                        final Element username =
-                                new Element("username").setContent(account.getUsername());
-                        final Element password =
-                                new Element("password").setContent(account.getPassword());
-                        register1.query(Namespace.REGISTER).addChild(username);
-                        register1.query().addChild(password);
-                        register1.setFrom(account.getJid().asBareJid());
-                        sendUnmodifiedIqPacket(register1, this::processRegistrationResponse, true);
-                    } else if (query.hasChild("x", Namespace.DATA)) {
-                        final Data data = Data.parse(query.findChild("x", Namespace.DATA));
-                        final Element blob = query.findChild("data", "urn:xmpp:bob");
-                        final String id = packet.getId();
-                        InputStream is;
-                        if (blob != null) {
-                            try {
-                                final String base64Blob = blob.getContent();
-                                final byte[] strBlob = Base64.decode(base64Blob, Base64.DEFAULT);
-                                is = new ByteArrayInputStream(strBlob);
-                            } catch (Exception e) {
-                                is = null;
-                            }
-                        } else {
-                            final boolean useTor = this.appSettings.isUseTor() || account.isOnion();
-                            try {
-                                final String url = data.getValue("url");
-                                final String fallbackUrl = data.getValue("captcha-fallback-url");
-                                if (url != null) {
-                                    is = HttpConnectionManager.open(url, useTor);
-                                } else if (fallbackUrl != null) {
-                                    is = HttpConnectionManager.open(fallbackUrl, useTor);
-                                } else {
-                                    is = null;
-                                }
-                            } catch (final IOException e) {
-                                Log.d(
-                                        Config.LOGTAG,
-                                        account.getJid().asBareJid() + ": unable to fetch captcha",
-                                        e);
-                                is = null;
-                            }
-                        }
 
-                        if (is != null) {
-                            Bitmap captcha = BitmapFactory.decodeStream(is);
-                            try {
-                                if (mXmppConnectionService.displayCaptchaRequest(
-                                        account, id, data, captcha)) {
-                                    return;
-                                }
-                            } catch (Exception e) {
-                                throw new StateChangingError(Account.State.REGISTRATION_FAILED);
-                            }
+                    @Override
+                    public void onFailure(@NonNull Throwable t) {
+                        if (t instanceof TimeoutException) {
+                            return;
                         }
-                        throw new StateChangingError(Account.State.REGISTRATION_FAILED);
-                    } else if (query.hasChild("instructions")
-                            || query.hasChild("x", Namespace.OOB)) {
-                        final String instructions = query.findChildContent("instructions");
-                        final Element oob = query.findChild("x", Namespace.OOB);
-                        final String url = oob == null ? null : oob.findChildContent("url");
-                        if (url != null) {
-                            setAccountCreationFailed(url);
-                        } else if (instructions != null) {
-                            final Matcher matcher = Patterns.URI_HTTP.matcher(instructions);
-                            if (matcher.find()) {
-                                setAccountCreationFailed(
-                                        instructions.substring(matcher.start(), matcher.end()));
-                            }
+                        if (t instanceof RegistrationManager.InvalidTokenException) {
+                            changeStateTerminal(Account.State.REGISTRATION_INVALID_TOKEN);
+                        } else {
+                            changeStateTerminal(Account.State.REGISTRATION_FAILED);
                         }
-                        throw new StateChangingError(Account.State.REGISTRATION_FAILED);
                     }
                 },
-                true);
+                MoreExecutors.directExecutor());
     }
 
-    public void sendCreateAccountWithCaptchaPacket(final String id, final Data data) {
-        final Iq request = IqGenerator.generateCreateAccountWithCaptcha(account, id, data);
-        this.sendUnmodifiedIqPacket(request, this::processRegistrationResponse, true);
+    public void register(
+            final im.conversations.android.xmpp.model.data.Data data, final String ocr) {
+        final var future = getManager(RegistrationManager.class).register(data, ocr);
+        awaitRegistrationResponse(future);
     }
 
-    private void processRegistrationResponse(final Iq response) {
-        if (response.getType() == Iq.Type.RESULT) {
-            account.setOption(Account.OPTION_REGISTER, false);
-            Log.d(
-                    Config.LOGTAG,
-                    account.getJid().asBareJid()
-                            + ": successfully registered new account on server");
-            throw new StateChangingError(Account.State.REGISTRATION_SUCCESSFUL);
-        } else {
-            final Account.State state = getRegistrationFailedState(response);
-            throw new StateChangingError(state);
-        }
-    }
-
-    @NonNull
-    private static Account.State getRegistrationFailedState(final Iq response) {
-        final List<String> PASSWORD_TOO_WEAK_MESSAGES =
-                Arrays.asList("The password is too weak", "Please use a longer password.");
-        final var error = response.getError();
-        final var condition = error == null ? null : error.getCondition();
-        final Account.State state;
-        if (condition instanceof Condition.Conflict) {
-            state = Account.State.REGISTRATION_CONFLICT;
-        } else if (condition instanceof Condition.ResourceConstraint) {
-            state = Account.State.REGISTRATION_PLEASE_WAIT;
-        } else if (condition instanceof Condition.NotAcceptable
-                && PASSWORD_TOO_WEAK_MESSAGES.contains(error.getTextAsString())) {
-            state = Account.State.REGISTRATION_PASSWORD_TOO_WEAK;
-        } else {
-            state = Account.State.REGISTRATION_FAILED;
-        }
-        return state;
+    private void awaitRegistrationResponse(final ListenableFuture<Void> registration) {
+        Futures.addCallback(
+                registration,
+                new FutureCallback<>() {
+                    @Override
+                    public void onSuccess(Void result) {
+                        account.setOption(Account.OPTION_REGISTER, false);
+                        Log.d(
+                                Config.LOGTAG,
+                                account.getJid().asBareJid()
+                                        + ": successfully registered new account on server");
+                        changeStateTerminal(Account.State.REGISTRATION_SUCCESSFUL);
+                    }
+
+                    @Override
+                    public void onFailure(@NonNull Throwable t) {
+                        if (t instanceof TimeoutException) {
+                            return;
+                        }
+                        if (t
+                                instanceof
+                                RegistrationManager.RegistrationFailedException exception) {
+                            changeStateTerminal(exception.asAccountState());
+                        } else {
+                            changeStateTerminal(Account.State.REGISTRATION_FAILED);
+                        }
+                    }
+                },
+                MoreExecutors.directExecutor());
     }
 
-    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(Account.State.REGISTRATION_WEB);
-        }
-        throw new StateChangingError(Account.State.REGISTRATION_FAILED);
+    public void cancelRegistration() {
+        this.changeStateTerminal(Account.State.REGISTRATION_FAILED);
     }
 
     public HttpUrl getRedirectionUrl() {
@@ -2608,6 +2541,7 @@ public class XmppConnection implements Runnable {
             return;
         }
         synchronized (this.mStanzaQueue) {
+            // TODO should we fail IQs for unbound streams?
             if (force || isBound) {
                 tagWriter.writeStanzaAsync(packet);
             } else {
@@ -2843,6 +2777,9 @@ public class XmppConnection implements Runnable {
     }
 
     public void triggerConnectionTimeout() {
+
+        // TODO not triggering timeout while waiting for captcha input
+
         final var duration = getConnectionDuration();
         Log.d(
                 Config.LOGTAG,
@@ -2851,11 +2788,7 @@ public class XmppConnection implements Runnable {
         // last connection time gets reset so time to next attempt is calculated correctly
         this.lastConnectionStarted = SystemClock.elapsedRealtime();
 
-        // interrupt needs to be called before status change; otherwise we interrupt the newly
-        // created thread
-        this.interrupt();
-        this.forceCloseSocket();
-        this.changeStatus(Account.State.CONNECTION_TIMEOUT);
+        this.changeStateTerminal(Account.State.CONNECTION_TIMEOUT);
     }
 
     public Account getAccount() {
@@ -3088,15 +3021,6 @@ public class XmppConnection implements Runnable {
                     account.getDomain(), Namespace.FLEXIBLE_OFFLINE_MESSAGE_RETRIEVAL);
         }
 
-        public boolean register() {
-            return hasDiscoFeature(account.getDomain(), Namespace.REGISTER);
-        }
-
-        public boolean invite() {
-            return connection.streamFeatures != null
-                    && connection.streamFeatures.hasChild("register", Namespace.INVITE);
-        }
-
         public boolean sm() {
             return streamId != null
                     || (connection.streamFeatures != null
@@ -3108,6 +3032,7 @@ public class XmppConnection implements Runnable {
                     && connection.streamFeatures.clientStateIndication();
         }
 
+        // TODO move to manager
         public boolean pep() {
             final var infoQuery = getManager(DiscoManager.class).get(account.getJid().asBareJid());
             return infoQuery != null && infoQuery.hasIdentityWithCategoryAndType("pubsub", "pep");

src/main/java/eu/siacs/conversations/xmpp/manager/RegistrationManager.java 🔗

@@ -1,14 +1,24 @@
 package eu.siacs.conversations.xmpp.manager;
 
 import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.util.Log;
 import android.util.Patterns;
 import androidx.annotation.NonNull;
+import com.google.common.base.Optional;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.MoreExecutors;
+import eu.siacs.conversations.Config;
+import eu.siacs.conversations.entities.Account;
 import eu.siacs.conversations.xml.Namespace;
 import eu.siacs.conversations.xmpp.XmppConnection;
+import im.conversations.android.xmpp.IqErrorException;
 import im.conversations.android.xmpp.model.data.Data;
+import im.conversations.android.xmpp.model.error.Condition;
 import im.conversations.android.xmpp.model.oob.OutOfBandData;
 import im.conversations.android.xmpp.model.pars.PreAuth;
 import im.conversations.android.xmpp.model.register.Instructions;
@@ -18,6 +28,7 @@ import im.conversations.android.xmpp.model.register.Remove;
 import im.conversations.android.xmpp.model.register.Username;
 import im.conversations.android.xmpp.model.stanza.Iq;
 import java.util.Arrays;
+import java.util.List;
 import java.util.regex.Matcher;
 import okhttp3.HttpUrl;
 
@@ -35,7 +46,63 @@ public class RegistrationManager extends AbstractManager {
         register.addUsername(account.getJid().getLocal());
         register.addPassword(password);
         return Futures.transform(
-                connection.sendIqPacket(iq), r -> null, MoreExecutors.directExecutor());
+                connection.sendIqPacket(iq),
+                r -> {
+                    account.setPassword(password);
+                    account.setOption(Account.OPTION_MAGIC_CREATE, false);
+                    getDatabase().updateAccount(account);
+                    return null;
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    public ListenableFuture<Void> register() {
+        final var account = getAccount();
+        final var iq = new Iq(Iq.Type.SET);
+        iq.setTo(account.getJid().getDomain());
+        final var register = iq.addExtension(new Register());
+        register.addUsername(account.getJid().getLocal());
+        register.addPassword(account.getPassword());
+        final ListenableFuture<Void> future =
+                Futures.transform(
+                        connection.sendIqPacket(iq, true),
+                        result -> null,
+                        MoreExecutors.directExecutor());
+        return Futures.catchingAsync(
+                future,
+                IqErrorException.class,
+                ex ->
+                        Futures.immediateFailedFuture(
+                                new RegistrationFailedException(ex.getResponse())),
+                MoreExecutors.directExecutor());
+    }
+
+    public ListenableFuture<Void> register(final Data data, final String ocr) {
+        final var account = getAccount();
+        final var submission =
+                data.submit(
+                        ImmutableMap.of(
+                                "username",
+                                account.getJid().getLocal(),
+                                "password",
+                                account.getPassword(),
+                                "ocr",
+                                ocr));
+        final var iq = new Iq(Iq.Type.SET);
+        final var register = iq.addExtension(new Register());
+        register.addExtension(submission);
+        final ListenableFuture<Void> future =
+                Futures.transform(
+                        connection.sendIqPacket(iq, true),
+                        result -> null,
+                        MoreExecutors.directExecutor());
+        return Futures.catchingAsync(
+                future,
+                IqErrorException.class,
+                ex ->
+                        Futures.immediateFailedFuture(
+                                new RegistrationFailedException(ex.getResponse())),
+                MoreExecutors.directExecutor());
     }
 
     public ListenableFuture<Void> unregister() {
@@ -54,7 +121,7 @@ public class RegistrationManager extends AbstractManager {
         iq.setTo(account.getDomain());
         iq.addExtension(new Register());
         final var future = connection.sendIqPacket(iq, true);
-        return Futures.transform(
+        return Futures.transformAsync(
                 future,
                 result -> {
                     final var register = result.getExtension(Register.class);
@@ -64,15 +131,18 @@ public class RegistrationManager extends AbstractManager {
                     }
                     if (register.hasExtension(Username.class)
                             && register.hasExtension(Password.class)) {
-                        return new SimpleRegistration();
+                        return Futures.immediateFuture(new SimpleRegistration());
                     }
+
+                    // find bits of binary and get captcha from there
+
                     final var data = register.getExtension(Data.class);
                     // note that the captcha namespace is incorrect here. That namespace is only
                     // used in message challenges. ejabberd uses the incorrect namespace though
                     if (data != null
                             && Arrays.asList(Namespace.REGISTER, Namespace.CAPTCHA)
                                     .contains(data.getFormType())) {
-                        return new ExtendedRegistration(data);
+                        return getExtendedRegistration(register, data);
                     }
                     final var oob = register.getExtension(OutOfBandData.class);
                     final var instructions = register.getExtension(Instructions.class);
@@ -80,14 +150,15 @@ public class RegistrationManager extends AbstractManager {
                             instructions == null ? null : instructions.getContent();
                     final String redirectUrl = oob == null ? null : oob.getURL();
                     if (redirectUrl != null) {
-                        return RedirectRegistration.ifValid(redirectUrl);
+                        return Futures.immediateFuture(RedirectRegistration.ifValid(redirectUrl));
                     }
                     if (instructionsText != null) {
                         final Matcher matcher = Patterns.WEB_URL.matcher(instructionsText);
                         if (matcher.find()) {
                             final String instructionsUrl =
                                     instructionsText.substring(matcher.start(), matcher.end());
-                            return RedirectRegistration.ifValid(instructionsUrl);
+                            return Futures.immediateFuture(
+                                    RedirectRegistration.ifValid(instructionsUrl));
                         }
                     }
                     throw new IllegalStateException("No supported registration method found");
@@ -95,6 +166,59 @@ public class RegistrationManager extends AbstractManager {
                 MoreExecutors.directExecutor());
     }
 
+    private ListenableFuture<Registration> getExtendedRegistration(
+            final Register register, final Data data) {
+        final var ocr = data.getFieldByName("ocr");
+        if (ocr == null) {
+            throw new IllegalArgumentException("Missing OCR form field");
+        }
+        final var ocrMedia = ocr.getMedia();
+        if (ocrMedia == null) {
+            throw new IllegalArgumentException("OCR form field missing media");
+        }
+        final var uris = ocrMedia.getUris();
+        final var bobUri = Iterables.find(uris, u -> "cid".equals(u.getScheme()), null);
+        final Optional<im.conversations.android.xmpp.model.bob.Data> bob;
+        if (bobUri != null) {
+            bob = im.conversations.android.xmpp.model.bob.Data.get(register, bobUri.getPath());
+        } else {
+            bob = Optional.absent();
+        }
+        if (bob.isPresent()) {
+            final var bytes = bob.get().asBytes();
+            final Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
+            return Futures.immediateFuture(new ExtendedRegistration(bitmap, data));
+        }
+        final var captchaFallbackUrl = data.getValue("captcha-fallback-url");
+        if (captchaFallbackUrl == null) {
+            throw new IllegalStateException("No captcha fallback URL provided");
+        }
+        final var captchFallbackHttpUrl = HttpUrl.parse(captchaFallbackUrl);
+        Log.d(Config.LOGTAG, "fallback url: " + captchFallbackHttpUrl);
+        throw new IllegalStateException("Not implemented");
+    }
+
+    public ListenableFuture<Registration> getRegistration(final String token) {
+        final var preAuthentication = sendPreAuthentication(token);
+        final var caught =
+                Futures.catchingAsync(
+                        preAuthentication,
+                        IqErrorException.class,
+                        ex -> {
+                            final var error = ex.getError();
+                            final var condition = error == null ? null : error.getCondition();
+                            if (condition instanceof Condition.ItemNotFound) {
+                                return Futures.immediateFailedFuture(
+                                        new InvalidTokenException(ex.getResponse()));
+                            } else {
+                                return Futures.immediateFuture(ex);
+                            }
+                        },
+                        MoreExecutors.directExecutor());
+        return Futures.transformAsync(
+                caught, v -> getRegistration(), MoreExecutors.directExecutor());
+    }
+
     public ListenableFuture<Void> sendPreAuthentication(final String token) {
         final var account = getAccount();
         final var iq = new Iq(Iq.Type.GET);
@@ -105,6 +229,10 @@ public class RegistrationManager extends AbstractManager {
         return Futures.transform(future, result -> null, MoreExecutors.directExecutor());
     }
 
+    public boolean hasFeature() {
+        return getManager(DiscoManager.class).hasServerFeature(Namespace.REGISTER);
+    }
+
     public abstract static class Registration {}
 
     // only requires Username + Password
@@ -112,12 +240,18 @@ public class RegistrationManager extends AbstractManager {
 
     // Captcha as shown here: https://xmpp.org/extensions/xep-0158.html#register
     public static class ExtendedRegistration extends Registration {
+        private final Bitmap captcha;
         private final Data data;
 
-        public ExtendedRegistration(Data data) {
+        public ExtendedRegistration(final Bitmap captcha, final Data data) {
+            this.captcha = captcha;
             this.data = data;
         }
 
+        public Bitmap getCaptcha() {
+            return this.captcha;
+        }
+
         public Data getData() {
             return this.data;
         }
@@ -144,4 +278,36 @@ public class RegistrationManager extends AbstractManager {
                     "A URL found the registration instructions is not valid");
         }
     }
+
+    public static class InvalidTokenException extends IqErrorException {
+
+        public InvalidTokenException(final Iq response) {
+            super(response);
+        }
+    }
+
+    public static class RegistrationFailedException extends IqErrorException {
+
+        private final List<String> PASSWORD_TOO_WEAK_MESSAGES =
+                Arrays.asList("The password is too weak", "Please use a longer password.");
+
+        public RegistrationFailedException(final Iq response) {
+            super(response);
+        }
+
+        public Account.State asAccountState() {
+            final var error = getError();
+            final var condition = error == null ? null : error.getCondition();
+            if (condition instanceof Condition.Conflict) {
+                return Account.State.REGISTRATION_CONFLICT;
+            } else if (condition instanceof Condition.ResourceConstraint) {
+                return Account.State.REGISTRATION_PLEASE_WAIT;
+            } else if (condition instanceof Condition.NotAcceptable
+                    && PASSWORD_TOO_WEAK_MESSAGES.contains(error.getTextAsString())) {
+                return Account.State.REGISTRATION_PASSWORD_TOO_WEAK;
+            } else {
+                return Account.State.REGISTRATION_FAILED;
+            }
+        }
+    }
 }

src/main/java/im/conversations/android/xmpp/model/bob/Data.java 🔗

@@ -0,0 +1,27 @@
+package im.conversations.android.xmpp.model.bob;
+
+import com.google.common.base.Optional;
+import com.google.common.collect.Iterables;
+import im.conversations.android.annotation.XmlElement;
+import im.conversations.android.xmpp.model.ByteContent;
+import im.conversations.android.xmpp.model.Extension;
+
+@XmlElement
+public class Data extends Extension implements ByteContent {
+
+    public Data() {
+        super(Data.class);
+    }
+
+    public String getCid() {
+        return this.getAttribute("cid");
+    }
+
+    public String getType() {
+        return this.getAttribute("type");
+    }
+
+    public static Optional<Data> get(final Extension stanza, final String cid) {
+        return Iterables.tryFind(stanza.getExtensions(Data.class), d -> cid.equals(d.getCid()));
+    }
+}

src/main/java/im/conversations/android/xmpp/model/data/Field.java 🔗

@@ -5,6 +5,7 @@ import com.google.common.collect.Iterables;
 import eu.siacs.conversations.xml.Element;
 import im.conversations.android.annotation.XmlElement;
 import im.conversations.android.xmpp.model.Extension;
+import im.conversations.android.xmpp.model.media.Media;
 import java.util.Collection;
 
 @XmlElement
@@ -32,4 +33,8 @@ public class Field extends Extension {
     public void setType(String type) {
         this.setAttribute("type", type);
     }
+
+    public Media getMedia() {
+        return getOnlyExtension(Media.class);
+    }
 }

src/main/java/im/conversations/android/xmpp/model/media/Media.java 🔗

@@ -0,0 +1,25 @@
+package im.conversations.android.xmpp.model.media;
+
+import com.google.common.collect.Collections2;
+import de.gultsch.common.MiniUri;
+import eu.siacs.conversations.xml.Element;
+import im.conversations.android.annotation.XmlElement;
+import im.conversations.android.xmpp.model.Extension;
+import java.util.Collection;
+import java.util.Objects;
+
+@XmlElement
+public class Media extends Extension {
+
+    public Media() {
+        super(Media.class);
+    }
+
+    public Collection<MiniUri> getUris() {
+        final var uris =
+                Collections2.filter(
+                        Collections2.transform(this.getExtensions(Uri.class), Element::getContent),
+                        Objects::nonNull);
+        return Collections2.transform(uris, MiniUri::new);
+    }
+}

src/main/java/im/conversations/android/xmpp/model/media/Uri.java 🔗

@@ -0,0 +1,12 @@
+package im.conversations.android.xmpp.model.media;
+
+import im.conversations.android.annotation.XmlElement;
+import im.conversations.android.xmpp.model.Extension;
+
+@XmlElement
+public class Uri extends Extension {
+
+    public Uri() {
+        super(Uri.class);
+    }
+}

src/main/java/im/conversations/android/xmpp/model/register/RegisterStreamFeature.java 🔗

@@ -0,0 +1,13 @@
+package im.conversations.android.xmpp.model.register;
+
+import eu.siacs.conversations.xml.Namespace;
+import im.conversations.android.annotation.XmlElement;
+import im.conversations.android.xmpp.model.StreamFeature;
+
+@XmlElement(name = "register", namespace = Namespace.REGISTER_STREAM_FEATURE)
+public class RegisterStreamFeature extends StreamFeature {
+
+    public RegisterStreamFeature() {
+        super(RegisterStreamFeature.class);
+    }
+}

src/main/java/im/conversations/android/xmpp/model/streams/Features.java 🔗

@@ -1,12 +1,13 @@
 package im.conversations.android.xmpp.model.streams;
 
+import eu.siacs.conversations.xml.Namespace;
 import im.conversations.android.annotation.XmlElement;
-import im.conversations.android.xmpp.model.Extension;
 import im.conversations.android.xmpp.model.StreamElement;
 import im.conversations.android.xmpp.model.StreamFeature;
 import im.conversations.android.xmpp.model.capabilties.EntityCapabilities;
-import eu.siacs.conversations.xml.Namespace;
+import im.conversations.android.xmpp.model.register.RegisterStreamFeature;
 import im.conversations.android.xmpp.model.sm.StreamManagement;
+import im.conversations.android.xmpp.model.token.Register;
 
 @XmlElement
 public class Features extends StreamElement implements EntityCapabilities {
@@ -18,14 +19,17 @@ public class Features extends StreamElement implements EntityCapabilities {
         return hasStreamFeature(StreamManagement.class);
     }
 
-    public boolean invite() {
-        return this.hasChild("register", Namespace.INVITE);
-    }
-
     public boolean clientStateIndication() {
         return this.hasChild("csi", Namespace.CSI);
     }
 
+    public boolean register() {
+        return hasStreamFeature(RegisterStreamFeature.class);
+    }
+
+    public boolean preAuthenticatedInBandRegistration() {
+        return hasStreamFeature(Register.class);
+    }
 
     public boolean hasStreamFeature(final Class<? extends StreamFeature> clazz) {
         return hasExtension(clazz);

src/main/java/im/conversations/android/xmpp/model/token/Register.java 🔗

@@ -0,0 +1,12 @@
+package im.conversations.android.xmpp.model.token;
+
+import im.conversations.android.annotation.XmlElement;
+import im.conversations.android.xmpp.model.StreamFeature;
+
+@XmlElement
+public class Register extends StreamFeature {
+
+    public Register() {
+        super(Register.class);
+    }
+}