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}