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