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