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