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