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