EnterJidDialog.java

  1package eu.siacs.conversations.ui;
  2
  3import android.app.Activity;
  4import android.app.Dialog;
  5import android.content.DialogInterface.OnClickListener;
  6import android.content.DialogInterface;
  7import android.os.Bundle;
  8import android.text.Editable;
  9import android.text.InputType;
 10import android.text.TextWatcher;
 11import android.util.Pair;
 12import android.view.LayoutInflater;
 13import android.view.View;
 14import android.view.ViewGroup;
 15import android.widget.AdapterView;
 16import android.widget.ArrayAdapter;
 17import android.widget.TextView;
 18import android.widget.ToggleButton;
 19
 20import androidx.annotation.NonNull;
 21import androidx.appcompat.app.AlertDialog;
 22import androidx.databinding.DataBindingUtil;
 23import androidx.fragment.app.DialogFragment;
 24import androidx.recyclerview.widget.RecyclerView;
 25
 26import java.util.ArrayList;
 27import java.util.Arrays;
 28import java.util.Collection;
 29import java.util.Collections;
 30import java.util.List;
 31import java.util.Map;
 32import org.solovyev.android.views.llm.LinearLayoutManager;
 33
 34import eu.siacs.conversations.Config;
 35import eu.siacs.conversations.R;
 36import eu.siacs.conversations.databinding.EnterJidDialogBinding;
 37import eu.siacs.conversations.services.XmppConnectionService;
 38import eu.siacs.conversations.entities.Account;
 39import eu.siacs.conversations.entities.Contact;
 40import eu.siacs.conversations.entities.Presence;
 41import eu.siacs.conversations.entities.ServiceDiscoveryResult;
 42import eu.siacs.conversations.ui.adapter.KnownHostsAdapter;
 43import eu.siacs.conversations.ui.interfaces.OnBackendConnected;
 44import eu.siacs.conversations.ui.util.DelayedHintHelper;
 45import eu.siacs.conversations.xmpp.Jid;
 46import eu.siacs.conversations.xmpp.OnGatewayResult;
 47
 48public class EnterJidDialog extends DialogFragment implements OnBackendConnected, TextWatcher {
 49
 50    private static final List<String> SUSPICIOUS_DOMAINS =
 51            Arrays.asList("conference", "muc", "room", "rooms", "chat");
 52
 53    private OnEnterJidDialogPositiveListener mListener = null;
 54
 55    private static final String TITLE_KEY = "title";
 56    private static final String POSITIVE_BUTTON_KEY = "positive_button";
 57    private static final String PREFILLED_JID_KEY = "prefilled_jid";
 58    private static final String ACCOUNT_KEY = "account";
 59    private static final String ALLOW_EDIT_JID_KEY = "allow_edit_jid";
 60    private static final String ACCOUNTS_LIST_KEY = "activated_accounts_list";
 61    private static final String SANITY_CHECK_JID = "sanity_check_jid";
 62
 63    private KnownHostsAdapter knownHostsAdapter;
 64    private Collection<String> whitelistedDomains = Collections.emptyList();
 65
 66    private EnterJidDialogBinding binding;
 67    private AlertDialog dialog;
 68    private boolean sanityCheckJid = false;
 69
 70    private boolean issuedWarning = false;
 71    private GatewayListAdapter gatewayListAdapter = new GatewayListAdapter();
 72
 73    public static EnterJidDialog newInstance(
 74            final List<String> activatedAccounts,
 75            final String title,
 76            final String positiveButton,
 77            final String prefilledJid,
 78            final String account,
 79            boolean allowEditJid,
 80            final boolean sanity_check_jid) {
 81        EnterJidDialog dialog = new EnterJidDialog();
 82        Bundle bundle = new Bundle();
 83        bundle.putString(TITLE_KEY, title);
 84        bundle.putString(POSITIVE_BUTTON_KEY, positiveButton);
 85        bundle.putString(PREFILLED_JID_KEY, prefilledJid);
 86        bundle.putString(ACCOUNT_KEY, account);
 87        bundle.putBoolean(ALLOW_EDIT_JID_KEY, allowEditJid);
 88        bundle.putStringArrayList(ACCOUNTS_LIST_KEY, (ArrayList<String>) activatedAccounts);
 89        bundle.putBoolean(SANITY_CHECK_JID, sanity_check_jid);
 90        dialog.setArguments(bundle);
 91        return dialog;
 92    }
 93
 94    @Override
 95    public void onActivityCreated(Bundle savedInstanceState) {
 96        super.onActivityCreated(savedInstanceState);
 97        setRetainInstance(true);
 98    }
 99
100    @Override
101    public void onStart() {
102        super.onStart();
103        final Activity activity = getActivity();
104        if (activity instanceof XmppActivity
105                && ((XmppActivity) activity).xmppConnectionService != null) {
106            refreshKnownHosts();
107        }
108    }
109
110    @NonNull
111    @Override
112    public Dialog onCreateDialog(Bundle savedInstanceState) {
113        final AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
114        builder.setTitle(getArguments().getString(TITLE_KEY));
115        binding =
116                DataBindingUtil.inflate(
117                        getActivity().getLayoutInflater(), R.layout.enter_jid_dialog, null, false);
118        this.knownHostsAdapter = new KnownHostsAdapter(getActivity(), R.layout.simple_list_item);
119        binding.jid.setAdapter(this.knownHostsAdapter);
120        binding.jid.addTextChangedListener(this);
121        String prefilledJid = getArguments().getString(PREFILLED_JID_KEY);
122        if (prefilledJid != null) {
123            binding.jid.append(prefilledJid);
124            if (!getArguments().getBoolean(ALLOW_EDIT_JID_KEY)) {
125                binding.jid.setFocusable(false);
126                binding.jid.setFocusableInTouchMode(false);
127                binding.jid.setClickable(false);
128                binding.jid.setCursorVisible(false);
129            }
130        }
131        sanityCheckJid = getArguments().getBoolean(SANITY_CHECK_JID, false);
132
133        DelayedHintHelper.setHint(R.string.account_settings_example_jabber_id, binding.jid);
134
135        String account = getArguments().getString(ACCOUNT_KEY);
136        if (account == null) {
137            StartConversationActivity.populateAccountSpinner(
138                    getActivity(),
139                    getArguments().getStringArrayList(ACCOUNTS_LIST_KEY),
140                    binding.account);
141        } else {
142            ArrayAdapter<String> adapter =
143                    new ArrayAdapter<>(
144                            getActivity(), R.layout.simple_list_item, new String[] {account});
145            binding.account.setEnabled(false);
146            adapter.setDropDownViewResource(R.layout.simple_list_item);
147            binding.account.setAdapter(adapter);
148        }
149
150        binding.gatewayList.setLayoutManager(new LinearLayoutManager(getActivity(), LinearLayoutManager.HORIZONTAL, false));
151        binding.gatewayList.setAdapter(gatewayListAdapter);
152
153        binding.account.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
154            @Override
155            public void onItemSelected(AdapterView accountSpinner, View view, int position, long id) {
156                XmppActivity context = (XmppActivity) getActivity();
157                if (context.xmppConnectionService == null || accountJid() == null) return;
158
159                gatewayListAdapter.clear();
160                final Account account = context.xmppConnectionService.findAccountByJid(accountJid());
161
162                for (final Contact contact : account.getRoster().getContacts()) {
163                    if (contact.showInRoster() && (contact.getPresences().anyIdentity("gateway", null) || contact.getPresences().anySupport("jabber:iq:gateway"))) {
164                        context.xmppConnectionService.fetchFromGateway(account, contact.getJid(), null, (final String prompt, String errorMessage) -> {
165                            if (prompt == null) return;
166
167                            context.runOnUiThread(() -> {
168                                gatewayListAdapter.add(contact, prompt);
169                            });
170                        });
171                    }
172                }
173            }
174
175            @Override
176            public void onNothingSelected(AdapterView accountSpinner) {
177                gatewayListAdapter.clear();
178            }
179        });
180
181        builder.setView(binding.getRoot());
182        builder.setNegativeButton(R.string.cancel, null);
183        builder.setPositiveButton(getArguments().getString(POSITIVE_BUTTON_KEY), null);
184        this.dialog = builder.create();
185
186        View.OnClickListener dialogOnClick =
187                v -> {
188                    handleEnter(binding, account);
189                };
190
191        binding.jid.setOnEditorActionListener(
192                (v, actionId, event) -> {
193                    handleEnter(binding, account);
194                    return true;
195                });
196
197        dialog.show();
198        dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(dialogOnClick);
199        return dialog;
200    }
201
202    protected Jid accountJid() {
203        try {
204            if (Config.DOMAIN_LOCK != null) {
205                return Jid.ofEscaped((String) binding.account.getSelectedItem(), Config.DOMAIN_LOCK, null);
206            } else {
207                return Jid.ofEscaped((String) binding.account.getSelectedItem());
208            }
209        } catch (final IllegalArgumentException e) {
210            return null;
211        }
212    }
213
214    private void handleEnter(EnterJidDialogBinding binding, String account) {
215        if (!binding.account.isEnabled() && account == null) {
216            return;
217        }
218        final Jid accountJid = accountJid();
219        final OnGatewayResult finish = (final String jidString, final String errorMessage) -> {
220            getActivity().runOnUiThread(() -> {
221                if (errorMessage != null) {
222                    binding.jidLayout.setError(errorMessage);
223                    return;
224                }
225                if (jidString == null) {
226                    binding.jidLayout.setError(getActivity().getString(R.string.invalid_jid));
227                    return;
228                }
229
230                final Jid contactJid;
231                try {
232                    contactJid = Jid.ofEscaped(jidString);
233                } catch (final IllegalArgumentException e) {
234                    binding.jidLayout.setError(getActivity().getString(R.string.invalid_jid));
235                    return;
236                }
237
238                if (!issuedWarning && sanityCheckJid) {
239                    if (contactJid.isDomainJid()) {
240                        binding.jidLayout.setError(getActivity().getString(R.string.this_looks_like_a_domain));
241                        dialog.getButton(AlertDialog.BUTTON_POSITIVE).setText(R.string.add_anway);
242                        issuedWarning = true;
243                        return;
244                    }
245                    if (suspiciousSubDomain(contactJid.getDomain().toEscapedString())) {
246                        binding.jidLayout.setError(getActivity().getString(R.string.this_looks_like_channel));
247                        dialog.getButton(AlertDialog.BUTTON_POSITIVE).setText(R.string.add_anway);
248                        issuedWarning = true;
249                        return;
250                    }
251                }
252
253                if (mListener != null) {
254                    try {
255                        if (mListener.onEnterJidDialogPositive(accountJid, contactJid)) {
256                            dialog.dismiss();
257                        }
258                    } catch (JidError error) {
259                        binding.jidLayout.setError(error.toString());
260                        dialog.getButton(AlertDialog.BUTTON_POSITIVE).setText(R.string.add);
261                        issuedWarning = false;
262                    }
263                }
264            });
265        };
266
267        Pair<String,Pair<Jid,Presence>> p = gatewayListAdapter.getSelected();
268
269        if (p == null) {
270            finish.onGatewayResult(binding.jid.getText().toString(), null);
271        } else if (p.first != null) { // Gateway already responsed to jabber:iq:gateway once
272            final Account acct = ((XmppActivity) getActivity()).xmppConnectionService.findAccountByJid(accountJid);
273            ((XmppActivity) getActivity()).xmppConnectionService.fetchFromGateway(acct, p.second.first, binding.jid.getText().toString(), finish);
274        } else if (p.second.first.isDomainJid() && p.second.second.getServiceDiscoveryResult().getFeatures().contains("jid\\20escaping")) {
275            finish.onGatewayResult(Jid.ofLocalAndDomain(binding.jid.getText().toString(), p.second.first.getDomain().toString()).toString(), null);
276        } else if (p.second.first.isDomainJid()) {
277            finish.onGatewayResult(Jid.ofLocalAndDomain(binding.jid.getText().toString().replace("@", "%"), p.second.first.getDomain().toString()).toString(), null);
278        } else {
279            finish.onGatewayResult(null, null);
280        }
281    }
282
283    public void setOnEnterJidDialogPositiveListener(OnEnterJidDialogPositiveListener listener) {
284        this.mListener = listener;
285    }
286
287    @Override
288    public void onBackendConnected() {
289        refreshKnownHosts();
290    }
291
292    private void refreshKnownHosts() {
293        final Activity activity = getActivity();
294        if (activity instanceof XmppActivity) {
295            final XmppConnectionService service = ((XmppActivity) activity).xmppConnectionService;
296            if (service == null) {
297                return;
298            }
299            final Collection<String> hosts = service.getKnownHosts();
300            this.knownHostsAdapter.refresh(hosts);
301            this.whitelistedDomains = hosts;
302        }
303    }
304
305    @Override
306    public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
307
308    @Override
309    public void onTextChanged(CharSequence s, int start, int before, int count) {}
310
311    @Override
312    public void afterTextChanged(Editable s) {
313        if (issuedWarning) {
314            dialog.getButton(AlertDialog.BUTTON_POSITIVE).setText(R.string.add);
315            binding.jidLayout.setError(null);
316            issuedWarning = false;
317        }
318    }
319
320    public interface OnEnterJidDialogPositiveListener {
321        boolean onEnterJidDialogPositive(Jid account, Jid contact) throws EnterJidDialog.JidError;
322    }
323
324    public static class JidError extends Exception {
325        final String msg;
326
327        public JidError(final String msg) {
328            this.msg = msg;
329        }
330
331        @NonNull
332        public String toString() {
333            return msg;
334        }
335    }
336
337    @Override
338    public void onDestroyView() {
339        Dialog dialog = getDialog();
340        if (dialog != null && getRetainInstance()) {
341            dialog.setDismissMessage(null);
342        }
343        super.onDestroyView();
344    }
345
346    private boolean suspiciousSubDomain(String domain) {
347        if (this.whitelistedDomains.contains(domain)) {
348            return false;
349        }
350        final String[] parts = domain.split("\\.");
351        return parts.length >= 3 && SUSPICIOUS_DOMAINS.contains(parts[0]);
352    }
353
354    protected class GatewayListAdapter extends RecyclerView.Adapter<GatewayListAdapter.ViewHolder> {
355        protected class ViewHolder extends RecyclerView.ViewHolder {
356            protected ToggleButton button;
357            protected int index;
358
359            public ViewHolder(View view, int i) {
360                super(view);
361                this.button = (ToggleButton) view.findViewById(R.id.button);
362                setIndex(i);
363                button.setOnClickListener(new View.OnClickListener() {
364                    @Override
365                    public void onClick(View v) {
366                        button.setChecked(true); // Force visual not to flap to unchecked
367                        setSelected(index);
368                    }
369                });
370            }
371
372            public void setIndex(int i) {
373                this.index = i;
374                button.setChecked(selected == i);
375            }
376
377            public void useButton(int res) {
378                button.setText(res);
379                button.setTextOff(button.getText());
380                button.setTextOn(button.getText());
381                button.setChecked(selected == this.index);
382                binding.gatewayList.setVisibility(View.VISIBLE);
383                button.setVisibility(View.VISIBLE);
384            }
385
386            public void useButton(String txt) {
387                button.setTextOff(txt);
388                button.setTextOn(txt);
389                button.setChecked(selected == this.index);
390                binding.gatewayList.setVisibility(View.VISIBLE);
391                button.setVisibility(View.VISIBLE);
392            }
393        }
394
395        protected List<Pair<Contact,String>> gateways = new ArrayList();
396        protected int selected = 0;
397
398        @Override
399        public ViewHolder onCreateViewHolder(ViewGroup viewGroup, int i) {
400            View view = LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.enter_jid_dialog_gateway_list_item, null);
401            return new ViewHolder(view, i);
402        }
403
404        @Override
405        public void onBindViewHolder(ViewHolder viewHolder, int i) {
406            viewHolder.setIndex(i);
407
408            if(i == 0) {
409                if(getItemCount() < 2) {
410                    binding.gatewayList.setVisibility(View.GONE);
411                } else {
412                    viewHolder.useButton(R.string.account_settings_jabber_id);
413                }
414            } else {
415                viewHolder.useButton(getLabel(i));
416            }
417        }
418
419        @Override
420        public int getItemCount() {
421            return this.gateways.size() + 1;
422        }
423
424        public void setSelected(int i) {
425            int old = this.selected;
426            this.selected = i;
427
428            if(i == 0) {
429                binding.jid.setThreshold(1);
430                binding.jid.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS | InputType.TYPE_TEXT_FLAG_AUTO_COMPLETE);
431                binding.jidLayout.setHint(R.string.account_settings_jabber_id);
432            } else {
433                binding.jid.setThreshold(999999); // do not autocomplete
434
435                String type = getType(i);
436                if (type.equals("pstn") || type.equals("sms")) {
437                    binding.jid.setInputType(InputType.TYPE_CLASS_PHONE);
438                } else if (type.equals("email") || type.equals("sip")) {
439                    binding.jid.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
440                } else {
441                    binding.jid.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
442                }
443
444                binding.jidLayout.setHint(this.gateways.get(i-1).second);
445                binding.jid.setHint(null);
446                binding.jid.setOnFocusChangeListener((v, hasFocus) -> {});
447            }
448
449            notifyItemChanged(old);
450            notifyItemChanged(i);
451        }
452
453        public String getLabel(int i) {
454            if (i == 0) return null;
455
456            String type = getType(i);
457            if (type != null) return type;
458
459            return gateways.get(i-1).first.getDisplayName();
460        }
461
462        public String getType(int i) {
463            if (i == 0) return null;
464
465            for(Presence p : this.gateways.get(i-1).first.getPresences().getPresences()) {
466                ServiceDiscoveryResult.Identity id;
467                if(p.getServiceDiscoveryResult() != null && (id = p.getServiceDiscoveryResult().getIdentity("gateway", null)) != null) {
468                    return id.getType();
469                }
470            }
471
472            return null;
473        }
474
475        public Pair<String, Pair<Jid,Presence>> getSelected() {
476            if(this.selected == 0) {
477                return null; // No gateway, just use direct JID entry
478            }
479
480            Pair<Contact,String> gateway = this.gateways.get(this.selected - 1);
481
482            Pair<Jid,Presence> presence = null;
483            for (Map.Entry<String,Presence> e : gateway.first.getPresences().getPresencesMap().entrySet()) {
484                Presence p = e.getValue();
485                if (p.getServiceDiscoveryResult() != null) {
486                    if (p.getServiceDiscoveryResult().getFeatures().contains("jabber:iq:gateway")) {
487                        if (e.getKey().equals("")) {
488                            presence = new Pair<>(gateway.first.getJid(), p);
489                        } else {
490                            presence = new Pair<>(gateway.first.getJid().withResource(e.getKey()), p);
491                        }
492                        break;
493                    }
494                    if (p.getServiceDiscoveryResult().hasIdentity("gateway", null)) {
495                        if (e.getKey().equals("")) {
496                            presence = new Pair<>(gateway.first.getJid(), p);
497                        } else {
498                            presence = new Pair<>(gateway.first.getJid().withResource(e.getKey()), p);
499                        }
500                    }
501                }
502            }
503
504            return presence == null ? null : new Pair(gateway.second, presence);
505        }
506
507        public void clear() {
508            this.gateways.clear();
509            notifyDataSetChanged();
510            setSelected(0);
511        }
512
513        public void add(Contact gateway, String prompt) {
514            binding.gatewayList.setVisibility(View.VISIBLE);
515            this.gateways.add(new Pair<>(gateway, prompt));
516            notifyDataSetChanged();
517        }
518    }
519}