1package eu.siacs.conversations.entities;
2
3import android.content.ContentValues;
4import android.content.Context;
5import android.content.DialogInterface;
6import android.content.Intent;
7import android.database.Cursor;
8import android.database.DataSetObserver;
9import android.graphics.drawable.BitmapDrawable;
10import android.graphics.drawable.Drawable;
11import android.graphics.Bitmap;
12import android.graphics.Canvas;
13import android.graphics.Rect;
14import android.os.Build;
15import android.net.Uri;
16import android.telephony.PhoneNumberUtils;
17import android.text.Editable;
18import android.text.InputType;
19import android.text.SpannableStringBuilder;
20import android.text.Spanned;
21import android.text.StaticLayout;
22import android.text.TextPaint;
23import android.text.TextUtils;
24import android.text.TextWatcher;
25import android.view.LayoutInflater;
26import android.view.MotionEvent;
27import android.view.Gravity;
28import android.view.View;
29import android.view.ViewGroup;
30import android.widget.AbsListView;
31import android.widget.ArrayAdapter;
32import android.widget.AdapterView;
33import android.widget.Button;
34import android.widget.CompoundButton;
35import android.widget.GridLayout;
36import android.widget.ListView;
37import android.widget.TextView;
38import android.widget.Toast;
39import android.widget.Spinner;
40import android.webkit.JavascriptInterface;
41import android.webkit.WebMessage;
42import android.webkit.WebView;
43import android.webkit.WebViewClient;
44import android.webkit.WebChromeClient;
45import android.util.DisplayMetrics;
46import android.util.LruCache;
47import android.util.Pair;
48import android.util.SparseArray;
49
50import androidx.annotation.NonNull;
51import androidx.annotation.Nullable;
52import androidx.appcompat.app.AlertDialog;
53import androidx.appcompat.app.AlertDialog.Builder;
54import androidx.core.content.ContextCompat;
55import androidx.core.util.Consumer;
56import androidx.databinding.DataBindingUtil;
57import androidx.databinding.ViewDataBinding;
58import androidx.viewpager.widget.PagerAdapter;
59import androidx.recyclerview.widget.RecyclerView;
60import androidx.recyclerview.widget.GridLayoutManager;
61import androidx.viewpager.widget.ViewPager;
62
63import com.caverock.androidsvg.SVG;
64import com.caverock.androidsvg.SVGParseException;
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 = cache.get(uri.toString());
1815 if (d == null) {
1816 synchronized (CommandSession.this) {
1817 waitingForRefresh = true;
1818 }
1819 int size = (int)(xmppConnectionService.getResources().getDisplayMetrics().density * 288);
1820 Message dummy = new Message(Conversation.this, uri.toString(), Message.ENCRYPTION_NONE);
1821 dummy.setFileParams(new Message.FileParams(uri.toString()));
1822 httpManager.createNewDownloadConnection(dummy, true, (file) -> {
1823 if (file == null) {
1824 dummy.getTransferable().start();
1825 } else {
1826 try {
1827 xmppConnectionService.getFileBackend().getThumbnail(file, xmppConnectionService.getResources(), size, false, uri.toString());
1828 } catch (final Exception e) { }
1829 }
1830 });
1831 } else {
1832 binding.mediaImage.setImageDrawable(d);
1833 binding.mediaImage.setVisibility(View.VISIBLE);
1834 }
1835 }
1836 }
1837 }
1838
1839 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1840 String datatype = validate == null ? null : validate.getAttribute("datatype");
1841
1842 ArrayAdapter<Option> values = new ArrayAdapter<>(binding.getRoot().getContext(), R.layout.simple_list_item);
1843 for (Element el : field.el.getChildren()) {
1844 if (el.getName().equals("value") && el.getNamespace().equals("jabber:x:data")) {
1845 values.add(new Option(el.getContent(), formatValue(datatype, el.getContent(), false)));
1846 }
1847 }
1848 binding.values.setAdapter(values);
1849 Util.justifyListViewHeightBasedOnChildren(binding.values);
1850
1851 if (field.getType().equals(Optional.of("jid-single")) || field.getType().equals(Optional.of("jid-multi"))) {
1852 binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
1853 new FixedURLSpan("xmpp:" + Jid.ofEscaped(values.getItem(pos).getValue()).toEscapedString(), account).onClick(binding.values);
1854 });
1855 } else if ("xs:anyURI".equals(datatype)) {
1856 binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
1857 new FixedURLSpan(values.getItem(pos).getValue(), account).onClick(binding.values);
1858 });
1859 } else if ("html:tel".equals(datatype)) {
1860 binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
1861 try {
1862 new FixedURLSpan("tel:" + PhoneNumberUtilWrapper.normalize(binding.getRoot().getContext(), values.getItem(pos).getValue()), account).onClick(binding.values);
1863 } catch (final IllegalArgumentException | NumberParseException | NullPointerException e) { }
1864 });
1865 }
1866
1867 binding.values.setOnItemLongClickListener((arg0, arg1, pos, id) -> {
1868 if (ShareUtil.copyTextToClipboard(binding.getRoot().getContext(), values.getItem(pos).getValue(), R.string.message)) {
1869 Toast.makeText(binding.getRoot().getContext(), R.string.message_copied_to_clipboard, Toast.LENGTH_SHORT).show();
1870 }
1871 return true;
1872 });
1873 }
1874 }
1875
1876 class ResultCellViewHolder extends ViewHolder<CommandResultCellBinding> {
1877 public ResultCellViewHolder(CommandResultCellBinding binding) { super(binding); }
1878
1879 @Override
1880 public void bind(Item item) {
1881 Cell cell = (Cell) item;
1882
1883 if (cell.el == null) {
1884 binding.text.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Subhead);
1885 setTextOrHide(binding.text, cell.reported.getLabel());
1886 } else {
1887 Element validate = cell.reported.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1888 String datatype = validate == null ? null : validate.getAttribute("datatype");
1889 String value = formatValue(datatype, cell.el.findChildContent("value", "jabber:x:data"), true);
1890 SpannableStringBuilder text = new SpannableStringBuilder(value == null ? "" : value);
1891 if (cell.reported.getType().equals(Optional.of("jid-single"))) {
1892 text.setSpan(new FixedURLSpan("xmpp:" + Jid.ofEscaped(text.toString()).toEscapedString(), account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1893 } else if ("xs:anyURI".equals(datatype)) {
1894 text.setSpan(new FixedURLSpan(text.toString(), account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1895 } else if ("html:tel".equals(datatype)) {
1896 try {
1897 text.setSpan(new FixedURLSpan("tel:" + PhoneNumberUtilWrapper.normalize(binding.getRoot().getContext(), text.toString()), account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1898 } catch (final IllegalArgumentException | NumberParseException | NullPointerException e) { }
1899 }
1900
1901 binding.text.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Body1);
1902 binding.text.setText(text);
1903
1904 BetterLinkMovementMethod method = BetterLinkMovementMethod.newInstance();
1905 method.setOnLinkLongClickListener((tv, url) -> {
1906 tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
1907 ShareUtil.copyLinkToClipboard(binding.getRoot().getContext(), url);
1908 return true;
1909 });
1910 binding.text.setMovementMethod(method);
1911 }
1912 }
1913 }
1914
1915 class ItemCardViewHolder extends ViewHolder<CommandItemCardBinding> {
1916 public ItemCardViewHolder(CommandItemCardBinding binding) { super(binding); }
1917
1918 @Override
1919 public void bind(Item item) {
1920 binding.fields.removeAllViews();
1921
1922 for (Field field : reported) {
1923 CommandResultFieldBinding row = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.command_result_field, binding.fields, false);
1924 GridLayout.LayoutParams param = new GridLayout.LayoutParams();
1925 param.columnSpec = GridLayout.spec(GridLayout.UNDEFINED, GridLayout.FILL, 1f);
1926 param.width = 0;
1927 row.getRoot().setLayoutParams(param);
1928 binding.fields.addView(row.getRoot());
1929 for (Element el : item.el.getChildren()) {
1930 if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data") && el.getAttribute("var") != null && el.getAttribute("var").equals(field.getVar())) {
1931 for (String label : field.getLabel().asSet()) {
1932 el.setAttribute("label", label);
1933 }
1934 for (String desc : field.getDesc().asSet()) {
1935 el.setAttribute("desc", desc);
1936 }
1937 for (String type : field.getType().asSet()) {
1938 el.setAttribute("type", type);
1939 }
1940 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1941 if (validate != null) el.addChild(validate);
1942 new ResultFieldViewHolder(row).bind(new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), -1));
1943 }
1944 }
1945 }
1946 }
1947 }
1948
1949 class CheckboxFieldViewHolder extends ViewHolder<CommandCheckboxFieldBinding> implements CompoundButton.OnCheckedChangeListener {
1950 public CheckboxFieldViewHolder(CommandCheckboxFieldBinding binding) {
1951 super(binding);
1952 binding.row.setOnClickListener((v) -> {
1953 binding.checkbox.toggle();
1954 });
1955 binding.checkbox.setOnCheckedChangeListener(this);
1956 }
1957 protected Element mValue = null;
1958
1959 @Override
1960 public void bind(Item item) {
1961 Field field = (Field) item;
1962 binding.label.setText(field.getLabel().or(""));
1963 setTextOrHide(binding.desc, field.getDesc());
1964 mValue = field.getValue();
1965 binding.checkbox.setChecked(mValue.getContent() != null && (mValue.getContent().equals("true") || mValue.getContent().equals("1")));
1966 }
1967
1968 @Override
1969 public void onCheckedChanged(CompoundButton checkbox, boolean isChecked) {
1970 if (mValue == null) return;
1971
1972 mValue.setContent(isChecked ? "true" : "false");
1973 }
1974 }
1975
1976 class SearchListFieldViewHolder extends ViewHolder<CommandSearchListFieldBinding> implements TextWatcher {
1977 public SearchListFieldViewHolder(CommandSearchListFieldBinding binding) {
1978 super(binding);
1979 binding.search.addTextChangedListener(this);
1980 }
1981 protected Element mValue = null;
1982 List<Option> options = new ArrayList<>();
1983 protected ArrayAdapter<Option> adapter;
1984 protected boolean open;
1985
1986 @Override
1987 public void bind(Item item) {
1988 Field field = (Field) item;
1989 setTextOrHide(binding.label, field.getLabel());
1990 setTextOrHide(binding.desc, field.getDesc());
1991
1992 if (field.error != null) {
1993 binding.desc.setVisibility(View.VISIBLE);
1994 binding.desc.setText(field.error);
1995 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
1996 } else {
1997 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
1998 }
1999
2000 mValue = field.getValue();
2001
2002 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2003 open = validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null;
2004 setupInputType(field.el, binding.search, null);
2005
2006 options = field.getOptions();
2007 binding.list.setOnItemClickListener((parent, view, position, id) -> {
2008 mValue.setContent(adapter.getItem(binding.list.getCheckedItemPosition()).getValue());
2009 if (open) binding.search.setText(mValue.getContent());
2010 });
2011 search("");
2012 }
2013
2014 @Override
2015 public void afterTextChanged(Editable s) {
2016 if (open) mValue.setContent(s.toString());
2017 search(s.toString());
2018 }
2019
2020 @Override
2021 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2022
2023 @Override
2024 public void onTextChanged(CharSequence s, int start, int count, int after) { }
2025
2026 protected void search(String s) {
2027 List<Option> filteredOptions;
2028 final String q = s.replaceAll("\\W", "").toLowerCase();
2029 if (q == null || q.equals("")) {
2030 filteredOptions = options;
2031 } else {
2032 filteredOptions = options.stream().filter(o -> o.toString().replaceAll("\\W", "").toLowerCase().contains(q)).collect(Collectors.toList());
2033 }
2034 adapter = new ArrayAdapter(binding.getRoot().getContext(), R.layout.simple_list_item, filteredOptions);
2035 binding.list.setAdapter(adapter);
2036
2037 int checkedPos = filteredOptions.indexOf(new Option(mValue.getContent(), ""));
2038 if (checkedPos >= 0) binding.list.setItemChecked(checkedPos, true);
2039 }
2040 }
2041
2042 class RadioEditFieldViewHolder extends ViewHolder<CommandRadioEditFieldBinding> implements CompoundButton.OnCheckedChangeListener, TextWatcher {
2043 public RadioEditFieldViewHolder(CommandRadioEditFieldBinding binding) {
2044 super(binding);
2045 binding.open.addTextChangedListener(this);
2046 options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.radio_grid_item) {
2047 @Override
2048 public View getView(int position, View convertView, ViewGroup parent) {
2049 CompoundButton v = (CompoundButton) super.getView(position, convertView, parent);
2050 v.setId(position);
2051 v.setChecked(getItem(position).getValue().equals(mValue.getContent()));
2052 v.setOnCheckedChangeListener(RadioEditFieldViewHolder.this);
2053 return v;
2054 }
2055 };
2056 }
2057 protected Element mValue = null;
2058 protected ArrayAdapter<Option> options;
2059
2060 @Override
2061 public void bind(Item item) {
2062 Field field = (Field) item;
2063 setTextOrHide(binding.label, field.getLabel());
2064 setTextOrHide(binding.desc, field.getDesc());
2065
2066 if (field.error != null) {
2067 binding.desc.setVisibility(View.VISIBLE);
2068 binding.desc.setText(field.error);
2069 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
2070 } else {
2071 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
2072 }
2073
2074 mValue = field.getValue();
2075
2076 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2077 binding.open.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
2078 binding.open.setText(mValue.getContent());
2079 setupInputType(field.el, binding.open, null);
2080
2081 options.clear();
2082 List<Option> theOptions = field.getOptions();
2083 options.addAll(theOptions);
2084
2085 float screenWidth = binding.getRoot().getContext().getResources().getDisplayMetrics().widthPixels;
2086 TextPaint paint = ((TextView) LayoutInflater.from(binding.getRoot().getContext()).inflate(R.layout.radio_grid_item, null)).getPaint();
2087 float maxColumnWidth = theOptions.stream().map((x) ->
2088 StaticLayout.getDesiredWidth(x.toString(), paint)
2089 ).max(Float::compare).orElse(new Float(0.0));
2090 if (maxColumnWidth * theOptions.size() < 0.90 * screenWidth) {
2091 binding.radios.setNumColumns(theOptions.size());
2092 } else if (maxColumnWidth * (theOptions.size() / 2) < 0.90 * screenWidth) {
2093 binding.radios.setNumColumns(theOptions.size() / 2);
2094 } else {
2095 binding.radios.setNumColumns(1);
2096 }
2097 binding.radios.setAdapter(options);
2098 }
2099
2100 @Override
2101 public void onCheckedChanged(CompoundButton radio, boolean isChecked) {
2102 if (mValue == null) return;
2103
2104 if (isChecked) {
2105 mValue.setContent(options.getItem(radio.getId()).getValue());
2106 binding.open.setText(mValue.getContent());
2107 }
2108 options.notifyDataSetChanged();
2109 }
2110
2111 @Override
2112 public void afterTextChanged(Editable s) {
2113 if (mValue == null) return;
2114
2115 mValue.setContent(s.toString());
2116 options.notifyDataSetChanged();
2117 }
2118
2119 @Override
2120 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2121
2122 @Override
2123 public void onTextChanged(CharSequence s, int start, int count, int after) { }
2124 }
2125
2126 class SpinnerFieldViewHolder extends ViewHolder<CommandSpinnerFieldBinding> implements AdapterView.OnItemSelectedListener {
2127 public SpinnerFieldViewHolder(CommandSpinnerFieldBinding binding) {
2128 super(binding);
2129 binding.spinner.setOnItemSelectedListener(this);
2130 }
2131 protected Element mValue = null;
2132
2133 @Override
2134 public void bind(Item item) {
2135 Field field = (Field) item;
2136 setTextOrHide(binding.label, field.getLabel());
2137 binding.spinner.setPrompt(field.getLabel().or(""));
2138 setTextOrHide(binding.desc, field.getDesc());
2139
2140 mValue = field.getValue();
2141
2142 ArrayAdapter<Option> options = new ArrayAdapter<Option>(binding.getRoot().getContext(), android.R.layout.simple_spinner_item);
2143 options.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
2144 options.addAll(field.getOptions());
2145
2146 binding.spinner.setAdapter(options);
2147 binding.spinner.setSelection(options.getPosition(new Option(mValue.getContent(), null)));
2148 }
2149
2150 @Override
2151 public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
2152 Option o = (Option) parent.getItemAtPosition(pos);
2153 if (mValue == null) return;
2154
2155 mValue.setContent(o == null ? "" : o.getValue());
2156 }
2157
2158 @Override
2159 public void onNothingSelected(AdapterView<?> parent) {
2160 mValue.setContent("");
2161 }
2162 }
2163
2164 class ButtonGridFieldViewHolder extends ViewHolder<CommandButtonGridFieldBinding> {
2165 public ButtonGridFieldViewHolder(CommandButtonGridFieldBinding binding) {
2166 super(binding);
2167 options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.button_grid_item) {
2168 @Override
2169 public View getView(int position, View convertView, ViewGroup parent) {
2170 Button v = (Button) super.getView(position, convertView, parent);
2171 v.setOnClickListener((view) -> {
2172 mValue.setContent(getItem(position).getValue());
2173 execute();
2174 loading = true;
2175 });
2176
2177 final SVG icon = getItem(position).getIcon();
2178 if (icon != null) {
2179 synchronized (CommandSession.this) {
2180 waitingForRefresh = true;
2181 }
2182 v.post(() -> {
2183 if (v.getHeight() == 0) return;
2184 icon.setDocumentPreserveAspectRatio(com.caverock.androidsvg.PreserveAspectRatio.LETTERBOX);
2185 try {
2186 icon.setDocumentWidth("100%");
2187 icon.setDocumentHeight("100%");
2188 } catch (final SVGParseException e) { }
2189 Bitmap bitmap = Bitmap.createBitmap(v.getHeight(), v.getHeight(), Bitmap.Config.ARGB_8888);
2190 Canvas bmcanvas = new Canvas(bitmap);
2191 icon.renderToCanvas(bmcanvas);
2192 v.setCompoundDrawablesRelativeWithIntrinsicBounds(new BitmapDrawable(bitmap), null, null, null);
2193 });
2194 }
2195
2196 return v;
2197 }
2198 };
2199 }
2200 protected Element mValue = null;
2201 protected ArrayAdapter<Option> options;
2202 protected Option defaultOption = null;
2203
2204 @Override
2205 public void bind(Item item) {
2206 Field field = (Field) item;
2207 setTextOrHide(binding.label, field.getLabel());
2208 setTextOrHide(binding.desc, field.getDesc());
2209
2210 if (field.error != null) {
2211 binding.desc.setVisibility(View.VISIBLE);
2212 binding.desc.setText(field.error);
2213 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
2214 } else {
2215 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
2216 }
2217
2218 mValue = field.getValue();
2219
2220 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2221 binding.openButton.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
2222 binding.openButton.setOnClickListener((view) -> {
2223 AlertDialog.Builder builder = new AlertDialog.Builder(binding.getRoot().getContext());
2224 DialogQuickeditBinding dialogBinding = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.dialog_quickedit, null, false);
2225 builder.setPositiveButton(R.string.action_execute, null);
2226 if (field.getDesc().isPresent()) {
2227 dialogBinding.inputLayout.setHint(field.getDesc().get());
2228 }
2229 dialogBinding.inputEditText.requestFocus();
2230 dialogBinding.inputEditText.getText().append(mValue.getContent());
2231 builder.setView(dialogBinding.getRoot());
2232 builder.setNegativeButton(R.string.cancel, null);
2233 final AlertDialog dialog = builder.create();
2234 dialog.setOnShowListener(d -> SoftKeyboardUtils.showKeyboard(dialogBinding.inputEditText));
2235 dialog.show();
2236 View.OnClickListener clickListener = v -> {
2237 String value = dialogBinding.inputEditText.getText().toString();
2238 mValue.setContent(value);
2239 SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2240 dialog.dismiss();
2241 execute();
2242 loading = true;
2243 };
2244 dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener(clickListener);
2245 dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setOnClickListener((v -> {
2246 SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2247 dialog.dismiss();
2248 }));
2249 dialog.setCanceledOnTouchOutside(false);
2250 dialog.setOnDismissListener(dialog1 -> {
2251 SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2252 });
2253 });
2254
2255 options.clear();
2256 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();
2257
2258 defaultOption = null;
2259 for (Option option : theOptions) {
2260 if (option.getValue().equals(mValue.getContent())) {
2261 defaultOption = option;
2262 break;
2263 }
2264 }
2265 if (defaultOption == null && !mValue.getContent().equals("")) {
2266 // Synthesize default option for custom value
2267 defaultOption = new Option(mValue.getContent(), mValue.getContent());
2268 }
2269 if (defaultOption == null) {
2270 binding.defaultButton.setVisibility(View.GONE);
2271 } else {
2272 theOptions.remove(defaultOption);
2273 binding.defaultButton.setVisibility(View.VISIBLE);
2274
2275 final SVG defaultIcon = defaultOption.getIcon();
2276 if (defaultIcon != null) {
2277 synchronized (CommandSession.this) {
2278 waitingForRefresh = true;
2279 }
2280 defaultIcon.setDocumentPreserveAspectRatio(com.caverock.androidsvg.PreserveAspectRatio.LETTERBOX);
2281 try {
2282 defaultIcon.setDocumentWidth("100%");
2283 defaultIcon.setDocumentHeight("100%");
2284 } catch (final SVGParseException e) { }
2285 DisplayMetrics display = mPager.getContext().getResources().getDisplayMetrics();
2286 Bitmap bitmap = Bitmap.createBitmap((int)(display.heightPixels*display.density/4), (int)(display.heightPixels*display.density/4), Bitmap.Config.ARGB_8888);
2287 bitmap.setDensity(display.densityDpi);
2288 Canvas bmcanvas = new Canvas(bitmap);
2289 defaultIcon.renderToCanvas(bmcanvas);
2290 binding.defaultButton.setCompoundDrawablesRelativeWithIntrinsicBounds(null, new BitmapDrawable(bitmap), null, null);
2291 }
2292
2293 binding.defaultButton.setText(defaultOption.toString());
2294 binding.defaultButton.setOnClickListener((view) -> {
2295 mValue.setContent(defaultOption.getValue());
2296 execute();
2297 loading = true;
2298 });
2299 }
2300
2301 options.addAll(theOptions);
2302 binding.buttons.setAdapter(options);
2303 }
2304 }
2305
2306 class TextFieldViewHolder extends ViewHolder<CommandTextFieldBinding> implements TextWatcher {
2307 public TextFieldViewHolder(CommandTextFieldBinding binding) {
2308 super(binding);
2309 binding.textinput.addTextChangedListener(this);
2310 }
2311 protected Field field = null;
2312
2313 @Override
2314 public void bind(Item item) {
2315 field = (Field) item;
2316 binding.textinputLayout.setHint(field.getLabel().or(""));
2317
2318 binding.textinputLayout.setHelperTextEnabled(field.getDesc().isPresent());
2319 for (String desc : field.getDesc().asSet()) {
2320 binding.textinputLayout.setHelperText(desc);
2321 }
2322
2323 binding.textinputLayout.setErrorEnabled(field.error != null);
2324 if (field.error != null) binding.textinputLayout.setError(field.error);
2325
2326 binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_GRAVITY);
2327 String suffixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/suffix-label");
2328 if (suffixLabel == null) {
2329 binding.textinputLayout.setSuffixText("");
2330 } else {
2331 binding.textinputLayout.setSuffixText(suffixLabel);
2332 binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_TEXT_END);
2333 }
2334
2335 String prefixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/prefix-label");
2336 binding.textinputLayout.setPrefixText(prefixLabel == null ? "" : prefixLabel);
2337
2338 binding.textinput.setText(String.join("\n", field.getValues()));
2339 setupInputType(field.el, binding.textinput, binding.textinputLayout);
2340 }
2341
2342 @Override
2343 public void afterTextChanged(Editable s) {
2344 if (field == null) return;
2345
2346 field.setValues(List.of(s.toString().split("\n")));
2347 }
2348
2349 @Override
2350 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2351
2352 @Override
2353 public void onTextChanged(CharSequence s, int start, int count, int after) { }
2354 }
2355
2356 class WebViewHolder extends ViewHolder<CommandWebviewBinding> {
2357 public WebViewHolder(CommandWebviewBinding binding) { super(binding); }
2358 protected String boundUrl = "";
2359
2360 @Override
2361 public void bind(Item oob) {
2362 setTextOrHide(binding.desc, Optional.fromNullable(oob.el.findChildContent("desc", "jabber:x:oob")));
2363 binding.webview.getSettings().setJavaScriptEnabled(true);
2364 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");
2365 binding.webview.getSettings().setDatabaseEnabled(true);
2366 binding.webview.getSettings().setDomStorageEnabled(true);
2367 binding.webview.setWebChromeClient(new WebChromeClient() {
2368 @Override
2369 public void onProgressChanged(WebView view, int newProgress) {
2370 binding.progressbar.setVisibility(newProgress < 100 ? View.VISIBLE : View.GONE);
2371 binding.progressbar.setProgress(newProgress);
2372 }
2373 });
2374 binding.webview.setWebViewClient(new WebViewClient() {
2375 @Override
2376 public void onPageFinished(WebView view, String url) {
2377 super.onPageFinished(view, url);
2378 mTitle = view.getTitle();
2379 ConversationPagerAdapter.this.notifyDataSetChanged();
2380 }
2381 });
2382 final String url = oob.el.findChildContent("url", "jabber:x:oob");
2383 if (!boundUrl.equals(url)) {
2384 binding.webview.addJavascriptInterface(new JsObject(), "xmpp_xep0050");
2385 binding.webview.loadUrl(url);
2386 boundUrl = url;
2387 }
2388 }
2389
2390 class JsObject {
2391 @JavascriptInterface
2392 public void execute() { execute("execute"); }
2393
2394 @JavascriptInterface
2395 public void execute(String action) {
2396 getView().post(() -> {
2397 actionToWebview = null;
2398 if(CommandSession.this.execute(action)) {
2399 removeSession(CommandSession.this);
2400 }
2401 });
2402 }
2403
2404 @JavascriptInterface
2405 public void preventDefault() {
2406 actionToWebview = binding.webview;
2407 }
2408 }
2409 }
2410
2411 class ProgressBarViewHolder extends ViewHolder<CommandProgressBarBinding> {
2412 public ProgressBarViewHolder(CommandProgressBarBinding binding) { super(binding); }
2413
2414 @Override
2415 public void bind(Item item) {
2416 binding.text.setVisibility(loadingHasBeenLong ? View.VISIBLE : View.GONE);
2417 }
2418 }
2419
2420 class Item {
2421 protected Element el;
2422 protected int viewType;
2423 protected String error = null;
2424
2425 Item(Element el, int viewType) {
2426 this.el = el;
2427 this.viewType = viewType;
2428 }
2429
2430 public boolean validate() {
2431 error = null;
2432 return true;
2433 }
2434 }
2435
2436 class Field extends Item {
2437 Field(eu.siacs.conversations.xmpp.forms.Field el, int viewType) { super(el, viewType); }
2438
2439 @Override
2440 public boolean validate() {
2441 if (!super.validate()) return false;
2442 if (el.findChild("required", "jabber:x:data") == null) return true;
2443 if (getValue().getContent() != null && !getValue().getContent().equals("")) return true;
2444
2445 error = "this value is required";
2446 return false;
2447 }
2448
2449 public String getVar() {
2450 return el.getAttribute("var");
2451 }
2452
2453 public Optional<String> getType() {
2454 return Optional.fromNullable(el.getAttribute("type"));
2455 }
2456
2457 public Optional<String> getLabel() {
2458 String label = el.getAttribute("label");
2459 if (label == null) label = getVar();
2460 return Optional.fromNullable(label);
2461 }
2462
2463 public Optional<String> getDesc() {
2464 return Optional.fromNullable(el.findChildContent("desc", "jabber:x:data"));
2465 }
2466
2467 public Element getValue() {
2468 Element value = el.findChild("value", "jabber:x:data");
2469 if (value == null) {
2470 value = el.addChild("value", "jabber:x:data");
2471 }
2472 return value;
2473 }
2474
2475 public void setValues(List<String> values) {
2476 for(Element child : el.getChildren()) {
2477 if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
2478 el.removeChild(child);
2479 }
2480 }
2481
2482 for (String value : values) {
2483 el.addChild("value", "jabber:x:data").setContent(value);
2484 }
2485 }
2486
2487 public List<String> getValues() {
2488 List<String> values = new ArrayList<>();
2489 for(Element child : el.getChildren()) {
2490 if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
2491 values.add(child.getContent());
2492 }
2493 }
2494 return values;
2495 }
2496
2497 public List<Option> getOptions() {
2498 return Option.forField(el);
2499 }
2500 }
2501
2502 class Cell extends Item {
2503 protected Field reported;
2504
2505 Cell(Field reported, Element item) {
2506 super(item, TYPE_RESULT_CELL);
2507 this.reported = reported;
2508 }
2509 }
2510
2511 protected Field mkField(Element el) {
2512 int viewType = -1;
2513
2514 String formType = responseElement.getAttribute("type");
2515 if (formType != null) {
2516 String fieldType = el.getAttribute("type");
2517 if (fieldType == null) fieldType = "text-single";
2518
2519 if (formType.equals("result") || fieldType.equals("fixed")) {
2520 viewType = TYPE_RESULT_FIELD;
2521 } else if (formType.equals("form")) {
2522 if (fieldType.equals("boolean")) {
2523 if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 1) {
2524 viewType = TYPE_BUTTON_GRID_FIELD;
2525 } else {
2526 viewType = TYPE_CHECKBOX_FIELD;
2527 }
2528 } else if (fieldType.equals("list-single")) {
2529 Element validate = el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2530 if (Option.forField(el).size() > 9) {
2531 viewType = TYPE_SEARCH_LIST_FIELD;
2532 } else if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 1) {
2533 viewType = TYPE_BUTTON_GRID_FIELD;
2534 } else if (el.findChild("value", "jabber:x:data") == null || (validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null)) {
2535 viewType = TYPE_RADIO_EDIT_FIELD;
2536 } else {
2537 viewType = TYPE_SPINNER_FIELD;
2538 }
2539 } else {
2540 viewType = TYPE_TEXT_FIELD;
2541 }
2542 }
2543
2544 return new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), viewType);
2545 }
2546
2547 return null;
2548 }
2549
2550 protected Item mkItem(Element el, int pos) {
2551 int viewType = -1;
2552
2553 if (response != null && response.getType() == IqPacket.TYPE.RESULT) {
2554 if (el.getName().equals("note")) {
2555 viewType = TYPE_NOTE;
2556 } else if (el.getNamespace().equals("jabber:x:oob")) {
2557 viewType = TYPE_WEB;
2558 } else if (el.getName().equals("instructions") && el.getNamespace().equals("jabber:x:data")) {
2559 viewType = TYPE_NOTE;
2560 } else if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data")) {
2561 Field field = mkField(el);
2562 if (field != null) {
2563 items.put(pos, field);
2564 return field;
2565 }
2566 }
2567 } else if (response != null) {
2568 viewType = TYPE_ERROR;
2569 }
2570
2571 Item item = new Item(el, viewType);
2572 items.put(pos, item);
2573 return item;
2574 }
2575
2576 class ActionsAdapter extends ArrayAdapter<Pair<String, String>> {
2577 protected Context ctx;
2578
2579 public ActionsAdapter(Context ctx) {
2580 super(ctx, R.layout.simple_list_item);
2581 this.ctx = ctx;
2582 }
2583
2584 @Override
2585 public View getView(int position, View convertView, ViewGroup parent) {
2586 View v = super.getView(position, convertView, parent);
2587 TextView tv = (TextView) v.findViewById(android.R.id.text1);
2588 tv.setGravity(Gravity.CENTER);
2589 tv.setText(getItem(position).second);
2590 int resId = ctx.getResources().getIdentifier("action_" + getItem(position).first, "string" , ctx.getPackageName());
2591 if (resId != 0 && getItem(position).second.equals(getItem(position).first)) tv.setText(ctx.getResources().getString(resId));
2592 tv.setTextColor(ContextCompat.getColor(ctx, R.color.white));
2593 tv.setBackgroundColor(UIHelper.getColorForName(getItem(position).first));
2594 return v;
2595 }
2596
2597 public int getPosition(String s) {
2598 for(int i = 0; i < getCount(); i++) {
2599 if (getItem(i).first.equals(s)) return i;
2600 }
2601 return -1;
2602 }
2603
2604 public int countExceptCancel() {
2605 int count = 0;
2606 for(int i = 0; i < getCount(); i++) {
2607 if (!getItem(i).first.equals("cancel")) count++;
2608 }
2609 return count;
2610 }
2611
2612 public void clearExceptCancel() {
2613 Pair<String,String> cancelItem = null;
2614 for(int i = 0; i < getCount(); i++) {
2615 if (getItem(i).first.equals("cancel")) cancelItem = getItem(i);
2616 }
2617 clear();
2618 if (cancelItem != null) add(cancelItem);
2619 }
2620 }
2621
2622 final int TYPE_ERROR = 1;
2623 final int TYPE_NOTE = 2;
2624 final int TYPE_WEB = 3;
2625 final int TYPE_RESULT_FIELD = 4;
2626 final int TYPE_TEXT_FIELD = 5;
2627 final int TYPE_CHECKBOX_FIELD = 6;
2628 final int TYPE_SPINNER_FIELD = 7;
2629 final int TYPE_RADIO_EDIT_FIELD = 8;
2630 final int TYPE_RESULT_CELL = 9;
2631 final int TYPE_PROGRESSBAR = 10;
2632 final int TYPE_SEARCH_LIST_FIELD = 11;
2633 final int TYPE_ITEM_CARD = 12;
2634 final int TYPE_BUTTON_GRID_FIELD = 13;
2635
2636 protected boolean executing = false;
2637 protected boolean loading = false;
2638 protected boolean loadingHasBeenLong = false;
2639 protected Timer loadingTimer = new Timer();
2640 protected String mTitle;
2641 protected String mNode;
2642 protected CommandPageBinding mBinding = null;
2643 protected IqPacket response = null;
2644 protected Element responseElement = null;
2645 protected boolean expectingRemoval = false;
2646 protected List<Field> reported = null;
2647 protected SparseArray<Item> items = new SparseArray<>();
2648 protected XmppConnectionService xmppConnectionService;
2649 protected ActionsAdapter actionsAdapter;
2650 protected GridLayoutManager layoutManager;
2651 protected WebView actionToWebview = null;
2652 protected int fillableFieldCount = 0;
2653 protected IqPacket pendingResponsePacket = null;
2654 protected boolean waitingForRefresh = false;
2655
2656 CommandSession(String title, String node, XmppConnectionService xmppConnectionService) {
2657 loading();
2658 mTitle = title;
2659 mNode = node;
2660 this.xmppConnectionService = xmppConnectionService;
2661 if (mPager != null) setupLayoutManager();
2662 actionsAdapter = new ActionsAdapter(xmppConnectionService);
2663 actionsAdapter.registerDataSetObserver(new DataSetObserver() {
2664 @Override
2665 public void onChanged() {
2666 if (mBinding == null) return;
2667
2668 mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
2669 }
2670
2671 @Override
2672 public void onInvalidated() {}
2673 });
2674 }
2675
2676 public String getTitle() {
2677 return mTitle;
2678 }
2679
2680 public String getNode() {
2681 return mNode;
2682 }
2683
2684 public void updateWithResponse(final IqPacket iq) {
2685 if (getView() != null && getView().isAttachedToWindow()) {
2686 getView().post(() -> updateWithResponseUiThread(iq));
2687 } else {
2688 pendingResponsePacket = iq;
2689 }
2690 }
2691
2692 protected void updateWithResponseUiThread(final IqPacket iq) {
2693 Timer oldTimer = this.loadingTimer;
2694 this.loadingTimer = new Timer();
2695 oldTimer.cancel();
2696 this.executing = false;
2697 this.loading = false;
2698 this.loadingHasBeenLong = false;
2699 this.responseElement = null;
2700 this.fillableFieldCount = 0;
2701 this.reported = null;
2702 this.response = iq;
2703 this.items.clear();
2704 this.actionsAdapter.clear();
2705 layoutManager.setSpanCount(1);
2706
2707 boolean actionsCleared = false;
2708 Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
2709 if (iq.getType() == IqPacket.TYPE.RESULT && command != null) {
2710 if (mNode.equals("jabber:iq:register") && command.getAttribute("status") != null && command.getAttribute("status").equals("completed")) {
2711 xmppConnectionService.createContact(getAccount().getRoster().getContact(iq.getFrom()), true);
2712 }
2713
2714 if (xmppConnectionService.isOnboarding() && mNode.equals("jabber:iq:register") && !"canceled".equals(command.getAttribute("status")) && xmppConnectionService.getPreferences().contains("onboarding_action")) {
2715 xmppConnectionService.getPreferences().edit().putBoolean("onboarding_continued", true).commit();
2716 }
2717
2718 Element actions = command.findChild("actions", "http://jabber.org/protocol/commands");
2719 if (actions != null) {
2720 for (Element action : actions.getChildren()) {
2721 if (!"http://jabber.org/protocol/commands".equals(action.getNamespace())) continue;
2722 if ("execute".equals(action.getName())) continue;
2723
2724 actionsAdapter.add(Pair.create(action.getName(), action.getName()));
2725 }
2726 }
2727
2728 for (Element el : command.getChildren()) {
2729 if ("x".equals(el.getName()) && "jabber:x:data".equals(el.getNamespace())) {
2730 Data form = Data.parse(el);
2731 String title = form.getTitle();
2732 if (title != null) {
2733 mTitle = title;
2734 ConversationPagerAdapter.this.notifyDataSetChanged();
2735 }
2736
2737 if ("result".equals(el.getAttribute("type")) || "form".equals(el.getAttribute("type"))) {
2738 this.responseElement = el;
2739 setupReported(el.findChild("reported", "jabber:x:data"));
2740 if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager());
2741 }
2742
2743 eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
2744 if (actionList != null) {
2745 actionsAdapter.clear();
2746
2747 for (Option action : actionList.getOptions()) {
2748 actionsAdapter.add(Pair.create(action.getValue(), action.toString()));
2749 }
2750 }
2751
2752 String fillableFieldType = null;
2753 String fillableFieldValue = null;
2754 for (eu.siacs.conversations.xmpp.forms.Field field : form.getFields()) {
2755 if ((field.getType() == null || (!field.getType().equals("hidden") && !field.getType().equals("fixed"))) && field.getFieldName() != null && !field.getFieldName().equals("http://jabber.org/protocol/commands#actions")) {
2756 fillableFieldType = field.getType();
2757 fillableFieldValue = field.getValue();
2758 fillableFieldCount++;
2759 }
2760 }
2761
2762 if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 2 && fillableFieldType != null && (fillableFieldType.equals("list-single") || (fillableFieldType.equals("boolean") && fillableFieldValue == null))) {
2763 actionsCleared = true;
2764 actionsAdapter.clearExceptCancel();
2765 }
2766 break;
2767 }
2768 if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
2769 String url = el.findChildContent("url", "jabber:x:oob");
2770 if (url != null) {
2771 String scheme = Uri.parse(url).getScheme();
2772 if (scheme.equals("http") || scheme.equals("https")) {
2773 this.responseElement = el;
2774 break;
2775 }
2776 if (scheme.equals("xmpp")) {
2777 expectingRemoval = true;
2778 final Intent intent = new Intent(getView().getContext(), UriHandlerActivity.class);
2779 intent.setAction(Intent.ACTION_VIEW);
2780 intent.setData(Uri.parse(url));
2781 getView().getContext().startActivity(intent);
2782 break;
2783 }
2784 }
2785 }
2786 if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2787 this.responseElement = el;
2788 break;
2789 }
2790 }
2791
2792 if (responseElement == null && command.getAttribute("status") != null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
2793 if ("jabber:iq:register".equals(mNode) && "canceled".equals(command.getAttribute("status"))) {
2794 if (xmppConnectionService.isOnboarding()) {
2795 if (xmppConnectionService.getPreferences().contains("onboarding_action")) {
2796 xmppConnectionService.deleteAccount(getAccount());
2797 } else {
2798 if (xmppConnectionService.getPreferences().getBoolean("onboarding_continued", false)) {
2799 removeSession(this);
2800 return;
2801 } else {
2802 xmppConnectionService.getPreferences().edit().putString("onboarding_action", "cancel").commit();
2803 xmppConnectionService.deleteAccount(getAccount());
2804 }
2805 }
2806 }
2807 xmppConnectionService.archiveConversation(Conversation.this);
2808 }
2809
2810 expectingRemoval = true;
2811 removeSession(this);
2812 return;
2813 }
2814
2815 if ("executing".equals(command.getAttribute("status")) && actionsAdapter.countExceptCancel() < 1 && !actionsCleared) {
2816 // No actions have been given, but we are not done?
2817 // This is probably a spec violation, but we should do *something*
2818 actionsAdapter.add(Pair.create("execute", "execute"));
2819 }
2820
2821 if (!actionsAdapter.isEmpty() || fillableFieldCount > 0) {
2822 if ("completed".equals(command.getAttribute("status")) || "canceled".equals(command.getAttribute("status"))) {
2823 actionsAdapter.add(Pair.create("close", "close"));
2824 } else if (actionsAdapter.getPosition("cancel") < 0 && !xmppConnectionService.isOnboarding()) {
2825 actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
2826 }
2827 }
2828 }
2829
2830 if (actionsAdapter.isEmpty()) {
2831 actionsAdapter.add(Pair.create("close", "close"));
2832 }
2833
2834 actionsAdapter.sort((x, y) -> {
2835 if (x.first.equals("cancel")) return -1;
2836 if (y.first.equals("cancel")) return 1;
2837 if (x.first.equals("prev") && xmppConnectionService.isOnboarding()) return -1;
2838 if (y.first.equals("prev") && xmppConnectionService.isOnboarding()) return 1;
2839 return 0;
2840 });
2841
2842 Data dataForm = null;
2843 if (responseElement != null && responseElement.getName().equals("x") && responseElement.getNamespace().equals("jabber:x:data")) dataForm = Data.parse(responseElement);
2844 if (mNode.equals("jabber:iq:register") &&
2845 xmppConnectionService.getPreferences().contains("onboarding_action") &&
2846 dataForm != null && dataForm.getFieldByName("gateway-jid") != null) {
2847
2848
2849 dataForm.put("gateway-jid", xmppConnectionService.getPreferences().getString("onboarding_action", ""));
2850 execute();
2851 }
2852 xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
2853 notifyDataSetChanged();
2854 }
2855
2856 protected void setupReported(Element el) {
2857 if (el == null) {
2858 reported = null;
2859 return;
2860 }
2861
2862 reported = new ArrayList<>();
2863 for (Element fieldEl : el.getChildren()) {
2864 if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
2865 reported.add(mkField(fieldEl));
2866 }
2867 }
2868
2869 @Override
2870 public int getItemCount() {
2871 if (loading) return 1;
2872 if (response == null) return 0;
2873 if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
2874 int i = 0;
2875 for (Element el : responseElement.getChildren()) {
2876 if (!el.getNamespace().equals("jabber:x:data")) continue;
2877 if (el.getName().equals("title")) continue;
2878 if (el.getName().equals("field")) {
2879 String type = el.getAttribute("type");
2880 if (type != null && type.equals("hidden")) continue;
2881 if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
2882 }
2883
2884 if (el.getName().equals("reported") || el.getName().equals("item")) {
2885 if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
2886 if (el.getName().equals("reported")) continue;
2887 i += 1;
2888 } else {
2889 if (reported != null) i += reported.size();
2890 }
2891 continue;
2892 }
2893
2894 i++;
2895 }
2896 return i;
2897 }
2898 return 1;
2899 }
2900
2901 public Item getItem(int position) {
2902 if (loading) return new Item(null, TYPE_PROGRESSBAR);
2903 if (items.get(position) != null) return items.get(position);
2904 if (response == null) return null;
2905
2906 if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null) {
2907 if (responseElement.getNamespace().equals("jabber:x:data")) {
2908 int i = 0;
2909 for (Element el : responseElement.getChildren()) {
2910 if (!el.getNamespace().equals("jabber:x:data")) continue;
2911 if (el.getName().equals("title")) continue;
2912 if (el.getName().equals("field")) {
2913 String type = el.getAttribute("type");
2914 if (type != null && type.equals("hidden")) continue;
2915 if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
2916 }
2917
2918 if (el.getName().equals("reported") || el.getName().equals("item")) {
2919 Cell cell = null;
2920
2921 if (reported != null) {
2922 if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
2923 if (el.getName().equals("reported")) continue;
2924 if (i == position) {
2925 items.put(position, new Item(el, TYPE_ITEM_CARD));
2926 return items.get(position);
2927 }
2928 } else {
2929 if (reported.size() > position - i) {
2930 Field reportedField = reported.get(position - i);
2931 Element itemField = null;
2932 if (el.getName().equals("item")) {
2933 for (Element subel : el.getChildren()) {
2934 if (subel.getAttribute("var").equals(reportedField.getVar())) {
2935 itemField = subel;
2936 break;
2937 }
2938 }
2939 }
2940 cell = new Cell(reportedField, itemField);
2941 } else {
2942 i += reported.size();
2943 continue;
2944 }
2945 }
2946 }
2947
2948 if (cell != null) {
2949 items.put(position, cell);
2950 return cell;
2951 }
2952 }
2953
2954 if (i < position) {
2955 i++;
2956 continue;
2957 }
2958
2959 return mkItem(el, position);
2960 }
2961 }
2962 }
2963
2964 return mkItem(responseElement == null ? response : responseElement, position);
2965 }
2966
2967 @Override
2968 public int getItemViewType(int position) {
2969 return getItem(position).viewType;
2970 }
2971
2972 @Override
2973 public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
2974 switch(viewType) {
2975 case TYPE_ERROR: {
2976 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2977 return new ErrorViewHolder(binding);
2978 }
2979 case TYPE_NOTE: {
2980 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2981 return new NoteViewHolder(binding);
2982 }
2983 case TYPE_WEB: {
2984 CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
2985 return new WebViewHolder(binding);
2986 }
2987 case TYPE_RESULT_FIELD: {
2988 CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
2989 return new ResultFieldViewHolder(binding);
2990 }
2991 case TYPE_RESULT_CELL: {
2992 CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
2993 return new ResultCellViewHolder(binding);
2994 }
2995 case TYPE_ITEM_CARD: {
2996 CommandItemCardBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_item_card, container, false);
2997 return new ItemCardViewHolder(binding);
2998 }
2999 case TYPE_CHECKBOX_FIELD: {
3000 CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
3001 return new CheckboxFieldViewHolder(binding);
3002 }
3003 case TYPE_SEARCH_LIST_FIELD: {
3004 CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
3005 return new SearchListFieldViewHolder(binding);
3006 }
3007 case TYPE_RADIO_EDIT_FIELD: {
3008 CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
3009 return new RadioEditFieldViewHolder(binding);
3010 }
3011 case TYPE_SPINNER_FIELD: {
3012 CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
3013 return new SpinnerFieldViewHolder(binding);
3014 }
3015 case TYPE_BUTTON_GRID_FIELD: {
3016 CommandButtonGridFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_button_grid_field, container, false);
3017 return new ButtonGridFieldViewHolder(binding);
3018 }
3019 case TYPE_TEXT_FIELD: {
3020 CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
3021 return new TextFieldViewHolder(binding);
3022 }
3023 case TYPE_PROGRESSBAR: {
3024 CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
3025 return new ProgressBarViewHolder(binding);
3026 }
3027 default:
3028 if (expectingRemoval) {
3029 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3030 return new NoteViewHolder(binding);
3031 }
3032
3033 throw new IllegalArgumentException("Unknown viewType: " + viewType + " based on: " + response + ", " + responseElement + ", " + expectingRemoval);
3034 }
3035 }
3036
3037 @Override
3038 public void onBindViewHolder(ViewHolder viewHolder, int position) {
3039 viewHolder.bind(getItem(position));
3040 }
3041
3042 public View getView() {
3043 if (mBinding == null) return null;
3044 return mBinding.getRoot();
3045 }
3046
3047 public boolean validate() {
3048 int count = getItemCount();
3049 boolean isValid = true;
3050 for (int i = 0; i < count; i++) {
3051 boolean oneIsValid = getItem(i).validate();
3052 isValid = isValid && oneIsValid;
3053 }
3054 notifyDataSetChanged();
3055 return isValid;
3056 }
3057
3058 public boolean execute() {
3059 return execute("execute");
3060 }
3061
3062 public boolean execute(int actionPosition) {
3063 return execute(actionsAdapter.getItem(actionPosition).first);
3064 }
3065
3066 public synchronized boolean execute(String action) {
3067 if (!"cancel".equals(action) && executing) {
3068 loadingHasBeenLong = true;
3069 notifyDataSetChanged();
3070 return false;
3071 }
3072 if (!action.equals("cancel") && !action.equals("prev") && !validate()) return false;
3073
3074 if (response == null) return true;
3075 Element command = response.findChild("command", "http://jabber.org/protocol/commands");
3076 if (command == null) return true;
3077 String status = command.getAttribute("status");
3078 if (status == null || (!status.equals("executing") && !action.equals("prev"))) return true;
3079
3080 if (actionToWebview != null && !action.equals("cancel") && Build.VERSION.SDK_INT >= 23) {
3081 actionToWebview.postWebMessage(new WebMessage("xmpp_xep0050/" + action), Uri.parse("*"));
3082 return false;
3083 }
3084
3085 final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
3086 packet.setTo(response.getFrom());
3087 final Element c = packet.addChild("command", Namespace.COMMANDS);
3088 c.setAttribute("node", mNode);
3089 c.setAttribute("sessionid", command.getAttribute("sessionid"));
3090
3091 String formType = responseElement == null ? null : responseElement.getAttribute("type");
3092 if (!action.equals("cancel") &&
3093 !action.equals("prev") &&
3094 responseElement != null &&
3095 responseElement.getName().equals("x") &&
3096 responseElement.getNamespace().equals("jabber:x:data") &&
3097 formType != null && formType.equals("form")) {
3098
3099 Data form = Data.parse(responseElement);
3100 eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
3101 if (actionList != null) {
3102 actionList.setValue(action);
3103 c.setAttribute("action", "execute");
3104 }
3105
3106 if (mNode.equals("jabber:iq:register") && xmppConnectionService.isOnboarding() && form.getFieldByName("gateway-jid") != null) {
3107 if (form.getValue("gateway-jid") == null) {
3108 xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
3109 } else {
3110 xmppConnectionService.getPreferences().edit().putString("onboarding_action", form.getValue("gateway-jid")).commit();
3111 }
3112 }
3113
3114 responseElement.setAttribute("type", "submit");
3115 Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
3116 if (rsm != null) {
3117 Element max = new Element("max", "http://jabber.org/protocol/rsm");
3118 max.setContent("1000");
3119 rsm.addChild(max);
3120 }
3121
3122 c.addChild(responseElement);
3123 }
3124
3125 if (c.getAttribute("action") == null) c.setAttribute("action", action);
3126
3127 executing = true;
3128 xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
3129 updateWithResponse(iq);
3130 }, 120L);
3131
3132 loading();
3133 return false;
3134 }
3135
3136 public void refresh() {
3137 synchronized(this) {
3138 if (waitingForRefresh) notifyDataSetChanged();
3139 }
3140 }
3141
3142 protected void loading() {
3143 View v = getView();
3144 try {
3145 loadingTimer.schedule(new TimerTask() {
3146 @Override
3147 public void run() {
3148 View v2 = getView();
3149 loading = true;
3150
3151 loadingTimer.schedule(new TimerTask() {
3152 @Override
3153 public void run() {
3154 loadingHasBeenLong = true;
3155 if (v == null && v2 == null) return;
3156 (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3157 }
3158 }, 3000);
3159
3160 if (v == null && v2 == null) return;
3161 (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3162 }
3163 }, 500);
3164 } catch (final IllegalStateException e) { }
3165 }
3166
3167 protected GridLayoutManager setupLayoutManager() {
3168 int spanCount = 1;
3169
3170 Context ctx = mPager == null ? getView().getContext() : mPager.getContext();
3171 if (reported != null) {
3172 float screenWidth = ctx.getResources().getDisplayMetrics().widthPixels;
3173 TextPaint paint = ((TextView) LayoutInflater.from(mPager.getContext()).inflate(R.layout.command_result_cell, null)).getPaint();
3174 float tableHeaderWidth = reported.stream().reduce(
3175 0f,
3176 (total, field) -> total + StaticLayout.getDesiredWidth(field.getLabel().or("--------") + "\t", paint),
3177 (a, b) -> a + b
3178 );
3179
3180 spanCount = tableHeaderWidth > 0.59 * screenWidth ? 1 : this.reported.size();
3181 }
3182
3183 if (layoutManager != null && layoutManager.getSpanCount() != spanCount) {
3184 items.clear();
3185 notifyDataSetChanged();
3186 }
3187
3188 layoutManager = new GridLayoutManager(ctx, spanCount);
3189 layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
3190 @Override
3191 public int getSpanSize(int position) {
3192 if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
3193 return 1;
3194 }
3195 });
3196 return layoutManager;
3197 }
3198
3199 protected void setBinding(CommandPageBinding b) {
3200 mBinding = b;
3201 // https://stackoverflow.com/a/32350474/8611
3202 mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
3203 @Override
3204 public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
3205 if(rv.getChildCount() > 0) {
3206 int[] location = new int[2];
3207 rv.getLocationOnScreen(location);
3208 View childView = rv.findChildViewUnder(e.getX(), e.getY());
3209 if (childView instanceof ViewGroup) {
3210 childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
3211 }
3212 int action = e.getAction();
3213 switch (action) {
3214 case MotionEvent.ACTION_DOWN:
3215 if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(1)) || childView instanceof WebView) {
3216 rv.requestDisallowInterceptTouchEvent(true);
3217 }
3218 case MotionEvent.ACTION_UP:
3219 if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(-11)) || childView instanceof WebView) {
3220 rv.requestDisallowInterceptTouchEvent(true);
3221 }
3222 }
3223 }
3224
3225 return false;
3226 }
3227
3228 @Override
3229 public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }
3230
3231 @Override
3232 public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
3233 });
3234 mBinding.form.setLayoutManager(setupLayoutManager());
3235 mBinding.form.setAdapter(this);
3236 mBinding.actions.setAdapter(actionsAdapter);
3237 mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
3238 if (execute(pos)) {
3239 removeSession(CommandSession.this);
3240 }
3241 });
3242
3243 actionsAdapter.notifyDataSetChanged();
3244
3245 if (pendingResponsePacket != null) {
3246 final IqPacket pending = pendingResponsePacket;
3247 pendingResponsePacket = null;
3248 updateWithResponseUiThread(pending);
3249 }
3250 }
3251
3252 public View inflateUi(Context context, Consumer<ConversationPage> remover) {
3253 CommandPageBinding binding = DataBindingUtil.inflate(LayoutInflater.from(context), R.layout.command_page, null, false);
3254 setBinding(binding);
3255 return binding.getRoot();
3256 }
3257
3258 // https://stackoverflow.com/a/36037991/8611
3259 private View findViewAt(ViewGroup viewGroup, float x, float y) {
3260 for(int i = 0; i < viewGroup.getChildCount(); i++) {
3261 View child = viewGroup.getChildAt(i);
3262 if (child instanceof ViewGroup && !(child instanceof AbsListView) && !(child instanceof WebView)) {
3263 View foundView = findViewAt((ViewGroup) child, x, y);
3264 if (foundView != null && foundView.isShown()) {
3265 return foundView;
3266 }
3267 } else {
3268 int[] location = new int[2];
3269 child.getLocationOnScreen(location);
3270 Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
3271 if (rect.contains((int)x, (int)y)) {
3272 return child;
3273 }
3274 }
3275 }
3276
3277 return null;
3278 }
3279 }
3280 }
3281}