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