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 static Float steppedSliderStep(final Element field) {
1873 final Element validate = field.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1874 final String datatype = validate == null ? null : validate.getAttribute("datatype");
1875 if (!isNumericDatatype(datatype)) return null;
1876
1877 final Element range = validate.findChild("range", "http://jabber.org/protocol/xdata-validate");
1878 final String minValue = range == null ? null : range.getAttribute("min");
1879 final String maxValue = range == null ? null : range.getAttribute("max");
1880 final boolean integerDatatype = isIntegerDatatype(datatype);
1881 if (integerDatatype && (!isIntegerLexicalValue(minValue) || !isIntegerLexicalValue(maxValue))) return null;
1882 final Float min = parseFloat(minValue);
1883 final Float max = parseFloat(maxValue);
1884 if (min == null || max == null || min >= max) return null;
1885
1886 final String value = firstValue(field);
1887 if (value == null || value.equals("")) return null;
1888 if (integerDatatype && !isIntegerLexicalValue(value)) return null;
1889 final Float parsedValue = parseFloat(value);
1890 if (parsedValue == null || parsedValue < min || parsedValue > max) return null;
1891
1892 final List<Float> options = optionValues(field, integerDatatype);
1893 if (options == null) return null;
1894
1895 final Float step;
1896 if (options.size() < 2) {
1897 step = integerDatatype ? 1f : 0f;
1898 } else {
1899 step = uniformStep(options);
1900 if (step == null) return null;
1901 for (final float option : options) {
1902 if (!landsOnStep(option, min, step)) return null;
1903 }
1904 }
1905 if (step == null || (step > 0f && !landsOnStep(max, min, step))) return null;
1906 return step == 0f || landsOnStep(parsedValue, min, step) ? step : null;
1907 }
1908
1909 static String formatSliderValue(final float value, final String datatype) {
1910 final BigDecimal decimal = new BigDecimal(Float.toString(value));
1911 if (isIntegerDatatype(datatype)) {
1912 return decimal.setScale(0, RoundingMode.HALF_UP).toPlainString();
1913 }
1914 if ("xs:decimal".equals(datatype)) {
1915 return decimal.stripTrailingZeros().toPlainString();
1916 }
1917 return Float.toString(value);
1918 }
1919
1920 private static Float uniformStep(final List<Float> options) {
1921 final List<Float> sortedOptions = new ArrayList<>(options);
1922 Collections.sort(sortedOptions);
1923 final float step = sortedOptions.get(1) - sortedOptions.get(0);
1924 if (step <= 0f) return null;
1925 for (int i = 2; i < sortedOptions.size(); i++) {
1926 if (!(Math.abs(step - (sortedOptions.get(i) - sortedOptions.get(i - 1))) < 0.0001f)) return null;
1927 }
1928 return step;
1929 }
1930
1931 private static List<Float> optionValues(final Element field, final boolean integerDatatype) {
1932 final List<Float> values = new ArrayList<>();
1933 for (final Option option : Option.forField(field)) {
1934 final String optionValue = option.getValue();
1935 if (integerDatatype && !isIntegerLexicalValue(optionValue)) return null;
1936 final Float value = parseFloat(optionValue);
1937 if (value == null) return null;
1938 values.add(value);
1939 }
1940 return values;
1941 }
1942
1943 private static Float rangeFloat(final Element range, final String name) {
1944 return range == null ? null : parseFloat(range.getAttribute(name));
1945 }
1946
1947 private static Float parseFloat(final String value) {
1948 if (value == null || value.equals("")) return null;
1949 try {
1950 final float parsed = Float.parseFloat(value);
1951 return Float.isFinite(parsed) ? parsed : null;
1952 } catch (final NumberFormatException e) {
1953 return null;
1954 }
1955 }
1956
1957 private static String firstValue(final Element field) {
1958 for (final Element child : field.getChildren()) {
1959 if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
1960 return child.getContent();
1961 }
1962 }
1963 return null;
1964 }
1965
1966 private static boolean landsOnStep(final float value, final float min, final float step) {
1967 final double multiple = ((double) value - (double) min) / (double) step;
1968 return Math.abs(Math.rint(multiple) - multiple) < 0.0001f;
1969 }
1970
1971 private static boolean isIntegerLexicalValue(final String value) {
1972 if (value == null || value.equals("")) return false;
1973 final int start = value.charAt(0) == '+' || value.charAt(0) == '-' ? 1 : 0;
1974 if (start == value.length()) return false;
1975 for (int i = start; i < value.length(); i++) {
1976 if (!Character.isDigit(value.charAt(i))) return false;
1977 }
1978 return true;
1979 }
1980
1981 private static boolean isIntegerDatatype(final String datatype) {
1982 return "xs:integer".equals(datatype)
1983 || "xs:int".equals(datatype)
1984 || "xs:long".equals(datatype)
1985 || "xs:short".equals(datatype)
1986 || "xs:byte".equals(datatype);
1987 }
1988
1989 private static boolean isNumericDatatype(final String datatype) {
1990 return isIntegerDatatype(datatype)
1991 || "xs:decimal".equals(datatype)
1992 || "xs:double".equals(datatype);
1993 }
1994
1995 public class ConversationPagerAdapter extends PagerAdapter {
1996 protected WeakReference<ViewPager> mPager = new WeakReference<>(null);
1997 protected WeakReference<TabLayout> mTabs = new WeakReference<>(null);
1998 ArrayList<ConversationPage> sessions = null;
1999 protected WeakReference<View> page1 = new WeakReference<>(null);
2000 protected WeakReference<View> page2 = new WeakReference<>(null);
2001 protected boolean mOnboarding = false;
2002
2003 public void setupViewPager(ViewPager pager, TabLayout tabs, boolean onboarding, Conversation oldConversation) {
2004 mPager = new WeakReference(pager);
2005 mTabs = new WeakReference(tabs);
2006 mOnboarding = onboarding;
2007
2008 if (oldConversation != null) {
2009 oldConversation.pagerAdapter.mPager.clear();
2010 oldConversation.pagerAdapter.mTabs.clear();
2011 }
2012
2013 if (pager == null) {
2014 page1.clear();
2015 page2.clear();
2016 return;
2017 }
2018 if (sessions != null) show();
2019
2020 if (pager.getChildAt(0) != null) page1 = new WeakReference<>(pager.getChildAt(0));
2021 if (pager.getChildAt(1) != null) page2 = new WeakReference<>(pager.getChildAt(1));
2022 if (page2.get() != null && page2.get().findViewById(R.id.commands_view) == null) {
2023 page1.clear();
2024 page2.clear();
2025 }
2026 if (page1.get() == null) page1 = oldConversation.pagerAdapter.page1;
2027 if (page2.get() == null) page2 = oldConversation.pagerAdapter.page2;
2028 if (page1.get() == null || page2.get() == null) {
2029 throw new IllegalStateException("page1 or page2 were not present as child or in model?");
2030 }
2031 pager.removeView(page1.get());
2032 pager.removeView(page2.get());
2033 pager.clearOnPageChangeListeners();
2034 pager.setAdapter(this);
2035 tabs.setupWithViewPager(pager);
2036 pager.post(() -> pager.setCurrentItem(getCurrentTab()));
2037
2038 pager.addOnPageChangeListener(new PagerChangeListener(Conversation.this));
2039 }
2040
2041 private static class PagerChangeListener implements ViewPager.OnPageChangeListener {
2042 private final Conversation conversation;
2043
2044 PagerChangeListener(final Conversation conversation) {
2045 this.conversation = conversation;
2046 }
2047
2048 @Override
2049 public void onPageScrollStateChanged(int state) { }
2050
2051 @Override
2052 public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { }
2053
2054 @Override
2055 public void onPageSelected(int position) {
2056 final ViewPager pager = conversation.pagerAdapter.mPager.get();
2057 if (pager == null || pager.getAdapter() != conversation.pagerAdapter) return;
2058 conversation.setCurrentTab(position);
2059 }
2060 }
2061
2062 public void show() {
2063 if (sessions == null) {
2064 sessions = new ArrayList<>();
2065 notifyDataSetChanged();
2066 }
2067 if (!mOnboarding && mTabs.get() != null) mTabs.get().setVisibility(View.VISIBLE);
2068 }
2069
2070 public void hide() {
2071 if (sessions != null && !sessions.isEmpty()) return; // Do not hide during active session
2072 if (mPager.get() != null) mPager.get().setCurrentItem(0);
2073 if (mTabs.get() != null) mTabs.get().setVisibility(View.GONE);
2074 sessions = null;
2075 notifyDataSetChanged();
2076 }
2077
2078 public void refreshSessions() {
2079 if (sessions == null) return;
2080
2081 for (ConversationPage session : sessions) {
2082 session.refresh();
2083 }
2084 }
2085
2086 public void webxdcRealtimeData(final Element thread, final String base64) {
2087 if (sessions == null) return;
2088
2089 for (ConversationPage session : sessions) {
2090 if (session instanceof WebxdcPage) {
2091 if (((WebxdcPage) session).threadMatches(thread)) {
2092 ((WebxdcPage) session).realtimeData(base64);
2093 }
2094 }
2095 }
2096 }
2097
2098 public void startWebxdc(WebxdcPage page) {
2099 show();
2100 sessions.add(page);
2101 notifyDataSetChanged();
2102 if (mPager.get() != null) mPager.get().setCurrentItem(getCount() - 1);
2103 }
2104
2105 public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
2106 show();
2107 CommandSession session = new CommandSession(command.getAttribute("name"), command.getAttribute("node"), xmppConnectionService);
2108
2109 final var packet = new Iq(Iq.Type.SET);
2110 packet.setTo(command.getAttributeAsJid("jid"));
2111 final Element c = packet.addChild("command", Namespace.COMMANDS);
2112 c.setAttribute("node", command.getAttribute("node"));
2113 c.setAttribute("action", "execute");
2114
2115 final TimerTask task = new TimerTask() {
2116 @Override
2117 public void run() {
2118 if (getAccount().getStatus() != Account.State.ONLINE) {
2119 final TimerTask self = this;
2120 new Timer().schedule(new TimerTask() {
2121 @Override
2122 public void run() {
2123 self.run();
2124 }
2125 }, 1000);
2126 } else {
2127 xmppConnectionService.sendIqPacket(getAccount(), packet, (iq) -> {
2128 session.updateWithResponse(iq);
2129 }, 120L);
2130 }
2131 }
2132 };
2133
2134 if (command.getAttribute("node").equals("jabber:iq:register") && packet.getTo().asBareJid().equals(Jid.of("cheogram.com"))) {
2135 new com.cheogram.android.CheogramLicenseChecker(mPager.get().getContext(), (signedData, signature) -> {
2136 if (signedData != null && signature != null) {
2137 c.addChild("license", "https://ns.cheogram.com/google-play").setContent(signedData);
2138 c.addChild("licenseSignature", "https://ns.cheogram.com/google-play").setContent(signature);
2139 }
2140
2141 task.run();
2142 }).checkLicense();
2143 } else {
2144 task.run();
2145 }
2146
2147 sessions.add(session);
2148 notifyDataSetChanged();
2149 if (mPager.get() != null) mPager.get().setCurrentItem(getCount() - 1);
2150 }
2151
2152 public void startMucConfig(XmppConnectionService xmppConnectionService) {
2153 MucConfigSession session = new MucConfigSession(xmppConnectionService);
2154 final var packet = new Iq(Iq.Type.GET);
2155 packet.setTo(Conversation.this.getJid().asBareJid());
2156 packet.addChild("query", "http://jabber.org/protocol/muc#owner");
2157
2158 final TimerTask task = new TimerTask() {
2159 @Override
2160 public void run() {
2161 if (getAccount().getStatus() != Account.State.ONLINE) {
2162 final TimerTask self = this;
2163 new Timer().schedule(new TimerTask() {
2164 @Override
2165 public void run() {
2166 self.run();
2167 }
2168 }, 1000);
2169 } else {
2170 xmppConnectionService.sendIqPacket(getAccount(), packet, (iq) -> {
2171 session.updateWithResponse(iq);
2172 }, 120L);
2173 }
2174 }
2175 };
2176 task.run();
2177
2178 sessions.add(session);
2179 notifyDataSetChanged();
2180 if (mPager.get() != null) mPager.get().setCurrentItem(getCount() - 1);
2181 }
2182
2183 public void removeSession(ConversationPage session) {
2184 if (sessions == null) return;
2185 if (!sessions.remove(session)) return;
2186 session.close();
2187 notifyDataSetChanged();
2188 if (session instanceof WebxdcPage && mPager.get() != null) mPager.get().setCurrentItem(0);
2189 }
2190
2191 public boolean switchToSession(final String node) {
2192 if (sessions == null) return false;
2193
2194 int i = 0;
2195 for (ConversationPage session : sessions) {
2196 if (session.getNode().equals(node)) {
2197 if (mPager.get() != null) mPager.get().setCurrentItem(i + 2);
2198 return true;
2199 }
2200 i++;
2201 }
2202
2203 return false;
2204 }
2205
2206 @NonNull
2207 @Override
2208 public Object instantiateItem(@NonNull ViewGroup container, int position) {
2209 if (position == 0) {
2210 final var pg1 = page1.get();
2211 if (pg1 != null && pg1.getParent() != null) {
2212 ((ViewGroup) pg1.getParent()).removeView(pg1);
2213 }
2214 container.addView(pg1);
2215 return pg1;
2216 }
2217 if (position == 1) {
2218 final var pg2 = page2.get();
2219 if (pg2 != null && pg2.getParent() != null) {
2220 ((ViewGroup) pg2.getParent()).removeView(pg2);
2221 }
2222 container.addView(pg2);
2223 return pg2;
2224 }
2225
2226 if (position-2 >= sessions.size()) return null;
2227 ConversationPage session = sessions.get(position-2);
2228 View v = session.inflateUi(container.getContext(), (s) -> removeSession(s));
2229 if (v != null && v.getParent() != null) {
2230 ((ViewGroup) v.getParent()).removeView(v);
2231 }
2232 container.addView(v);
2233 return session;
2234 }
2235
2236 @Override
2237 public void destroyItem(@NonNull ViewGroup container, int position, Object o) {
2238 if (o == null) return;
2239 if (position < 2) {
2240 container.removeView((View) o);
2241 return;
2242 }
2243
2244 container.removeView(((ConversationPage) o).getView());
2245 }
2246
2247 @Override
2248 public int getItemPosition(Object o) {
2249 if (mPager.get() != null) {
2250 final var page1Deref = page1.get();
2251 if (o == page1Deref) {
2252 if (page1Deref == null) return PagerAdapter.POSITION_NONE;
2253 else return PagerAdapter.POSITION_UNCHANGED;
2254 }
2255 final var page2Deref = page2.get();
2256 if (o == page2Deref) {
2257 if (page2Deref == null) return PagerAdapter.POSITION_NONE;
2258 else return PagerAdapter.POSITION_UNCHANGED;
2259 }
2260 }
2261
2262 int pos = sessions == null ? -1 : sessions.indexOf(o);
2263 if (pos < 0) return PagerAdapter.POSITION_NONE;
2264 return pos + 2;
2265 }
2266
2267 @Override
2268 public int getCount() {
2269 if (sessions == null) return 1;
2270
2271 int count = 2 + sessions.size();
2272 if (mTabs.get() == null) return count;
2273
2274 if (count > 2) {
2275 mTabs.get().setTabMode(TabLayout.MODE_SCROLLABLE);
2276 } else {
2277 mTabs.get().setTabMode(TabLayout.MODE_FIXED);
2278 }
2279 return count;
2280 }
2281
2282 @Override
2283 public boolean isViewFromObject(@NonNull View view, @NonNull Object o) {
2284 if (view == o) return true;
2285
2286 if (o instanceof ConversationPage) {
2287 return ((ConversationPage) o).getView() == view;
2288 }
2289
2290 return false;
2291 }
2292
2293 @Nullable
2294 @Override
2295 public CharSequence getPageTitle(int position) {
2296 switch (position) {
2297 case 0:
2298 return "Conversation";
2299 case 1:
2300 return "Commands";
2301 default:
2302 ConversationPage session = sessions.get(position-2);
2303 if (session == null) return super.getPageTitle(position);
2304 return session.getTitle();
2305 }
2306 }
2307
2308 class CommandSession extends RecyclerView.Adapter<CommandSession.ViewHolder> implements ConversationPage {
2309 abstract class ViewHolder<T extends ViewDataBinding> extends RecyclerView.ViewHolder {
2310 protected T binding;
2311
2312 public ViewHolder(T binding) {
2313 super(binding.getRoot());
2314 this.binding = binding;
2315 }
2316
2317 abstract public void bind(Item el);
2318
2319 protected void setTextOrHide(TextView v, Optional<String> s) {
2320 if (s == null || !s.isPresent()) {
2321 v.setVisibility(View.GONE);
2322 } else {
2323 v.setVisibility(View.VISIBLE);
2324 v.setText(s.get());
2325 }
2326 }
2327
2328 protected void setupInputType(Element field, TextView textinput, TextInputLayout layout) {
2329 int flags = 0;
2330 if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_NONE);
2331 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
2332
2333 String type = field.getAttribute("type");
2334 if (type != null) {
2335 if (type.equals("text-multi") || type.equals("jid-multi")) {
2336 flags |= InputType.TYPE_TEXT_FLAG_MULTI_LINE;
2337 }
2338
2339 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
2340
2341 if (type.equals("jid-single") || type.equals("jid-multi")) {
2342 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
2343 }
2344
2345 if (type.equals("text-private")) {
2346 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
2347 if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_PASSWORD_TOGGLE);
2348 }
2349 }
2350
2351 Element validate = field.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2352 if (validate == null) return;
2353 String datatype = validate.getAttribute("datatype");
2354 if (datatype == null) return;
2355
2356 if (isIntegerDatatype(datatype)) {
2357 textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED);
2358 }
2359
2360 if (datatype.equals("xs:decimal") || datatype.equals("xs:double")) {
2361 textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED | InputType.TYPE_NUMBER_FLAG_DECIMAL);
2362 }
2363
2364 if (datatype.equals("xs:date")) {
2365 textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_DATE);
2366 }
2367
2368 if (datatype.equals("xs:dateTime")) {
2369 textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_NORMAL);
2370 }
2371
2372 if (datatype.equals("xs:time")) {
2373 textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_TIME);
2374 }
2375
2376 if (datatype.equals("xs:anyURI")) {
2377 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
2378 }
2379
2380 if (datatype.equals("html:tel")) {
2381 textinput.setInputType(flags | InputType.TYPE_CLASS_PHONE);
2382 }
2383
2384 if (datatype.equals("html:email")) {
2385 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
2386 }
2387 }
2388
2389 protected String formatValue(String datatype, String value, boolean compact) {
2390 if ("xs:dateTime".equals(datatype)) {
2391 ZonedDateTime zonedDateTime = null;
2392 try {
2393 zonedDateTime = ZonedDateTime.parse(value, DateTimeFormatter.ISO_DATE_TIME);
2394 } catch (final DateTimeParseException e) {
2395 try {
2396 DateTimeFormatter almostIso = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm[:ss] X");
2397 zonedDateTime = ZonedDateTime.parse(value, almostIso);
2398 } catch (final DateTimeParseException e2) { }
2399 }
2400 if (zonedDateTime == null) return value;
2401 ZonedDateTime localZonedDateTime = zonedDateTime.withZoneSameInstant(ZoneId.systemDefault());
2402 DateTimeFormatter outputFormat = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT);
2403 return localZonedDateTime.toLocalDateTime().format(outputFormat);
2404 }
2405
2406 if ("html:tel".equals(datatype) && !compact) {
2407 return PhoneNumberUtils.formatNumber(value, value, null);
2408 }
2409
2410 return value;
2411 }
2412 }
2413
2414 class ErrorViewHolder extends ViewHolder<CommandNoteBinding> {
2415 public ErrorViewHolder(CommandNoteBinding binding) { super(binding); }
2416
2417 @Override
2418 public void bind(Item iq) {
2419 binding.errorIcon.setVisibility(View.VISIBLE);
2420
2421 if (iq == null || iq.el == null) return;
2422 Element error = iq.el.findChild("error");
2423 if (error == null) {
2424 binding.message.setText("Unexpected response: " + iq);
2425 return;
2426 }
2427 String text = error.findChildContent("text", "urn:ietf:params:xml:ns:xmpp-stanzas");
2428 if (text == null || text.equals("")) {
2429 text = error.getChildren().get(0).getName();
2430 }
2431 binding.message.setText(text);
2432 }
2433 }
2434
2435 class NoteViewHolder extends ViewHolder<CommandNoteBinding> {
2436 public NoteViewHolder(CommandNoteBinding binding) { super(binding); }
2437
2438 @Override
2439 public void bind(Item note) {
2440 binding.message.setText(note != null && note.el != null ? note.el.getContent() : "");
2441
2442 String type = note.el.getAttribute("type");
2443 if (type != null && type.equals("error")) {
2444 binding.errorIcon.setVisibility(View.VISIBLE);
2445 }
2446 }
2447 }
2448
2449 class ResultFieldViewHolder extends ViewHolder<CommandResultFieldBinding> {
2450 public ResultFieldViewHolder(CommandResultFieldBinding binding) { super(binding); }
2451
2452 @Override
2453 public void bind(Item item) {
2454 Field field = (Field) item;
2455 setTextOrHide(binding.label, field.getLabel());
2456 setTextOrHide(binding.desc, field.getDesc());
2457
2458 Element media = field.el.findChild("media", "urn:xmpp:media-element");
2459 if (media == null) {
2460 binding.mediaImage.setVisibility(View.GONE);
2461 } else {
2462 final LruCache<String, Drawable> cache = xmppConnectionService.getDrawableCache();
2463 final HttpConnectionManager httpManager = xmppConnectionService.getHttpConnectionManager();
2464 for (Element uriEl : media.getChildren()) {
2465 if (!"uri".equals(uriEl.getName())) continue;
2466 if (!"urn:xmpp:media-element".equals(uriEl.getNamespace())) continue;
2467 String mimeType = uriEl.getAttribute("type");
2468 String uriS = uriEl.getContent();
2469 if (mimeType == null || uriS == null) continue;
2470 Uri uri = Uri.parse(uriS);
2471 if (mimeType.startsWith("image/") && "https".equals(uri.getScheme())) {
2472 final Drawable d = getDrawableForUrl(uri.toString());
2473 if (d != null) {
2474 binding.mediaImage.setImageDrawable(d);
2475 binding.mediaImage.setVisibility(View.VISIBLE);
2476 }
2477 }
2478 }
2479 }
2480
2481 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2482 String datatype = validate == null ? null : validate.getAttribute("datatype");
2483
2484 ArrayAdapter<Option> values = new ArrayAdapter<>(binding.getRoot().getContext(), R.layout.simple_list_item);
2485 for (Element el : field.el.getChildren()) {
2486 if (el.getName().equals("value") && el.getNamespace().equals("jabber:x:data")) {
2487 values.add(new Option(el.getContent(), formatValue(datatype, el.getContent(), false)));
2488 }
2489 }
2490 binding.values.setAdapter(values);
2491 Util.justifyListViewHeightBasedOnChildren(binding.values);
2492
2493 if (field.getType().equals(Optional.of("jid-single")) || field.getType().equals(Optional.of("jid-multi"))) {
2494 binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
2495 final var jid = Uri.encode(Jid.of(values.getItem(pos).getValue()).toString(), "@/+");
2496 new FixedURLSpan("xmpp:" + jid, account).onClick(binding.values);
2497 });
2498 } else if ("xs:anyURI".equals(datatype)) {
2499 binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
2500 new FixedURLSpan(values.getItem(pos).getValue(), account).onClick(binding.values);
2501 });
2502 } else if ("html:tel".equals(datatype)) {
2503 binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
2504 try {
2505 new FixedURLSpan("tel:" + PhoneNumberUtilWrapper.normalize(binding.getRoot().getContext(), values.getItem(pos).getValue()), account).onClick(binding.values);
2506 } catch (final IllegalArgumentException | NumberParseException | NullPointerException e) { }
2507 });
2508 }
2509
2510 binding.values.setOnItemLongClickListener((arg0, arg1, pos, id) -> {
2511 if (ShareUtil.copyTextToClipboard(binding.getRoot().getContext(), values.getItem(pos).getValue(), R.string.message)) {
2512 Toast.makeText(binding.getRoot().getContext(), R.string.message_copied_to_clipboard, Toast.LENGTH_SHORT).show();
2513 }
2514 return true;
2515 });
2516 }
2517 }
2518
2519 class ResultCellViewHolder extends ViewHolder<CommandResultCellBinding> {
2520 public ResultCellViewHolder(CommandResultCellBinding binding) { super(binding); }
2521
2522 @Override
2523 public void bind(Item item) {
2524 Cell cell = (Cell) item;
2525
2526 if (cell.el == null) {
2527 binding.text.setTextAppearance(binding.getRoot().getContext(), com.google.android.material.R.style.TextAppearance_Material3_TitleMedium);
2528 setTextOrHide(binding.text, cell.reported.getLabel());
2529 } else {
2530 Element validate = cell.reported.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2531 String datatype = validate == null ? null : validate.getAttribute("datatype");
2532 String value = formatValue(datatype, cell.el.findChildContent("value", "jabber:x:data"), true);
2533 SpannableStringBuilder text = new SpannableStringBuilder(value == null ? "" : value);
2534 if (cell.reported.getType().equals(Optional.of("jid-single"))) {
2535 final var jid = Uri.encode(Jid.of(text.toString()).toString(), "@/+");
2536 text.setSpan(new FixedURLSpan("xmpp:" + jid, account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
2537 } else if ("xs:anyURI".equals(datatype)) {
2538 text.setSpan(new FixedURLSpan(text.toString(), account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
2539 } else if ("html:tel".equals(datatype)) {
2540 try {
2541 text.setSpan(new FixedURLSpan("tel:" + PhoneNumberUtilWrapper.normalize(binding.getRoot().getContext(), text.toString()), account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
2542 } catch (final IllegalArgumentException | NumberParseException | NullPointerException e) { }
2543 }
2544
2545 binding.text.setTextAppearance(binding.getRoot().getContext(), com.google.android.material.R.style.TextAppearance_Material3_BodyMedium);
2546 binding.text.setText(text);
2547
2548 BetterLinkMovementMethod method = BetterLinkMovementMethod.newInstance();
2549 method.setOnLinkLongClickListener((tv, url) -> {
2550 tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
2551 ShareUtil.copyLinkToClipboard(binding.getRoot().getContext(), url);
2552 return true;
2553 });
2554 binding.text.setMovementMethod(method);
2555 }
2556 }
2557 }
2558
2559 class ItemCardViewHolder extends ViewHolder<CommandItemCardBinding> {
2560 public ItemCardViewHolder(CommandItemCardBinding binding) { super(binding); }
2561
2562 @Override
2563 public void bind(Item item) {
2564 binding.fields.removeAllViews();
2565
2566 for (Field field : reported) {
2567 CommandResultFieldBinding row = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.command_result_field, binding.fields, false);
2568 GridLayout.LayoutParams param = new GridLayout.LayoutParams();
2569 param.columnSpec = GridLayout.spec(GridLayout.UNDEFINED, GridLayout.FILL, 1f);
2570 param.width = 0;
2571 row.getRoot().setLayoutParams(param);
2572 binding.fields.addView(row.getRoot());
2573 for (Element el : item.el.getChildren()) {
2574 if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data") && el.getAttribute("var") != null && el.getAttribute("var").equals(field.getVar())) {
2575 for (String label : field.getLabel().asSet()) {
2576 el.setAttribute("label", label);
2577 }
2578 for (String desc : field.getDesc().asSet()) {
2579 el.setAttribute("desc", desc);
2580 }
2581 for (String type : field.getType().asSet()) {
2582 el.setAttribute("type", type);
2583 }
2584 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2585 if (validate != null) el.addChild(validate);
2586 new ResultFieldViewHolder(row).bind(new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), -1));
2587 }
2588 }
2589 }
2590 }
2591 }
2592
2593 class CheckboxFieldViewHolder extends ViewHolder<CommandCheckboxFieldBinding> implements CompoundButton.OnCheckedChangeListener {
2594 public CheckboxFieldViewHolder(CommandCheckboxFieldBinding binding) {
2595 super(binding);
2596 binding.row.setOnClickListener((v) -> {
2597 binding.checkbox.toggle();
2598 });
2599 binding.checkbox.setOnCheckedChangeListener(this);
2600 }
2601 protected Element mValue = null;
2602
2603 @Override
2604 public void bind(Item item) {
2605 Field field = (Field) item;
2606 binding.label.setText(field.getLabel().or(""));
2607 setTextOrHide(binding.desc, field.getDesc());
2608 mValue = field.getValue();
2609 final var isChecked = mValue.getContent() != null && (mValue.getContent().equals("true") || mValue.getContent().equals("1"));
2610 mValue.setContent(isChecked ? "true" : "false");
2611 binding.checkbox.setChecked(isChecked);
2612 }
2613
2614 @Override
2615 public void onCheckedChanged(CompoundButton checkbox, boolean isChecked) {
2616 if (mValue == null) return;
2617
2618 mValue.setContent(isChecked ? "true" : "false");
2619 }
2620 }
2621
2622 class SearchListFieldViewHolder extends ViewHolder<CommandSearchListFieldBinding> implements TextWatcher {
2623 public SearchListFieldViewHolder(CommandSearchListFieldBinding binding) {
2624 super(binding);
2625 binding.search.addTextChangedListener(this);
2626 }
2627 protected Field field = null;
2628 Set<String> filteredValues;
2629 List<Option> options = new ArrayList<>();
2630 protected ArrayAdapter<Option> adapter;
2631 protected boolean open;
2632 protected boolean multi;
2633 protected int textColor = -1;
2634
2635 @Override
2636 public void bind(Item item) {
2637 ViewGroup.LayoutParams layout = binding.list.getLayoutParams();
2638 final float density = xmppConnectionService.getResources().getDisplayMetrics().density;
2639 if (fillableFieldCount > 1) {
2640 layout.height = (int) (density * 200);
2641 } else {
2642 layout.height = (int) Math.max(density * 200, xmppConnectionService.getResources().getDisplayMetrics().heightPixels / 2);
2643 }
2644 binding.list.setLayoutParams(layout);
2645
2646 field = (Field) item;
2647 setTextOrHide(binding.label, field.getLabel());
2648 setTextOrHide(binding.desc, field.getDesc());
2649
2650 if (textColor == -1) textColor = binding.desc.getCurrentTextColor();
2651 if (field.error != null) {
2652 binding.desc.setVisibility(View.VISIBLE);
2653 binding.desc.setText(field.error);
2654 binding.desc.setTextColor(com.google.android.material.R.attr.colorError);
2655 } else {
2656 binding.desc.setTextColor(textColor);
2657 }
2658
2659 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2660 open = validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null;
2661 setupInputType(field.el, binding.search, null);
2662
2663 multi = field.getType().equals(Optional.of("list-multi"));
2664 if (multi) {
2665 binding.list.setChoiceMode(AbsListView.CHOICE_MODE_MULTIPLE);
2666 } else {
2667 binding.list.setChoiceMode(AbsListView.CHOICE_MODE_SINGLE);
2668 }
2669
2670 options = field.getOptions();
2671 binding.list.setOnItemClickListener((parent, view, position, id) -> {
2672 Set<String> values = new HashSet<>();
2673 if (multi) {
2674 final var optionValues = options.stream().map(o -> o.getValue()).collect(Collectors.toSet());
2675 values.addAll(field.getValues());
2676 for (final String value : field.getValues()) {
2677 if (filteredValues.contains(value) || (!open && !optionValues.contains(value))) {
2678 values.remove(value);
2679 }
2680 }
2681 }
2682
2683 SparseBooleanArray positions = binding.list.getCheckedItemPositions();
2684 for (int i = 0; i < positions.size(); i++) {
2685 if (positions.valueAt(i)) values.add(adapter.getItem(positions.keyAt(i)).getValue());
2686 }
2687 field.setValues(values);
2688
2689 if (!multi && open) binding.search.setText(String.join("\n", field.getValues()));
2690 });
2691 search("");
2692 }
2693
2694 @Override
2695 public void afterTextChanged(Editable s) {
2696 if (!multi && open) field.setValues(List.of(s.toString()));
2697 search(s.toString());
2698 }
2699
2700 @Override
2701 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2702
2703 @Override
2704 public void onTextChanged(CharSequence s, int start, int count, int after) { }
2705
2706 protected void search(String s) {
2707 List<Option> filteredOptions;
2708 final String q = s.replaceAll("\\W", "").toLowerCase();
2709 if (q == null || q.equals("")) {
2710 filteredOptions = options;
2711 } else {
2712 filteredOptions = options.stream().filter(o -> o.toString().replaceAll("\\W", "").toLowerCase().contains(q)).collect(Collectors.toList());
2713 }
2714 filteredValues = filteredOptions.stream().map(o -> o.getValue()).collect(Collectors.toSet());
2715 adapter = new ArrayAdapter(binding.getRoot().getContext(), R.layout.simple_list_item, filteredOptions);
2716 binding.list.setAdapter(adapter);
2717
2718 for (final String value : field.getValues()) {
2719 int checkedPos = filteredOptions.indexOf(new Option(value, ""));
2720 if (checkedPos >= 0) binding.list.setItemChecked(checkedPos, true);
2721 }
2722 }
2723 }
2724
2725 class RadioEditFieldViewHolder extends ViewHolder<CommandRadioEditFieldBinding> implements CompoundButton.OnCheckedChangeListener, TextWatcher {
2726 public RadioEditFieldViewHolder(CommandRadioEditFieldBinding binding) {
2727 super(binding);
2728 binding.open.addTextChangedListener(this);
2729 options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.radio_grid_item) {
2730 @Override
2731 public View getView(int position, View convertView, ViewGroup parent) {
2732 CompoundButton v = (CompoundButton) super.getView(position, convertView, parent);
2733 v.setId(position);
2734 v.setChecked(getItem(position).getValue().equals(mValue.getContent()));
2735 v.setOnCheckedChangeListener(RadioEditFieldViewHolder.this);
2736 return v;
2737 }
2738 };
2739 }
2740 protected Element mValue = null;
2741 protected ArrayAdapter<Option> options;
2742 protected int textColor = -1;
2743
2744 @Override
2745 public void bind(Item item) {
2746 Field field = (Field) item;
2747 setTextOrHide(binding.label, field.getLabel());
2748 setTextOrHide(binding.desc, field.getDesc());
2749
2750 if (textColor == -1) textColor = binding.desc.getCurrentTextColor();
2751 if (field.error != null) {
2752 binding.desc.setVisibility(View.VISIBLE);
2753 binding.desc.setText(field.error);
2754 binding.desc.setTextColor(com.google.android.material.R.attr.colorError);
2755 } else {
2756 binding.desc.setTextColor(textColor);
2757 }
2758
2759 mValue = field.getValue();
2760
2761 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2762 binding.open.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
2763 binding.open.setText(mValue.getContent());
2764 setupInputType(field.el, binding.open, null);
2765
2766 options.clear();
2767 List<Option> theOptions = field.getOptions();
2768 options.addAll(theOptions);
2769
2770 float screenWidth = binding.getRoot().getContext().getResources().getDisplayMetrics().widthPixels;
2771 TextPaint paint = ((TextView) LayoutInflater.from(binding.getRoot().getContext()).inflate(R.layout.radio_grid_item, null)).getPaint();
2772 float maxColumnWidth = theOptions.stream().map((x) ->
2773 StaticLayout.getDesiredWidth(x.toString(), paint)
2774 ).max(Float::compare).orElse(new Float(0.0));
2775 if (maxColumnWidth * theOptions.size() < 0.90 * screenWidth) {
2776 binding.radios.setNumColumns(theOptions.size());
2777 } else if (maxColumnWidth * (theOptions.size() / 2) < 0.90 * screenWidth) {
2778 binding.radios.setNumColumns(theOptions.size() / 2);
2779 } else {
2780 binding.radios.setNumColumns(1);
2781 }
2782 binding.radios.setAdapter(options);
2783 }
2784
2785 @Override
2786 public void onCheckedChanged(CompoundButton radio, boolean isChecked) {
2787 if (mValue == null) return;
2788
2789 if (isChecked) {
2790 mValue.setContent(options.getItem(radio.getId()).getValue());
2791 binding.open.setText(mValue.getContent());
2792 }
2793 options.notifyDataSetChanged();
2794 }
2795
2796 @Override
2797 public void afterTextChanged(Editable s) {
2798 if (mValue == null) return;
2799
2800 mValue.setContent(s.toString());
2801 options.notifyDataSetChanged();
2802 }
2803
2804 @Override
2805 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2806
2807 @Override
2808 public void onTextChanged(CharSequence s, int start, int count, int after) { }
2809 }
2810
2811 class SpinnerFieldViewHolder extends ViewHolder<CommandSpinnerFieldBinding> implements AdapterView.OnItemSelectedListener {
2812 public SpinnerFieldViewHolder(CommandSpinnerFieldBinding binding) {
2813 super(binding);
2814 binding.spinner.setOnItemSelectedListener(this);
2815 }
2816 protected Element mValue = null;
2817
2818 @Override
2819 public void bind(Item item) {
2820 Field field = (Field) item;
2821 setTextOrHide(binding.label, field.getLabel());
2822 binding.spinner.setPrompt(field.getLabel().or(""));
2823 setTextOrHide(binding.desc, field.getDesc());
2824
2825 mValue = field.getValue();
2826
2827 ArrayAdapter<Option> options = new ArrayAdapter<Option>(binding.getRoot().getContext(), android.R.layout.simple_spinner_item);
2828 options.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
2829 options.addAll(field.getOptions());
2830
2831 binding.spinner.setAdapter(options);
2832 binding.spinner.setSelection(options.getPosition(new Option(mValue.getContent(), null)));
2833 }
2834
2835 @Override
2836 public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
2837 Option o = (Option) parent.getItemAtPosition(pos);
2838 if (mValue == null) return;
2839
2840 mValue.setContent(o == null ? "" : o.getValue());
2841 }
2842
2843 @Override
2844 public void onNothingSelected(AdapterView<?> parent) {
2845 mValue.setContent("");
2846 }
2847 }
2848
2849 class ButtonGridFieldViewHolder extends ViewHolder<CommandButtonGridFieldBinding> {
2850 public ButtonGridFieldViewHolder(CommandButtonGridFieldBinding binding) {
2851 super(binding);
2852 options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.button_grid_item) {
2853 protected int height = 0;
2854
2855 @Override
2856 public View getView(int position, View convertView, ViewGroup parent) {
2857 Button v = (Button) super.getView(position, convertView, parent);
2858 v.setOnClickListener((view) -> {
2859 mValue.setContent(getItem(position).getValue());
2860 execute();
2861 loading = true;
2862 });
2863
2864 final SVG icon = getItem(position).getIcon();
2865 if (icon != null) {
2866 final Element iconEl = getItem(position).getIconEl();
2867 if (height < 1) {
2868 v.measure(0, 0);
2869 height = v.getMeasuredHeight();
2870 }
2871 if (height < 1) return v;
2872 if (mediaSelector) {
2873 final Drawable d = getDrawableForSVG(icon, iconEl, height * 4);
2874 if (d != null) {
2875 final int boundsHeight = 35 + (int)((height * 4) / xmppConnectionService.getResources().getDisplayMetrics().density);
2876 d.setBounds(0, 0, d.getIntrinsicWidth(), boundsHeight);
2877 }
2878 v.setCompoundDrawables(null, d, null, null);
2879 } else {
2880 v.setCompoundDrawablesRelativeWithIntrinsicBounds(getDrawableForSVG(icon, iconEl, height), null, null, null);
2881 }
2882 }
2883
2884 return v;
2885 }
2886 };
2887 }
2888 protected Element mValue = null;
2889 protected ArrayAdapter<Option> options;
2890 protected Option defaultOption = null;
2891 protected boolean mediaSelector = false;
2892 protected int textColor = -1;
2893
2894 @Override
2895 public void bind(Item item) {
2896 Field field = (Field) item;
2897 setTextOrHide(binding.label, field.getLabel());
2898 setTextOrHide(binding.desc, field.getDesc());
2899
2900 if (textColor == -1) textColor = binding.desc.getCurrentTextColor();
2901 if (field.error != null) {
2902 binding.desc.setVisibility(View.VISIBLE);
2903 binding.desc.setText(field.error);
2904 binding.desc.setTextColor(com.google.android.material.R.attr.colorError);
2905 } else {
2906 binding.desc.setTextColor(textColor);
2907 }
2908
2909 mValue = field.getValue();
2910 mediaSelector = field.el.findChild("media-selector", "https://ns.cheogram.com/") != null;
2911
2912 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2913 binding.openButton.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
2914 binding.openButton.setOnClickListener((view) -> {
2915 AlertDialog.Builder builder = new AlertDialog.Builder(binding.getRoot().getContext());
2916 DialogQuickeditBinding dialogBinding = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.dialog_quickedit, null, false);
2917 builder.setPositiveButton(R.string.action_execute, null);
2918 if (field.getDesc().isPresent()) {
2919 dialogBinding.inputLayout.setHint(field.getDesc().get());
2920 }
2921 dialogBinding.inputEditText.requestFocus();
2922 dialogBinding.inputEditText.getText().append(mValue.getContent());
2923 builder.setView(dialogBinding.getRoot());
2924 builder.setNegativeButton(R.string.cancel, null);
2925 final AlertDialog dialog = builder.create();
2926 dialog.setOnShowListener(d -> SoftKeyboardUtils.showKeyboard(dialogBinding.inputEditText));
2927 dialog.show();
2928 View.OnClickListener clickListener = v -> {
2929 String value = dialogBinding.inputEditText.getText().toString();
2930 mValue.setContent(value);
2931 SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2932 dialog.dismiss();
2933 execute();
2934 loading = true;
2935 };
2936 dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener(clickListener);
2937 dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setOnClickListener((v -> {
2938 SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2939 dialog.dismiss();
2940 }));
2941 dialog.setCanceledOnTouchOutside(false);
2942 dialog.setOnDismissListener(dialog1 -> {
2943 SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2944 });
2945 });
2946
2947 options.clear();
2948 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();
2949
2950 defaultOption = null;
2951 for (Option option : theOptions) {
2952 if (option.getValue().equals(mValue.getContent())) {
2953 defaultOption = option;
2954 break;
2955 }
2956 }
2957 if (defaultOption == null && !mValue.getContent().equals("")) {
2958 // Synthesize default option for custom value
2959 defaultOption = new Option(mValue.getContent(), mValue.getContent());
2960 }
2961 if (defaultOption == null) {
2962 binding.defaultButton.setVisibility(View.GONE);
2963 binding.defaultButtonSeperator.setVisibility(View.GONE);
2964 } else {
2965 theOptions.remove(defaultOption);
2966 binding.defaultButton.setVisibility(View.VISIBLE);
2967 binding.defaultButtonSeperator.setVisibility(View.VISIBLE);
2968
2969 final SVG defaultIcon = defaultOption.getIcon();
2970 if (defaultIcon != null) {
2971 DisplayMetrics display = mPager.get().getContext().getResources().getDisplayMetrics();
2972 int height = (int)(display.heightPixels*display.density/8);
2973 binding.defaultButton.setCompoundDrawablesRelativeWithIntrinsicBounds(null, getDrawableForSVG(defaultIcon, defaultOption.getIconEl(), height), null, null);
2974 }
2975
2976 binding.defaultButton.setText(defaultOption.toString());
2977 binding.defaultButton.setOnClickListener((view) -> {
2978 mValue.setContent(defaultOption.getValue());
2979 execute();
2980 loading = true;
2981 });
2982 }
2983
2984 options.addAll(theOptions);
2985 binding.buttons.setAdapter(options);
2986 }
2987 }
2988
2989 class TextFieldViewHolder extends ViewHolder<CommandTextFieldBinding> implements TextWatcher {
2990 public TextFieldViewHolder(CommandTextFieldBinding binding) {
2991 super(binding);
2992 binding.textinput.addTextChangedListener(this);
2993 }
2994 protected Field field = null;
2995
2996 @Override
2997 public void bind(Item item) {
2998 field = (Field) item;
2999 binding.textinputLayout.setHint(field.getLabel().or(""));
3000
3001 binding.textinputLayout.setHelperTextEnabled(field.getDesc().isPresent());
3002 for (String desc : field.getDesc().asSet()) {
3003 binding.textinputLayout.setHelperText(desc);
3004 }
3005
3006 binding.textinputLayout.setErrorEnabled(field.error != null);
3007 if (field.error != null) binding.textinputLayout.setError(field.error);
3008
3009 binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_GRAVITY);
3010 String suffixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/suffix-label");
3011 if (suffixLabel == null) {
3012 binding.textinputLayout.setSuffixText("");
3013 } else {
3014 binding.textinputLayout.setSuffixText(suffixLabel);
3015 binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_TEXT_END);
3016 }
3017
3018 String prefixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/prefix-label");
3019 binding.textinputLayout.setPrefixText(prefixLabel == null ? "" : prefixLabel);
3020
3021 binding.textinput.setText(String.join("\n", field.getValues()));
3022 setupInputType(field.el, binding.textinput, binding.textinputLayout);
3023 }
3024
3025 @Override
3026 public void afterTextChanged(Editable s) {
3027 if (field == null) return;
3028
3029 field.setValues(List.of(s.toString().split("\n")));
3030 }
3031
3032 @Override
3033 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
3034
3035 @Override
3036 public void onTextChanged(CharSequence s, int start, int count, int after) { }
3037 }
3038
3039 class SliderFieldViewHolder extends ViewHolder<CommandSliderFieldBinding> {
3040 public SliderFieldViewHolder(CommandSliderFieldBinding binding) { super(binding); }
3041 protected Field field = null;
3042
3043 @Override
3044 public void bind(Item item) {
3045 final Field boundField = (Field) item;
3046 final Element validate = boundField.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
3047 final String datatype = validate == null ? null : validate.getAttribute("datatype");
3048 final Element range = validate == null ? null : validate.findChild("range", "http://jabber.org/protocol/xdata-validate");
3049 final Float step = steppedSliderStep(boundField.el);
3050 final Float min = rangeFloat(range, "min");
3051 final Float max = rangeFloat(range, "max");
3052 if (step == null || min == null || max == null) {
3053 throw new IllegalStateException("Invalid slider field bound to slider view holder");
3054 }
3055
3056 float value = min;
3057 final Float parsedValue = parseFloat(firstValue(boundField.el));
3058 if (parsedValue != null && parsedValue >= min && parsedValue <= max) {
3059 value = parsedValue;
3060 }
3061
3062 field = boundField;
3063 binding.slider.clearOnChangeListeners();
3064 setTextOrHide(binding.label, field.getLabel());
3065 setTextOrHide(binding.desc, field.getDesc());
3066 binding.slider.setValueFrom(min);
3067 binding.slider.setValueTo(max);
3068 binding.slider.setValue(value);
3069 binding.slider.setStepSize(step);
3070
3071 binding.slider.addOnChangeListener((slider, sliderValue, fromUser) -> {
3072 if (!fromUser) return;
3073 field.setValues(List.of(formatSliderValue(sliderValue, datatype)));
3074 });
3075 }
3076 }
3077
3078 class WebViewHolder extends ViewHolder<CommandWebviewBinding> {
3079 public WebViewHolder(CommandWebviewBinding binding) { super(binding); }
3080 protected String boundUrl = "";
3081
3082 @Override
3083 public void bind(Item oob) {
3084 setTextOrHide(binding.desc, Optional.fromNullable(oob.el.findChildContent("desc", "jabber:x:oob")));
3085 binding.webview.getSettings().setJavaScriptEnabled(true);
3086 binding.webview.getSettings().setMediaPlaybackRequiresUserGesture(false);
3087 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");
3088 binding.webview.getSettings().setDatabaseEnabled(true);
3089 binding.webview.getSettings().setDomStorageEnabled(true);
3090 binding.webview.setWebChromeClient(new WebChromeClient() {
3091 @Override
3092 public void onProgressChanged(WebView view, int newProgress) {
3093 binding.progressbar.setVisibility(newProgress < 100 ? View.VISIBLE : View.GONE);
3094 binding.progressbar.setProgress(newProgress);
3095 }
3096
3097 @Override
3098 public void onPermissionRequest(final PermissionRequest request) {
3099 getView().post(() -> {
3100 request.grant(request.getResources());
3101 });
3102 }
3103 });
3104 binding.webview.setWebViewClient(new WebViewClient() {
3105 @Override
3106 public void onPageFinished(WebView view, String url) {
3107 super.onPageFinished(view, url);
3108 mTitle = view.getTitle();
3109 ConversationPagerAdapter.this.notifyDataSetChanged();
3110 }
3111 });
3112 final String url = oob.el.findChildContent("url", "jabber:x:oob");
3113 if (!boundUrl.equals(url)) {
3114 binding.webview.addJavascriptInterface(new JsObject(), "xmpp_xep0050");
3115 binding.webview.loadUrl(url);
3116 boundUrl = url;
3117 }
3118 }
3119
3120 class JsObject {
3121 @JavascriptInterface
3122 public void execute() { execute("execute"); }
3123
3124 @JavascriptInterface
3125 public void execute(String action) {
3126 getView().post(() -> {
3127 actionToWebview = null;
3128 if(CommandSession.this.execute(action)) {
3129 removeSession(CommandSession.this);
3130 }
3131 });
3132 }
3133
3134 @JavascriptInterface
3135 public void preventDefault() {
3136 actionToWebview = binding.webview;
3137 }
3138 }
3139 }
3140
3141 class ProgressBarViewHolder extends ViewHolder<CommandProgressBarBinding> {
3142 public ProgressBarViewHolder(CommandProgressBarBinding binding) { super(binding); }
3143
3144 @Override
3145 public void bind(Item item) {
3146 binding.text.setVisibility(loadingHasBeenLong ? View.VISIBLE : View.GONE);
3147 }
3148 }
3149
3150 class Item {
3151 protected Element el;
3152 protected int viewType;
3153 protected String error = null;
3154
3155 Item(Element el, int viewType) {
3156 this.el = el;
3157 this.viewType = viewType;
3158 }
3159
3160 public boolean validate() {
3161 error = null;
3162 return true;
3163 }
3164 }
3165
3166 class Field extends Item {
3167 Field(eu.siacs.conversations.xmpp.forms.Field el, int viewType) { super(el, viewType); }
3168
3169 @Override
3170 public boolean validate() {
3171 if (!super.validate()) return false;
3172 if (el.findChild("required", "jabber:x:data") == null) return true;
3173 if (getValue().getContent() != null && !getValue().getContent().equals("")) return true;
3174
3175 error = "this value is required";
3176 return false;
3177 }
3178
3179 public String getVar() {
3180 return el.getAttribute("var");
3181 }
3182
3183 public Optional<String> getType() {
3184 return Optional.fromNullable(el.getAttribute("type"));
3185 }
3186
3187 public Optional<String> getLabel() {
3188 String label = el.getAttribute("label");
3189 if (label == null) label = getVar();
3190 return Optional.fromNullable(label);
3191 }
3192
3193 public Optional<String> getDesc() {
3194 return Optional.fromNullable(el.findChildContent("desc", "jabber:x:data"));
3195 }
3196
3197 public Element getValue() {
3198 Element value = el.findChild("value", "jabber:x:data");
3199 if (value == null) {
3200 value = el.addChild("value", "jabber:x:data");
3201 }
3202 return value;
3203 }
3204
3205 public void setValues(Collection<String> values) {
3206 for(Element child : el.getChildren()) {
3207 if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
3208 el.removeChild(child);
3209 }
3210 }
3211
3212 for (String value : values) {
3213 el.addChild("value", "jabber:x:data").setContent(value);
3214 }
3215 }
3216
3217 public List<String> getValues() {
3218 List<String> values = new ArrayList<>();
3219 for(Element child : el.getChildren()) {
3220 if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
3221 values.add(child.getContent());
3222 }
3223 }
3224 return values;
3225 }
3226
3227 public List<Option> getOptions() {
3228 return Option.forField(el);
3229 }
3230 }
3231
3232 class Cell extends Item {
3233 protected Field reported;
3234
3235 Cell(Field reported, Element item) {
3236 super(item, TYPE_RESULT_CELL);
3237 this.reported = reported;
3238 }
3239 }
3240
3241 protected Field mkField(Element el) {
3242 int viewType = -1;
3243
3244 String formType = responseElement.getAttribute("type");
3245 if (formType != null) {
3246 String fieldType = el.getAttribute("type");
3247 if (fieldType == null) fieldType = "text-single";
3248
3249 if (formType.equals("result") || fieldType.equals("fixed")) {
3250 viewType = TYPE_RESULT_FIELD;
3251 } else if (formType.equals("form")) {
3252 final Element validate = el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
3253 final String datatype = validate == null ? null : validate.getAttribute("datatype");
3254 final Element range = validate == null ? null : validate.findChild("range", "http://jabber.org/protocol/xdata-validate");
3255 if (fieldType.equals("boolean")) {
3256 if (fillableFieldCount == 1 && actionsAdapter.countProceed() < 1) {
3257 viewType = TYPE_BUTTON_GRID_FIELD;
3258 } else {
3259 viewType = TYPE_CHECKBOX_FIELD;
3260 }
3261 } else if (
3262 range != null && range.getAttribute("min") != null && range.getAttribute("max") != null && isNumericDatatype(datatype)
3263 ) {
3264 // has a range and is numeric, use a slider if it can represent every valid value
3265 viewType = steppedSliderStep(el) == null ? TYPE_TEXT_FIELD : TYPE_SLIDER_FIELD;
3266 } else if (fieldType.equals("list-single")) {
3267 if (fillableFieldCount == 1 && actionsAdapter.countProceed() < 1 && Option.forField(el).size() < 50) {
3268 viewType = TYPE_BUTTON_GRID_FIELD;
3269 } else if (Option.forField(el).size() > 9) {
3270 viewType = TYPE_SEARCH_LIST_FIELD;
3271 } else if (el.findChild("value", "jabber:x:data") == null || (validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null)) {
3272 viewType = TYPE_RADIO_EDIT_FIELD;
3273 } else {
3274 viewType = TYPE_SPINNER_FIELD;
3275 }
3276 } else if (fieldType.equals("list-multi")) {
3277 viewType = TYPE_SEARCH_LIST_FIELD;
3278 } else {
3279 viewType = TYPE_TEXT_FIELD;
3280 }
3281 }
3282
3283 return new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), viewType);
3284 }
3285
3286 return null;
3287 }
3288
3289 protected Item mkItem(Element el, int pos) {
3290 int viewType = TYPE_ERROR;
3291
3292 if (response != null && response.getType() == Iq.Type.RESULT) {
3293 if (el.getName().equals("note")) {
3294 viewType = TYPE_NOTE;
3295 } else if (el.getNamespace().equals("jabber:x:oob")) {
3296 viewType = TYPE_WEB;
3297 } else if (el.getName().equals("instructions") && el.getNamespace().equals("jabber:x:data")) {
3298 viewType = TYPE_NOTE;
3299 } else if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data")) {
3300 Field field = mkField(el);
3301 if (field != null) {
3302 items.put(pos, field);
3303 return field;
3304 }
3305 }
3306 }
3307
3308 Item item = new Item(el, viewType);
3309 items.put(pos, item);
3310 return item;
3311 }
3312
3313 class ActionsAdapter extends ArrayAdapter<Pair<String, String>> {
3314 protected Context ctx;
3315
3316 public ActionsAdapter(Context ctx) {
3317 super(ctx, R.layout.simple_list_item);
3318 this.ctx = ctx;
3319 }
3320
3321 @Override
3322 public View getView(int position, View convertView, ViewGroup parent) {
3323 View v = super.getView(position, convertView, parent);
3324 TextView tv = (TextView) v.findViewById(android.R.id.text1);
3325 tv.setGravity(Gravity.CENTER);
3326 tv.setText(getItem(position).second);
3327 int resId = ctx.getResources().getIdentifier("action_" + getItem(position).first, "string" , ctx.getPackageName());
3328 if (resId != 0 && getItem(position).second.equals(getItem(position).first)) tv.setText(ctx.getResources().getString(resId));
3329 final var colors = MaterialColors.getColorRoles(ctx, UIHelper.getColorForName(getItem(position).first));
3330 tv.setTextColor(colors.getOnAccent());
3331 tv.setBackgroundColor(MaterialColors.harmonizeWithPrimary(ctx, colors.getAccent()));
3332 return v;
3333 }
3334
3335 public int getPosition(String s) {
3336 for(int i = 0; i < getCount(); i++) {
3337 if (getItem(i).first.equals(s)) return i;
3338 }
3339 return -1;
3340 }
3341
3342 public int countProceed() {
3343 int count = 0;
3344 for(int i = 0; i < getCount(); i++) {
3345 if (!"cancel".equals(getItem(i).first) && !"prev".equals(getItem(i).first)) count++;
3346 }
3347 return count;
3348 }
3349
3350 public int countExceptCancel() {
3351 int count = 0;
3352 for(int i = 0; i < getCount(); i++) {
3353 if (!getItem(i).first.equals("cancel")) count++;
3354 }
3355 return count;
3356 }
3357
3358 public void clearProceed() {
3359 Pair<String,String> cancelItem = null;
3360 Pair<String,String> prevItem = null;
3361 for(int i = 0; i < getCount(); i++) {
3362 if (getItem(i).first.equals("cancel")) cancelItem = getItem(i);
3363 if (getItem(i).first.equals("prev")) prevItem = getItem(i);
3364 }
3365 clear();
3366 if (cancelItem != null) add(cancelItem);
3367 if (prevItem != null) add(prevItem);
3368 }
3369 }
3370
3371 final int TYPE_ERROR = 1;
3372 final int TYPE_NOTE = 2;
3373 final int TYPE_WEB = 3;
3374 final int TYPE_RESULT_FIELD = 4;
3375 final int TYPE_TEXT_FIELD = 5;
3376 final int TYPE_CHECKBOX_FIELD = 6;
3377 final int TYPE_SPINNER_FIELD = 7;
3378 final int TYPE_RADIO_EDIT_FIELD = 8;
3379 final int TYPE_RESULT_CELL = 9;
3380 final int TYPE_PROGRESSBAR = 10;
3381 final int TYPE_SEARCH_LIST_FIELD = 11;
3382 final int TYPE_ITEM_CARD = 12;
3383 final int TYPE_BUTTON_GRID_FIELD = 13;
3384 final int TYPE_SLIDER_FIELD = 14;
3385
3386 protected boolean executing = false;
3387 protected boolean loading = false;
3388 protected boolean loadingHasBeenLong = false;
3389 protected Timer loadingTimer = new Timer();
3390 protected String mTitle;
3391 protected String mNode;
3392 protected CommandPageBinding mBinding = null;
3393 protected Iq response = null;
3394 protected Element responseElement = null;
3395 protected boolean expectingRemoval = false;
3396 protected List<Field> reported = null;
3397 protected SparseArray<Item> items = new SparseArray<>();
3398 protected XmppConnectionService xmppConnectionService;
3399 protected ActionsAdapter actionsAdapter = null;
3400 protected GridLayoutManager layoutManager;
3401 protected WebView actionToWebview = null;
3402 protected int fillableFieldCount = 0;
3403 protected Iq pendingResponsePacket = null;
3404 protected boolean waitingForRefresh = false;
3405
3406 CommandSession(String title, String node, XmppConnectionService xmppConnectionService) {
3407 loading();
3408 mTitle = title;
3409 mNode = node;
3410 this.xmppConnectionService = xmppConnectionService;
3411 ViewPager pager = mPager.get();
3412 if (pager != null) setupLayoutManager(pager.getContext());
3413 }
3414
3415 public String getTitle() {
3416 return mTitle;
3417 }
3418
3419 public String getNode() {
3420 return mNode;
3421 }
3422
3423 public void updateWithResponse(final Iq iq) {
3424 if (getView() != null && getView().isAttachedToWindow()) {
3425 getView().post(() -> updateWithResponseUiThread(iq));
3426 } else {
3427 pendingResponsePacket = iq;
3428 }
3429 }
3430
3431 protected void updateWithResponseUiThread(final Iq iq) {
3432 Timer oldTimer = this.loadingTimer;
3433 this.loadingTimer = new Timer();
3434 oldTimer.cancel();
3435 this.executing = false;
3436 this.loading = false;
3437 this.loadingHasBeenLong = false;
3438 this.responseElement = null;
3439 this.fillableFieldCount = 0;
3440 this.reported = null;
3441 this.response = iq;
3442 this.items.clear();
3443 this.actionsAdapter.clear();
3444 layoutManager.setSpanCount(1);
3445
3446 boolean actionsCleared = false;
3447 Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
3448 if (iq.getType() == Iq.Type.RESULT && command != null) {
3449 if (mNode.equals("jabber:iq:register") && command.getAttribute("status") != null && command.getAttribute("status").equals("completed")) {
3450 xmppConnectionService.createContact(getAccount().getRoster().getContact(iq.getFrom()));
3451 }
3452
3453 if (xmppConnectionService.isOnboarding() && mNode.equals("jabber:iq:register") && !"canceled".equals(command.getAttribute("status")) && xmppConnectionService.getPreferences().contains("onboarding_action")) {
3454 xmppConnectionService.getPreferences().edit().putBoolean("onboarding_continued", true).commit();
3455 }
3456
3457 Element actions = command.findChild("actions", "http://jabber.org/protocol/commands");
3458 if (actions != null) {
3459 for (Element action : actions.getChildren()) {
3460 if (!"http://jabber.org/protocol/commands".equals(action.getNamespace())) continue;
3461 if ("execute".equals(action.getName())) continue;
3462
3463 actionsAdapter.add(Pair.create(action.getName(), action.getName()));
3464 }
3465 }
3466
3467 for (Element el : command.getChildren()) {
3468 if ("x".equals(el.getName()) && "jabber:x:data".equals(el.getNamespace())) {
3469 Data form = Data.parse(el);
3470 String title = form.getTitle();
3471 if (title != null) {
3472 mTitle = title;
3473 ConversationPagerAdapter.this.notifyDataSetChanged();
3474 }
3475
3476 if ("result".equals(el.getAttribute("type")) || "form".equals(el.getAttribute("type"))) {
3477 this.responseElement = el;
3478 setupReported(el.findChild("reported", "jabber:x:data"));
3479 if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager(mBinding.getRoot().getContext()));
3480 }
3481
3482 eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
3483 if (actionList != null) {
3484 actionsAdapter.clear();
3485
3486 for (Option action : actionList.getOptions()) {
3487 actionsAdapter.add(Pair.create(action.getValue(), action.toString()));
3488 }
3489 }
3490
3491 eu.siacs.conversations.xmpp.forms.Field fillableField = null;
3492 for (eu.siacs.conversations.xmpp.forms.Field field : form.getFields()) {
3493 if ((field.getType() == null || (!field.getType().equals("hidden") && !field.getType().equals("fixed"))) && field.getFieldName() != null && !field.getFieldName().equals("http://jabber.org/protocol/commands#actions")) {
3494 final var validate = field.findChild("validate", "http://jabber.org/protocol/xdata-validate");
3495 final var range = validate == null ? null : validate.findChild("range", "http://jabber.org/protocol/xdata-validate");
3496 fillableField = range == null ? field : null;
3497 fillableFieldCount++;
3498 }
3499 }
3500
3501 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))) {
3502 actionsCleared = true;
3503 actionsAdapter.clearProceed();
3504 }
3505 break;
3506 }
3507 if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
3508 String url = el.findChildContent("url", "jabber:x:oob");
3509 if (url != null) {
3510 String scheme = Uri.parse(url).getScheme();
3511 if (scheme == null) {
3512 break;
3513 }
3514 if (scheme.equals("http") || scheme.equals("https")) {
3515 this.responseElement = el;
3516 break;
3517 }
3518 if (scheme.equals("xmpp")) {
3519 expectingRemoval = true;
3520 final Intent intent = new Intent(getView().getContext(), UriHandlerActivity.class);
3521 intent.setAction(Intent.ACTION_VIEW);
3522 intent.setData(Uri.parse(url));
3523 getView().getContext().startActivity(intent);
3524 break;
3525 }
3526 }
3527 }
3528 if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
3529 this.responseElement = el;
3530 break;
3531 }
3532 }
3533
3534 if (responseElement == null && command.getAttribute("status") != null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
3535 if ("jabber:iq:register".equals(mNode) && "canceled".equals(command.getAttribute("status"))) {
3536 if (xmppConnectionService.isOnboarding()) {
3537 if (xmppConnectionService.getPreferences().contains("onboarding_action")) {
3538 xmppConnectionService.deleteAccount(getAccount());
3539 } else {
3540 if (xmppConnectionService.getPreferences().getBoolean("onboarding_continued", false)) {
3541 removeSession(this);
3542 return;
3543 } else {
3544 xmppConnectionService.getPreferences().edit().putString("onboarding_action", "cancel").commit();
3545 xmppConnectionService.deleteAccount(getAccount());
3546 }
3547 }
3548 }
3549 xmppConnectionService.archiveConversation(Conversation.this);
3550 }
3551
3552 expectingRemoval = true;
3553 removeSession(this);
3554 return;
3555 }
3556
3557 if ("executing".equals(command.getAttribute("status")) && actionsAdapter.countExceptCancel() < 1 && !actionsCleared) {
3558 // No actions have been given, but we are not done?
3559 // This is probably a spec violation, but we should do *something*
3560 actionsAdapter.add(Pair.create("execute", "execute"));
3561 }
3562
3563 if (!actionsAdapter.isEmpty() || fillableFieldCount > 0) {
3564 if ("completed".equals(command.getAttribute("status")) || "canceled".equals(command.getAttribute("status"))) {
3565 actionsAdapter.add(Pair.create("close", "close"));
3566 } else if (actionsAdapter.getPosition("cancel") < 0 && !xmppConnectionService.isOnboarding()) {
3567 actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
3568 }
3569 }
3570 }
3571
3572 if (actionsAdapter.isEmpty()) {
3573 actionsAdapter.add(Pair.create("close", "close"));
3574 }
3575
3576 actionsAdapter.sort((x, y) -> {
3577 if (x.first.equals("cancel")) return -1;
3578 if (y.first.equals("cancel")) return 1;
3579 if (x.first.equals("prev") && xmppConnectionService.isOnboarding()) return -1;
3580 if (y.first.equals("prev") && xmppConnectionService.isOnboarding()) return 1;
3581 return 0;
3582 });
3583
3584 Data dataForm = null;
3585 if (responseElement != null && responseElement.getName().equals("x") && responseElement.getNamespace().equals("jabber:x:data")) dataForm = Data.parse(responseElement);
3586 if (mNode.equals("jabber:iq:register") &&
3587 xmppConnectionService.getPreferences().contains("onboarding_action") &&
3588 dataForm != null && dataForm.getFieldByName("gateway-jid") != null) {
3589
3590
3591 dataForm.put("gateway-jid", xmppConnectionService.getPreferences().getString("onboarding_action", ""));
3592 execute();
3593 }
3594 xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
3595 notifyDataSetChanged();
3596 }
3597
3598 protected void setupReported(Element el) {
3599 if (el == null) {
3600 reported = null;
3601 return;
3602 }
3603
3604 reported = new ArrayList<>();
3605 for (Element fieldEl : el.getChildren()) {
3606 if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
3607 reported.add(mkField(fieldEl));
3608 }
3609 }
3610
3611 @Override
3612 public int getItemCount() {
3613 if (loading) return 1;
3614 if (response == null) return 0;
3615 if (response.getType() == Iq.Type.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
3616 int i = 0;
3617 for (Element el : responseElement.getChildren()) {
3618 if (!el.getNamespace().equals("jabber:x:data")) continue;
3619 if (el.getName().equals("title")) continue;
3620 if (el.getName().equals("field")) {
3621 String type = el.getAttribute("type");
3622 if (type != null && type.equals("hidden")) continue;
3623 if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
3624 }
3625
3626 if (el.getName().equals("reported") || el.getName().equals("item")) {
3627 if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
3628 if (el.getName().equals("reported")) continue;
3629 i += 1;
3630 } else {
3631 if (reported != null) i += reported.size();
3632 }
3633 continue;
3634 }
3635
3636 i++;
3637 }
3638 return i;
3639 }
3640 return 1;
3641 }
3642
3643 public Item getItem(int position) {
3644 if (loading) return new Item(null, TYPE_PROGRESSBAR);
3645 if (items.get(position) != null) return items.get(position);
3646 if (response == null) return null;
3647
3648 if (response.getType() == Iq.Type.RESULT && responseElement != null) {
3649 if (responseElement.getNamespace().equals("jabber:x:data")) {
3650 int i = 0;
3651 for (Element el : responseElement.getChildren()) {
3652 if (!el.getNamespace().equals("jabber:x:data")) continue;
3653 if (el.getName().equals("title")) continue;
3654 if (el.getName().equals("field")) {
3655 String type = el.getAttribute("type");
3656 if (type != null && type.equals("hidden")) continue;
3657 if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
3658 }
3659
3660 if (el.getName().equals("reported") || el.getName().equals("item")) {
3661 Cell cell = null;
3662
3663 if (reported != null) {
3664 if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
3665 if (el.getName().equals("reported")) continue;
3666 if (i == position) {
3667 items.put(position, new Item(el, TYPE_ITEM_CARD));
3668 return items.get(position);
3669 }
3670 } else {
3671 if (reported.size() > position - i) {
3672 Field reportedField = reported.get(position - i);
3673 Element itemField = null;
3674 if (el.getName().equals("item")) {
3675 for (Element subel : el.getChildren()) {
3676 if (subel.getAttribute("var").equals(reportedField.getVar())) {
3677 itemField = subel;
3678 break;
3679 }
3680 }
3681 }
3682 cell = new Cell(reportedField, itemField);
3683 } else {
3684 i += reported.size();
3685 continue;
3686 }
3687 }
3688 }
3689
3690 if (cell != null) {
3691 items.put(position, cell);
3692 return cell;
3693 }
3694 }
3695
3696 if (i < position) {
3697 i++;
3698 continue;
3699 }
3700
3701 return mkItem(el, position);
3702 }
3703 }
3704 }
3705
3706 return mkItem(responseElement == null ? response : responseElement, position);
3707 }
3708
3709 @Override
3710 public int getItemViewType(int position) {
3711 return getItem(position).viewType;
3712 }
3713
3714 @Override
3715 public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
3716 switch(viewType) {
3717 case TYPE_ERROR: {
3718 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3719 return new ErrorViewHolder(binding);
3720 }
3721 case TYPE_NOTE: {
3722 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3723 return new NoteViewHolder(binding);
3724 }
3725 case TYPE_WEB: {
3726 CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
3727 return new WebViewHolder(binding);
3728 }
3729 case TYPE_RESULT_FIELD: {
3730 CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
3731 return new ResultFieldViewHolder(binding);
3732 }
3733 case TYPE_RESULT_CELL: {
3734 CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
3735 return new ResultCellViewHolder(binding);
3736 }
3737 case TYPE_ITEM_CARD: {
3738 CommandItemCardBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_item_card, container, false);
3739 return new ItemCardViewHolder(binding);
3740 }
3741 case TYPE_CHECKBOX_FIELD: {
3742 CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
3743 return new CheckboxFieldViewHolder(binding);
3744 }
3745 case TYPE_SEARCH_LIST_FIELD: {
3746 CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
3747 return new SearchListFieldViewHolder(binding);
3748 }
3749 case TYPE_RADIO_EDIT_FIELD: {
3750 CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
3751 return new RadioEditFieldViewHolder(binding);
3752 }
3753 case TYPE_SPINNER_FIELD: {
3754 CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
3755 return new SpinnerFieldViewHolder(binding);
3756 }
3757 case TYPE_BUTTON_GRID_FIELD: {
3758 CommandButtonGridFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_button_grid_field, container, false);
3759 return new ButtonGridFieldViewHolder(binding);
3760 }
3761 case TYPE_TEXT_FIELD: {
3762 CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
3763 return new TextFieldViewHolder(binding);
3764 }
3765 case TYPE_SLIDER_FIELD: {
3766 CommandSliderFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_slider_field, container, false);
3767 return new SliderFieldViewHolder(binding);
3768 }
3769 case TYPE_PROGRESSBAR: {
3770 CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
3771 return new ProgressBarViewHolder(binding);
3772 }
3773 default:
3774 if (expectingRemoval) {
3775 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3776 return new NoteViewHolder(binding);
3777 }
3778
3779 throw new IllegalArgumentException("Unknown viewType: " + viewType + " based on: " + response + ", " + responseElement + ", " + expectingRemoval);
3780 }
3781 }
3782
3783 @Override
3784 public void onBindViewHolder(ViewHolder viewHolder, int position) {
3785 viewHolder.bind(getItem(position));
3786 }
3787
3788 public View getView() {
3789 if (mBinding == null) return null;
3790 return mBinding.getRoot();
3791 }
3792
3793 public boolean validate() {
3794 int count = getItemCount();
3795 boolean isValid = true;
3796 for (int i = 0; i < count; i++) {
3797 boolean oneIsValid = getItem(i).validate();
3798 isValid = isValid && oneIsValid;
3799 }
3800 notifyDataSetChanged();
3801 return isValid;
3802 }
3803
3804 public boolean execute() {
3805 return execute("execute");
3806 }
3807
3808 public boolean execute(int actionPosition) {
3809 return execute(actionsAdapter.getItem(actionPosition).first);
3810 }
3811
3812 public synchronized boolean execute(String action) {
3813 if (!"cancel".equals(action) && executing) {
3814 loadingHasBeenLong = true;
3815 notifyDataSetChanged();
3816 return false;
3817 }
3818 if (!action.equals("cancel") && !action.equals("prev") && !validate()) return false;
3819
3820 if (response == null) return true;
3821 Element command = response.findChild("command", "http://jabber.org/protocol/commands");
3822 if (command == null) return true;
3823 String status = command.getAttribute("status");
3824 if (status == null || (!status.equals("executing") && !action.equals("prev"))) return true;
3825
3826 if (actionToWebview != null && !action.equals("cancel") && Build.VERSION.SDK_INT >= 23) {
3827 actionToWebview.postWebMessage(new WebMessage("xmpp_xep0050/" + action), Uri.parse("*"));
3828 return false;
3829 }
3830
3831 final var packet = new Iq(Iq.Type.SET);
3832 packet.setTo(response.getFrom());
3833 final Element c = packet.addChild("command", Namespace.COMMANDS);
3834 c.setAttribute("node", mNode);
3835 c.setAttribute("sessionid", command.getAttribute("sessionid"));
3836
3837 String formType = responseElement == null ? null : responseElement.getAttribute("type");
3838 if (!action.equals("cancel") &&
3839 !action.equals("prev") &&
3840 responseElement != null &&
3841 responseElement.getName().equals("x") &&
3842 responseElement.getNamespace().equals("jabber:x:data") &&
3843 formType != null && formType.equals("form")) {
3844
3845 Data form = Data.parse(responseElement);
3846 eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
3847 if (actionList != null) {
3848 actionList.setValue(action);
3849 c.setAttribute("action", "execute");
3850 }
3851
3852 if (mNode.equals("jabber:iq:register") && xmppConnectionService.isOnboarding() && form.getFieldByName("gateway-jid") != null) {
3853 if (form.getValue("gateway-jid") == null) {
3854 xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
3855 } else {
3856 xmppConnectionService.getPreferences().edit().putString("onboarding_action", form.getValue("gateway-jid")).commit();
3857 }
3858 }
3859
3860 responseElement.setAttribute("type", "submit");
3861 Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
3862 if (rsm != null) {
3863 Element max = new Element("max", "http://jabber.org/protocol/rsm");
3864 max.setContent("1000");
3865 rsm.addChild(max);
3866 }
3867
3868 c.addChild(responseElement);
3869 }
3870
3871 if (c.getAttribute("action") == null) c.setAttribute("action", action);
3872
3873 executing = true;
3874 xmppConnectionService.sendIqPacket(getAccount(), packet, (iq) -> {
3875 updateWithResponse(iq);
3876 }, 120L);
3877
3878 loading();
3879 return false;
3880 }
3881
3882 public void refresh() {
3883 synchronized(this) {
3884 if (waitingForRefresh) notifyDataSetChanged();
3885 }
3886 }
3887
3888 protected void loading() {
3889 View v = getView();
3890 try {
3891 loadingTimer.schedule(new TimerTask() {
3892 @Override
3893 public void run() {
3894 View v2 = getView();
3895 loading = true;
3896
3897 try {
3898 loadingTimer.schedule(new TimerTask() {
3899 @Override
3900 public void run() {
3901 loadingHasBeenLong = true;
3902 if (v == null && v2 == null) return;
3903 (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3904 }
3905 }, 3000);
3906 } catch (final IllegalStateException e) { }
3907
3908 if (v == null && v2 == null) return;
3909 (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3910 }
3911 }, 500);
3912 } catch (final IllegalStateException e) { }
3913 }
3914
3915 protected GridLayoutManager setupLayoutManager(final Context ctx) {
3916 int spanCount = 1;
3917
3918 if (reported != null) {
3919 float screenWidth = ctx.getResources().getDisplayMetrics().widthPixels;
3920 TextPaint paint = ((TextView) LayoutInflater.from(ctx).inflate(R.layout.command_result_cell, null)).getPaint();
3921 float tableHeaderWidth = reported.stream().reduce(
3922 0f,
3923 (total, field) -> total + StaticLayout.getDesiredWidth(field.getLabel().or("--------") + "\t", paint),
3924 (a, b) -> a + b
3925 );
3926
3927 spanCount = tableHeaderWidth > 0.59 * screenWidth ? 1 : this.reported.size();
3928 }
3929
3930 if (layoutManager != null && layoutManager.getSpanCount() != spanCount) {
3931 items.clear();
3932 notifyDataSetChanged();
3933 }
3934
3935 layoutManager = new GridLayoutManager(ctx, spanCount);
3936 layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
3937 @Override
3938 public int getSpanSize(int position) {
3939 if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
3940 return 1;
3941 }
3942 });
3943 return layoutManager;
3944 }
3945
3946 protected void setBinding(CommandPageBinding b) {
3947 mBinding = b;
3948 // https://stackoverflow.com/a/32350474/8611
3949 mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
3950 @Override
3951 public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
3952 if(rv.getChildCount() > 0) {
3953 int[] location = new int[2];
3954 rv.getLocationOnScreen(location);
3955 View childView = rv.findChildViewUnder(e.getX(), e.getY());
3956 if (childView instanceof ViewGroup) {
3957 childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
3958 }
3959 int action = e.getAction();
3960 switch (action) {
3961 case MotionEvent.ACTION_DOWN:
3962 if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(1)) || childView instanceof WebView) {
3963 rv.requestDisallowInterceptTouchEvent(true);
3964 }
3965 case MotionEvent.ACTION_UP:
3966 if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(-11)) || childView instanceof WebView) {
3967 rv.requestDisallowInterceptTouchEvent(true);
3968 }
3969 }
3970 }
3971
3972 return false;
3973 }
3974
3975 @Override
3976 public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }
3977
3978 @Override
3979 public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
3980 });
3981 mBinding.form.setLayoutManager(setupLayoutManager(mBinding.getRoot().getContext()));
3982 mBinding.form.setAdapter(this);
3983
3984 if (actionsAdapter == null) {
3985 actionsAdapter = new ActionsAdapter(mBinding.getRoot().getContext());
3986 actionsAdapter.registerDataSetObserver(new DataSetObserver() {
3987 @Override
3988 public void onChanged() {
3989 if (mBinding == null) return;
3990
3991 mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
3992 }
3993
3994 @Override
3995 public void onInvalidated() {}
3996 });
3997 }
3998
3999 mBinding.actions.setAdapter(actionsAdapter);
4000 mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
4001 if (execute(pos)) {
4002 removeSession(CommandSession.this);
4003 }
4004 });
4005
4006 actionsAdapter.notifyDataSetChanged();
4007
4008 if (pendingResponsePacket != null) {
4009 final var pending = pendingResponsePacket;
4010 pendingResponsePacket = null;
4011 updateWithResponseUiThread(pending);
4012 }
4013 }
4014
4015 private Drawable getDrawableForSVG(SVG svg, Element svgElement, int size) {
4016 if (svgElement != null && svgElement.getChildren().size() == 1 && svgElement.getChildren().get(0).getName().equals("image")) {
4017 return getDrawableForUrl(svgElement.getChildren().get(0).getAttribute("href"));
4018 } else {
4019 return xmppConnectionService.getFileBackend().drawSVG(svg, size);
4020 }
4021 }
4022
4023 private Drawable getDrawableForUrl(final String url) {
4024 final LruCache<String, Drawable> cache = xmppConnectionService.getDrawableCache();
4025 final HttpConnectionManager httpManager = xmppConnectionService.getHttpConnectionManager();
4026 final Drawable d = cache.get(url);
4027 if (Build.VERSION.SDK_INT >= 28 && d instanceof AnimatedImageDrawable) ((AnimatedImageDrawable) d).start();
4028 if (d == null) {
4029 synchronized (CommandSession.this) {
4030 waitingForRefresh = true;
4031 }
4032 int size = (int)(xmppConnectionService.getResources().getDisplayMetrics().density * 288);
4033 Message dummy = new Message(Conversation.this, url, Message.ENCRYPTION_NONE);
4034 dummy.setStatus(Message.STATUS_DUMMY);
4035 dummy.setFileParams(new Message.FileParams(url));
4036 httpManager.createNewDownloadConnection(dummy, true, (file) -> {
4037 if (file == null) {
4038 dummy.getTransferable().start();
4039 } else {
4040 try {
4041 xmppConnectionService.getFileBackend().getThumbnail(file, xmppConnectionService.getResources(), size, false, url);
4042 } catch (final Exception e) { }
4043 }
4044 });
4045 }
4046 return d;
4047 }
4048
4049 public View inflateUi(Context context, Consumer<ConversationPage> remover) {
4050 CommandPageBinding binding = DataBindingUtil.inflate(LayoutInflater.from(context), R.layout.command_page, null, false);
4051 setBinding(binding);
4052 return binding.getRoot();
4053 }
4054
4055 // https://stackoverflow.com/a/36037991/8611
4056 private View findViewAt(ViewGroup viewGroup, float x, float y) {
4057 for(int i = 0; i < viewGroup.getChildCount(); i++) {
4058 View child = viewGroup.getChildAt(i);
4059 if (child instanceof ViewGroup && !(child instanceof AbsListView) && !(child instanceof WebView)) {
4060 View foundView = findViewAt((ViewGroup) child, x, y);
4061 if (foundView != null && foundView.isShown()) {
4062 return foundView;
4063 }
4064 } else {
4065 int[] location = new int[2];
4066 child.getLocationOnScreen(location);
4067 Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
4068 if (rect.contains((int)x, (int)y)) {
4069 return child;
4070 }
4071 }
4072 }
4073
4074 return null;
4075 }
4076 }
4077
4078 class MucConfigSession extends CommandSession {
4079 MucConfigSession(XmppConnectionService xmppConnectionService) {
4080 super("Configure Channel", null, xmppConnectionService);
4081 }
4082
4083 @Override
4084 protected void updateWithResponseUiThread(final Iq iq) {
4085 Timer oldTimer = this.loadingTimer;
4086 this.loadingTimer = new Timer();
4087 oldTimer.cancel();
4088 this.executing = false;
4089 this.loading = false;
4090 this.loadingHasBeenLong = false;
4091 this.responseElement = null;
4092 this.fillableFieldCount = 0;
4093 this.reported = null;
4094 this.response = iq;
4095 this.items.clear();
4096 this.actionsAdapter.clear();
4097 layoutManager.setSpanCount(1);
4098
4099 final Element query = iq.findChild("query", "http://jabber.org/protocol/muc#owner");
4100 if (iq.getType() == Iq.Type.RESULT && query != null) {
4101 final Data form = Data.parse(query.findChild("x", "jabber:x:data"));
4102 final String title = form.getTitle();
4103 if (title != null) {
4104 mTitle = title;
4105 ConversationPagerAdapter.this.notifyDataSetChanged();
4106 }
4107
4108 this.responseElement = form;
4109 setupReported(form.findChild("reported", "jabber:x:data"));
4110 if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager(mBinding.getRoot().getContext()));
4111
4112 if (actionsAdapter.countExceptCancel() < 1) {
4113 actionsAdapter.add(Pair.create("save", "Save"));
4114 }
4115
4116 if (actionsAdapter.getPosition("cancel") < 0) {
4117 actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
4118 }
4119 } else if (iq.getType() == Iq.Type.RESULT) {
4120 expectingRemoval = true;
4121 removeSession(this);
4122 return;
4123 } else {
4124 actionsAdapter.add(Pair.create("close", "close"));
4125 }
4126
4127 notifyDataSetChanged();
4128 }
4129
4130 @Override
4131 public synchronized boolean execute(String action) {
4132 if ("cancel".equals(action)) {
4133 final var packet = new Iq(Iq.Type.SET);
4134 packet.setTo(response.getFrom());
4135 final Element form = packet
4136 .addChild("query", "http://jabber.org/protocol/muc#owner")
4137 .addChild("x", "jabber:x:data");
4138 form.setAttribute("type", "cancel");
4139 xmppConnectionService.sendIqPacket(getAccount(), packet, null);
4140 return true;
4141 }
4142
4143 if (!"save".equals(action)) return true;
4144
4145 final var packet = new Iq(Iq.Type.SET);
4146 packet.setTo(response.getFrom());
4147
4148 String formType = responseElement == null ? null : responseElement.getAttribute("type");
4149 if (responseElement != null &&
4150 responseElement.getName().equals("x") &&
4151 responseElement.getNamespace().equals("jabber:x:data") &&
4152 formType != null && formType.equals("form")) {
4153
4154 responseElement.setAttribute("type", "submit");
4155 packet
4156 .addChild("query", "http://jabber.org/protocol/muc#owner")
4157 .addChild(responseElement);
4158 }
4159
4160 executing = true;
4161 xmppConnectionService.sendIqPacket(getAccount(), packet, (iq) -> {
4162 updateWithResponse(iq);
4163 }, 120L);
4164
4165 loading();
4166
4167 return false;
4168 }
4169 }
4170 }
4171
4172 public static class Thread {
4173 protected Message subject = null;
4174 protected Message first = null;
4175 protected Message last = null;
4176 protected final String threadId;
4177
4178 protected Thread(final String threadId) {
4179 this.threadId = threadId;
4180 }
4181
4182 public String getThreadId() {
4183 return threadId;
4184 }
4185
4186 public String getSubject() {
4187 if (subject == null) return null;
4188
4189 return subject.getSubject();
4190 }
4191
4192 public String getDisplay() {
4193 final String s = getSubject();
4194 if (s != null) return s;
4195
4196 if (first != null) {
4197 return first.getBody();
4198 }
4199
4200 return "";
4201 }
4202
4203 public long getLastTime() {
4204 if (last == null) return 0;
4205
4206 return last.getTimeSent();
4207 }
4208 }
4209}