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