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