Merge branch 'jabber-iq-gateway2'

Stephen Paul Weber created

* jabber-iq-gateway2:
  Improve example hinting
  Move GONE/VISIBLE to the controller instead of the model
  Show identity category gateway even without jabber:iq:gateway prompt
  Sort gateways
  Switch to AndroidX LinearLayoutManager
  Parse phone numbers using local settings before asking gateway
  Try all gateway translations options
  Change input type based on gateway type
  Load gateways into UI
  Use a RecyclerView for list of gateway options
  Fetch jabber:iq:gateway prompt

Change summary

build.gradle                                                              |   1 
src/main/java/eu/siacs/conversations/entities/ServiceDiscoveryResult.java |  10 
src/main/java/eu/siacs/conversations/services/XmppConnectionService.java  |  19 
src/main/java/eu/siacs/conversations/ui/EnterJidDialog.java               | 368 
src/main/java/eu/siacs/conversations/xmpp/OnGatewayResult.java            |   7 
src/main/res/layout/enter_jid_dialog.xml                                  |   5 
src/main/res/layout/enter_jid_dialog_gateway_list_item.xml                |  14 
7 files changed, 379 insertions(+), 45 deletions(-)

Detailed changes

build.gradle 🔗

@@ -94,6 +94,7 @@ dependencies {
     implementation 'com.google.guava:guava:30.1.1-android'
     implementation 'io.michaelrocks:libphonenumber-android:8.12.36'
     implementation 'io.github.nishkarsh:android-permissions:2.1.6'
+    implementation 'androidx.recyclerview:recyclerview:1.1.0'
     implementation urlFile('https://cloudflare-ipfs.com/ipfs/QmeqMiLxHi8AAjXobxr3QTfa1bSSLyAu86YviAqQnjxCjM/libwebrtc.aar', 'libwebrtc.aar')
     // INSERT
 }

src/main/java/eu/siacs/conversations/entities/ServiceDiscoveryResult.java 🔗

@@ -168,15 +168,19 @@ public class ServiceDiscoveryResult {
 		return this.features;
 	}
 
-	public boolean hasIdentity(String category, String type) {
+	public Identity getIdentity(String category, String type) {
 		for (Identity id : this.getIdentities()) {
 			if ((category == null || id.getCategory().equals(category)) &&
 					(type == null || id.getType().equals(type))) {
-				return true;
+				return id;
 			}
 		}
 
-		return false;
+		return null;
+	}
+
+	public boolean hasIdentity(String category, String type) {
+		return getIdentity(category, type) != null;
 	}
 
 	public String getExtendedDiscoInformation(String formType, String name) {

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

@@ -145,6 +145,7 @@ import eu.siacs.conversations.xml.Namespace;
 import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.OnBindListener;
 import eu.siacs.conversations.xmpp.OnContactStatusChanged;
+import eu.siacs.conversations.xmpp.OnGatewayResult;
 import eu.siacs.conversations.xmpp.OnIqPacketReceived;
 import eu.siacs.conversations.xmpp.OnKeyStatusUpdated;
 import eu.siacs.conversations.xmpp.OnMessageAcknowledged;
@@ -4655,6 +4656,24 @@ public class XmppConnectionService extends Service {
         }
     }
 
+    public void fetchFromGateway(Account account, final Jid jid, final String input, final OnGatewayResult callback) {
+        IqPacket request = new IqPacket(input == null ? IqPacket.TYPE.GET : IqPacket.TYPE.SET);
+        request.setTo(jid);
+        Element query = request.query("jabber:iq:gateway");
+        if (input != null) {
+            Element prompt = query.addChild("prompt");
+            prompt.setContent(input);
+        }
+        sendIqPacket(account, request, (Account acct, IqPacket packet) -> {
+            if (packet.getType() == IqPacket.TYPE.RESULT) {
+                callback.onGatewayResult(packet.query().findChildContent(input == null ? "prompt" : "jid"), null);
+            } else {
+                Element error = packet.findChild("error");
+                callback.onGatewayResult(null, error == null ? null : error.findChildContent("text"));
+            }
+        });
+    }
+
     public void fetchCaps(Account account, final Jid jid, final Presence presence) {
         final Pair<String, String> key = new Pair<>(presence.getHash(), presence.getVer());
         final ServiceDiscoveryResult disco = getCachedServiceDiscoveryResult(key);

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

@@ -2,31 +2,51 @@ package eu.siacs.conversations.ui;
 
 import android.app.Activity;
 import android.app.Dialog;
+import android.content.DialogInterface.OnClickListener;
+import android.content.DialogInterface;
 import android.os.Bundle;
 import android.text.Editable;
+import android.text.InputType;
 import android.text.TextWatcher;
+import android.util.Pair;
+import android.view.LayoutInflater;
 import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AdapterView;
 import android.widget.ArrayAdapter;
+import android.widget.TextView;
+import android.widget.ToggleButton;
 
 import androidx.annotation.NonNull;
 import androidx.appcompat.app.AlertDialog;
 import androidx.databinding.DataBindingUtil;
 import androidx.fragment.app.DialogFragment;
+import androidx.recyclerview.widget.RecyclerView;
+import androidx.recyclerview.widget.LinearLayoutManager;
 
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
+import java.util.Map;
+
+import io.michaelrocks.libphonenumber.android.NumberParseException;
 
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.databinding.EnterJidDialogBinding;
 import eu.siacs.conversations.services.XmppConnectionService;
+import eu.siacs.conversations.entities.Account;
+import eu.siacs.conversations.entities.Contact;
+import eu.siacs.conversations.entities.Presence;
+import eu.siacs.conversations.entities.ServiceDiscoveryResult;
 import eu.siacs.conversations.ui.adapter.KnownHostsAdapter;
 import eu.siacs.conversations.ui.interfaces.OnBackendConnected;
 import eu.siacs.conversations.ui.util.DelayedHintHelper;
+import eu.siacs.conversations.utils.PhoneNumberUtilWrapper;
 import eu.siacs.conversations.xmpp.Jid;
+import eu.siacs.conversations.xmpp.OnGatewayResult;
 
 public class EnterJidDialog extends DialogFragment implements OnBackendConnected, TextWatcher {
 
@@ -51,6 +71,7 @@ public class EnterJidDialog extends DialogFragment implements OnBackendConnected
     private boolean sanityCheckJid = false;
 
     private boolean issuedWarning = false;
+    private GatewayListAdapter gatewayListAdapter = new GatewayListAdapter();
 
     public static EnterJidDialog newInstance(
             final List<String> activatedAccounts,
@@ -129,6 +150,39 @@ public class EnterJidDialog extends DialogFragment implements OnBackendConnected
             binding.account.setAdapter(adapter);
         }
 
+        binding.gatewayList.setLayoutManager(new LinearLayoutManager(getActivity(), LinearLayoutManager.HORIZONTAL, false));
+        binding.gatewayList.setAdapter(gatewayListAdapter);
+        gatewayListAdapter.setOnEmpty(() -> binding.gatewayList.setVisibility(View.GONE));
+        gatewayListAdapter.setOnNonEmpty(() -> binding.gatewayList.setVisibility(View.VISIBLE));
+
+        binding.account.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
+            @Override
+            public void onItemSelected(AdapterView accountSpinner, View view, int position, long id) {
+                XmppActivity context = (XmppActivity) getActivity();
+                if (context.xmppConnectionService == null || accountJid() == null) return;
+
+                gatewayListAdapter.clear();
+                final Account account = context.xmppConnectionService.findAccountByJid(accountJid());
+
+                for (final Contact contact : account.getRoster().getContacts()) {
+                    if (contact.showInRoster() && (contact.getPresences().anyIdentity("gateway", null) || contact.getPresences().anySupport("jabber:iq:gateway"))) {
+                        context.xmppConnectionService.fetchFromGateway(account, contact.getJid(), null, (final String prompt, String errorMessage) -> {
+                            if (prompt == null && !contact.getPresences().anyIdentity("gateway", null)) return;
+
+                            context.runOnUiThread(() -> {
+                                gatewayListAdapter.add(contact, prompt);
+                            });
+                        });
+                    }
+                }
+            }
+
+            @Override
+            public void onNothingSelected(AdapterView accountSpinner) {
+                gatewayListAdapter.clear();
+            }
+        });
+
         builder.setView(binding.getRoot());
         builder.setNegativeButton(R.string.cancel, null);
         builder.setPositiveButton(getArguments().getString(POSITIVE_BUTTON_KEY), null);
@@ -150,59 +204,92 @@ public class EnterJidDialog extends DialogFragment implements OnBackendConnected
         return dialog;
     }
 
-    private void handleEnter(EnterJidDialogBinding binding, String account) {
-        final Jid accountJid;
-        if (!binding.account.isEnabled() && account == null) {
-            return;
-        }
+    protected Jid accountJid() {
         try {
             if (Config.DOMAIN_LOCK != null) {
-                accountJid =
-                        Jid.ofEscaped(
-                                (String) binding.account.getSelectedItem(),
-                                Config.DOMAIN_LOCK,
-                                null);
+                return Jid.ofEscaped((String) binding.account.getSelectedItem(), Config.DOMAIN_LOCK, null);
             } else {
-                accountJid = Jid.ofEscaped((String) binding.account.getSelectedItem());
+                return Jid.ofEscaped((String) binding.account.getSelectedItem());
             }
         } catch (final IllegalArgumentException e) {
-            return;
+            return null;
         }
-        final Jid contactJid;
-        try {
-            contactJid = Jid.ofEscaped(binding.jid.getText().toString());
-        } catch (final IllegalArgumentException e) {
-            binding.jidLayout.setError(getActivity().getString(R.string.invalid_jid));
+    }
+
+    private void handleEnter(EnterJidDialogBinding binding, String account) {
+        if (!binding.account.isEnabled() && account == null) {
             return;
         }
+        final Jid accountJid = accountJid();
+        final OnGatewayResult finish = (final String jidString, final String errorMessage) -> {
+            getActivity().runOnUiThread(() -> {
+                if (errorMessage != null) {
+                    binding.jidLayout.setError(errorMessage);
+                    return;
+                }
+                if (jidString == null) {
+                    binding.jidLayout.setError(getActivity().getString(R.string.invalid_jid));
+                    return;
+                }
 
-        if (!issuedWarning && sanityCheckJid) {
-            if (contactJid.isDomainJid()) {
-                binding.jidLayout.setError(
-                        getActivity().getString(R.string.this_looks_like_a_domain));
-                dialog.getButton(AlertDialog.BUTTON_POSITIVE).setText(R.string.add_anway);
-                issuedWarning = true;
-                return;
-            }
-            if (suspiciousSubDomain(contactJid.getDomain().toEscapedString())) {
-                binding.jidLayout.setError(
-                        getActivity().getString(R.string.this_looks_like_channel));
-                dialog.getButton(AlertDialog.BUTTON_POSITIVE).setText(R.string.add_anway);
-                issuedWarning = true;
-                return;
-            }
-        }
+                final Jid contactJid;
+                try {
+                    contactJid = Jid.ofEscaped(jidString);
+                } catch (final IllegalArgumentException e) {
+                    binding.jidLayout.setError(getActivity().getString(R.string.invalid_jid));
+                    return;
+                }
 
-        if (mListener != null) {
-            try {
-                if (mListener.onEnterJidDialogPositive(accountJid, contactJid)) {
-                    dialog.dismiss();
+                if (!issuedWarning && sanityCheckJid) {
+                    if (contactJid.isDomainJid()) {
+                        binding.jidLayout.setError(getActivity().getString(R.string.this_looks_like_a_domain));
+                        dialog.getButton(AlertDialog.BUTTON_POSITIVE).setText(R.string.add_anway);
+                        issuedWarning = true;
+                        return;
+                    }
+                    if (suspiciousSubDomain(contactJid.getDomain().toEscapedString())) {
+                        binding.jidLayout.setError(getActivity().getString(R.string.this_looks_like_channel));
+                        dialog.getButton(AlertDialog.BUTTON_POSITIVE).setText(R.string.add_anway);
+                        issuedWarning = true;
+                        return;
+                    }
                 }
-            } catch (JidError error) {
-                binding.jidLayout.setError(error.toString());
-                dialog.getButton(AlertDialog.BUTTON_POSITIVE).setText(R.string.add);
-                issuedWarning = false;
-            }
+
+                if (mListener != null) {
+                    try {
+                        if (mListener.onEnterJidDialogPositive(accountJid, contactJid)) {
+                            dialog.dismiss();
+                        }
+                    } catch (JidError error) {
+                        binding.jidLayout.setError(error.toString());
+                        dialog.getButton(AlertDialog.BUTTON_POSITIVE).setText(R.string.add);
+                        issuedWarning = false;
+                    }
+                }
+            });
+        };
+
+        Pair<String,Pair<Jid,Presence>> p = gatewayListAdapter.getSelected();
+        final String type = gatewayListAdapter.getSelectedType();
+
+        // Resolve based on local settings before submission
+        if (type.equals("pstn") || type.equals("sms")) {
+            try {
+                binding.jid.setText(PhoneNumberUtilWrapper.normalize(getActivity(), binding.jid.getText().toString()));
+            } catch (NumberParseException | NullPointerException e) { }
+        }
+
+        if (p == null) {
+            finish.onGatewayResult(binding.jid.getText().toString(), null);
+        } else if (p.first != null) { // Gateway already responsed to jabber:iq:gateway once
+            final Account acct = ((XmppActivity) getActivity()).xmppConnectionService.findAccountByJid(accountJid);
+            ((XmppActivity) getActivity()).xmppConnectionService.fetchFromGateway(acct, p.second.first, binding.jid.getText().toString(), finish);
+        } else if (p.second.first.isDomainJid() && p.second.second.getServiceDiscoveryResult().getFeatures().contains("jid\\20escaping")) {
+            finish.onGatewayResult(Jid.ofLocalAndDomain(binding.jid.getText().toString(), p.second.first.getDomain().toString()).toString(), null);
+        } else if (p.second.first.isDomainJid()) {
+            finish.onGatewayResult(Jid.ofLocalAndDomain(binding.jid.getText().toString().replace("@", "%"), p.second.first.getDomain().toString()).toString(), null);
+        } else {
+            finish.onGatewayResult(null, null);
         }
     }
 
@@ -276,4 +363,201 @@ public class EnterJidDialog extends DialogFragment implements OnBackendConnected
         final String[] parts = domain.split("\\.");
         return parts.length >= 3 && SUSPICIOUS_DOMAINS.contains(parts[0]);
     }
+
+    protected class GatewayListAdapter extends RecyclerView.Adapter<GatewayListAdapter.ViewHolder> {
+        protected class ViewHolder extends RecyclerView.ViewHolder {
+            protected ToggleButton button;
+            protected int index;
+
+            public ViewHolder(View view, int i) {
+                super(view);
+                this.button = (ToggleButton) view.findViewById(R.id.button);
+                setIndex(i);
+                button.setOnClickListener(new View.OnClickListener() {
+                    @Override
+                    public void onClick(View v) {
+                        button.setChecked(true); // Force visual not to flap to unchecked
+                        setSelected(index);
+                    }
+                });
+            }
+
+            public void setIndex(int i) {
+                this.index = i;
+                button.setChecked(selected == i);
+            }
+
+            public void useButton(int res) {
+                button.setText(res);
+                button.setTextOff(button.getText());
+                button.setTextOn(button.getText());
+                button.setChecked(selected == this.index);
+                binding.gatewayList.setVisibility(View.VISIBLE);
+                button.setVisibility(View.VISIBLE);
+            }
+
+            public void useButton(String txt) {
+                button.setTextOff(txt);
+                button.setTextOn(txt);
+                button.setChecked(selected == this.index);
+                binding.gatewayList.setVisibility(View.VISIBLE);
+                button.setVisibility(View.VISIBLE);
+            }
+        }
+
+        protected List<Pair<Contact,String>> gateways = new ArrayList();
+        protected int selected = 0;
+        protected Runnable onEmpty = () -> {};
+        protected Runnable onNonEmpty = () -> {};
+
+        @Override
+        public ViewHolder onCreateViewHolder(ViewGroup viewGroup, int i) {
+            View view = LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.enter_jid_dialog_gateway_list_item, null);
+            return new ViewHolder(view, i);
+        }
+
+        @Override
+        public void onBindViewHolder(ViewHolder viewHolder, int i) {
+            viewHolder.setIndex(i);
+
+            if(i == 0) {
+                viewHolder.useButton(R.string.account_settings_jabber_id);
+            } else {
+                viewHolder.useButton(getLabel(i));
+            }
+        }
+
+        @Override
+        public int getItemCount() {
+            return this.gateways.size() + 1;
+        }
+
+        public void setSelected(int i) {
+            int old = this.selected;
+            this.selected = i;
+
+            if(i == 0) {
+                binding.jid.setThreshold(1);
+                binding.jid.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS | InputType.TYPE_TEXT_FLAG_AUTO_COMPLETE);
+                binding.jidLayout.setHint(R.string.account_settings_jabber_id);
+
+                if(binding.jid.hasFocus()) {
+                    binding.jid.setHint(R.string.account_settings_example_jabber_id);
+                } else {
+                    DelayedHintHelper.setHint(R.string.account_settings_example_jabber_id, binding.jid);
+                }
+            } else {
+                binding.jid.setThreshold(999999); // do not autocomplete
+                binding.jid.setHint(null);
+                binding.jid.setOnFocusChangeListener((v, hasFocus) -> {});
+                binding.jidLayout.setHint(this.gateways.get(i-1).second);
+
+                String type = getType(i);
+                if (type.equals("pstn") || type.equals("sms")) {
+                    binding.jid.setInputType(InputType.TYPE_CLASS_PHONE);
+                } else if (type.equals("email") || type.equals("sip")) {
+                    binding.jid.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
+
+                    if(binding.jid.hasFocus()) {
+                        binding.jid.setHint(R.string.account_settings_example_jabber_id);
+                    } else {
+                        DelayedHintHelper.setHint(R.string.account_settings_example_jabber_id, binding.jid);
+                    }
+                } else {
+                    binding.jid.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
+                }
+            }
+
+            notifyItemChanged(old);
+            notifyItemChanged(i);
+        }
+
+        public String getLabel(Contact gateway) {
+            String type = getType(gateway);
+            if (type != null) return type;
+
+            return gateway.getDisplayName();
+        }
+
+        public String getLabel(int i) {
+            if (i == 0) return null;
+
+            return getLabel(this.gateways.get(i-1).first);
+        }
+
+        public String getType(int i) {
+            if (i == 0) return null;
+
+            return getType(this.gateways.get(i-1).first);
+        }
+
+        public String getType(Contact gateway) {
+            for(Presence p : gateway.getPresences().getPresences()) {
+                ServiceDiscoveryResult.Identity id;
+                if(p.getServiceDiscoveryResult() != null && (id = p.getServiceDiscoveryResult().getIdentity("gateway", null)) != null) {
+                    return id.getType();
+                }
+            }
+
+            return null;
+        }
+
+        public String getSelectedType() {
+            return getType(selected);
+        }
+
+        public Pair<String, Pair<Jid,Presence>> getSelected() {
+            if(this.selected == 0) {
+                return null; // No gateway, just use direct JID entry
+            }
+
+            Pair<Contact,String> gateway = this.gateways.get(this.selected - 1);
+
+            Pair<Jid,Presence> presence = null;
+            for (Map.Entry<String,Presence> e : gateway.first.getPresences().getPresencesMap().entrySet()) {
+                Presence p = e.getValue();
+                if (p.getServiceDiscoveryResult() != null) {
+                    if (p.getServiceDiscoveryResult().getFeatures().contains("jabber:iq:gateway")) {
+                        if (e.getKey().equals("")) {
+                            presence = new Pair<>(gateway.first.getJid(), p);
+                        } else {
+                            presence = new Pair<>(gateway.first.getJid().withResource(e.getKey()), p);
+                        }
+                        break;
+                    }
+                    if (p.getServiceDiscoveryResult().hasIdentity("gateway", null)) {
+                        if (e.getKey().equals("")) {
+                            presence = new Pair<>(gateway.first.getJid(), p);
+                        } else {
+                            presence = new Pair<>(gateway.first.getJid().withResource(e.getKey()), p);
+                        }
+                    }
+                }
+            }
+
+            return presence == null ? null : new Pair(gateway.second, presence);
+        }
+
+        public void setOnEmpty(Runnable r) {
+            onEmpty = r;
+        }
+
+        public void setOnNonEmpty(Runnable r) {
+            onNonEmpty = r;
+        }
+
+        public void clear() {
+            gateways.clear();
+            onEmpty.run();
+            notifyDataSetChanged();
+            setSelected(0);
+        }
+
+        public void add(Contact gateway, String prompt) {
+            if (getItemCount() < 2) onNonEmpty.run();
+            this.gateways.add(new Pair<>(gateway, prompt));
+            Collections.sort(this.gateways, (x, y) -> getLabel(x.first).compareTo(getLabel(y.first)));
+            notifyDataSetChanged();
+        }
+    }
 }

src/main/res/layout/enter_jid_dialog.xml 🔗

@@ -22,6 +22,11 @@
             android:layout_width="fill_parent"
             android:layout_height="wrap_content"/>
 
+        <androidx.recyclerview.widget.RecyclerView
+            android:id="@+id/gateway_list"
+            android:layout_width="fill_parent"
+            android:layout_height="wrap_content" />
+
         <com.google.android.material.textfield.TextInputLayout
             android:id="@+id/jid_layout"
             android:layout_width="match_parent"

src/main/res/layout/enter_jid_dialog_gateway_list_item.xml 🔗

@@ -0,0 +1,14 @@
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+              android:layout_width="wrap_content"
+              android:layout_height="wrap_content"
+              android:orientation="vertical"
+              android:paddingRight="5dp">
+    <ToggleButton
+        android:id="@+id/button"
+        android:gravity="center"
+        android:visibility="gone"
+        android:layout_width="fill_parent"
+        android:layout_height="wrap_content"
+        android:textColor="?attr/edit_text_color"
+        android:textSize="?attr/TextSizeBody1" />
+</LinearLayout>