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