1package eu.siacs.conversations.entities;
2
3import android.content.ContentValues;
4import android.content.Context;
5import android.content.DialogInterface;
6import android.content.Intent;
7import android.database.Cursor;
8import android.database.DataSetObserver;
9import android.graphics.drawable.BitmapDrawable;
10import android.graphics.drawable.Drawable;
11import android.graphics.Bitmap;
12import android.graphics.Canvas;
13import android.graphics.Rect;
14import android.os.Build;
15import android.net.Uri;
16import android.telephony.PhoneNumberUtils;
17import android.text.Editable;
18import android.text.InputType;
19import android.text.SpannableStringBuilder;
20import android.text.Spanned;
21import android.text.StaticLayout;
22import android.text.TextPaint;
23import android.text.TextUtils;
24import android.text.TextWatcher;
25import android.view.LayoutInflater;
26import android.view.MotionEvent;
27import android.view.Gravity;
28import android.view.View;
29import android.view.ViewGroup;
30import android.widget.AbsListView;
31import android.widget.ArrayAdapter;
32import android.widget.AdapterView;
33import android.widget.Button;
34import android.widget.CompoundButton;
35import android.widget.GridLayout;
36import android.widget.ListView;
37import android.widget.TextView;
38import android.widget.Toast;
39import android.widget.Spinner;
40import android.webkit.JavascriptInterface;
41import android.webkit.WebMessage;
42import android.webkit.WebView;
43import android.webkit.WebViewClient;
44import android.webkit.WebChromeClient;
45import android.util.DisplayMetrics;
46import android.util.LruCache;
47import android.util.Pair;
48import android.util.SparseArray;
49
50import androidx.annotation.NonNull;
51import androidx.annotation.Nullable;
52import androidx.appcompat.app.AlertDialog;
53import androidx.appcompat.app.AlertDialog.Builder;
54import androidx.core.content.ContextCompat;
55import androidx.core.util.Consumer;
56import androidx.databinding.DataBindingUtil;
57import androidx.databinding.ViewDataBinding;
58import androidx.viewpager.widget.PagerAdapter;
59import androidx.recyclerview.widget.RecyclerView;
60import androidx.recyclerview.widget.GridLayoutManager;
61import androidx.viewpager.widget.ViewPager;
62
63import com.caverock.androidsvg.SVG;
64
65import com.cheogram.android.ConversationPage;
66import com.cheogram.android.Util;
67import com.cheogram.android.WebxdcPage;
68
69import com.google.android.material.tabs.TabLayout;
70import com.google.android.material.textfield.TextInputLayout;
71import com.google.common.base.Optional;
72import com.google.common.collect.ComparisonChain;
73import com.google.common.collect.Lists;
74
75import io.ipfs.cid.Cid;
76
77import io.michaelrocks.libphonenumber.android.NumberParseException;
78
79import org.json.JSONArray;
80import org.json.JSONException;
81import org.json.JSONObject;
82
83import java.time.LocalDateTime;
84import java.time.ZoneId;
85import java.time.ZonedDateTime;
86import java.time.format.DateTimeParseException;
87import java.time.format.DateTimeFormatter;
88import java.time.format.FormatStyle;
89import java.util.ArrayList;
90import java.util.Collections;
91import java.util.Iterator;
92import java.util.HashSet;
93import java.util.List;
94import java.util.ListIterator;
95import java.util.concurrent.atomic.AtomicBoolean;
96import java.util.stream.Collectors;
97import java.util.Set;
98import java.util.Timer;
99import java.util.TimerTask;
100
101import me.saket.bettermovementmethod.BetterLinkMovementMethod;
102
103import eu.siacs.conversations.Config;
104import eu.siacs.conversations.R;
105import eu.siacs.conversations.crypto.OmemoSetting;
106import eu.siacs.conversations.crypto.PgpDecryptionService;
107import eu.siacs.conversations.databinding.CommandButtonGridFieldBinding;
108import eu.siacs.conversations.databinding.CommandCheckboxFieldBinding;
109import eu.siacs.conversations.databinding.CommandItemCardBinding;
110import eu.siacs.conversations.databinding.CommandNoteBinding;
111import eu.siacs.conversations.databinding.CommandPageBinding;
112import eu.siacs.conversations.databinding.CommandProgressBarBinding;
113import eu.siacs.conversations.databinding.CommandRadioEditFieldBinding;
114import eu.siacs.conversations.databinding.CommandResultCellBinding;
115import eu.siacs.conversations.databinding.CommandResultFieldBinding;
116import eu.siacs.conversations.databinding.CommandSearchListFieldBinding;
117import eu.siacs.conversations.databinding.CommandSpinnerFieldBinding;
118import eu.siacs.conversations.databinding.CommandTextFieldBinding;
119import eu.siacs.conversations.databinding.CommandWebviewBinding;
120import eu.siacs.conversations.databinding.DialogQuickeditBinding;
121import eu.siacs.conversations.http.HttpConnectionManager;
122import eu.siacs.conversations.persistance.DatabaseBackend;
123import eu.siacs.conversations.services.AvatarService;
124import eu.siacs.conversations.services.QuickConversationsService;
125import eu.siacs.conversations.services.XmppConnectionService;
126import eu.siacs.conversations.ui.UriHandlerActivity;
127import eu.siacs.conversations.ui.text.FixedURLSpan;
128import eu.siacs.conversations.ui.util.ShareUtil;
129import eu.siacs.conversations.ui.util.SoftKeyboardUtils;
130import eu.siacs.conversations.utils.JidHelper;
131import eu.siacs.conversations.utils.MessageUtils;
132import eu.siacs.conversations.utils.PhoneNumberUtilWrapper;
133import eu.siacs.conversations.utils.UIHelper;
134import eu.siacs.conversations.xml.Element;
135import eu.siacs.conversations.xml.Namespace;
136import eu.siacs.conversations.xmpp.Jid;
137import eu.siacs.conversations.xmpp.chatstate.ChatState;
138import eu.siacs.conversations.xmpp.forms.Data;
139import eu.siacs.conversations.xmpp.forms.Option;
140import eu.siacs.conversations.xmpp.mam.MamReference;
141import eu.siacs.conversations.xmpp.stanzas.IqPacket;
142
143import static eu.siacs.conversations.entities.Bookmark.printableValue;
144
145
146public class Conversation extends AbstractEntity implements Blockable, Comparable<Conversation>, Conversational, AvatarService.Avatarable {
147 public static final String TABLENAME = "conversations";
148
149 public static final int STATUS_AVAILABLE = 0;
150 public static final int STATUS_ARCHIVED = 1;
151
152 public static final String NAME = "name";
153 public static final String ACCOUNT = "accountUuid";
154 public static final String CONTACT = "contactUuid";
155 public static final String CONTACTJID = "contactJid";
156 public static final String STATUS = "status";
157 public static final String CREATED = "created";
158 public static final String MODE = "mode";
159 public static final String ATTRIBUTES = "attributes";
160
161 public static final String ATTRIBUTE_MUTED_TILL = "muted_till";
162 public static final String ATTRIBUTE_ALWAYS_NOTIFY = "always_notify";
163 public static final String ATTRIBUTE_NOTIFY_REPLIES = "notify_replies";
164 public static final String ATTRIBUTE_LAST_CLEAR_HISTORY = "last_clear_history";
165 public static final String ATTRIBUTE_FORMERLY_PRIVATE_NON_ANONYMOUS = "formerly_private_non_anonymous";
166 public static final String ATTRIBUTE_PINNED_ON_TOP = "pinned_on_top";
167 static final String ATTRIBUTE_MUC_PASSWORD = "muc_password";
168 static final String ATTRIBUTE_MEMBERS_ONLY = "members_only";
169 static final String ATTRIBUTE_MODERATED = "moderated";
170 static final String ATTRIBUTE_NON_ANONYMOUS = "non_anonymous";
171 private static final String ATTRIBUTE_NEXT_MESSAGE = "next_message";
172 private static final String ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP = "next_message_timestamp";
173 private static final String ATTRIBUTE_CRYPTO_TARGETS = "crypto_targets";
174 private static final String ATTRIBUTE_NEXT_ENCRYPTION = "next_encryption";
175 private static final String ATTRIBUTE_CORRECTING_MESSAGE = "correcting_message";
176 protected final ArrayList<Message> messages = new ArrayList<>();
177 public AtomicBoolean messagesLoaded = new AtomicBoolean(true);
178 protected Account account = null;
179 private String draftMessage;
180 private final String name;
181 private final String contactUuid;
182 private final String accountUuid;
183 private Jid contactJid;
184 private int status;
185 private final long created;
186 private int mode;
187 private JSONObject attributes;
188 private Jid nextCounterpart;
189 private transient MucOptions mucOptions = null;
190 private boolean messagesLeftOnServer = true;
191 private ChatState mOutgoingChatState = Config.DEFAULT_CHAT_STATE;
192 private ChatState mIncomingChatState = Config.DEFAULT_CHAT_STATE;
193 private String mFirstMamReference = null;
194 protected int mCurrentTab = -1;
195 protected ConversationPagerAdapter pagerAdapter = new ConversationPagerAdapter();
196 protected Element thread = null;
197 protected boolean lockThread = false;
198 protected boolean userSelectedThread = false;
199 protected Message replyTo = null;
200
201 public Conversation(final String name, final Account account, final Jid contactJid,
202 final int mode) {
203 this(java.util.UUID.randomUUID().toString(), name, null, account
204 .getUuid(), contactJid, System.currentTimeMillis(),
205 STATUS_AVAILABLE, mode, "");
206 this.account = account;
207 }
208
209 public Conversation(final String uuid, final String name, final String contactUuid,
210 final String accountUuid, final Jid contactJid, final long created, final int status,
211 final int mode, final String attributes) {
212 this.uuid = uuid;
213 this.name = name;
214 this.contactUuid = contactUuid;
215 this.accountUuid = accountUuid;
216 this.contactJid = contactJid;
217 this.created = created;
218 this.status = status;
219 this.mode = mode;
220 try {
221 this.attributes = new JSONObject(attributes == null ? "" : attributes);
222 } catch (JSONException e) {
223 this.attributes = new JSONObject();
224 }
225 }
226
227 public static Conversation fromCursor(Cursor cursor) {
228 return new Conversation(cursor.getString(cursor.getColumnIndex(UUID)),
229 cursor.getString(cursor.getColumnIndex(NAME)),
230 cursor.getString(cursor.getColumnIndex(CONTACT)),
231 cursor.getString(cursor.getColumnIndex(ACCOUNT)),
232 JidHelper.parseOrFallbackToInvalid(cursor.getString(cursor.getColumnIndex(CONTACTJID))),
233 cursor.getLong(cursor.getColumnIndex(CREATED)),
234 cursor.getInt(cursor.getColumnIndex(STATUS)),
235 cursor.getInt(cursor.getColumnIndex(MODE)),
236 cursor.getString(cursor.getColumnIndex(ATTRIBUTES)));
237 }
238
239 public static Message getLatestMarkableMessage(final List<Message> messages, boolean isPrivateAndNonAnonymousMuc) {
240 for (int i = messages.size() - 1; i >= 0; --i) {
241 final Message message = messages.get(i);
242 if (message.getStatus() <= Message.STATUS_RECEIVED
243 && (message.markable || isPrivateAndNonAnonymousMuc)
244 && !message.isPrivateMessage()) {
245 return message;
246 }
247 }
248 return null;
249 }
250
251 private static boolean suitableForOmemoByDefault(final Conversation conversation) {
252 if (conversation.getJid().asBareJid().equals(Config.BUG_REPORTS)) {
253 return false;
254 }
255 if (conversation.getContact().isOwnServer()) {
256 return false;
257 }
258 final String contact = conversation.getJid().getDomain().toEscapedString();
259 final String account = conversation.getAccount().getServer();
260 if (Config.OMEMO_EXCEPTIONS.matchesContactDomain(contact) || Config.OMEMO_EXCEPTIONS.ACCOUNT_DOMAINS.contains(account)) {
261 return false;
262 }
263 return conversation.isSingleOrPrivateAndNonAnonymous() || conversation.getBooleanAttribute(ATTRIBUTE_FORMERLY_PRIVATE_NON_ANONYMOUS, false);
264 }
265
266 public boolean hasMessagesLeftOnServer() {
267 return messagesLeftOnServer;
268 }
269
270 public void setHasMessagesLeftOnServer(boolean value) {
271 this.messagesLeftOnServer = value;
272 }
273
274 public Message getFirstUnreadMessage() {
275 Message first = null;
276 synchronized (this.messages) {
277 for (int i = messages.size() - 1; i >= 0; --i) {
278 if (messages.get(i).isRead()) {
279 return first;
280 } else {
281 first = messages.get(i);
282 }
283 }
284 }
285 return first;
286 }
287
288 public String findMostRecentRemoteDisplayableId() {
289 final boolean multi = mode == Conversation.MODE_MULTI;
290 synchronized (this.messages) {
291 for (final Message message : Lists.reverse(this.messages)) {
292 if (message.getStatus() == Message.STATUS_RECEIVED) {
293 final String serverMsgId = message.getServerMsgId();
294 if (serverMsgId != null && multi) {
295 return serverMsgId;
296 }
297 return message.getRemoteMsgId();
298 }
299 }
300 }
301 return null;
302 }
303
304 public int countFailedDeliveries() {
305 int count = 0;
306 synchronized (this.messages) {
307 for(final Message message : this.messages) {
308 if (message.getStatus() == Message.STATUS_SEND_FAILED) {
309 ++count;
310 }
311 }
312 }
313 return count;
314 }
315
316 public Message getLastEditableMessage() {
317 synchronized (this.messages) {
318 for (final Message message : Lists.reverse(this.messages)) {
319 if (message.isEditable()) {
320 if (message.isGeoUri() || message.getType() != Message.TYPE_TEXT) {
321 return null;
322 }
323 return message;
324 }
325 }
326 }
327 return null;
328 }
329
330
331 public Message findUnsentMessageWithUuid(String uuid) {
332 synchronized (this.messages) {
333 for (final Message message : this.messages) {
334 final int s = message.getStatus();
335 if ((s == Message.STATUS_UNSEND || s == Message.STATUS_WAITING) && message.getUuid().equals(uuid)) {
336 return message;
337 }
338 }
339 }
340 return null;
341 }
342
343 public void findWaitingMessages(OnMessageFound onMessageFound) {
344 final ArrayList<Message> results = new ArrayList<>();
345 synchronized (this.messages) {
346 for (Message message : this.messages) {
347 if (message.getStatus() == Message.STATUS_WAITING) {
348 results.add(message);
349 }
350 }
351 }
352 for (Message result : results) {
353 onMessageFound.onMessageFound(result);
354 }
355 }
356
357 public void findUnreadMessagesAndCalls(OnMessageFound onMessageFound) {
358 final ArrayList<Message> results = new ArrayList<>();
359 synchronized (this.messages) {
360 for (final Message message : this.messages) {
361 if (message.isRead()) {
362 continue;
363 }
364 results.add(message);
365 }
366 }
367 for (final Message result : results) {
368 onMessageFound.onMessageFound(result);
369 }
370 }
371
372 public Message findMessageWithFileAndUuid(final String uuid) {
373 synchronized (this.messages) {
374 for (final Message message : this.messages) {
375 final Transferable transferable = message.getTransferable();
376 final boolean unInitiatedButKnownSize = MessageUtils.unInitiatedButKnownSize(message);
377 if (message.getUuid().equals(uuid)
378 && message.getEncryption() != Message.ENCRYPTION_PGP
379 && (message.isFileOrImage() || message.treatAsDownloadable() || unInitiatedButKnownSize || (transferable != null && transferable.getStatus() != Transferable.STATUS_UPLOADING))) {
380 return message;
381 }
382 }
383 }
384 return null;
385 }
386
387 public Message findMessageWithUuid(final String uuid) {
388 synchronized (this.messages) {
389 for (final Message message : this.messages) {
390 if (message.getUuid().equals(uuid)) {
391 return message;
392 }
393 }
394 }
395 return null;
396 }
397
398 public boolean markAsDeleted(final List<String> uuids) {
399 boolean deleted = false;
400 final PgpDecryptionService pgpDecryptionService = account.getPgpDecryptionService();
401 synchronized (this.messages) {
402 for (Message message : this.messages) {
403 if (uuids.contains(message.getUuid())) {
404 message.setDeleted(true);
405 deleted = true;
406 if (message.getEncryption() == Message.ENCRYPTION_PGP && pgpDecryptionService != null) {
407 pgpDecryptionService.discard(message);
408 }
409 }
410 }
411 }
412 return deleted;
413 }
414
415 public boolean markAsChanged(final List<DatabaseBackend.FilePathInfo> files) {
416 boolean changed = false;
417 final PgpDecryptionService pgpDecryptionService = account.getPgpDecryptionService();
418 synchronized (this.messages) {
419 for (Message message : this.messages) {
420 for (final DatabaseBackend.FilePathInfo file : files)
421 if (file.uuid.toString().equals(message.getUuid())) {
422 message.setDeleted(file.deleted);
423 changed = true;
424 if (file.deleted && message.getEncryption() == Message.ENCRYPTION_PGP && pgpDecryptionService != null) {
425 pgpDecryptionService.discard(message);
426 }
427 }
428 }
429 }
430 return changed;
431 }
432
433 public void clearMessages() {
434 synchronized (this.messages) {
435 this.messages.clear();
436 }
437 }
438
439 public boolean setIncomingChatState(ChatState state) {
440 if (this.mIncomingChatState == state) {
441 return false;
442 }
443 this.mIncomingChatState = state;
444 return true;
445 }
446
447 public ChatState getIncomingChatState() {
448 return this.mIncomingChatState;
449 }
450
451 public boolean setOutgoingChatState(ChatState state) {
452 if (mode == MODE_SINGLE && !getContact().isSelf() || (isPrivateAndNonAnonymous() && getNextCounterpart() == null)) {
453 if (this.mOutgoingChatState != state) {
454 this.mOutgoingChatState = state;
455 return true;
456 }
457 }
458 return false;
459 }
460
461 public ChatState getOutgoingChatState() {
462 return this.mOutgoingChatState;
463 }
464
465 public void trim() {
466 synchronized (this.messages) {
467 final int size = messages.size();
468 final int maxsize = Config.PAGE_SIZE * Config.MAX_NUM_PAGES;
469 if (size > maxsize) {
470 List<Message> discards = this.messages.subList(0, size - maxsize);
471 final PgpDecryptionService pgpDecryptionService = account.getPgpDecryptionService();
472 if (pgpDecryptionService != null) {
473 pgpDecryptionService.discard(discards);
474 }
475 discards.clear();
476 untieMessages();
477 }
478 }
479 }
480
481 public void findUnsentTextMessages(OnMessageFound onMessageFound) {
482 final ArrayList<Message> results = new ArrayList<>();
483 synchronized (this.messages) {
484 for (Message message : this.messages) {
485 if ((message.getType() == Message.TYPE_TEXT || message.hasFileOnRemoteHost()) && message.getStatus() == Message.STATUS_UNSEND) {
486 results.add(message);
487 }
488 }
489 }
490 for (Message result : results) {
491 onMessageFound.onMessageFound(result);
492 }
493 }
494
495 public Message findSentMessageWithUuidOrRemoteId(String id) {
496 synchronized (this.messages) {
497 for (Message message : this.messages) {
498 if (id.equals(message.getUuid())
499 || (message.getStatus() >= Message.STATUS_SEND
500 && id.equals(message.getRemoteMsgId()))) {
501 return message;
502 }
503 }
504 }
505 return null;
506 }
507
508 public Message findMessageWithRemoteIdAndCounterpart(String id, Jid counterpart) {
509 synchronized (this.messages) {
510 for (int i = this.messages.size() - 1; i >= 0; --i) {
511 final Message message = messages.get(i);
512 final Jid mcp = message.getCounterpart();
513 if (mcp == null && counterpart != null) {
514 continue;
515 }
516 if (counterpart == null || mcp.equals(counterpart) || mcp.asBareJid().equals(counterpart)) {
517 final boolean idMatch = id.equals(message.getUuid()) || id.equals(message.getRemoteMsgId()) || message.remoteMsgIdMatchInEdit(id) || (getMode() == MODE_MULTI && id.equals(message.getServerMsgId()));
518 if (idMatch) return message;
519 }
520 }
521 }
522 return null;
523 }
524
525 public Message findSentMessageWithUuid(String id) {
526 synchronized (this.messages) {
527 for (Message message : this.messages) {
528 if (id.equals(message.getUuid())) {
529 return message;
530 }
531 }
532 }
533 return null;
534 }
535
536 public Message findMessageWithRemoteId(String id, Jid counterpart) {
537 synchronized (this.messages) {
538 for (Message message : this.messages) {
539 if (counterpart.equals(message.getCounterpart())
540 && (id.equals(message.getRemoteMsgId()) || id.equals(message.getUuid()))) {
541 return message;
542 }
543 }
544 }
545 return null;
546 }
547
548 public Message findMessageWithServerMsgId(String id) {
549 synchronized (this.messages) {
550 for (Message message : this.messages) {
551 if (id != null && id.equals(message.getServerMsgId())) {
552 return message;
553 }
554 }
555 }
556 return null;
557 }
558
559 public boolean hasMessageWithCounterpart(Jid counterpart) {
560 synchronized (this.messages) {
561 for (Message message : this.messages) {
562 if (counterpart.equals(message.getCounterpart())) {
563 return true;
564 }
565 }
566 }
567 return false;
568 }
569
570 public Message findMessageReactingTo(String id, Jid reactor) {
571 if (id == null) return null;
572
573 synchronized (this.messages) {
574 for (int i = this.messages.size() - 1; i >= 0; --i) {
575 final Message message = messages.get(i);
576 if (reactor == null && message.getStatus() < Message.STATUS_SEND) continue;
577 if (reactor != null && message.getCounterpart() == null) continue;
578 if (reactor != null && !(message.getCounterpart().equals(reactor) || message.getCounterpart().asBareJid().equals(reactor))) continue;
579
580 final Element r = message.getReactions();
581 if (r != null && r.getAttribute("id") != null && id.equals(r.getAttribute("id"))) {
582 return message;
583 }
584 }
585 }
586 return null;
587 }
588
589 public Set<String> findReactionsTo(String id, Jid reactor) {
590 Set<String> reactionEmoji = new HashSet<>();
591 Message reactM = findMessageReactingTo(id, reactor);
592 Element reactions = reactM == null ? null : reactM.getReactions();
593 if (reactions != null) {
594 for (Element el : reactions.getChildren()) {
595 if (el.getName().equals("reaction") && el.getNamespace().equals("urn:xmpp:reactions:0")) {
596 reactionEmoji.add(el.getContent());
597 }
598 }
599 }
600 return reactionEmoji;
601 }
602
603 public Set<Message> findReplies(String id) {
604 Set<Message> replies = new HashSet<>();
605
606 synchronized (this.messages) {
607 for (int i = this.messages.size() - 1; i >= 0; --i) {
608 final Message message = messages.get(i);
609 if (id.equals(message.getServerMsgId())) break;
610 if (id.equals(message.getUuid())) break;
611 final Element r = message.getReply();
612 if (r != null && r.getAttribute("id") != null && id.equals(r.getAttribute("id"))) {
613 replies.add(message);
614 }
615 }
616 }
617 return replies;
618 }
619
620 public long loadMoreTimestamp() {
621 if (messages.size() < 1) return 0;
622 if (getLockThread() && messages.size() > 5000) return 0;
623
624 if (messages.get(0).getType() == Message.TYPE_STATUS && messages.size() >= 2) {
625 return messages.get(1).getTimeSent();
626 } else {
627 return messages.get(0).getTimeSent();
628 }
629 }
630
631 public void populateWithMessages(final List<Message> messages) {
632 synchronized (this.messages) {
633 messages.clear();
634 messages.addAll(this.messages);
635 }
636 Set<String> extraIds = new HashSet<>();
637 for (ListIterator<Message> iterator = messages.listIterator(messages.size()); iterator.hasPrevious(); ) {
638 Message m = iterator.previous();
639 final Element mthread = m.getThread();
640 if (m.wasMergedIntoPrevious() || (getLockThread() && !extraIds.contains(m.replyId()) && (mthread == null || !mthread.getContent().equals(getThread() == null ? "" : getThread().getContent())))) {
641 iterator.remove();
642 } else if (getLockThread() && mthread != null) {
643 Element reply = m.getReply();
644 if (reply != null && reply.getAttribute("id") != null) extraIds.add(reply.getAttribute("id"));
645 Element reactions = m.getReactions();
646 if (reactions != null && reactions.getAttribute("id") != null) extraIds.add(reactions.getAttribute("id"));
647 }
648 }
649 }
650
651 @Override
652 public boolean isBlocked() {
653 return getContact().isBlocked();
654 }
655
656 @Override
657 public boolean isDomainBlocked() {
658 return getContact().isDomainBlocked();
659 }
660
661 @Override
662 public Jid getBlockedJid() {
663 return getContact().getBlockedJid();
664 }
665
666 public int countMessages() {
667 synchronized (this.messages) {
668 return this.messages.size();
669 }
670 }
671
672 public String getFirstMamReference() {
673 return this.mFirstMamReference;
674 }
675
676 public void setFirstMamReference(String reference) {
677 this.mFirstMamReference = reference;
678 }
679
680 public void setLastClearHistory(long time, String reference) {
681 if (reference != null) {
682 setAttribute(ATTRIBUTE_LAST_CLEAR_HISTORY, time + ":" + reference);
683 } else {
684 setAttribute(ATTRIBUTE_LAST_CLEAR_HISTORY, time);
685 }
686 }
687
688 public MamReference getLastClearHistory() {
689 return MamReference.fromAttribute(getAttribute(ATTRIBUTE_LAST_CLEAR_HISTORY));
690 }
691
692 public List<Jid> getAcceptedCryptoTargets() {
693 if (mode == MODE_SINGLE) {
694 return Collections.singletonList(getJid().asBareJid());
695 } else {
696 return getJidListAttribute(ATTRIBUTE_CRYPTO_TARGETS);
697 }
698 }
699
700 public void setAcceptedCryptoTargets(List<Jid> acceptedTargets) {
701 setAttribute(ATTRIBUTE_CRYPTO_TARGETS, acceptedTargets);
702 }
703
704 public boolean setCorrectingMessage(Message correctingMessage) {
705 setAttribute(ATTRIBUTE_CORRECTING_MESSAGE, correctingMessage == null ? null : correctingMessage.getUuid());
706 return correctingMessage == null && draftMessage != null;
707 }
708
709 public Message getCorrectingMessage() {
710 final String uuid = getAttribute(ATTRIBUTE_CORRECTING_MESSAGE);
711 return uuid == null ? null : findSentMessageWithUuid(uuid);
712 }
713
714 public boolean withSelf() {
715 return getContact().isSelf();
716 }
717
718 @Override
719 public int compareTo(@NonNull Conversation another) {
720 return ComparisonChain.start()
721 .compareFalseFirst(another.getBooleanAttribute(ATTRIBUTE_PINNED_ON_TOP, false), getBooleanAttribute(ATTRIBUTE_PINNED_ON_TOP, false))
722 .compare(another.getSortableTime(), getSortableTime())
723 .result();
724 }
725
726 public long getSortableTime() {
727 Draft draft = getDraft();
728 long messageTime = getLatestMessage().getTimeReceived();
729 if (draft == null) {
730 return messageTime;
731 } else {
732 return Math.max(messageTime, draft.getTimestamp());
733 }
734 }
735
736 public String getDraftMessage() {
737 return draftMessage;
738 }
739
740 public void setDraftMessage(String draftMessage) {
741 this.draftMessage = draftMessage;
742 }
743
744 public Element getThread() {
745 return this.thread;
746 }
747
748 public void setThread(Element thread) {
749 this.thread = thread;
750 }
751
752 public void setLockThread(boolean flag) {
753 this.lockThread = flag;
754 if (flag) setUserSelectedThread(true);
755 }
756
757 public boolean getLockThread() {
758 return this.lockThread;
759 }
760
761 public void setUserSelectedThread(boolean flag) {
762 this.userSelectedThread = flag;
763 }
764
765 public boolean getUserSelectedThread() {
766 return this.userSelectedThread;
767 }
768
769 public void setReplyTo(Message m) {
770 this.replyTo = m;
771 }
772
773 public Message getReplyTo() {
774 return this.replyTo;
775 }
776
777 public boolean isRead() {
778 synchronized (this.messages) {
779 for(final Message message : Lists.reverse(this.messages)) {
780 if (message.isRead() && message.getType() == Message.TYPE_RTP_SESSION) {
781 continue;
782 }
783 return message.isRead();
784 }
785 return true;
786 }
787 }
788
789 public List<Message> markRead(String upToUuid) {
790 final List<Message> unread = new ArrayList<>();
791 synchronized (this.messages) {
792 for (Message message : this.messages) {
793 if (!message.isRead()) {
794 message.markRead();
795 unread.add(message);
796 }
797 if (message.getUuid().equals(upToUuid)) {
798 return unread;
799 }
800 }
801 }
802 return unread;
803 }
804
805 public Message getLatestMessage() {
806 synchronized (this.messages) {
807 if (this.messages.size() == 0) {
808 Message message = new Message(this, "", Message.ENCRYPTION_NONE);
809 message.setType(Message.TYPE_STATUS);
810 message.setTime(Math.max(getCreated(), getLastClearHistory().getTimestamp()));
811 message.setTimeReceived(Math.max(getCreated(), getLastClearHistory().getTimestamp()));
812 return message;
813 } else {
814 return this.messages.get(this.messages.size() - 1);
815 }
816 }
817 }
818
819 public @NonNull
820 CharSequence getName() {
821 if (getMode() == MODE_MULTI) {
822 final String roomName = getMucOptions().getName();
823 final String subject = getMucOptions().getSubject();
824 final Bookmark bookmark = getBookmark();
825 final String bookmarkName = bookmark != null ? bookmark.getBookmarkName() : null;
826 if (printableValue(roomName)) {
827 return roomName;
828 } else if (printableValue(subject)) {
829 return subject;
830 } else if (printableValue(bookmarkName, false)) {
831 return bookmarkName;
832 } else {
833 final String generatedName = getMucOptions().createNameFromParticipants();
834 if (printableValue(generatedName)) {
835 return generatedName;
836 } else {
837 return contactJid.getLocal() != null ? contactJid.getLocal() : contactJid;
838 }
839 }
840 } else if ((QuickConversationsService.isConversations() || !Config.QUICKSY_DOMAIN.equals(contactJid.getDomain())) && isWithStranger()) {
841 return contactJid;
842 } else {
843 return this.getContact().getDisplayName();
844 }
845 }
846
847 public String getAccountUuid() {
848 return this.accountUuid;
849 }
850
851 public Account getAccount() {
852 return this.account;
853 }
854
855 public void setAccount(final Account account) {
856 this.account = account;
857 }
858
859 public Contact getContact() {
860 return this.account.getRoster().getContact(this.contactJid);
861 }
862
863 @Override
864 public Jid getJid() {
865 return this.contactJid;
866 }
867
868 public int getStatus() {
869 return this.status;
870 }
871
872 public void setStatus(int status) {
873 this.status = status;
874 }
875
876 public long getCreated() {
877 return this.created;
878 }
879
880 public ContentValues getContentValues() {
881 ContentValues values = new ContentValues();
882 values.put(UUID, uuid);
883 values.put(NAME, name);
884 values.put(CONTACT, contactUuid);
885 values.put(ACCOUNT, accountUuid);
886 values.put(CONTACTJID, contactJid.toString());
887 values.put(CREATED, created);
888 values.put(STATUS, status);
889 values.put(MODE, mode);
890 synchronized (this.attributes) {
891 values.put(ATTRIBUTES, attributes.toString());
892 }
893 return values;
894 }
895
896 public int getMode() {
897 return this.mode;
898 }
899
900 public void setMode(int mode) {
901 this.mode = mode;
902 }
903
904 /**
905 * short for is Private and Non-anonymous
906 */
907 public boolean isSingleOrPrivateAndNonAnonymous() {
908 return mode == MODE_SINGLE || isPrivateAndNonAnonymous();
909 }
910
911 public boolean isPrivateAndNonAnonymous() {
912 return getMucOptions().isPrivateAndNonAnonymous();
913 }
914
915 public synchronized MucOptions getMucOptions() {
916 if (this.mucOptions == null) {
917 this.mucOptions = new MucOptions(this);
918 }
919 return this.mucOptions;
920 }
921
922 public void resetMucOptions() {
923 this.mucOptions = null;
924 }
925
926 public void setContactJid(final Jid jid) {
927 this.contactJid = jid;
928 }
929
930 public Jid getNextCounterpart() {
931 return this.nextCounterpart;
932 }
933
934 public void setNextCounterpart(Jid jid) {
935 this.nextCounterpart = jid;
936 }
937
938 public int getNextEncryption() {
939 if (!Config.supportOmemo() && !Config.supportOpenPgp()) {
940 return Message.ENCRYPTION_NONE;
941 }
942 if (OmemoSetting.isAlways()) {
943 return suitableForOmemoByDefault(this) ? Message.ENCRYPTION_AXOLOTL : Message.ENCRYPTION_NONE;
944 }
945 final int defaultEncryption;
946 if (suitableForOmemoByDefault(this)) {
947 defaultEncryption = OmemoSetting.getEncryption();
948 } else {
949 defaultEncryption = Message.ENCRYPTION_NONE;
950 }
951 int encryption = this.getIntAttribute(ATTRIBUTE_NEXT_ENCRYPTION, defaultEncryption);
952 if (encryption == Message.ENCRYPTION_OTR || encryption < 0) {
953 return defaultEncryption;
954 } else {
955 return encryption;
956 }
957 }
958
959 public boolean setNextEncryption(int encryption) {
960 return this.setAttribute(ATTRIBUTE_NEXT_ENCRYPTION, encryption);
961 }
962
963 public String getNextMessage() {
964 final String nextMessage = getAttribute(ATTRIBUTE_NEXT_MESSAGE);
965 return nextMessage == null ? "" : nextMessage;
966 }
967
968 public @Nullable
969 Draft getDraft() {
970 long timestamp = getLongAttribute(ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP, 0);
971 if (timestamp > getLatestMessage().getTimeSent()) {
972 String message = getAttribute(ATTRIBUTE_NEXT_MESSAGE);
973 if (!TextUtils.isEmpty(message) && timestamp != 0) {
974 return new Draft(message, timestamp);
975 }
976 }
977 return null;
978 }
979
980 public boolean setNextMessage(final String input) {
981 final String message = input == null || input.trim().isEmpty() ? null : input;
982 boolean changed = !getNextMessage().equals(message);
983 this.setAttribute(ATTRIBUTE_NEXT_MESSAGE, message);
984 if (changed) {
985 this.setAttribute(ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP, message == null ? 0 : System.currentTimeMillis());
986 }
987 return changed;
988 }
989
990 public Bookmark getBookmark() {
991 return this.account.getBookmark(this.contactJid);
992 }
993
994 public Message findDuplicateMessage(Message message) {
995 synchronized (this.messages) {
996 for (int i = this.messages.size() - 1; i >= 0; --i) {
997 if (this.messages.get(i).similar(message)) {
998 return this.messages.get(i);
999 }
1000 }
1001 }
1002 return null;
1003 }
1004
1005 public boolean hasDuplicateMessage(Message message) {
1006 return findDuplicateMessage(message) != null;
1007 }
1008
1009 public Message findSentMessageWithBody(String body) {
1010 synchronized (this.messages) {
1011 for (int i = this.messages.size() - 1; i >= 0; --i) {
1012 Message message = this.messages.get(i);
1013 if (message.getStatus() == Message.STATUS_UNSEND || message.getStatus() == Message.STATUS_SEND) {
1014 String otherBody;
1015 if (message.hasFileOnRemoteHost()) {
1016 otherBody = message.getFileParams().url;
1017 } else {
1018 otherBody = message.body;
1019 }
1020 if (otherBody != null && otherBody.equals(body)) {
1021 return message;
1022 }
1023 }
1024 }
1025 return null;
1026 }
1027 }
1028
1029 public Message findRtpSession(final String sessionId, final int s) {
1030 synchronized (this.messages) {
1031 for (int i = this.messages.size() - 1; i >= 0; --i) {
1032 final Message message = this.messages.get(i);
1033 if ((message.getStatus() == s) && (message.getType() == Message.TYPE_RTP_SESSION) && sessionId.equals(message.getRemoteMsgId())) {
1034 return message;
1035 }
1036 }
1037 }
1038 return null;
1039 }
1040
1041 public boolean possibleDuplicate(final String serverMsgId, final String remoteMsgId) {
1042 if (serverMsgId == null || remoteMsgId == null) {
1043 return false;
1044 }
1045 synchronized (this.messages) {
1046 for (Message message : this.messages) {
1047 if (serverMsgId.equals(message.getServerMsgId()) || remoteMsgId.equals(message.getRemoteMsgId())) {
1048 return true;
1049 }
1050 }
1051 }
1052 return false;
1053 }
1054
1055 public MamReference getLastMessageTransmitted() {
1056 final MamReference lastClear = getLastClearHistory();
1057 MamReference lastReceived = new MamReference(0);
1058 synchronized (this.messages) {
1059 for (int i = this.messages.size() - 1; i >= 0; --i) {
1060 final Message message = this.messages.get(i);
1061 if (message.isPrivateMessage()) {
1062 continue; //it's unsafe to use private messages as anchor. They could be coming from user archive
1063 }
1064 if (message.getStatus() == Message.STATUS_RECEIVED || message.isCarbon() || message.getServerMsgId() != null) {
1065 lastReceived = new MamReference(message.getTimeSent(), message.getServerMsgId());
1066 break;
1067 }
1068 }
1069 }
1070 return MamReference.max(lastClear, lastReceived);
1071 }
1072
1073 public void setMutedTill(long value) {
1074 this.setAttribute(ATTRIBUTE_MUTED_TILL, String.valueOf(value));
1075 }
1076
1077 public boolean isMuted() {
1078 return System.currentTimeMillis() < this.getLongAttribute(ATTRIBUTE_MUTED_TILL, 0);
1079 }
1080
1081 public boolean alwaysNotify() {
1082 return mode == MODE_SINGLE || getBooleanAttribute(ATTRIBUTE_ALWAYS_NOTIFY, Config.ALWAYS_NOTIFY_BY_DEFAULT || isPrivateAndNonAnonymous());
1083 }
1084
1085 public boolean notifyReplies() {
1086 return alwaysNotify() || getBooleanAttribute(ATTRIBUTE_NOTIFY_REPLIES, false);
1087 }
1088
1089 public boolean setAttribute(String key, boolean value) {
1090 return setAttribute(key, String.valueOf(value));
1091 }
1092
1093 private boolean setAttribute(String key, long value) {
1094 return setAttribute(key, Long.toString(value));
1095 }
1096
1097 private boolean setAttribute(String key, int value) {
1098 return setAttribute(key, String.valueOf(value));
1099 }
1100
1101 public boolean setAttribute(String key, String value) {
1102 synchronized (this.attributes) {
1103 try {
1104 if (value == null) {
1105 if (this.attributes.has(key)) {
1106 this.attributes.remove(key);
1107 return true;
1108 } else {
1109 return false;
1110 }
1111 } else {
1112 final String prev = this.attributes.optString(key, null);
1113 this.attributes.put(key, value);
1114 return !value.equals(prev);
1115 }
1116 } catch (JSONException e) {
1117 throw new AssertionError(e);
1118 }
1119 }
1120 }
1121
1122 public boolean setAttribute(String key, List<Jid> jids) {
1123 JSONArray array = new JSONArray();
1124 for (Jid jid : jids) {
1125 array.put(jid.asBareJid().toString());
1126 }
1127 synchronized (this.attributes) {
1128 try {
1129 this.attributes.put(key, array);
1130 return true;
1131 } catch (JSONException e) {
1132 return false;
1133 }
1134 }
1135 }
1136
1137 public String getAttribute(String key) {
1138 synchronized (this.attributes) {
1139 return this.attributes.optString(key, null);
1140 }
1141 }
1142
1143 private List<Jid> getJidListAttribute(String key) {
1144 ArrayList<Jid> list = new ArrayList<>();
1145 synchronized (this.attributes) {
1146 try {
1147 JSONArray array = this.attributes.getJSONArray(key);
1148 for (int i = 0; i < array.length(); ++i) {
1149 try {
1150 list.add(Jid.of(array.getString(i)));
1151 } catch (IllegalArgumentException e) {
1152 //ignored
1153 }
1154 }
1155 } catch (JSONException e) {
1156 //ignored
1157 }
1158 }
1159 return list;
1160 }
1161
1162 private int getIntAttribute(String key, int defaultValue) {
1163 String value = this.getAttribute(key);
1164 if (value == null) {
1165 return defaultValue;
1166 } else {
1167 try {
1168 return Integer.parseInt(value);
1169 } catch (NumberFormatException e) {
1170 return defaultValue;
1171 }
1172 }
1173 }
1174
1175 public long getLongAttribute(String key, long defaultValue) {
1176 String value = this.getAttribute(key);
1177 if (value == null) {
1178 return defaultValue;
1179 } else {
1180 try {
1181 return Long.parseLong(value);
1182 } catch (NumberFormatException e) {
1183 return defaultValue;
1184 }
1185 }
1186 }
1187
1188 public boolean getBooleanAttribute(String key, boolean defaultValue) {
1189 String value = this.getAttribute(key);
1190 if (value == null) {
1191 return defaultValue;
1192 } else {
1193 return Boolean.parseBoolean(value);
1194 }
1195 }
1196
1197 public void add(Message message) {
1198 synchronized (this.messages) {
1199 this.messages.add(message);
1200 }
1201 }
1202
1203 public void prepend(int offset, Message message) {
1204 synchronized (this.messages) {
1205 this.messages.add(Math.min(offset, this.messages.size()), message);
1206 }
1207 }
1208
1209 public void addAll(int index, List<Message> messages) {
1210 synchronized (this.messages) {
1211 this.messages.addAll(index, messages);
1212 }
1213 account.getPgpDecryptionService().decrypt(messages);
1214 }
1215
1216 public void expireOldMessages(long timestamp) {
1217 synchronized (this.messages) {
1218 for (ListIterator<Message> iterator = this.messages.listIterator(); iterator.hasNext(); ) {
1219 if (iterator.next().getTimeSent() < timestamp) {
1220 iterator.remove();
1221 }
1222 }
1223 untieMessages();
1224 }
1225 }
1226
1227 public void sort() {
1228 synchronized (this.messages) {
1229 Collections.sort(this.messages, (left, right) -> {
1230 if (left.getTimeSent() < right.getTimeSent()) {
1231 return -1;
1232 } else if (left.getTimeSent() > right.getTimeSent()) {
1233 return 1;
1234 } else {
1235 return 0;
1236 }
1237 });
1238 untieMessages();
1239 }
1240 }
1241
1242 private void untieMessages() {
1243 for (Message message : this.messages) {
1244 message.untie();
1245 }
1246 }
1247
1248 public int unreadCount() {
1249 synchronized (this.messages) {
1250 int count = 0;
1251 for(final Message message : Lists.reverse(this.messages)) {
1252 if (message.isRead()) {
1253 if (message.getType() == Message.TYPE_RTP_SESSION) {
1254 continue;
1255 }
1256 return count;
1257 }
1258 ++count;
1259 }
1260 return count;
1261 }
1262 }
1263
1264 public int receivedMessagesCount() {
1265 int count = 0;
1266 synchronized (this.messages) {
1267 for (Message message : messages) {
1268 if (message.getStatus() == Message.STATUS_RECEIVED) {
1269 ++count;
1270 }
1271 }
1272 }
1273 return count;
1274 }
1275
1276 public int sentMessagesCount() {
1277 int count = 0;
1278 synchronized (this.messages) {
1279 for (Message message : messages) {
1280 if (message.getStatus() != Message.STATUS_RECEIVED) {
1281 ++count;
1282 }
1283 }
1284 }
1285 return count;
1286 }
1287
1288 public boolean canInferPresence() {
1289 final Contact contact = getContact();
1290 if (contact != null && contact.canInferPresence()) return true;
1291 return sentMessagesCount() > 0;
1292 }
1293
1294 public boolean isWithStranger() {
1295 final Contact contact = getContact();
1296 return mode == MODE_SINGLE
1297 && !contact.isOwnServer()
1298 && !contact.showInContactList()
1299 && !contact.isSelf()
1300 && !(contact.getJid().isDomainJid() && JidHelper.isQuicksyDomain(contact.getJid()))
1301 && sentMessagesCount() == 0;
1302 }
1303
1304 public int getReceivedMessagesCountSinceUuid(String uuid) {
1305 if (uuid == null) {
1306 return 0;
1307 }
1308 int count = 0;
1309 synchronized (this.messages) {
1310 for (int i = messages.size() - 1; i >= 0; i--) {
1311 final Message message = messages.get(i);
1312 if (uuid.equals(message.getUuid())) {
1313 return count;
1314 }
1315 if (message.getStatus() <= Message.STATUS_RECEIVED) {
1316 ++count;
1317 }
1318 }
1319 }
1320 return 0;
1321 }
1322
1323 @Override
1324 public int getAvatarBackgroundColor() {
1325 return UIHelper.getColorForName(getName().toString());
1326 }
1327
1328 @Override
1329 public String getAvatarName() {
1330 return getName().toString();
1331 }
1332
1333 public void setCurrentTab(int tab) {
1334 mCurrentTab = tab;
1335 }
1336
1337 public int getCurrentTab() {
1338 if (mCurrentTab >= 0) return mCurrentTab;
1339
1340 if (!isRead() || getContact().resourceWhichSupport(Namespace.COMMANDS) == null) {
1341 return 0;
1342 }
1343
1344 return 1;
1345 }
1346
1347 public void refreshSessions() {
1348 pagerAdapter.refreshSessions();
1349 }
1350
1351 public void startWebxdc(WebxdcPage page) {
1352 pagerAdapter.startWebxdc(page);
1353 }
1354
1355 public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
1356 pagerAdapter.startCommand(command, xmppConnectionService);
1357 }
1358
1359 public boolean switchToSession(final String node) {
1360 return pagerAdapter.switchToSession(node);
1361 }
1362
1363 public void setupViewPager(ViewPager pager, TabLayout tabs, boolean onboarding, Conversation oldConversation) {
1364 pagerAdapter.setupViewPager(pager, tabs, onboarding, oldConversation);
1365 }
1366
1367 public void showViewPager() {
1368 pagerAdapter.show();
1369 }
1370
1371 public void hideViewPager() {
1372 pagerAdapter.hide();
1373 }
1374
1375 public interface OnMessageFound {
1376 void onMessageFound(final Message message);
1377 }
1378
1379 public static class Draft {
1380 private final String message;
1381 private final long timestamp;
1382
1383 private Draft(String message, long timestamp) {
1384 this.message = message;
1385 this.timestamp = timestamp;
1386 }
1387
1388 public long getTimestamp() {
1389 return timestamp;
1390 }
1391
1392 public String getMessage() {
1393 return message;
1394 }
1395 }
1396
1397 public class ConversationPagerAdapter extends PagerAdapter {
1398 protected ViewPager mPager = null;
1399 protected TabLayout mTabs = null;
1400 ArrayList<ConversationPage> sessions = null;
1401 protected View page1 = null;
1402 protected View page2 = null;
1403 protected boolean mOnboarding = false;
1404
1405 public void setupViewPager(ViewPager pager, TabLayout tabs, boolean onboarding, Conversation oldConversation) {
1406 mPager = pager;
1407 mTabs = tabs;
1408 mOnboarding = onboarding;
1409
1410 if (oldConversation != null) {
1411 oldConversation.pagerAdapter.mPager = null;
1412 oldConversation.pagerAdapter.mTabs = null;
1413 }
1414
1415 if (mPager == null) {
1416 page1 = null;
1417 page2 = null;
1418 return;
1419 }
1420 if (sessions != null) show();
1421
1422 if (pager.getChildAt(0) != null) page1 = pager.getChildAt(0);
1423 if (pager.getChildAt(1) != null) page2 = pager.getChildAt(1);
1424 if (page2 != null && page2.findViewById(R.id.commands_view) == null) {
1425 page1 = null;
1426 page2 = null;
1427 }
1428 if (page1 == null) page1 = oldConversation.pagerAdapter.page1;
1429 if (page2 == null) page2 = oldConversation.pagerAdapter.page2;
1430 if (page1 == null || page2 == null) {
1431 throw new IllegalStateException("page1 or page2 were not present as child or in model?");
1432 }
1433 pager.removeView(page1);
1434 pager.removeView(page2);
1435 pager.setAdapter(this);
1436 tabs.setupWithViewPager(mPager);
1437 pager.post(() -> pager.setCurrentItem(getCurrentTab()));
1438
1439 mPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
1440 public void onPageScrollStateChanged(int state) { }
1441 public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { }
1442
1443 public void onPageSelected(int position) {
1444 setCurrentTab(position);
1445 }
1446 });
1447 }
1448
1449 public void show() {
1450 if (sessions == null) {
1451 sessions = new ArrayList<>();
1452 notifyDataSetChanged();
1453 }
1454 if (!mOnboarding && mTabs != null) mTabs.setVisibility(View.VISIBLE);
1455 }
1456
1457 public void hide() {
1458 if (sessions != null && !sessions.isEmpty()) return; // Do not hide during active session
1459 if (mPager != null) mPager.setCurrentItem(0);
1460 if (mTabs != null) mTabs.setVisibility(View.GONE);
1461 sessions = null;
1462 notifyDataSetChanged();
1463 }
1464
1465 public void refreshSessions() {
1466 if (sessions == null) return;
1467
1468 for (ConversationPage session : sessions) {
1469 session.refresh();
1470 }
1471 }
1472
1473 public void startWebxdc(WebxdcPage page) {
1474 show();
1475 sessions.add(page);
1476 notifyDataSetChanged();
1477 if (mPager != null) mPager.setCurrentItem(getCount() - 1);
1478 }
1479
1480 public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
1481 show();
1482 CommandSession session = new CommandSession(command.getAttribute("name"), command.getAttribute("node"), xmppConnectionService);
1483
1484 final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
1485 packet.setTo(command.getAttributeAsJid("jid"));
1486 final Element c = packet.addChild("command", Namespace.COMMANDS);
1487 c.setAttribute("node", command.getAttribute("node"));
1488 c.setAttribute("action", "execute");
1489
1490 final TimerTask task = new TimerTask() {
1491 @Override
1492 public void run() {
1493 if (getAccount().getStatus() != Account.State.ONLINE) {
1494 final TimerTask self = this;
1495 new Timer().schedule(new TimerTask() {
1496 @Override
1497 public void run() {
1498 self.run();
1499 }
1500 }, 1000);
1501 } else {
1502 xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
1503 session.updateWithResponse(iq);
1504 }, 120L);
1505 }
1506 }
1507 };
1508
1509 if (command.getAttribute("node").equals("jabber:iq:register") && packet.getTo().asBareJid().equals(Jid.of("cheogram.com"))) {
1510 new com.cheogram.android.CheogramLicenseChecker(mPager.getContext(), (signedData, signature) -> {
1511 if (signedData != null && signature != null) {
1512 c.addChild("license", "https://ns.cheogram.com/google-play").setContent(signedData);
1513 c.addChild("licenseSignature", "https://ns.cheogram.com/google-play").setContent(signature);
1514 }
1515
1516 task.run();
1517 }).checkLicense();
1518 } else {
1519 task.run();
1520 }
1521
1522 sessions.add(session);
1523 notifyDataSetChanged();
1524 if (mPager != null) mPager.setCurrentItem(getCount() - 1);
1525 }
1526
1527 public void removeSession(ConversationPage session) {
1528 sessions.remove(session);
1529 notifyDataSetChanged();
1530 if (session instanceof WebxdcPage) mPager.setCurrentItem(0);
1531 }
1532
1533 public boolean switchToSession(final String node) {
1534 if (sessions == null) return false;
1535
1536 int i = 0;
1537 for (ConversationPage session : sessions) {
1538 if (session.getNode().equals(node)) {
1539 if (mPager != null) mPager.setCurrentItem(i + 2);
1540 return true;
1541 }
1542 i++;
1543 }
1544
1545 return false;
1546 }
1547
1548 @NonNull
1549 @Override
1550 public Object instantiateItem(@NonNull ViewGroup container, int position) {
1551 if (position == 0) {
1552 if (page1 != null && page1.getParent() != null) {
1553 ((ViewGroup) page1.getParent()).removeView(page1);
1554 }
1555 container.addView(page1);
1556 return page1;
1557 }
1558 if (position == 1) {
1559 if (page2 != null && page2.getParent() != null) {
1560 ((ViewGroup) page2.getParent()).removeView(page2);
1561 }
1562 container.addView(page2);
1563 return page2;
1564 }
1565
1566 ConversationPage session = sessions.get(position-2);
1567 View v = session.inflateUi(container.getContext(), (s) -> removeSession(s));
1568 if (v != null && v.getParent() != null) {
1569 ((ViewGroup) v.getParent()).removeView(v);
1570 }
1571 container.addView(v);
1572 return session;
1573 }
1574
1575 @Override
1576 public void destroyItem(@NonNull ViewGroup container, int position, Object o) {
1577 if (position < 2) {
1578 container.removeView((View) o);
1579 return;
1580 }
1581
1582 container.removeView(((ConversationPage) o).getView());
1583 }
1584
1585 @Override
1586 public int getItemPosition(Object o) {
1587 if (mPager != null) {
1588 if (o == page1) return PagerAdapter.POSITION_UNCHANGED;
1589 if (o == page2) return PagerAdapter.POSITION_UNCHANGED;
1590 }
1591
1592 int pos = sessions == null ? -1 : sessions.indexOf(o);
1593 if (pos < 0) return PagerAdapter.POSITION_NONE;
1594 return pos + 2;
1595 }
1596
1597 @Override
1598 public int getCount() {
1599 if (sessions == null) return 1;
1600
1601 int count = 2 + sessions.size();
1602 if (mTabs == null) return count;
1603
1604 if (count > 2) {
1605 mTabs.setTabMode(TabLayout.MODE_SCROLLABLE);
1606 } else {
1607 mTabs.setTabMode(TabLayout.MODE_FIXED);
1608 }
1609 return count;
1610 }
1611
1612 @Override
1613 public boolean isViewFromObject(@NonNull View view, @NonNull Object o) {
1614 if (view == o) return true;
1615
1616 if (o instanceof ConversationPage) {
1617 return ((ConversationPage) o).getView() == view;
1618 }
1619
1620 return false;
1621 }
1622
1623 @Nullable
1624 @Override
1625 public CharSequence getPageTitle(int position) {
1626 switch (position) {
1627 case 0:
1628 return "Conversation";
1629 case 1:
1630 return "Commands";
1631 default:
1632 ConversationPage session = sessions.get(position-2);
1633 if (session == null) return super.getPageTitle(position);
1634 return session.getTitle();
1635 }
1636 }
1637
1638 class CommandSession extends RecyclerView.Adapter<CommandSession.ViewHolder> implements ConversationPage {
1639 abstract class ViewHolder<T extends ViewDataBinding> extends RecyclerView.ViewHolder {
1640 protected T binding;
1641
1642 public ViewHolder(T binding) {
1643 super(binding.getRoot());
1644 this.binding = binding;
1645 }
1646
1647 abstract public void bind(Item el);
1648
1649 protected void setTextOrHide(TextView v, Optional<String> s) {
1650 if (s == null || !s.isPresent()) {
1651 v.setVisibility(View.GONE);
1652 } else {
1653 v.setVisibility(View.VISIBLE);
1654 v.setText(s.get());
1655 }
1656 }
1657
1658 protected void setupInputType(Element field, TextView textinput, TextInputLayout layout) {
1659 int flags = 0;
1660 if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_NONE);
1661 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1662
1663 String type = field.getAttribute("type");
1664 if (type != null) {
1665 if (type.equals("text-multi") || type.equals("jid-multi")) {
1666 flags |= InputType.TYPE_TEXT_FLAG_MULTI_LINE;
1667 }
1668
1669 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1670
1671 if (type.equals("jid-single") || type.equals("jid-multi")) {
1672 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
1673 }
1674
1675 if (type.equals("text-private")) {
1676 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
1677 if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_PASSWORD_TOGGLE);
1678 }
1679 }
1680
1681 Element validate = field.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1682 if (validate == null) return;
1683 String datatype = validate.getAttribute("datatype");
1684 if (datatype == null) return;
1685
1686 if (datatype.equals("xs:integer") || datatype.equals("xs:int") || datatype.equals("xs:long") || datatype.equals("xs:short") || datatype.equals("xs:byte")) {
1687 textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED);
1688 }
1689
1690 if (datatype.equals("xs:decimal") || datatype.equals("xs:double")) {
1691 textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED | InputType.TYPE_NUMBER_FLAG_DECIMAL);
1692 }
1693
1694 if (datatype.equals("xs:date")) {
1695 textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_DATE);
1696 }
1697
1698 if (datatype.equals("xs:dateTime")) {
1699 textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_NORMAL);
1700 }
1701
1702 if (datatype.equals("xs:time")) {
1703 textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_TIME);
1704 }
1705
1706 if (datatype.equals("xs:anyURI")) {
1707 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
1708 }
1709
1710 if (datatype.equals("html:tel")) {
1711 textinput.setInputType(flags | InputType.TYPE_CLASS_PHONE);
1712 }
1713
1714 if (datatype.equals("html:email")) {
1715 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
1716 }
1717 }
1718
1719 protected String formatValue(String datatype, String value, boolean compact) {
1720 if ("xs:dateTime".equals(datatype)) {
1721 ZonedDateTime zonedDateTime = null;
1722 try {
1723 zonedDateTime = ZonedDateTime.parse(value, DateTimeFormatter.ISO_DATE_TIME);
1724 } catch (final DateTimeParseException e) {
1725 try {
1726 DateTimeFormatter almostIso = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm[:ss] X");
1727 zonedDateTime = ZonedDateTime.parse(value, almostIso);
1728 } catch (final DateTimeParseException e2) { }
1729 }
1730 if (zonedDateTime == null) return value;
1731 ZonedDateTime localZonedDateTime = zonedDateTime.withZoneSameInstant(ZoneId.systemDefault());
1732 DateTimeFormatter outputFormat = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT);
1733 return localZonedDateTime.toLocalDateTime().format(outputFormat);
1734 }
1735
1736 if ("html:tel".equals(datatype) && !compact) {
1737 return PhoneNumberUtils.formatNumber(value, value, null);
1738 }
1739
1740 return value;
1741 }
1742 }
1743
1744 class ErrorViewHolder extends ViewHolder<CommandNoteBinding> {
1745 public ErrorViewHolder(CommandNoteBinding binding) { super(binding); }
1746
1747 @Override
1748 public void bind(Item iq) {
1749 binding.errorIcon.setVisibility(View.VISIBLE);
1750
1751 Element error = iq.el.findChild("error");
1752 if (error == null) return;
1753 String text = error.findChildContent("text", "urn:ietf:params:xml:ns:xmpp-stanzas");
1754 if (text == null || text.equals("")) {
1755 text = error.getChildren().get(0).getName();
1756 }
1757 binding.message.setText(text);
1758 }
1759 }
1760
1761 class NoteViewHolder extends ViewHolder<CommandNoteBinding> {
1762 public NoteViewHolder(CommandNoteBinding binding) { super(binding); }
1763
1764 @Override
1765 public void bind(Item note) {
1766 binding.message.setText(note.el.getContent());
1767
1768 String type = note.el.getAttribute("type");
1769 if (type != null && type.equals("error")) {
1770 binding.errorIcon.setVisibility(View.VISIBLE);
1771 }
1772 }
1773 }
1774
1775 class ResultFieldViewHolder extends ViewHolder<CommandResultFieldBinding> {
1776 public ResultFieldViewHolder(CommandResultFieldBinding binding) { super(binding); }
1777
1778 @Override
1779 public void bind(Item item) {
1780 Field field = (Field) item;
1781 setTextOrHide(binding.label, field.getLabel());
1782 setTextOrHide(binding.desc, field.getDesc());
1783
1784 Element media = field.el.findChild("media", "urn:xmpp:media-element");
1785 if (media == null) {
1786 binding.mediaImage.setVisibility(View.GONE);
1787 } else {
1788 final LruCache<String, Drawable> cache = xmppConnectionService.getDrawableCache();
1789 final HttpConnectionManager httpManager = xmppConnectionService.getHttpConnectionManager();
1790 for (Element uriEl : media.getChildren()) {
1791 if (!"uri".equals(uriEl.getName())) continue;
1792 if (!"urn:xmpp:media-element".equals(uriEl.getNamespace())) continue;
1793 String mimeType = uriEl.getAttribute("type");
1794 String uriS = uriEl.getContent();
1795 if (mimeType == null || uriS == null) continue;
1796 Uri uri = Uri.parse(uriS);
1797 if (mimeType.startsWith("image/") && "https".equals(uri.getScheme())) {
1798 final Drawable d = cache.get(uri.toString());
1799 if (d == null) {
1800 synchronized (CommandSession.this) {
1801 waitingForRefresh = true;
1802 }
1803 int size = (int)(xmppConnectionService.getResources().getDisplayMetrics().density * 288);
1804 Message dummy = new Message(Conversation.this, uri.toString(), Message.ENCRYPTION_NONE);
1805 dummy.setFileParams(new Message.FileParams(uri.toString()));
1806 httpManager.createNewDownloadConnection(dummy, true, (file) -> {
1807 if (file == null) {
1808 dummy.getTransferable().start();
1809 } else {
1810 try {
1811 xmppConnectionService.getFileBackend().getThumbnail(file, xmppConnectionService.getResources(), size, false, uri.toString());
1812 } catch (final Exception e) { }
1813 }
1814 });
1815 } else {
1816 binding.mediaImage.setImageDrawable(d);
1817 binding.mediaImage.setVisibility(View.VISIBLE);
1818 }
1819 }
1820 }
1821 }
1822
1823 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1824 String datatype = validate == null ? null : validate.getAttribute("datatype");
1825
1826 ArrayAdapter<Option> values = new ArrayAdapter<>(binding.getRoot().getContext(), R.layout.simple_list_item);
1827 for (Element el : field.el.getChildren()) {
1828 if (el.getName().equals("value") && el.getNamespace().equals("jabber:x:data")) {
1829 values.add(new Option(el.getContent(), formatValue(datatype, el.getContent(), false)));
1830 }
1831 }
1832 binding.values.setAdapter(values);
1833 Util.justifyListViewHeightBasedOnChildren(binding.values);
1834
1835 if (field.getType().equals(Optional.of("jid-single")) || field.getType().equals(Optional.of("jid-multi"))) {
1836 binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
1837 new FixedURLSpan("xmpp:" + Jid.ofEscaped(values.getItem(pos).getValue()).toEscapedString(), account).onClick(binding.values);
1838 });
1839 } else if ("xs:anyURI".equals(datatype)) {
1840 binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
1841 new FixedURLSpan(values.getItem(pos).getValue(), account).onClick(binding.values);
1842 });
1843 } else if ("html:tel".equals(datatype)) {
1844 binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
1845 try {
1846 new FixedURLSpan("tel:" + PhoneNumberUtilWrapper.normalize(binding.getRoot().getContext(), values.getItem(pos).getValue()), account).onClick(binding.values);
1847 } catch (final IllegalArgumentException | NumberParseException | NullPointerException e) { }
1848 });
1849 }
1850
1851 binding.values.setOnItemLongClickListener((arg0, arg1, pos, id) -> {
1852 if (ShareUtil.copyTextToClipboard(binding.getRoot().getContext(), values.getItem(pos).getValue(), R.string.message)) {
1853 Toast.makeText(binding.getRoot().getContext(), R.string.message_copied_to_clipboard, Toast.LENGTH_SHORT).show();
1854 }
1855 return true;
1856 });
1857 }
1858 }
1859
1860 class ResultCellViewHolder extends ViewHolder<CommandResultCellBinding> {
1861 public ResultCellViewHolder(CommandResultCellBinding binding) { super(binding); }
1862
1863 @Override
1864 public void bind(Item item) {
1865 Cell cell = (Cell) item;
1866
1867 if (cell.el == null) {
1868 binding.text.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Subhead);
1869 setTextOrHide(binding.text, cell.reported.getLabel());
1870 } else {
1871 Element validate = cell.reported.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1872 String datatype = validate == null ? null : validate.getAttribute("datatype");
1873 String value = formatValue(datatype, cell.el.findChildContent("value", "jabber:x:data"), true);
1874 SpannableStringBuilder text = new SpannableStringBuilder(value == null ? "" : value);
1875 if (cell.reported.getType().equals(Optional.of("jid-single"))) {
1876 text.setSpan(new FixedURLSpan("xmpp:" + Jid.ofEscaped(text.toString()).toEscapedString(), account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1877 } else if ("xs:anyURI".equals(datatype)) {
1878 text.setSpan(new FixedURLSpan(text.toString(), account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1879 } else if ("html:tel".equals(datatype)) {
1880 try {
1881 text.setSpan(new FixedURLSpan("tel:" + PhoneNumberUtilWrapper.normalize(binding.getRoot().getContext(), text.toString()), account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1882 } catch (final IllegalArgumentException | NumberParseException | NullPointerException e) { }
1883 }
1884
1885 binding.text.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Body1);
1886 binding.text.setText(text);
1887
1888 BetterLinkMovementMethod method = BetterLinkMovementMethod.newInstance();
1889 method.setOnLinkLongClickListener((tv, url) -> {
1890 tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
1891 ShareUtil.copyLinkToClipboard(binding.getRoot().getContext(), url);
1892 return true;
1893 });
1894 binding.text.setMovementMethod(method);
1895 }
1896 }
1897 }
1898
1899 class ItemCardViewHolder extends ViewHolder<CommandItemCardBinding> {
1900 public ItemCardViewHolder(CommandItemCardBinding binding) { super(binding); }
1901
1902 @Override
1903 public void bind(Item item) {
1904 binding.fields.removeAllViews();
1905
1906 for (Field field : reported) {
1907 CommandResultFieldBinding row = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.command_result_field, binding.fields, false);
1908 GridLayout.LayoutParams param = new GridLayout.LayoutParams();
1909 param.columnSpec = GridLayout.spec(GridLayout.UNDEFINED, GridLayout.FILL, 1f);
1910 param.width = 0;
1911 row.getRoot().setLayoutParams(param);
1912 binding.fields.addView(row.getRoot());
1913 for (Element el : item.el.getChildren()) {
1914 if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data") && el.getAttribute("var") != null && el.getAttribute("var").equals(field.getVar())) {
1915 for (String label : field.getLabel().asSet()) {
1916 el.setAttribute("label", label);
1917 }
1918 for (String desc : field.getDesc().asSet()) {
1919 el.setAttribute("desc", desc);
1920 }
1921 for (String type : field.getType().asSet()) {
1922 el.setAttribute("type", type);
1923 }
1924 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1925 if (validate != null) el.addChild(validate);
1926 new ResultFieldViewHolder(row).bind(new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), -1));
1927 }
1928 }
1929 }
1930 }
1931 }
1932
1933 class CheckboxFieldViewHolder extends ViewHolder<CommandCheckboxFieldBinding> implements CompoundButton.OnCheckedChangeListener {
1934 public CheckboxFieldViewHolder(CommandCheckboxFieldBinding binding) {
1935 super(binding);
1936 binding.row.setOnClickListener((v) -> {
1937 binding.checkbox.toggle();
1938 });
1939 binding.checkbox.setOnCheckedChangeListener(this);
1940 }
1941 protected Element mValue = null;
1942
1943 @Override
1944 public void bind(Item item) {
1945 Field field = (Field) item;
1946 binding.label.setText(field.getLabel().or(""));
1947 setTextOrHide(binding.desc, field.getDesc());
1948 mValue = field.getValue();
1949 binding.checkbox.setChecked(mValue.getContent() != null && (mValue.getContent().equals("true") || mValue.getContent().equals("1")));
1950 }
1951
1952 @Override
1953 public void onCheckedChanged(CompoundButton checkbox, boolean isChecked) {
1954 if (mValue == null) return;
1955
1956 mValue.setContent(isChecked ? "true" : "false");
1957 }
1958 }
1959
1960 class SearchListFieldViewHolder extends ViewHolder<CommandSearchListFieldBinding> implements TextWatcher {
1961 public SearchListFieldViewHolder(CommandSearchListFieldBinding binding) {
1962 super(binding);
1963 binding.search.addTextChangedListener(this);
1964 }
1965 protected Element mValue = null;
1966 List<Option> options = new ArrayList<>();
1967 protected ArrayAdapter<Option> adapter;
1968 protected boolean open;
1969
1970 @Override
1971 public void bind(Item item) {
1972 Field field = (Field) item;
1973 setTextOrHide(binding.label, field.getLabel());
1974 setTextOrHide(binding.desc, field.getDesc());
1975
1976 if (field.error != null) {
1977 binding.desc.setVisibility(View.VISIBLE);
1978 binding.desc.setText(field.error);
1979 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
1980 } else {
1981 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
1982 }
1983
1984 mValue = field.getValue();
1985
1986 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1987 open = validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null;
1988 setupInputType(field.el, binding.search, null);
1989
1990 options = field.getOptions();
1991 binding.list.setOnItemClickListener((parent, view, position, id) -> {
1992 mValue.setContent(adapter.getItem(binding.list.getCheckedItemPosition()).getValue());
1993 if (open) binding.search.setText(mValue.getContent());
1994 });
1995 search("");
1996 }
1997
1998 @Override
1999 public void afterTextChanged(Editable s) {
2000 if (open) mValue.setContent(s.toString());
2001 search(s.toString());
2002 }
2003
2004 @Override
2005 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2006
2007 @Override
2008 public void onTextChanged(CharSequence s, int start, int count, int after) { }
2009
2010 protected void search(String s) {
2011 List<Option> filteredOptions;
2012 final String q = s.replaceAll("\\W", "").toLowerCase();
2013 if (q == null || q.equals("")) {
2014 filteredOptions = options;
2015 } else {
2016 filteredOptions = options.stream().filter(o -> o.toString().replaceAll("\\W", "").toLowerCase().contains(q)).collect(Collectors.toList());
2017 }
2018 adapter = new ArrayAdapter(binding.getRoot().getContext(), R.layout.simple_list_item, filteredOptions);
2019 binding.list.setAdapter(adapter);
2020
2021 int checkedPos = filteredOptions.indexOf(new Option(mValue.getContent(), ""));
2022 if (checkedPos >= 0) binding.list.setItemChecked(checkedPos, true);
2023 }
2024 }
2025
2026 class RadioEditFieldViewHolder extends ViewHolder<CommandRadioEditFieldBinding> implements CompoundButton.OnCheckedChangeListener, TextWatcher {
2027 public RadioEditFieldViewHolder(CommandRadioEditFieldBinding binding) {
2028 super(binding);
2029 binding.open.addTextChangedListener(this);
2030 options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.radio_grid_item) {
2031 @Override
2032 public View getView(int position, View convertView, ViewGroup parent) {
2033 CompoundButton v = (CompoundButton) super.getView(position, convertView, parent);
2034 v.setId(position);
2035 v.setChecked(getItem(position).getValue().equals(mValue.getContent()));
2036 v.setOnCheckedChangeListener(RadioEditFieldViewHolder.this);
2037 return v;
2038 }
2039 };
2040 }
2041 protected Element mValue = null;
2042 protected ArrayAdapter<Option> options;
2043
2044 @Override
2045 public void bind(Item item) {
2046 Field field = (Field) item;
2047 setTextOrHide(binding.label, field.getLabel());
2048 setTextOrHide(binding.desc, field.getDesc());
2049
2050 if (field.error != null) {
2051 binding.desc.setVisibility(View.VISIBLE);
2052 binding.desc.setText(field.error);
2053 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
2054 } else {
2055 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
2056 }
2057
2058 mValue = field.getValue();
2059
2060 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2061 binding.open.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
2062 binding.open.setText(mValue.getContent());
2063 setupInputType(field.el, binding.open, null);
2064
2065 options.clear();
2066 List<Option> theOptions = field.getOptions();
2067 options.addAll(theOptions);
2068
2069 float screenWidth = binding.getRoot().getContext().getResources().getDisplayMetrics().widthPixels;
2070 TextPaint paint = ((TextView) LayoutInflater.from(binding.getRoot().getContext()).inflate(R.layout.radio_grid_item, null)).getPaint();
2071 float maxColumnWidth = theOptions.stream().map((x) ->
2072 StaticLayout.getDesiredWidth(x.toString(), paint)
2073 ).max(Float::compare).orElse(new Float(0.0));
2074 if (maxColumnWidth * theOptions.size() < 0.90 * screenWidth) {
2075 binding.radios.setNumColumns(theOptions.size());
2076 } else if (maxColumnWidth * (theOptions.size() / 2) < 0.90 * screenWidth) {
2077 binding.radios.setNumColumns(theOptions.size() / 2);
2078 } else {
2079 binding.radios.setNumColumns(1);
2080 }
2081 binding.radios.setAdapter(options);
2082 }
2083
2084 @Override
2085 public void onCheckedChanged(CompoundButton radio, boolean isChecked) {
2086 if (mValue == null) return;
2087
2088 if (isChecked) {
2089 mValue.setContent(options.getItem(radio.getId()).getValue());
2090 binding.open.setText(mValue.getContent());
2091 }
2092 options.notifyDataSetChanged();
2093 }
2094
2095 @Override
2096 public void afterTextChanged(Editable s) {
2097 if (mValue == null) return;
2098
2099 mValue.setContent(s.toString());
2100 options.notifyDataSetChanged();
2101 }
2102
2103 @Override
2104 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2105
2106 @Override
2107 public void onTextChanged(CharSequence s, int start, int count, int after) { }
2108 }
2109
2110 class SpinnerFieldViewHolder extends ViewHolder<CommandSpinnerFieldBinding> implements AdapterView.OnItemSelectedListener {
2111 public SpinnerFieldViewHolder(CommandSpinnerFieldBinding binding) {
2112 super(binding);
2113 binding.spinner.setOnItemSelectedListener(this);
2114 }
2115 protected Element mValue = null;
2116
2117 @Override
2118 public void bind(Item item) {
2119 Field field = (Field) item;
2120 setTextOrHide(binding.label, field.getLabel());
2121 binding.spinner.setPrompt(field.getLabel().or(""));
2122 setTextOrHide(binding.desc, field.getDesc());
2123
2124 mValue = field.getValue();
2125
2126 ArrayAdapter<Option> options = new ArrayAdapter<Option>(binding.getRoot().getContext(), android.R.layout.simple_spinner_item);
2127 options.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
2128 options.addAll(field.getOptions());
2129
2130 binding.spinner.setAdapter(options);
2131 binding.spinner.setSelection(options.getPosition(new Option(mValue.getContent(), null)));
2132 }
2133
2134 @Override
2135 public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
2136 Option o = (Option) parent.getItemAtPosition(pos);
2137 if (mValue == null) return;
2138
2139 mValue.setContent(o == null ? "" : o.getValue());
2140 }
2141
2142 @Override
2143 public void onNothingSelected(AdapterView<?> parent) {
2144 mValue.setContent("");
2145 }
2146 }
2147
2148 class ButtonGridFieldViewHolder extends ViewHolder<CommandButtonGridFieldBinding> {
2149 public ButtonGridFieldViewHolder(CommandButtonGridFieldBinding binding) {
2150 super(binding);
2151 options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.button_grid_item) {
2152 @Override
2153 public View getView(int position, View convertView, ViewGroup parent) {
2154 Button v = (Button) super.getView(position, convertView, parent);
2155 v.setOnClickListener((view) -> {
2156 mValue.setContent(getItem(position).getValue());
2157 execute();
2158 loading = true;
2159 });
2160
2161 final SVG icon = getItem(position).getIcon();
2162 if (icon != null) {
2163 v.post(() -> {
2164 if (v.getHeight() == 0) return;
2165 icon.setDocumentPreserveAspectRatio(com.caverock.androidsvg.PreserveAspectRatio.TOP);
2166 Bitmap bitmap = Bitmap.createBitmap(v.getHeight(), v.getHeight(), Bitmap.Config.ARGB_8888);
2167 Canvas bmcanvas = new Canvas(bitmap);
2168 icon.renderToCanvas(bmcanvas);
2169 v.setCompoundDrawablesRelativeWithIntrinsicBounds(new BitmapDrawable(bitmap), null, null, null);
2170 });
2171 }
2172
2173 return v;
2174 }
2175 };
2176 }
2177 protected Element mValue = null;
2178 protected ArrayAdapter<Option> options;
2179 protected Option defaultOption = null;
2180
2181 @Override
2182 public void bind(Item item) {
2183 Field field = (Field) item;
2184 setTextOrHide(binding.label, field.getLabel());
2185 setTextOrHide(binding.desc, field.getDesc());
2186
2187 if (field.error != null) {
2188 binding.desc.setVisibility(View.VISIBLE);
2189 binding.desc.setText(field.error);
2190 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
2191 } else {
2192 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
2193 }
2194
2195 mValue = field.getValue();
2196
2197 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2198 binding.openButton.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
2199 binding.openButton.setOnClickListener((view) -> {
2200 AlertDialog.Builder builder = new AlertDialog.Builder(binding.getRoot().getContext());
2201 DialogQuickeditBinding dialogBinding = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.dialog_quickedit, null, false);
2202 builder.setPositiveButton(R.string.action_execute, null);
2203 if (field.getDesc().isPresent()) {
2204 dialogBinding.inputLayout.setHint(field.getDesc().get());
2205 }
2206 dialogBinding.inputEditText.requestFocus();
2207 dialogBinding.inputEditText.getText().append(mValue.getContent());
2208 builder.setView(dialogBinding.getRoot());
2209 builder.setNegativeButton(R.string.cancel, null);
2210 final AlertDialog dialog = builder.create();
2211 dialog.setOnShowListener(d -> SoftKeyboardUtils.showKeyboard(dialogBinding.inputEditText));
2212 dialog.show();
2213 View.OnClickListener clickListener = v -> {
2214 String value = dialogBinding.inputEditText.getText().toString();
2215 mValue.setContent(value);
2216 SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2217 dialog.dismiss();
2218 execute();
2219 loading = true;
2220 };
2221 dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener(clickListener);
2222 dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setOnClickListener((v -> {
2223 SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2224 dialog.dismiss();
2225 }));
2226 dialog.setCanceledOnTouchOutside(false);
2227 dialog.setOnDismissListener(dialog1 -> {
2228 SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2229 });
2230 });
2231
2232 options.clear();
2233 List<Option> theOptions = field.getType().equals(Optional.of("boolean")) ? new ArrayList<>(List.of(new Option("false", binding.getRoot().getContext().getString(R.string.no)), new Option("true", binding.getRoot().getContext().getString(R.string.yes)))) : field.getOptions();
2234
2235 defaultOption = null;
2236 for (Option option : theOptions) {
2237 if (option.getValue().equals(mValue.getContent())) {
2238 defaultOption = option;
2239 break;
2240 }
2241 }
2242 if (defaultOption == null && !mValue.getContent().equals("")) {
2243 // Synthesize default option for custom value
2244 defaultOption = new Option(mValue.getContent(), mValue.getContent());
2245 }
2246 if (defaultOption == null) {
2247 binding.defaultButton.setVisibility(View.GONE);
2248 } else {
2249 theOptions.remove(defaultOption);
2250 binding.defaultButton.setVisibility(View.VISIBLE);
2251
2252 final SVG defaultIcon = defaultOption.getIcon();
2253 if (defaultIcon != null) {
2254 defaultIcon.setDocumentPreserveAspectRatio(com.caverock.androidsvg.PreserveAspectRatio.TOP);
2255 DisplayMetrics display = mPager.getContext().getResources().getDisplayMetrics();
2256 Bitmap bitmap = Bitmap.createBitmap((int)(display.heightPixels*display.density/4), (int)(display.heightPixels*display.density/4), Bitmap.Config.ARGB_8888);
2257 bitmap.setDensity(display.densityDpi);
2258 Canvas bmcanvas = new Canvas(bitmap);
2259 defaultIcon.renderToCanvas(bmcanvas);
2260 binding.defaultButton.setCompoundDrawablesRelativeWithIntrinsicBounds(null, new BitmapDrawable(bitmap), null, null);
2261 }
2262
2263 binding.defaultButton.setText(defaultOption.toString());
2264 binding.defaultButton.setOnClickListener((view) -> {
2265 mValue.setContent(defaultOption.getValue());
2266 execute();
2267 loading = true;
2268 });
2269 }
2270
2271 options.addAll(theOptions);
2272 binding.buttons.setAdapter(options);
2273 }
2274 }
2275
2276 class TextFieldViewHolder extends ViewHolder<CommandTextFieldBinding> implements TextWatcher {
2277 public TextFieldViewHolder(CommandTextFieldBinding binding) {
2278 super(binding);
2279 binding.textinput.addTextChangedListener(this);
2280 }
2281 protected Field field = null;
2282
2283 @Override
2284 public void bind(Item item) {
2285 field = (Field) item;
2286 binding.textinputLayout.setHint(field.getLabel().or(""));
2287
2288 binding.textinputLayout.setHelperTextEnabled(field.getDesc().isPresent());
2289 for (String desc : field.getDesc().asSet()) {
2290 binding.textinputLayout.setHelperText(desc);
2291 }
2292
2293 binding.textinputLayout.setErrorEnabled(field.error != null);
2294 if (field.error != null) binding.textinputLayout.setError(field.error);
2295
2296 binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_GRAVITY);
2297 String suffixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/suffix-label");
2298 if (suffixLabel == null) {
2299 binding.textinputLayout.setSuffixText("");
2300 } else {
2301 binding.textinputLayout.setSuffixText(suffixLabel);
2302 binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_TEXT_END);
2303 }
2304
2305 String prefixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/prefix-label");
2306 binding.textinputLayout.setPrefixText(prefixLabel == null ? "" : prefixLabel);
2307
2308 binding.textinput.setText(String.join("\n", field.getValues()));
2309 setupInputType(field.el, binding.textinput, binding.textinputLayout);
2310 }
2311
2312 @Override
2313 public void afterTextChanged(Editable s) {
2314 if (field == null) return;
2315
2316 field.setValues(List.of(s.toString().split("\n")));
2317 }
2318
2319 @Override
2320 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2321
2322 @Override
2323 public void onTextChanged(CharSequence s, int start, int count, int after) { }
2324 }
2325
2326 class WebViewHolder extends ViewHolder<CommandWebviewBinding> {
2327 public WebViewHolder(CommandWebviewBinding binding) { super(binding); }
2328 protected String boundUrl = "";
2329
2330 @Override
2331 public void bind(Item oob) {
2332 setTextOrHide(binding.desc, Optional.fromNullable(oob.el.findChildContent("desc", "jabber:x:oob")));
2333 binding.webview.getSettings().setJavaScriptEnabled(true);
2334 binding.webview.getSettings().setUserAgentString("Mozilla/5.0 (Linux; Android 11) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.106 Mobile Safari/537.36");
2335 binding.webview.getSettings().setDatabaseEnabled(true);
2336 binding.webview.getSettings().setDomStorageEnabled(true);
2337 binding.webview.setWebChromeClient(new WebChromeClient() {
2338 @Override
2339 public void onProgressChanged(WebView view, int newProgress) {
2340 binding.progressbar.setVisibility(newProgress < 100 ? View.VISIBLE : View.GONE);
2341 binding.progressbar.setProgress(newProgress);
2342 }
2343 });
2344 binding.webview.setWebViewClient(new WebViewClient() {
2345 @Override
2346 public void onPageFinished(WebView view, String url) {
2347 super.onPageFinished(view, url);
2348 mTitle = view.getTitle();
2349 ConversationPagerAdapter.this.notifyDataSetChanged();
2350 }
2351 });
2352 final String url = oob.el.findChildContent("url", "jabber:x:oob");
2353 if (!boundUrl.equals(url)) {
2354 binding.webview.addJavascriptInterface(new JsObject(), "xmpp_xep0050");
2355 binding.webview.loadUrl(url);
2356 boundUrl = url;
2357 }
2358 }
2359
2360 class JsObject {
2361 @JavascriptInterface
2362 public void execute() { execute("execute"); }
2363
2364 @JavascriptInterface
2365 public void execute(String action) {
2366 getView().post(() -> {
2367 actionToWebview = null;
2368 if(CommandSession.this.execute(action)) {
2369 removeSession(CommandSession.this);
2370 }
2371 });
2372 }
2373
2374 @JavascriptInterface
2375 public void preventDefault() {
2376 actionToWebview = binding.webview;
2377 }
2378 }
2379 }
2380
2381 class ProgressBarViewHolder extends ViewHolder<CommandProgressBarBinding> {
2382 public ProgressBarViewHolder(CommandProgressBarBinding binding) { super(binding); }
2383
2384 @Override
2385 public void bind(Item item) {
2386 binding.text.setVisibility(loadingHasBeenLong ? View.VISIBLE : View.GONE);
2387 }
2388 }
2389
2390 class Item {
2391 protected Element el;
2392 protected int viewType;
2393 protected String error = null;
2394
2395 Item(Element el, int viewType) {
2396 this.el = el;
2397 this.viewType = viewType;
2398 }
2399
2400 public boolean validate() {
2401 error = null;
2402 return true;
2403 }
2404 }
2405
2406 class Field extends Item {
2407 Field(eu.siacs.conversations.xmpp.forms.Field el, int viewType) { super(el, viewType); }
2408
2409 @Override
2410 public boolean validate() {
2411 if (!super.validate()) return false;
2412 if (el.findChild("required", "jabber:x:data") == null) return true;
2413 if (getValue().getContent() != null && !getValue().getContent().equals("")) return true;
2414
2415 error = "this value is required";
2416 return false;
2417 }
2418
2419 public String getVar() {
2420 return el.getAttribute("var");
2421 }
2422
2423 public Optional<String> getType() {
2424 return Optional.fromNullable(el.getAttribute("type"));
2425 }
2426
2427 public Optional<String> getLabel() {
2428 String label = el.getAttribute("label");
2429 if (label == null) label = getVar();
2430 return Optional.fromNullable(label);
2431 }
2432
2433 public Optional<String> getDesc() {
2434 return Optional.fromNullable(el.findChildContent("desc", "jabber:x:data"));
2435 }
2436
2437 public Element getValue() {
2438 Element value = el.findChild("value", "jabber:x:data");
2439 if (value == null) {
2440 value = el.addChild("value", "jabber:x:data");
2441 }
2442 return value;
2443 }
2444
2445 public void setValues(List<String> values) {
2446 for(Element child : el.getChildren()) {
2447 if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
2448 el.removeChild(child);
2449 }
2450 }
2451
2452 for (String value : values) {
2453 el.addChild("value", "jabber:x:data").setContent(value);
2454 }
2455 }
2456
2457 public List<String> getValues() {
2458 List<String> values = new ArrayList<>();
2459 for(Element child : el.getChildren()) {
2460 if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
2461 values.add(child.getContent());
2462 }
2463 }
2464 return values;
2465 }
2466
2467 public List<Option> getOptions() {
2468 return Option.forField(el);
2469 }
2470 }
2471
2472 class Cell extends Item {
2473 protected Field reported;
2474
2475 Cell(Field reported, Element item) {
2476 super(item, TYPE_RESULT_CELL);
2477 this.reported = reported;
2478 }
2479 }
2480
2481 protected Field mkField(Element el) {
2482 int viewType = -1;
2483
2484 String formType = responseElement.getAttribute("type");
2485 if (formType != null) {
2486 String fieldType = el.getAttribute("type");
2487 if (fieldType == null) fieldType = "text-single";
2488
2489 if (formType.equals("result") || fieldType.equals("fixed")) {
2490 viewType = TYPE_RESULT_FIELD;
2491 } else if (formType.equals("form")) {
2492 if (fieldType.equals("boolean")) {
2493 if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 1) {
2494 viewType = TYPE_BUTTON_GRID_FIELD;
2495 } else {
2496 viewType = TYPE_CHECKBOX_FIELD;
2497 }
2498 } else if (fieldType.equals("list-single")) {
2499 Element validate = el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2500 if (Option.forField(el).size() > 9) {
2501 viewType = TYPE_SEARCH_LIST_FIELD;
2502 } else if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 1) {
2503 viewType = TYPE_BUTTON_GRID_FIELD;
2504 } else if (el.findChild("value", "jabber:x:data") == null || (validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null)) {
2505 viewType = TYPE_RADIO_EDIT_FIELD;
2506 } else {
2507 viewType = TYPE_SPINNER_FIELD;
2508 }
2509 } else {
2510 viewType = TYPE_TEXT_FIELD;
2511 }
2512 }
2513
2514 return new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), viewType);
2515 }
2516
2517 return null;
2518 }
2519
2520 protected Item mkItem(Element el, int pos) {
2521 int viewType = -1;
2522
2523 if (response != null && response.getType() == IqPacket.TYPE.RESULT) {
2524 if (el.getName().equals("note")) {
2525 viewType = TYPE_NOTE;
2526 } else if (el.getNamespace().equals("jabber:x:oob")) {
2527 viewType = TYPE_WEB;
2528 } else if (el.getName().equals("instructions") && el.getNamespace().equals("jabber:x:data")) {
2529 viewType = TYPE_NOTE;
2530 } else if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data")) {
2531 Field field = mkField(el);
2532 if (field != null) {
2533 items.put(pos, field);
2534 return field;
2535 }
2536 }
2537 } else if (response != null) {
2538 viewType = TYPE_ERROR;
2539 }
2540
2541 Item item = new Item(el, viewType);
2542 items.put(pos, item);
2543 return item;
2544 }
2545
2546 class ActionsAdapter extends ArrayAdapter<Pair<String, String>> {
2547 protected Context ctx;
2548
2549 public ActionsAdapter(Context ctx) {
2550 super(ctx, R.layout.simple_list_item);
2551 this.ctx = ctx;
2552 }
2553
2554 @Override
2555 public View getView(int position, View convertView, ViewGroup parent) {
2556 View v = super.getView(position, convertView, parent);
2557 TextView tv = (TextView) v.findViewById(android.R.id.text1);
2558 tv.setGravity(Gravity.CENTER);
2559 tv.setText(getItem(position).second);
2560 int resId = ctx.getResources().getIdentifier("action_" + getItem(position).first, "string" , ctx.getPackageName());
2561 if (resId != 0 && getItem(position).second.equals(getItem(position).first)) tv.setText(ctx.getResources().getString(resId));
2562 tv.setTextColor(ContextCompat.getColor(ctx, R.color.white));
2563 tv.setBackgroundColor(UIHelper.getColorForName(getItem(position).first));
2564 return v;
2565 }
2566
2567 public int getPosition(String s) {
2568 for(int i = 0; i < getCount(); i++) {
2569 if (getItem(i).first.equals(s)) return i;
2570 }
2571 return -1;
2572 }
2573
2574 public int countExceptCancel() {
2575 int count = 0;
2576 for(int i = 0; i < getCount(); i++) {
2577 if (!getItem(i).first.equals("cancel")) count++;
2578 }
2579 return count;
2580 }
2581
2582 public void clearExceptCancel() {
2583 Pair<String,String> cancelItem = null;
2584 for(int i = 0; i < getCount(); i++) {
2585 if (getItem(i).first.equals("cancel")) cancelItem = getItem(i);
2586 }
2587 clear();
2588 if (cancelItem != null) add(cancelItem);
2589 }
2590 }
2591
2592 final int TYPE_ERROR = 1;
2593 final int TYPE_NOTE = 2;
2594 final int TYPE_WEB = 3;
2595 final int TYPE_RESULT_FIELD = 4;
2596 final int TYPE_TEXT_FIELD = 5;
2597 final int TYPE_CHECKBOX_FIELD = 6;
2598 final int TYPE_SPINNER_FIELD = 7;
2599 final int TYPE_RADIO_EDIT_FIELD = 8;
2600 final int TYPE_RESULT_CELL = 9;
2601 final int TYPE_PROGRESSBAR = 10;
2602 final int TYPE_SEARCH_LIST_FIELD = 11;
2603 final int TYPE_ITEM_CARD = 12;
2604 final int TYPE_BUTTON_GRID_FIELD = 13;
2605
2606 protected boolean executing = false;
2607 protected boolean loading = false;
2608 protected boolean loadingHasBeenLong = false;
2609 protected Timer loadingTimer = new Timer();
2610 protected String mTitle;
2611 protected String mNode;
2612 protected CommandPageBinding mBinding = null;
2613 protected IqPacket response = null;
2614 protected Element responseElement = null;
2615 protected List<Field> reported = null;
2616 protected SparseArray<Item> items = new SparseArray<>();
2617 protected XmppConnectionService xmppConnectionService;
2618 protected ActionsAdapter actionsAdapter;
2619 protected GridLayoutManager layoutManager;
2620 protected WebView actionToWebview = null;
2621 protected int fillableFieldCount = 0;
2622 protected IqPacket pendingResponsePacket = null;
2623 protected boolean waitingForRefresh = false;
2624
2625 CommandSession(String title, String node, XmppConnectionService xmppConnectionService) {
2626 loading();
2627 mTitle = title;
2628 mNode = node;
2629 this.xmppConnectionService = xmppConnectionService;
2630 if (mPager != null) setupLayoutManager();
2631 actionsAdapter = new ActionsAdapter(xmppConnectionService);
2632 actionsAdapter.registerDataSetObserver(new DataSetObserver() {
2633 @Override
2634 public void onChanged() {
2635 if (mBinding == null) return;
2636
2637 mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
2638 }
2639
2640 @Override
2641 public void onInvalidated() {}
2642 });
2643 }
2644
2645 public String getTitle() {
2646 return mTitle;
2647 }
2648
2649 public String getNode() {
2650 return mNode;
2651 }
2652
2653 public void updateWithResponse(final IqPacket iq) {
2654 if (getView() != null && getView().isAttachedToWindow()) {
2655 getView().post(() -> updateWithResponseUiThread(iq));
2656 } else {
2657 pendingResponsePacket = iq;
2658 }
2659 }
2660
2661 protected void updateWithResponseUiThread(final IqPacket iq) {
2662 Timer oldTimer = this.loadingTimer;
2663 this.loadingTimer = new Timer();
2664 oldTimer.cancel();
2665 this.executing = false;
2666 this.loading = false;
2667 this.loadingHasBeenLong = false;
2668 this.responseElement = null;
2669 this.fillableFieldCount = 0;
2670 this.reported = null;
2671 this.response = iq;
2672 this.items.clear();
2673 this.actionsAdapter.clear();
2674 layoutManager.setSpanCount(1);
2675
2676 boolean actionsCleared = false;
2677 Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
2678 if (iq.getType() == IqPacket.TYPE.RESULT && command != null) {
2679 if (mNode.equals("jabber:iq:register") && command.getAttribute("status") != null && command.getAttribute("status").equals("completed")) {
2680 xmppConnectionService.createContact(getAccount().getRoster().getContact(iq.getFrom()), true);
2681 }
2682
2683 if (xmppConnectionService.isOnboarding() && mNode.equals("jabber:iq:register") && !"canceled".equals(command.getAttribute("status")) && xmppConnectionService.getPreferences().contains("onboarding_action")) {
2684 xmppConnectionService.getPreferences().edit().putBoolean("onboarding_continued", true).commit();
2685 }
2686
2687 Element actions = command.findChild("actions", "http://jabber.org/protocol/commands");
2688 if (actions != null) {
2689 for (Element action : actions.getChildren()) {
2690 if (!"http://jabber.org/protocol/commands".equals(action.getNamespace())) continue;
2691 if ("execute".equals(action.getName())) continue;
2692
2693 actionsAdapter.add(Pair.create(action.getName(), action.getName()));
2694 }
2695 }
2696
2697 for (Element el : command.getChildren()) {
2698 if ("x".equals(el.getName()) && "jabber:x:data".equals(el.getNamespace())) {
2699 Data form = Data.parse(el);
2700 String title = form.getTitle();
2701 if (title != null) {
2702 mTitle = title;
2703 ConversationPagerAdapter.this.notifyDataSetChanged();
2704 }
2705
2706 if ("result".equals(el.getAttribute("type")) || "form".equals(el.getAttribute("type"))) {
2707 this.responseElement = el;
2708 setupReported(el.findChild("reported", "jabber:x:data"));
2709 if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager());
2710 }
2711
2712 eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
2713 if (actionList != null) {
2714 actionsAdapter.clear();
2715
2716 for (Option action : actionList.getOptions()) {
2717 actionsAdapter.add(Pair.create(action.getValue(), action.toString()));
2718 }
2719 }
2720
2721 String fillableFieldType = null;
2722 String fillableFieldValue = null;
2723 for (eu.siacs.conversations.xmpp.forms.Field field : form.getFields()) {
2724 if ((field.getType() == null || (!field.getType().equals("hidden") && !field.getType().equals("fixed"))) && field.getFieldName() != null && !field.getFieldName().equals("http://jabber.org/protocol/commands#actions")) {
2725 fillableFieldType = field.getType();
2726 fillableFieldValue = field.getValue();
2727 fillableFieldCount++;
2728 }
2729 }
2730
2731 if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 2 && fillableFieldType != null && (fillableFieldType.equals("list-single") || (fillableFieldType.equals("boolean") && fillableFieldValue == null))) {
2732 actionsCleared = true;
2733 actionsAdapter.clearExceptCancel();
2734 }
2735 break;
2736 }
2737 if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
2738 String url = el.findChildContent("url", "jabber:x:oob");
2739 if (url != null) {
2740 String scheme = Uri.parse(url).getScheme();
2741 if (scheme.equals("http") || scheme.equals("https")) {
2742 this.responseElement = el;
2743 break;
2744 }
2745 if (scheme.equals("xmpp")) {
2746 final Intent intent = new Intent(getView().getContext(), UriHandlerActivity.class);
2747 intent.setAction(Intent.ACTION_VIEW);
2748 intent.setData(Uri.parse(url));
2749 getView().getContext().startActivity(intent);
2750 break;
2751 }
2752 }
2753 }
2754 if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2755 this.responseElement = el;
2756 break;
2757 }
2758 }
2759
2760 if (responseElement == null && command.getAttribute("status") != null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
2761 if ("jabber:iq:register".equals(mNode) && "canceled".equals(command.getAttribute("status"))) {
2762 if (xmppConnectionService.isOnboarding()) {
2763 if (xmppConnectionService.getPreferences().contains("onboarding_action")) {
2764 xmppConnectionService.deleteAccount(getAccount());
2765 } else {
2766 if (xmppConnectionService.getPreferences().getBoolean("onboarding_continued", false)) {
2767 removeSession(this);
2768 return;
2769 } else {
2770 xmppConnectionService.getPreferences().edit().putString("onboarding_action", "cancel").commit();
2771 xmppConnectionService.deleteAccount(getAccount());
2772 }
2773 }
2774 }
2775 xmppConnectionService.archiveConversation(Conversation.this);
2776 }
2777
2778 removeSession(this);
2779 return;
2780 }
2781
2782 if ("executing".equals(command.getAttribute("status")) && actionsAdapter.countExceptCancel() < 1 && !actionsCleared) {
2783 // No actions have been given, but we are not done?
2784 // This is probably a spec violation, but we should do *something*
2785 actionsAdapter.add(Pair.create("execute", "execute"));
2786 }
2787
2788 if (!actionsAdapter.isEmpty() || fillableFieldCount > 0) {
2789 if ("completed".equals(command.getAttribute("status")) || "canceled".equals(command.getAttribute("status"))) {
2790 actionsAdapter.add(Pair.create("close", "close"));
2791 } else if (actionsAdapter.getPosition("cancel") < 0 && !xmppConnectionService.isOnboarding()) {
2792 actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
2793 }
2794 }
2795 }
2796
2797 if (actionsAdapter.isEmpty()) {
2798 actionsAdapter.add(Pair.create("close", "close"));
2799 }
2800
2801 actionsAdapter.sort((x, y) -> {
2802 if (x.first.equals("cancel")) return -1;
2803 if (y.first.equals("cancel")) return 1;
2804 if (x.first.equals("prev") && xmppConnectionService.isOnboarding()) return -1;
2805 if (y.first.equals("prev") && xmppConnectionService.isOnboarding()) return 1;
2806 return 0;
2807 });
2808
2809 Data dataForm = null;
2810 if (responseElement != null && responseElement.getName().equals("x") && responseElement.getNamespace().equals("jabber:x:data")) dataForm = Data.parse(responseElement);
2811 if (mNode.equals("jabber:iq:register") &&
2812 xmppConnectionService.getPreferences().contains("onboarding_action") &&
2813 dataForm != null && dataForm.getFieldByName("gateway-jid") != null) {
2814
2815
2816 dataForm.put("gateway-jid", xmppConnectionService.getPreferences().getString("onboarding_action", ""));
2817 execute();
2818 }
2819 xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
2820 notifyDataSetChanged();
2821 }
2822
2823 protected void setupReported(Element el) {
2824 if (el == null) {
2825 reported = null;
2826 return;
2827 }
2828
2829 reported = new ArrayList<>();
2830 for (Element fieldEl : el.getChildren()) {
2831 if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
2832 reported.add(mkField(fieldEl));
2833 }
2834 }
2835
2836 @Override
2837 public int getItemCount() {
2838 if (loading) return 1;
2839 if (response == null) return 0;
2840 if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
2841 int i = 0;
2842 for (Element el : responseElement.getChildren()) {
2843 if (!el.getNamespace().equals("jabber:x:data")) continue;
2844 if (el.getName().equals("title")) continue;
2845 if (el.getName().equals("field")) {
2846 String type = el.getAttribute("type");
2847 if (type != null && type.equals("hidden")) continue;
2848 if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
2849 }
2850
2851 if (el.getName().equals("reported") || el.getName().equals("item")) {
2852 if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
2853 if (el.getName().equals("reported")) continue;
2854 i += 1;
2855 } else {
2856 if (reported != null) i += reported.size();
2857 }
2858 continue;
2859 }
2860
2861 i++;
2862 }
2863 return i;
2864 }
2865 return 1;
2866 }
2867
2868 public Item getItem(int position) {
2869 if (loading) return new Item(null, TYPE_PROGRESSBAR);
2870 if (items.get(position) != null) return items.get(position);
2871 if (response == null) return null;
2872
2873 if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null) {
2874 if (responseElement.getNamespace().equals("jabber:x:data")) {
2875 int i = 0;
2876 for (Element el : responseElement.getChildren()) {
2877 if (!el.getNamespace().equals("jabber:x:data")) continue;
2878 if (el.getName().equals("title")) continue;
2879 if (el.getName().equals("field")) {
2880 String type = el.getAttribute("type");
2881 if (type != null && type.equals("hidden")) continue;
2882 if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
2883 }
2884
2885 if (el.getName().equals("reported") || el.getName().equals("item")) {
2886 Cell cell = null;
2887
2888 if (reported != null) {
2889 if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
2890 if (el.getName().equals("reported")) continue;
2891 if (i == position) {
2892 items.put(position, new Item(el, TYPE_ITEM_CARD));
2893 return items.get(position);
2894 }
2895 } else {
2896 if (reported.size() > position - i) {
2897 Field reportedField = reported.get(position - i);
2898 Element itemField = null;
2899 if (el.getName().equals("item")) {
2900 for (Element subel : el.getChildren()) {
2901 if (subel.getAttribute("var").equals(reportedField.getVar())) {
2902 itemField = subel;
2903 break;
2904 }
2905 }
2906 }
2907 cell = new Cell(reportedField, itemField);
2908 } else {
2909 i += reported.size();
2910 continue;
2911 }
2912 }
2913 }
2914
2915 if (cell != null) {
2916 items.put(position, cell);
2917 return cell;
2918 }
2919 }
2920
2921 if (i < position) {
2922 i++;
2923 continue;
2924 }
2925
2926 return mkItem(el, position);
2927 }
2928 }
2929 }
2930
2931 return mkItem(responseElement == null ? response : responseElement, position);
2932 }
2933
2934 @Override
2935 public int getItemViewType(int position) {
2936 return getItem(position).viewType;
2937 }
2938
2939 @Override
2940 public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
2941 switch(viewType) {
2942 case TYPE_ERROR: {
2943 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2944 return new ErrorViewHolder(binding);
2945 }
2946 case TYPE_NOTE: {
2947 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2948 return new NoteViewHolder(binding);
2949 }
2950 case TYPE_WEB: {
2951 CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
2952 return new WebViewHolder(binding);
2953 }
2954 case TYPE_RESULT_FIELD: {
2955 CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
2956 return new ResultFieldViewHolder(binding);
2957 }
2958 case TYPE_RESULT_CELL: {
2959 CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
2960 return new ResultCellViewHolder(binding);
2961 }
2962 case TYPE_ITEM_CARD: {
2963 CommandItemCardBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_item_card, container, false);
2964 return new ItemCardViewHolder(binding);
2965 }
2966 case TYPE_CHECKBOX_FIELD: {
2967 CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
2968 return new CheckboxFieldViewHolder(binding);
2969 }
2970 case TYPE_SEARCH_LIST_FIELD: {
2971 CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
2972 return new SearchListFieldViewHolder(binding);
2973 }
2974 case TYPE_RADIO_EDIT_FIELD: {
2975 CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
2976 return new RadioEditFieldViewHolder(binding);
2977 }
2978 case TYPE_SPINNER_FIELD: {
2979 CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
2980 return new SpinnerFieldViewHolder(binding);
2981 }
2982 case TYPE_BUTTON_GRID_FIELD: {
2983 CommandButtonGridFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_button_grid_field, container, false);
2984 return new ButtonGridFieldViewHolder(binding);
2985 }
2986 case TYPE_TEXT_FIELD: {
2987 CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
2988 return new TextFieldViewHolder(binding);
2989 }
2990 case TYPE_PROGRESSBAR: {
2991 CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
2992 return new ProgressBarViewHolder(binding);
2993 }
2994 default:
2995 throw new IllegalArgumentException("Unknown viewType: " + viewType + " based on: " + response);
2996 }
2997 }
2998
2999 @Override
3000 public void onBindViewHolder(ViewHolder viewHolder, int position) {
3001 viewHolder.bind(getItem(position));
3002 }
3003
3004 public View getView() {
3005 if (mBinding == null) return null;
3006 return mBinding.getRoot();
3007 }
3008
3009 public boolean validate() {
3010 int count = getItemCount();
3011 boolean isValid = true;
3012 for (int i = 0; i < count; i++) {
3013 boolean oneIsValid = getItem(i).validate();
3014 isValid = isValid && oneIsValid;
3015 }
3016 notifyDataSetChanged();
3017 return isValid;
3018 }
3019
3020 public boolean execute() {
3021 return execute("execute");
3022 }
3023
3024 public boolean execute(int actionPosition) {
3025 return execute(actionsAdapter.getItem(actionPosition).first);
3026 }
3027
3028 public synchronized boolean execute(String action) {
3029 if (!"cancel".equals(action) && executing) {
3030 loadingHasBeenLong = true;
3031 notifyDataSetChanged();
3032 return false;
3033 }
3034 if (!action.equals("cancel") && !action.equals("prev") && !validate()) return false;
3035
3036 if (response == null) return true;
3037 Element command = response.findChild("command", "http://jabber.org/protocol/commands");
3038 if (command == null) return true;
3039 String status = command.getAttribute("status");
3040 if (status == null || (!status.equals("executing") && !action.equals("prev"))) return true;
3041
3042 if (actionToWebview != null && !action.equals("cancel") && Build.VERSION.SDK_INT >= 23) {
3043 actionToWebview.postWebMessage(new WebMessage("xmpp_xep0050/" + action), Uri.parse("*"));
3044 return false;
3045 }
3046
3047 final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
3048 packet.setTo(response.getFrom());
3049 final Element c = packet.addChild("command", Namespace.COMMANDS);
3050 c.setAttribute("node", mNode);
3051 c.setAttribute("sessionid", command.getAttribute("sessionid"));
3052
3053 String formType = responseElement == null ? null : responseElement.getAttribute("type");
3054 if (!action.equals("cancel") &&
3055 !action.equals("prev") &&
3056 responseElement != null &&
3057 responseElement.getName().equals("x") &&
3058 responseElement.getNamespace().equals("jabber:x:data") &&
3059 formType != null && formType.equals("form")) {
3060
3061 Data form = Data.parse(responseElement);
3062 eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
3063 if (actionList != null) {
3064 actionList.setValue(action);
3065 c.setAttribute("action", "execute");
3066 }
3067
3068 if (mNode.equals("jabber:iq:register") && xmppConnectionService.isOnboarding() && form.getFieldByName("gateway-jid") != null) {
3069 if (form.getValue("gateway-jid") == null) {
3070 xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
3071 } else {
3072 xmppConnectionService.getPreferences().edit().putString("onboarding_action", form.getValue("gateway-jid")).commit();
3073 }
3074 }
3075
3076 responseElement.setAttribute("type", "submit");
3077 Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
3078 if (rsm != null) {
3079 Element max = new Element("max", "http://jabber.org/protocol/rsm");
3080 max.setContent("1000");
3081 rsm.addChild(max);
3082 }
3083
3084 c.addChild(responseElement);
3085 }
3086
3087 if (c.getAttribute("action") == null) c.setAttribute("action", action);
3088
3089 executing = true;
3090 xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
3091 updateWithResponse(iq);
3092 }, 120L);
3093
3094 loading();
3095 return false;
3096 }
3097
3098 public void refresh() {
3099 synchronized(this) {
3100 if (waitingForRefresh) notifyDataSetChanged();
3101 }
3102 }
3103
3104 protected void loading() {
3105 View v = getView();
3106 try {
3107 loadingTimer.schedule(new TimerTask() {
3108 @Override
3109 public void run() {
3110 View v2 = getView();
3111 loading = true;
3112
3113 loadingTimer.schedule(new TimerTask() {
3114 @Override
3115 public void run() {
3116 loadingHasBeenLong = true;
3117 if (v == null && v2 == null) return;
3118 (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3119 }
3120 }, 3000);
3121
3122 if (v == null && v2 == null) return;
3123 (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3124 }
3125 }, 500);
3126 } catch (final IllegalStateException e) { }
3127 }
3128
3129 protected GridLayoutManager setupLayoutManager() {
3130 int spanCount = 1;
3131
3132 Context ctx = mPager == null ? getView().getContext() : mPager.getContext();
3133 if (reported != null) {
3134 float screenWidth = ctx.getResources().getDisplayMetrics().widthPixels;
3135 TextPaint paint = ((TextView) LayoutInflater.from(mPager.getContext()).inflate(R.layout.command_result_cell, null)).getPaint();
3136 float tableHeaderWidth = reported.stream().reduce(
3137 0f,
3138 (total, field) -> total + StaticLayout.getDesiredWidth(field.getLabel().or("--------") + "\t", paint),
3139 (a, b) -> a + b
3140 );
3141
3142 spanCount = tableHeaderWidth > 0.59 * screenWidth ? 1 : this.reported.size();
3143 }
3144
3145 if (layoutManager != null && layoutManager.getSpanCount() != spanCount) {
3146 items.clear();
3147 notifyDataSetChanged();
3148 }
3149
3150 layoutManager = new GridLayoutManager(ctx, spanCount);
3151 layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
3152 @Override
3153 public int getSpanSize(int position) {
3154 if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
3155 return 1;
3156 }
3157 });
3158 return layoutManager;
3159 }
3160
3161 protected void setBinding(CommandPageBinding b) {
3162 mBinding = b;
3163 // https://stackoverflow.com/a/32350474/8611
3164 mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
3165 @Override
3166 public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
3167 if(rv.getChildCount() > 0) {
3168 int[] location = new int[2];
3169 rv.getLocationOnScreen(location);
3170 View childView = rv.findChildViewUnder(e.getX(), e.getY());
3171 if (childView instanceof ViewGroup) {
3172 childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
3173 }
3174 int action = e.getAction();
3175 switch (action) {
3176 case MotionEvent.ACTION_DOWN:
3177 if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(1)) || childView instanceof WebView) {
3178 rv.requestDisallowInterceptTouchEvent(true);
3179 }
3180 case MotionEvent.ACTION_UP:
3181 if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(-11)) || childView instanceof WebView) {
3182 rv.requestDisallowInterceptTouchEvent(true);
3183 }
3184 }
3185 }
3186
3187 return false;
3188 }
3189
3190 @Override
3191 public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }
3192
3193 @Override
3194 public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
3195 });
3196 mBinding.form.setLayoutManager(setupLayoutManager());
3197 mBinding.form.setAdapter(this);
3198 mBinding.actions.setAdapter(actionsAdapter);
3199 mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
3200 if (execute(pos)) {
3201 removeSession(CommandSession.this);
3202 }
3203 });
3204
3205 actionsAdapter.notifyDataSetChanged();
3206
3207 if (pendingResponsePacket != null) {
3208 final IqPacket pending = pendingResponsePacket;
3209 pendingResponsePacket = null;
3210 updateWithResponseUiThread(pending);
3211 }
3212 }
3213
3214 public View inflateUi(Context context, Consumer<ConversationPage> remover) {
3215 CommandPageBinding binding = DataBindingUtil.inflate(LayoutInflater.from(context), R.layout.command_page, null, false);
3216 setBinding(binding);
3217 return binding.getRoot();
3218 }
3219
3220 // https://stackoverflow.com/a/36037991/8611
3221 private View findViewAt(ViewGroup viewGroup, float x, float y) {
3222 for(int i = 0; i < viewGroup.getChildCount(); i++) {
3223 View child = viewGroup.getChildAt(i);
3224 if (child instanceof ViewGroup && !(child instanceof AbsListView) && !(child instanceof WebView)) {
3225 View foundView = findViewAt((ViewGroup) child, x, y);
3226 if (foundView != null && foundView.isShown()) {
3227 return foundView;
3228 }
3229 } else {
3230 int[] location = new int[2];
3231 child.getLocationOnScreen(location);
3232 Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
3233 if (rect.contains((int)x, (int)y)) {
3234 return child;
3235 }
3236 }
3237 }
3238
3239 return null;
3240 }
3241 }
3242 }
3243}