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.OnGatewayPromptResult;
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.fetchGatewayPrompt(account, contact.getJid(), (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 Jid contactJid;
220 try {
221 contactJid = Jid.ofEscaped(binding.jid.getText().toString());
222 } catch (final IllegalArgumentException e) {
223 binding.jidLayout.setError(getActivity().getString(R.string.invalid_jid));
224 return;
225 }
226
227 if (!issuedWarning && sanityCheckJid) {
228 if (contactJid.isDomainJid()) {
229 binding.jidLayout.setError(
230 getActivity().getString(R.string.this_looks_like_a_domain));
231 dialog.getButton(AlertDialog.BUTTON_POSITIVE).setText(R.string.add_anway);
232 issuedWarning = true;
233 return;
234 }
235 if (suspiciousSubDomain(contactJid.getDomain().toEscapedString())) {
236 binding.jidLayout.setError(
237 getActivity().getString(R.string.this_looks_like_channel));
238 dialog.getButton(AlertDialog.BUTTON_POSITIVE).setText(R.string.add_anway);
239 issuedWarning = true;
240 return;
241 }
242 }
243
244 if (mListener != null) {
245 try {
246 if (mListener.onEnterJidDialogPositive(accountJid, contactJid)) {
247 dialog.dismiss();
248 }
249 } catch (JidError error) {
250 binding.jidLayout.setError(error.toString());
251 dialog.getButton(AlertDialog.BUTTON_POSITIVE).setText(R.string.add);
252 issuedWarning = false;
253 }
254 }
255 }
256
257 public void setOnEnterJidDialogPositiveListener(OnEnterJidDialogPositiveListener listener) {
258 this.mListener = listener;
259 }
260
261 @Override
262 public void onBackendConnected() {
263 refreshKnownHosts();
264 }
265
266 private void refreshKnownHosts() {
267 final Activity activity = getActivity();
268 if (activity instanceof XmppActivity) {
269 final XmppConnectionService service = ((XmppActivity) activity).xmppConnectionService;
270 if (service == null) {
271 return;
272 }
273 final Collection<String> hosts = service.getKnownHosts();
274 this.knownHostsAdapter.refresh(hosts);
275 this.whitelistedDomains = hosts;
276 }
277 }
278
279 @Override
280 public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
281
282 @Override
283 public void onTextChanged(CharSequence s, int start, int before, int count) {}
284
285 @Override
286 public void afterTextChanged(Editable s) {
287 if (issuedWarning) {
288 dialog.getButton(AlertDialog.BUTTON_POSITIVE).setText(R.string.add);
289 binding.jidLayout.setError(null);
290 issuedWarning = false;
291 }
292 }
293
294 public interface OnEnterJidDialogPositiveListener {
295 boolean onEnterJidDialogPositive(Jid account, Jid contact) throws EnterJidDialog.JidError;
296 }
297
298 public static class JidError extends Exception {
299 final String msg;
300
301 public JidError(final String msg) {
302 this.msg = msg;
303 }
304
305 @NonNull
306 public String toString() {
307 return msg;
308 }
309 }
310
311 @Override
312 public void onDestroyView() {
313 Dialog dialog = getDialog();
314 if (dialog != null && getRetainInstance()) {
315 dialog.setDismissMessage(null);
316 }
317 super.onDestroyView();
318 }
319
320 private boolean suspiciousSubDomain(String domain) {
321 if (this.whitelistedDomains.contains(domain)) {
322 return false;
323 }
324 final String[] parts = domain.split("\\.");
325 return parts.length >= 3 && SUSPICIOUS_DOMAINS.contains(parts[0]);
326 }
327
328 protected class GatewayListAdapter extends RecyclerView.Adapter<GatewayListAdapter.ViewHolder> {
329 protected class ViewHolder extends RecyclerView.ViewHolder {
330 protected ToggleButton button;
331 protected int index;
332
333 public ViewHolder(View view, int i) {
334 super(view);
335 this.button = (ToggleButton) view.findViewById(R.id.button);
336 setIndex(i);
337 button.setOnClickListener(new View.OnClickListener() {
338 @Override
339 public void onClick(View v) {
340 button.setChecked(true); // Force visual not to flap to unchecked
341 setSelected(index);
342 }
343 });
344 }
345
346 public void setIndex(int i) {
347 this.index = i;
348 button.setChecked(selected == i);
349 }
350
351 public void useButton(int res) {
352 button.setText(res);
353 button.setTextOff(button.getText());
354 button.setTextOn(button.getText());
355 button.setChecked(selected == this.index);
356 binding.gatewayList.setVisibility(View.VISIBLE);
357 button.setVisibility(View.VISIBLE);
358 }
359
360 public void useButton(String txt) {
361 button.setTextOff(txt);
362 button.setTextOn(txt);
363 button.setChecked(selected == this.index);
364 binding.gatewayList.setVisibility(View.VISIBLE);
365 button.setVisibility(View.VISIBLE);
366 }
367 }
368
369 protected List<Pair<Contact,String>> gateways = new ArrayList();
370 protected int selected = 0;
371
372 @Override
373 public ViewHolder onCreateViewHolder(ViewGroup viewGroup, int i) {
374 View view = LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.enter_jid_dialog_gateway_list_item, null);
375 return new ViewHolder(view, i);
376 }
377
378 @Override
379 public void onBindViewHolder(ViewHolder viewHolder, int i) {
380 viewHolder.setIndex(i);
381
382 if(i == 0) {
383 if(getItemCount() < 2) {
384 binding.gatewayList.setVisibility(View.GONE);
385 } else {
386 viewHolder.useButton(R.string.account_settings_jabber_id);
387 }
388 } else {
389 viewHolder.useButton(getLabel(i));
390 }
391 }
392
393 @Override
394 public int getItemCount() {
395 return this.gateways.size() + 1;
396 }
397
398 public void setSelected(int i) {
399 int old = this.selected;
400 this.selected = i;
401
402 if(i == 0) {
403 binding.jid.setThreshold(1);
404 binding.jid.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS | InputType.TYPE_TEXT_FLAG_AUTO_COMPLETE);
405 binding.jidLayout.setHint(R.string.account_settings_jabber_id);
406 } else {
407 binding.jid.setThreshold(999999); // do not autocomplete
408 binding.jid.setInputType(InputType.TYPE_CLASS_TEXT);
409 binding.jidLayout.setHint(this.gateways.get(i-1).second);
410 binding.jid.setHint(null);
411 binding.jid.setOnFocusChangeListener((v, hasFocus) -> {});
412 }
413
414 notifyItemChanged(old);
415 notifyItemChanged(i);
416 }
417
418 public String getLabel(int i) {
419 if (i == 0) return null;
420
421 for(Presence p : this.gateways.get(i-1).first.getPresences().getPresences()) {
422 ServiceDiscoveryResult.Identity id;
423 if(p.getServiceDiscoveryResult() != null && (id = p.getServiceDiscoveryResult().getIdentity("gateway", null)) != null) {
424 return id.getType();
425 }
426 }
427
428 return gateways.get(i-1).first.getDisplayName();
429 }
430
431 public String getSelectedLabel() {
432 return getLabel(selected);
433 }
434
435 public Pair<String, Pair<Jid,Presence>> getSelected() {
436 if(this.selected == 0) {
437 return null; // No gateway, just use direct JID entry
438 }
439
440 Pair<Contact,String> gateway = this.gateways.get(this.selected - 1);
441
442 Pair<Jid,Presence> presence = null;
443 for (Map.Entry<String,Presence> e : gateway.first.getPresences().getPresencesMap().entrySet()) {
444 Presence p = e.getValue();
445 if (p.getServiceDiscoveryResult() != null) {
446 if (p.getServiceDiscoveryResult().getFeatures().contains("jabber:iq:gateway")) {
447 if (e.getKey().equals("")) {
448 presence = new Pair<>(gateway.first.getJid(), p);
449 } else {
450 presence = new Pair<>(gateway.first.getJid().withResource(e.getKey()), p);
451 }
452 break;
453 }
454 if (p.getServiceDiscoveryResult().hasIdentity("gateway", null)) {
455 if (e.getKey().equals("")) {
456 presence = new Pair<>(gateway.first.getJid(), p);
457 } else {
458 presence = new Pair<>(gateway.first.getJid().withResource(e.getKey()), p);
459 }
460 }
461 }
462 }
463
464 return presence == null ? null : new Pair(gateway.second, presence);
465 }
466
467 public void clear() {
468 this.gateways.clear();
469 notifyDataSetChanged();
470 setSelected(0);
471 }
472
473 public void add(Contact gateway, String prompt) {
474 binding.gatewayList.setVisibility(View.VISIBLE);
475 this.gateways.add(new Pair<>(gateway, prompt));
476 notifyDataSetChanged();
477 }
478 }
479}