EnterJidDialog.java

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