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