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