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