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