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