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