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