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