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