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