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