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