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