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