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