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