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 if (messages.isEmpty()) return;
1617 checkSpam(messages.toArray(new Message[0]));
1618
1619 List<Message> newM = new ArrayList<>();
1620
1621 if (nextCounterpart == null) {
1622 for(Message m : messages) {
1623 if (!m.isPrivateMessage() && m.encryption != Message.ENCRYPTION_OTR) {
1624 newM.add(m);
1625 }
1626 }
1627
1628 } else {
1629 for(Message m : messages) {
1630 String res1 = m.getCounterpart() == null ? null : m.getCounterpart().getResource();
1631 String res2 = nextCounterpart == null ? null : nextCounterpart.getResource();
1632
1633
1634 if ((m.isPrivateMessage() || m.encryption == Message.ENCRYPTION_OTR) && Objects.equals(res1, res2)) {
1635 newM.add(m);
1636 }
1637 }
1638
1639 }
1640
1641 synchronized (this.messages) {
1642 List<Message> properListToAdd;
1643
1644 if (fromPagination && !historyPartMessages.isEmpty() && checkIsMergeable(newM)) {
1645 historyPartMessages.addAll(newM);
1646 newM = filterExisted(historyPartMessages);
1647 index = 0;
1648 jumpToLatest();
1649 }
1650
1651 if (fromPagination && !historyPartMessages.isEmpty()) {
1652 properListToAdd = historyPartMessages;
1653 } else {
1654 properListToAdd = this.messages;
1655 }
1656
1657 if (index == -1) {
1658 properListToAdd.addAll(newM);
1659 } else {
1660 properListToAdd.addAll(index, newM);
1661 }
1662 }
1663 account.getPgpDecryptionService().decrypt(messages);
1664 }
1665
1666 public void expireOldMessages(long timestamp) {
1667 synchronized (this.messages) {
1668 for (ListIterator<Message> iterator = this.messages.listIterator();
1669 iterator.hasNext(); ) {
1670 if (iterator.next().getTimeSent() < timestamp) {
1671 iterator.remove();
1672 }
1673 }
1674 untieMessages();
1675 }
1676 }
1677
1678 public void sort() {
1679 synchronized (this.messages) {
1680 Collections.sort(
1681 this.messages,
1682 (left, right) -> {
1683 if (left.getTimeSent() < right.getTimeSent()) {
1684 return -1;
1685 } else if (left.getTimeSent() > right.getTimeSent()) {
1686 return 1;
1687 } else {
1688 return 0;
1689 }
1690 });
1691 untieMessages();
1692 }
1693 }
1694
1695 public void jumpToHistoryPart(List<Message> messages) {
1696 historyPartMessages.clear();
1697
1698 if (checkIsMergeable(messages)) {
1699 addAll(0, filterExisted(messages), false);
1700 } else {
1701 historyPartMessages.addAll(messages);
1702 }
1703 }
1704
1705 public void jumpToLatest() {
1706 historyPartMessages.clear();
1707 }
1708
1709 public boolean isInHistoryPart() {
1710 return !historyPartMessages.isEmpty();
1711 }
1712
1713 private boolean checkIsMergeable(List<Message> messages) {
1714 if (messages.isEmpty()) return true;
1715 return findDuplicateMessage(messages.get(messages.size() - 1)) != null;
1716 }
1717
1718 private List<Message> filterExisted(List<Message> messages) {
1719 if (messages.isEmpty()) return Collections.emptyList();
1720
1721 List<Message> result = new ArrayList<>();
1722
1723 for (Message m : messages) {
1724 if (findDuplicateMessage(m) == null) {
1725 result.add(m);
1726 }
1727 }
1728
1729 return result;
1730 }
1731
1732 private void untieMessages() {
1733 for (Message message : this.messages) {
1734 message.untie();
1735 }
1736 }
1737
1738 public int unreadCount(XmppConnectionService xmppConnectionService) {
1739 synchronized (this.messages) {
1740 int count = 0;
1741 for (final Message message : Lists.reverse(this.messages)) {
1742 if (message.getSubject() != null && !message.isOOb() && (message.getRawBody() == null || message.getRawBody().length() == 0)) continue;
1743 if (asReaction(message) != null) continue;
1744 if ((message.getRawBody() == null || "".equals(message.getRawBody()) || " ".equals(message.getRawBody())) && message.getReply() != null && message.edited() && message.getHtml() != null) continue;
1745 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));
1746 if (muted) continue;
1747 if (message.isRead()) {
1748 if (message.getType() == Message.TYPE_RTP_SESSION) {
1749 continue;
1750 }
1751 return count;
1752 }
1753 ++count;
1754 }
1755 return count;
1756 }
1757 }
1758
1759 public int receivedMessagesCount() {
1760 int count = 0;
1761 synchronized (this.messages) {
1762 for (Message message : messages) {
1763 if (message.getSubject() != null && !message.isOOb() && (message.getRawBody() == null || message.getRawBody().length() == 0)) continue;
1764 if (asReaction(message) != null) continue;
1765 if ((message.getRawBody() == null || "".equals(message.getRawBody()) || " ".equals(message.getRawBody())) && message.getReply() != null && message.edited() && message.getHtml() != null) continue;
1766 if (message.getStatus() == Message.STATUS_RECEIVED) {
1767 ++count;
1768 }
1769 }
1770 }
1771 return count;
1772 }
1773
1774 public int sentMessagesCount() {
1775 int count = 0;
1776 synchronized (this.messages) {
1777 for (Message message : messages) {
1778 if (message.getStatus() != Message.STATUS_RECEIVED) {
1779 ++count;
1780 }
1781 }
1782 }
1783 return count;
1784 }
1785
1786 public boolean canInferPresence() {
1787 final Contact contact = getContact();
1788 if (contact != null && contact.canInferPresence()) return true;
1789 return sentMessagesCount() > 0;
1790 }
1791
1792 public boolean isChatRequest(final String pref) {
1793 if ("disable".equals(pref)) return false;
1794 if ("strangers".equals(pref)) return isWithStranger();
1795 if (!isWithStranger() && !strangerInvited()) return false;
1796 return anyMatchSpam;
1797 }
1798
1799 public boolean isWithStranger() {
1800 final Contact contact = getContact();
1801 return mode == MODE_SINGLE
1802 && !contact.isOwnServer()
1803 && !contact.showInContactList()
1804 && !contact.isSelf()
1805 && !(contact.getJid().isDomainJid() && JidHelper.isQuicksyDomain(contact.getJid()))
1806 && sentMessagesCount() == 0;
1807 }
1808
1809 public boolean strangerInvited() {
1810 final var inviterS = getAttribute("inviter");
1811 if (inviterS == null) return false;
1812 final var inviter = account.getRoster().getContact(Jid.of(inviterS));
1813 return getBookmark() == null && !inviter.showInContactList() && !inviter.isSelf() && sentMessagesCount() == 0;
1814 }
1815
1816 public int getReceivedMessagesCountSinceUuid(String uuid) {
1817 if (uuid == null) {
1818 return 0;
1819 }
1820 int count = 0;
1821 synchronized (this.messages) {
1822 for (int i = messages.size() - 1; i >= 0; i--) {
1823 final Message message = messages.get(i);
1824 if (uuid.equals(message.getUuid())) {
1825 return count;
1826 }
1827 if (message.getStatus() <= Message.STATUS_RECEIVED) {
1828 ++count;
1829 }
1830 }
1831 }
1832 return 0;
1833 }
1834
1835 @Override
1836 public int getAvatarBackgroundColor() {
1837 return UIHelper.getColorForName(getName().toString());
1838 }
1839
1840 @Override
1841 public String getAvatarName() {
1842 return getName().toString();
1843 }
1844
1845 public void setCurrentTab(int tab) {
1846 mCurrentTab = tab;
1847 }
1848
1849 public int getCurrentTab() {
1850 if (mCurrentTab >= 0) return mCurrentTab;
1851
1852 if (!getContact().isApp() || !isRead(null)) {
1853 return 0;
1854 }
1855
1856 return 1;
1857 }
1858
1859 public void refreshSessions() {
1860 pagerAdapter.refreshSessions();
1861 }
1862
1863 public void startWebxdc(WebxdcPage page) {
1864 pagerAdapter.startWebxdc(page);
1865 }
1866
1867 public void webxdcRealtimeData(final Element thread, final String base64) {
1868 pagerAdapter.webxdcRealtimeData(thread, base64);
1869 }
1870
1871 public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
1872 pagerAdapter.startCommand(command, xmppConnectionService);
1873 }
1874
1875 public void startMucConfig(XmppConnectionService xmppConnectionService) {
1876 pagerAdapter.startMucConfig(xmppConnectionService);
1877 }
1878
1879 public boolean switchToSession(final String node) {
1880 return pagerAdapter.switchToSession(node);
1881 }
1882
1883 public void setupViewPager(ViewPager pager, TabLayout tabs, boolean onboarding, Conversation oldConversation) {
1884 pagerAdapter.setupViewPager(pager, tabs, onboarding, oldConversation);
1885 }
1886
1887 public void showViewPager() {
1888 pagerAdapter.show();
1889 }
1890
1891 public void hideViewPager() {
1892 pagerAdapter.hide();
1893 }
1894
1895 public void setDisplayState(final String stanzaId) {
1896 this.displayState = stanzaId;
1897 }
1898
1899 public String getDisplayState() {
1900 return this.displayState;
1901 }
1902
1903 public interface OnMessageFound {
1904 void onMessageFound(final Message message);
1905 }
1906
1907 public static class Draft {
1908 private final String message;
1909 private final long timestamp;
1910
1911 private Draft(String message, long timestamp) {
1912 this.message = message;
1913 this.timestamp = timestamp;
1914 }
1915
1916 public long getTimestamp() {
1917 return timestamp;
1918 }
1919
1920 public String getMessage() {
1921 return message;
1922 }
1923 }
1924
1925 public class ConversationPagerAdapter extends PagerAdapter {
1926 protected WeakReference<ViewPager> mPager = new WeakReference<>(null);
1927 protected WeakReference<TabLayout> mTabs = new WeakReference<>(null);
1928 ArrayList<ConversationPage> sessions = null;
1929 protected WeakReference<View> page1 = new WeakReference<>(null);
1930 protected WeakReference<View> page2 = new WeakReference<>(null);
1931 protected boolean mOnboarding = false;
1932
1933 public void setupViewPager(ViewPager pager, TabLayout tabs, boolean onboarding, Conversation oldConversation) {
1934 mPager = new WeakReference(pager);
1935 mTabs = new WeakReference(tabs);
1936 mOnboarding = onboarding;
1937
1938 if (oldConversation != null) {
1939 oldConversation.pagerAdapter.mPager.clear();
1940 oldConversation.pagerAdapter.mTabs.clear();
1941 }
1942
1943 if (pager == null) {
1944 page1.clear();
1945 page2.clear();
1946 return;
1947 }
1948 if (sessions != null) show();
1949
1950 if (pager.getChildAt(0) != null) page1 = new WeakReference<>(pager.getChildAt(0));
1951 if (pager.getChildAt(1) != null) page2 = new WeakReference<>(pager.getChildAt(1));
1952 if (page2.get() != null && page2.get().findViewById(R.id.commands_view) == null) {
1953 page1.clear();
1954 page2.clear();
1955 }
1956 if (page1.get() == null) page1 = oldConversation.pagerAdapter.page1;
1957 if (page2.get() == null) page2 = oldConversation.pagerAdapter.page2;
1958 if (page1.get() == null || page2.get() == null) {
1959 throw new IllegalStateException("page1 or page2 were not present as child or in model?");
1960 }
1961 pager.removeView(page1.get());
1962 pager.removeView(page2.get());
1963 pager.clearOnPageChangeListeners();
1964 pager.setAdapter(this);
1965 tabs.setupWithViewPager(pager);
1966 pager.post(() -> pager.setCurrentItem(getCurrentTab()));
1967
1968 pager.addOnPageChangeListener(new PagerChangeListener(Conversation.this));
1969 }
1970
1971 private static class PagerChangeListener implements ViewPager.OnPageChangeListener {
1972 private final Conversation conversation;
1973
1974 PagerChangeListener(final Conversation conversation) {
1975 this.conversation = conversation;
1976 }
1977
1978 @Override
1979 public void onPageScrollStateChanged(int state) { }
1980
1981 @Override
1982 public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { }
1983
1984 @Override
1985 public void onPageSelected(int position) {
1986 final ViewPager pager = conversation.pagerAdapter.mPager.get();
1987 if (pager == null || pager.getAdapter() != conversation.pagerAdapter) return;
1988 conversation.setCurrentTab(position);
1989 }
1990 }
1991
1992 public void show() {
1993 if (sessions == null) {
1994 sessions = new ArrayList<>();
1995 notifyDataSetChanged();
1996 }
1997 if (!mOnboarding && mTabs.get() != null) mTabs.get().setVisibility(View.VISIBLE);
1998 }
1999
2000 public void hide() {
2001 if (sessions != null && !sessions.isEmpty()) return; // Do not hide during active session
2002 if (mPager.get() != null) mPager.get().setCurrentItem(0);
2003 if (mTabs.get() != null) mTabs.get().setVisibility(View.GONE);
2004 sessions = null;
2005 notifyDataSetChanged();
2006 }
2007
2008 public void refreshSessions() {
2009 if (sessions == null) return;
2010
2011 for (ConversationPage session : sessions) {
2012 session.refresh();
2013 }
2014 }
2015
2016 public void webxdcRealtimeData(final Element thread, final String base64) {
2017 if (sessions == null) return;
2018
2019 for (ConversationPage session : sessions) {
2020 if (session instanceof WebxdcPage) {
2021 if (((WebxdcPage) session).threadMatches(thread)) {
2022 ((WebxdcPage) session).realtimeData(base64);
2023 }
2024 }
2025 }
2026 }
2027
2028 public void startWebxdc(WebxdcPage page) {
2029 show();
2030 sessions.add(page);
2031 notifyDataSetChanged();
2032 if (mPager.get() != null) mPager.get().setCurrentItem(getCount() - 1);
2033 }
2034
2035 public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
2036 show();
2037 CommandSession session = new CommandSession(command.getAttribute("name"), command.getAttribute("node"), xmppConnectionService);
2038
2039 final var packet = new Iq(Iq.Type.SET);
2040 packet.setTo(command.getAttributeAsJid("jid"));
2041 final Element c = packet.addChild("command", Namespace.COMMANDS);
2042 c.setAttribute("node", command.getAttribute("node"));
2043 c.setAttribute("action", "execute");
2044
2045 final TimerTask task = new TimerTask() {
2046 @Override
2047 public void run() {
2048 if (getAccount().getStatus() != Account.State.ONLINE) {
2049 final TimerTask self = this;
2050 new Timer().schedule(new TimerTask() {
2051 @Override
2052 public void run() {
2053 self.run();
2054 }
2055 }, 1000);
2056 } else {
2057 xmppConnectionService.sendIqPacket(getAccount(), packet, (iq) -> {
2058 session.updateWithResponse(iq);
2059 }, 120L);
2060 }
2061 }
2062 };
2063
2064 if (command.getAttribute("node").equals("jabber:iq:register") && packet.getTo().asBareJid().equals(Jid.of("cheogram.com"))) {
2065 new com.cheogram.android.CheogramLicenseChecker(mPager.get().getContext(), (signedData, signature) -> {
2066 if (signedData != null && signature != null) {
2067 c.addChild("license", "https://ns.cheogram.com/google-play").setContent(signedData);
2068 c.addChild("licenseSignature", "https://ns.cheogram.com/google-play").setContent(signature);
2069 }
2070
2071 task.run();
2072 }).checkLicense();
2073 } else {
2074 task.run();
2075 }
2076
2077 sessions.add(session);
2078 notifyDataSetChanged();
2079 if (mPager.get() != null) mPager.get().setCurrentItem(getCount() - 1);
2080 }
2081
2082 public void startMucConfig(XmppConnectionService xmppConnectionService) {
2083 MucConfigSession session = new MucConfigSession(xmppConnectionService);
2084 final var packet = new Iq(Iq.Type.GET);
2085 packet.setTo(Conversation.this.getJid().asBareJid());
2086 packet.addChild("query", "http://jabber.org/protocol/muc#owner");
2087
2088 final TimerTask task = new TimerTask() {
2089 @Override
2090 public void run() {
2091 if (getAccount().getStatus() != Account.State.ONLINE) {
2092 final TimerTask self = this;
2093 new Timer().schedule(new TimerTask() {
2094 @Override
2095 public void run() {
2096 self.run();
2097 }
2098 }, 1000);
2099 } else {
2100 xmppConnectionService.sendIqPacket(getAccount(), packet, (iq) -> {
2101 session.updateWithResponse(iq);
2102 }, 120L);
2103 }
2104 }
2105 };
2106 task.run();
2107
2108 sessions.add(session);
2109 notifyDataSetChanged();
2110 if (mPager.get() != null) mPager.get().setCurrentItem(getCount() - 1);
2111 }
2112
2113 public void removeSession(ConversationPage session) {
2114 sessions.remove(session);
2115 notifyDataSetChanged();
2116 if (session instanceof WebxdcPage) mPager.get().setCurrentItem(0);
2117 }
2118
2119 public boolean switchToSession(final String node) {
2120 if (sessions == null) return false;
2121
2122 int i = 0;
2123 for (ConversationPage session : sessions) {
2124 if (session.getNode().equals(node)) {
2125 if (mPager.get() != null) mPager.get().setCurrentItem(i + 2);
2126 return true;
2127 }
2128 i++;
2129 }
2130
2131 return false;
2132 }
2133
2134 @NonNull
2135 @Override
2136 public Object instantiateItem(@NonNull ViewGroup container, int position) {
2137 if (position == 0) {
2138 final var pg1 = page1.get();
2139 if (pg1 != null && pg1.getParent() != null) {
2140 ((ViewGroup) pg1.getParent()).removeView(pg1);
2141 }
2142 container.addView(pg1);
2143 return pg1;
2144 }
2145 if (position == 1) {
2146 final var pg2 = page2.get();
2147 if (pg2 != null && pg2.getParent() != null) {
2148 ((ViewGroup) pg2.getParent()).removeView(pg2);
2149 }
2150 container.addView(pg2);
2151 return pg2;
2152 }
2153
2154 if (position-2 >= sessions.size()) return null;
2155 ConversationPage session = sessions.get(position-2);
2156 View v = session.inflateUi(container.getContext(), (s) -> removeSession(s));
2157 if (v != null && v.getParent() != null) {
2158 ((ViewGroup) v.getParent()).removeView(v);
2159 }
2160 container.addView(v);
2161 return session;
2162 }
2163
2164 @Override
2165 public void destroyItem(@NonNull ViewGroup container, int position, Object o) {
2166 if (o == null) return;
2167 if (position < 2) {
2168 container.removeView((View) o);
2169 return;
2170 }
2171
2172 container.removeView(((ConversationPage) o).getView());
2173 }
2174
2175 @Override
2176 public int getItemPosition(Object o) {
2177 if (mPager.get() != null) {
2178 final var page1Deref = page1.get();
2179 if (o == page1Deref) {
2180 if (page1Deref == null) return PagerAdapter.POSITION_NONE;
2181 else return PagerAdapter.POSITION_UNCHANGED;
2182 }
2183 final var page2Deref = page2.get();
2184 if (o == page2Deref) {
2185 if (page2Deref == null) return PagerAdapter.POSITION_NONE;
2186 else return PagerAdapter.POSITION_UNCHANGED;
2187 }
2188 }
2189
2190 int pos = sessions == null ? -1 : sessions.indexOf(o);
2191 if (pos < 0) return PagerAdapter.POSITION_NONE;
2192 return pos + 2;
2193 }
2194
2195 @Override
2196 public int getCount() {
2197 if (sessions == null) return 1;
2198
2199 int count = 2 + sessions.size();
2200 if (mTabs.get() == null) return count;
2201
2202 if (count > 2) {
2203 mTabs.get().setTabMode(TabLayout.MODE_SCROLLABLE);
2204 } else {
2205 mTabs.get().setTabMode(TabLayout.MODE_FIXED);
2206 }
2207 return count;
2208 }
2209
2210 @Override
2211 public boolean isViewFromObject(@NonNull View view, @NonNull Object o) {
2212 if (view == o) return true;
2213
2214 if (o instanceof ConversationPage) {
2215 return ((ConversationPage) o).getView() == view;
2216 }
2217
2218 return false;
2219 }
2220
2221 @Nullable
2222 @Override
2223 public CharSequence getPageTitle(int position) {
2224 switch (position) {
2225 case 0:
2226 return "Conversation";
2227 case 1:
2228 return "Commands";
2229 default:
2230 ConversationPage session = sessions.get(position-2);
2231 if (session == null) return super.getPageTitle(position);
2232 return session.getTitle();
2233 }
2234 }
2235
2236 class CommandSession extends RecyclerView.Adapter<CommandSession.ViewHolder> implements ConversationPage {
2237 abstract class ViewHolder<T extends ViewDataBinding> extends RecyclerView.ViewHolder {
2238 protected T binding;
2239
2240 public ViewHolder(T binding) {
2241 super(binding.getRoot());
2242 this.binding = binding;
2243 }
2244
2245 abstract public void bind(Item el);
2246
2247 protected void setTextOrHide(TextView v, Optional<String> s) {
2248 if (s == null || !s.isPresent()) {
2249 v.setVisibility(View.GONE);
2250 } else {
2251 v.setVisibility(View.VISIBLE);
2252 v.setText(s.get());
2253 }
2254 }
2255
2256 protected void setupInputType(Element field, TextView textinput, TextInputLayout layout) {
2257 int flags = 0;
2258 if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_NONE);
2259 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
2260
2261 String type = field.getAttribute("type");
2262 if (type != null) {
2263 if (type.equals("text-multi") || type.equals("jid-multi")) {
2264 flags |= InputType.TYPE_TEXT_FLAG_MULTI_LINE;
2265 }
2266
2267 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
2268
2269 if (type.equals("jid-single") || type.equals("jid-multi")) {
2270 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
2271 }
2272
2273 if (type.equals("text-private")) {
2274 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
2275 if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_PASSWORD_TOGGLE);
2276 }
2277 }
2278
2279 Element validate = field.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2280 if (validate == null) return;
2281 String datatype = validate.getAttribute("datatype");
2282 if (datatype == null) return;
2283
2284 if (datatype.equals("xs:integer") || datatype.equals("xs:int") || datatype.equals("xs:long") || datatype.equals("xs:short") || datatype.equals("xs:byte")) {
2285 textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED);
2286 }
2287
2288 if (datatype.equals("xs:decimal") || datatype.equals("xs:double")) {
2289 textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED | InputType.TYPE_NUMBER_FLAG_DECIMAL);
2290 }
2291
2292 if (datatype.equals("xs:date")) {
2293 textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_DATE);
2294 }
2295
2296 if (datatype.equals("xs:dateTime")) {
2297 textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_NORMAL);
2298 }
2299
2300 if (datatype.equals("xs:time")) {
2301 textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_TIME);
2302 }
2303
2304 if (datatype.equals("xs:anyURI")) {
2305 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
2306 }
2307
2308 if (datatype.equals("html:tel")) {
2309 textinput.setInputType(flags | InputType.TYPE_CLASS_PHONE);
2310 }
2311
2312 if (datatype.equals("html:email")) {
2313 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
2314 }
2315 }
2316
2317 protected String formatValue(String datatype, String value, boolean compact) {
2318 if ("xs:dateTime".equals(datatype)) {
2319 ZonedDateTime zonedDateTime = null;
2320 try {
2321 zonedDateTime = ZonedDateTime.parse(value, DateTimeFormatter.ISO_DATE_TIME);
2322 } catch (final DateTimeParseException e) {
2323 try {
2324 DateTimeFormatter almostIso = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm[:ss] X");
2325 zonedDateTime = ZonedDateTime.parse(value, almostIso);
2326 } catch (final DateTimeParseException e2) { }
2327 }
2328 if (zonedDateTime == null) return value;
2329 ZonedDateTime localZonedDateTime = zonedDateTime.withZoneSameInstant(ZoneId.systemDefault());
2330 DateTimeFormatter outputFormat = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT);
2331 return localZonedDateTime.toLocalDateTime().format(outputFormat);
2332 }
2333
2334 if ("html:tel".equals(datatype) && !compact) {
2335 return PhoneNumberUtils.formatNumber(value, value, null);
2336 }
2337
2338 return value;
2339 }
2340 }
2341
2342 class ErrorViewHolder extends ViewHolder<CommandNoteBinding> {
2343 public ErrorViewHolder(CommandNoteBinding binding) { super(binding); }
2344
2345 @Override
2346 public void bind(Item iq) {
2347 binding.errorIcon.setVisibility(View.VISIBLE);
2348
2349 if (iq == null || iq.el == null) return;
2350 Element error = iq.el.findChild("error");
2351 if (error == null) {
2352 binding.message.setText("Unexpected response: " + iq);
2353 return;
2354 }
2355 String text = error.findChildContent("text", "urn:ietf:params:xml:ns:xmpp-stanzas");
2356 if (text == null || text.equals("")) {
2357 text = error.getChildren().get(0).getName();
2358 }
2359 binding.message.setText(text);
2360 }
2361 }
2362
2363 class NoteViewHolder extends ViewHolder<CommandNoteBinding> {
2364 public NoteViewHolder(CommandNoteBinding binding) { super(binding); }
2365
2366 @Override
2367 public void bind(Item note) {
2368 binding.message.setText(note != null && note.el != null ? note.el.getContent() : "");
2369
2370 String type = note.el.getAttribute("type");
2371 if (type != null && type.equals("error")) {
2372 binding.errorIcon.setVisibility(View.VISIBLE);
2373 }
2374 }
2375 }
2376
2377 class ResultFieldViewHolder extends ViewHolder<CommandResultFieldBinding> {
2378 public ResultFieldViewHolder(CommandResultFieldBinding binding) { super(binding); }
2379
2380 @Override
2381 public void bind(Item item) {
2382 Field field = (Field) item;
2383 setTextOrHide(binding.label, field.getLabel());
2384 setTextOrHide(binding.desc, field.getDesc());
2385
2386 Element media = field.el.findChild("media", "urn:xmpp:media-element");
2387 if (media == null) {
2388 binding.mediaImage.setVisibility(View.GONE);
2389 } else {
2390 final LruCache<String, Drawable> cache = xmppConnectionService.getDrawableCache();
2391 final HttpConnectionManager httpManager = xmppConnectionService.getHttpConnectionManager();
2392 for (Element uriEl : media.getChildren()) {
2393 if (!"uri".equals(uriEl.getName())) continue;
2394 if (!"urn:xmpp:media-element".equals(uriEl.getNamespace())) continue;
2395 String mimeType = uriEl.getAttribute("type");
2396 String uriS = uriEl.getContent();
2397 if (mimeType == null || uriS == null) continue;
2398 Uri uri = Uri.parse(uriS);
2399 if (mimeType.startsWith("image/") && "https".equals(uri.getScheme())) {
2400 final Drawable d = getDrawableForUrl(uri.toString());
2401 if (d != null) {
2402 binding.mediaImage.setImageDrawable(d);
2403 binding.mediaImage.setVisibility(View.VISIBLE);
2404 }
2405 }
2406 }
2407 }
2408
2409 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2410 String datatype = validate == null ? null : validate.getAttribute("datatype");
2411
2412 ArrayAdapter<Option> values = new ArrayAdapter<>(binding.getRoot().getContext(), R.layout.simple_list_item);
2413 for (Element el : field.el.getChildren()) {
2414 if (el.getName().equals("value") && el.getNamespace().equals("jabber:x:data")) {
2415 values.add(new Option(el.getContent(), formatValue(datatype, el.getContent(), false)));
2416 }
2417 }
2418 binding.values.setAdapter(values);
2419 Util.justifyListViewHeightBasedOnChildren(binding.values);
2420
2421 if (field.getType().equals(Optional.of("jid-single")) || field.getType().equals(Optional.of("jid-multi"))) {
2422 binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
2423 final var jid = Uri.encode(Jid.of(values.getItem(pos).getValue()).toString(), "@/+");
2424 new FixedURLSpan("xmpp:" + jid, account).onClick(binding.values);
2425 });
2426 } else if ("xs:anyURI".equals(datatype)) {
2427 binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
2428 new FixedURLSpan(values.getItem(pos).getValue(), account).onClick(binding.values);
2429 });
2430 } else if ("html:tel".equals(datatype)) {
2431 binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
2432 try {
2433 new FixedURLSpan("tel:" + PhoneNumberUtilWrapper.normalize(binding.getRoot().getContext(), values.getItem(pos).getValue()), account).onClick(binding.values);
2434 } catch (final IllegalArgumentException | NumberParseException | NullPointerException e) { }
2435 });
2436 }
2437
2438 binding.values.setOnItemLongClickListener((arg0, arg1, pos, id) -> {
2439 if (ShareUtil.copyTextToClipboard(binding.getRoot().getContext(), values.getItem(pos).getValue(), R.string.message)) {
2440 Toast.makeText(binding.getRoot().getContext(), R.string.message_copied_to_clipboard, Toast.LENGTH_SHORT).show();
2441 }
2442 return true;
2443 });
2444 }
2445 }
2446
2447 class ResultCellViewHolder extends ViewHolder<CommandResultCellBinding> {
2448 public ResultCellViewHolder(CommandResultCellBinding binding) { super(binding); }
2449
2450 @Override
2451 public void bind(Item item) {
2452 Cell cell = (Cell) item;
2453
2454 if (cell.el == null) {
2455 binding.text.setTextAppearance(binding.getRoot().getContext(), com.google.android.material.R.style.TextAppearance_Material3_TitleMedium);
2456 setTextOrHide(binding.text, cell.reported.getLabel());
2457 } else {
2458 Element validate = cell.reported.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2459 String datatype = validate == null ? null : validate.getAttribute("datatype");
2460 String value = formatValue(datatype, cell.el.findChildContent("value", "jabber:x:data"), true);
2461 SpannableStringBuilder text = new SpannableStringBuilder(value == null ? "" : value);
2462 if (cell.reported.getType().equals(Optional.of("jid-single"))) {
2463 final var jid = Uri.encode(Jid.of(text.toString()).toString(), "@/+");
2464 text.setSpan(new FixedURLSpan("xmpp:" + jid, account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
2465 } else if ("xs:anyURI".equals(datatype)) {
2466 text.setSpan(new FixedURLSpan(text.toString(), account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
2467 } else if ("html:tel".equals(datatype)) {
2468 try {
2469 text.setSpan(new FixedURLSpan("tel:" + PhoneNumberUtilWrapper.normalize(binding.getRoot().getContext(), text.toString()), account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
2470 } catch (final IllegalArgumentException | NumberParseException | NullPointerException e) { }
2471 }
2472
2473 binding.text.setTextAppearance(binding.getRoot().getContext(), com.google.android.material.R.style.TextAppearance_Material3_BodyMedium);
2474 binding.text.setText(text);
2475
2476 BetterLinkMovementMethod method = BetterLinkMovementMethod.newInstance();
2477 method.setOnLinkLongClickListener((tv, url) -> {
2478 tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
2479 ShareUtil.copyLinkToClipboard(binding.getRoot().getContext(), url);
2480 return true;
2481 });
2482 binding.text.setMovementMethod(method);
2483 }
2484 }
2485 }
2486
2487 class ItemCardViewHolder extends ViewHolder<CommandItemCardBinding> {
2488 public ItemCardViewHolder(CommandItemCardBinding binding) { super(binding); }
2489
2490 @Override
2491 public void bind(Item item) {
2492 binding.fields.removeAllViews();
2493
2494 for (Field field : reported) {
2495 CommandResultFieldBinding row = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.command_result_field, binding.fields, false);
2496 GridLayout.LayoutParams param = new GridLayout.LayoutParams();
2497 param.columnSpec = GridLayout.spec(GridLayout.UNDEFINED, GridLayout.FILL, 1f);
2498 param.width = 0;
2499 row.getRoot().setLayoutParams(param);
2500 binding.fields.addView(row.getRoot());
2501 for (Element el : item.el.getChildren()) {
2502 if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data") && el.getAttribute("var") != null && el.getAttribute("var").equals(field.getVar())) {
2503 for (String label : field.getLabel().asSet()) {
2504 el.setAttribute("label", label);
2505 }
2506 for (String desc : field.getDesc().asSet()) {
2507 el.setAttribute("desc", desc);
2508 }
2509 for (String type : field.getType().asSet()) {
2510 el.setAttribute("type", type);
2511 }
2512 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2513 if (validate != null) el.addChild(validate);
2514 new ResultFieldViewHolder(row).bind(new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), -1));
2515 }
2516 }
2517 }
2518 }
2519 }
2520
2521 class CheckboxFieldViewHolder extends ViewHolder<CommandCheckboxFieldBinding> implements CompoundButton.OnCheckedChangeListener {
2522 public CheckboxFieldViewHolder(CommandCheckboxFieldBinding binding) {
2523 super(binding);
2524 binding.row.setOnClickListener((v) -> {
2525 binding.checkbox.toggle();
2526 });
2527 binding.checkbox.setOnCheckedChangeListener(this);
2528 }
2529 protected Element mValue = null;
2530
2531 @Override
2532 public void bind(Item item) {
2533 Field field = (Field) item;
2534 binding.label.setText(field.getLabel().or(""));
2535 setTextOrHide(binding.desc, field.getDesc());
2536 mValue = field.getValue();
2537 final var isChecked = mValue.getContent() != null && (mValue.getContent().equals("true") || mValue.getContent().equals("1"));
2538 mValue.setContent(isChecked ? "true" : "false");
2539 binding.checkbox.setChecked(isChecked);
2540 }
2541
2542 @Override
2543 public void onCheckedChanged(CompoundButton checkbox, boolean isChecked) {
2544 if (mValue == null) return;
2545
2546 mValue.setContent(isChecked ? "true" : "false");
2547 }
2548 }
2549
2550 class SearchListFieldViewHolder extends ViewHolder<CommandSearchListFieldBinding> implements TextWatcher {
2551 public SearchListFieldViewHolder(CommandSearchListFieldBinding binding) {
2552 super(binding);
2553 binding.search.addTextChangedListener(this);
2554 }
2555 protected Field field = null;
2556 Set<String> filteredValues;
2557 List<Option> options = new ArrayList<>();
2558 protected ArrayAdapter<Option> adapter;
2559 protected boolean open;
2560 protected boolean multi;
2561 protected int textColor = -1;
2562
2563 @Override
2564 public void bind(Item item) {
2565 ViewGroup.LayoutParams layout = binding.list.getLayoutParams();
2566 final float density = xmppConnectionService.getResources().getDisplayMetrics().density;
2567 if (fillableFieldCount > 1) {
2568 layout.height = (int) (density * 200);
2569 } else {
2570 layout.height = (int) Math.max(density * 200, xmppConnectionService.getResources().getDisplayMetrics().heightPixels / 2);
2571 }
2572 binding.list.setLayoutParams(layout);
2573
2574 field = (Field) item;
2575 setTextOrHide(binding.label, field.getLabel());
2576 setTextOrHide(binding.desc, field.getDesc());
2577
2578 if (textColor == -1) textColor = binding.desc.getCurrentTextColor();
2579 if (field.error != null) {
2580 binding.desc.setVisibility(View.VISIBLE);
2581 binding.desc.setText(field.error);
2582 binding.desc.setTextColor(com.google.android.material.R.attr.colorError);
2583 } else {
2584 binding.desc.setTextColor(textColor);
2585 }
2586
2587 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2588 open = validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null;
2589 setupInputType(field.el, binding.search, null);
2590
2591 multi = field.getType().equals(Optional.of("list-multi"));
2592 if (multi) {
2593 binding.list.setChoiceMode(AbsListView.CHOICE_MODE_MULTIPLE);
2594 } else {
2595 binding.list.setChoiceMode(AbsListView.CHOICE_MODE_SINGLE);
2596 }
2597
2598 options = field.getOptions();
2599 binding.list.setOnItemClickListener((parent, view, position, id) -> {
2600 Set<String> values = new HashSet<>();
2601 if (multi) {
2602 final var optionValues = options.stream().map(o -> o.getValue()).collect(Collectors.toSet());
2603 values.addAll(field.getValues());
2604 for (final String value : field.getValues()) {
2605 if (filteredValues.contains(value) || (!open && !optionValues.contains(value))) {
2606 values.remove(value);
2607 }
2608 }
2609 }
2610
2611 SparseBooleanArray positions = binding.list.getCheckedItemPositions();
2612 for (int i = 0; i < positions.size(); i++) {
2613 if (positions.valueAt(i)) values.add(adapter.getItem(positions.keyAt(i)).getValue());
2614 }
2615 field.setValues(values);
2616
2617 if (!multi && open) binding.search.setText(String.join("\n", field.getValues()));
2618 });
2619 search("");
2620 }
2621
2622 @Override
2623 public void afterTextChanged(Editable s) {
2624 if (!multi && open) field.setValues(List.of(s.toString()));
2625 search(s.toString());
2626 }
2627
2628 @Override
2629 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2630
2631 @Override
2632 public void onTextChanged(CharSequence s, int start, int count, int after) { }
2633
2634 protected void search(String s) {
2635 List<Option> filteredOptions;
2636 final String q = s.replaceAll("\\W", "").toLowerCase();
2637 if (q == null || q.equals("")) {
2638 filteredOptions = options;
2639 } else {
2640 filteredOptions = options.stream().filter(o -> o.toString().replaceAll("\\W", "").toLowerCase().contains(q)).collect(Collectors.toList());
2641 }
2642 filteredValues = filteredOptions.stream().map(o -> o.getValue()).collect(Collectors.toSet());
2643 adapter = new ArrayAdapter(binding.getRoot().getContext(), R.layout.simple_list_item, filteredOptions);
2644 binding.list.setAdapter(adapter);
2645
2646 for (final String value : field.getValues()) {
2647 int checkedPos = filteredOptions.indexOf(new Option(value, ""));
2648 if (checkedPos >= 0) binding.list.setItemChecked(checkedPos, true);
2649 }
2650 }
2651 }
2652
2653 class RadioEditFieldViewHolder extends ViewHolder<CommandRadioEditFieldBinding> implements CompoundButton.OnCheckedChangeListener, TextWatcher {
2654 public RadioEditFieldViewHolder(CommandRadioEditFieldBinding binding) {
2655 super(binding);
2656 binding.open.addTextChangedListener(this);
2657 options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.radio_grid_item) {
2658 @Override
2659 public View getView(int position, View convertView, ViewGroup parent) {
2660 CompoundButton v = (CompoundButton) super.getView(position, convertView, parent);
2661 v.setId(position);
2662 v.setChecked(getItem(position).getValue().equals(mValue.getContent()));
2663 v.setOnCheckedChangeListener(RadioEditFieldViewHolder.this);
2664 return v;
2665 }
2666 };
2667 }
2668 protected Element mValue = null;
2669 protected ArrayAdapter<Option> options;
2670 protected int textColor = -1;
2671
2672 @Override
2673 public void bind(Item item) {
2674 Field field = (Field) item;
2675 setTextOrHide(binding.label, field.getLabel());
2676 setTextOrHide(binding.desc, field.getDesc());
2677
2678 if (textColor == -1) textColor = binding.desc.getCurrentTextColor();
2679 if (field.error != null) {
2680 binding.desc.setVisibility(View.VISIBLE);
2681 binding.desc.setText(field.error);
2682 binding.desc.setTextColor(com.google.android.material.R.attr.colorError);
2683 } else {
2684 binding.desc.setTextColor(textColor);
2685 }
2686
2687 mValue = field.getValue();
2688
2689 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2690 binding.open.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
2691 binding.open.setText(mValue.getContent());
2692 setupInputType(field.el, binding.open, null);
2693
2694 options.clear();
2695 List<Option> theOptions = field.getOptions();
2696 options.addAll(theOptions);
2697
2698 float screenWidth = binding.getRoot().getContext().getResources().getDisplayMetrics().widthPixels;
2699 TextPaint paint = ((TextView) LayoutInflater.from(binding.getRoot().getContext()).inflate(R.layout.radio_grid_item, null)).getPaint();
2700 float maxColumnWidth = theOptions.stream().map((x) ->
2701 StaticLayout.getDesiredWidth(x.toString(), paint)
2702 ).max(Float::compare).orElse(new Float(0.0));
2703 if (maxColumnWidth * theOptions.size() < 0.90 * screenWidth) {
2704 binding.radios.setNumColumns(theOptions.size());
2705 } else if (maxColumnWidth * (theOptions.size() / 2) < 0.90 * screenWidth) {
2706 binding.radios.setNumColumns(theOptions.size() / 2);
2707 } else {
2708 binding.radios.setNumColumns(1);
2709 }
2710 binding.radios.setAdapter(options);
2711 }
2712
2713 @Override
2714 public void onCheckedChanged(CompoundButton radio, boolean isChecked) {
2715 if (mValue == null) return;
2716
2717 if (isChecked) {
2718 mValue.setContent(options.getItem(radio.getId()).getValue());
2719 binding.open.setText(mValue.getContent());
2720 }
2721 options.notifyDataSetChanged();
2722 }
2723
2724 @Override
2725 public void afterTextChanged(Editable s) {
2726 if (mValue == null) return;
2727
2728 mValue.setContent(s.toString());
2729 options.notifyDataSetChanged();
2730 }
2731
2732 @Override
2733 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2734
2735 @Override
2736 public void onTextChanged(CharSequence s, int start, int count, int after) { }
2737 }
2738
2739 class SpinnerFieldViewHolder extends ViewHolder<CommandSpinnerFieldBinding> implements AdapterView.OnItemSelectedListener {
2740 public SpinnerFieldViewHolder(CommandSpinnerFieldBinding binding) {
2741 super(binding);
2742 binding.spinner.setOnItemSelectedListener(this);
2743 }
2744 protected Element mValue = null;
2745
2746 @Override
2747 public void bind(Item item) {
2748 Field field = (Field) item;
2749 setTextOrHide(binding.label, field.getLabel());
2750 binding.spinner.setPrompt(field.getLabel().or(""));
2751 setTextOrHide(binding.desc, field.getDesc());
2752
2753 mValue = field.getValue();
2754
2755 ArrayAdapter<Option> options = new ArrayAdapter<Option>(binding.getRoot().getContext(), android.R.layout.simple_spinner_item);
2756 options.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
2757 options.addAll(field.getOptions());
2758
2759 binding.spinner.setAdapter(options);
2760 binding.spinner.setSelection(options.getPosition(new Option(mValue.getContent(), null)));
2761 }
2762
2763 @Override
2764 public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
2765 Option o = (Option) parent.getItemAtPosition(pos);
2766 if (mValue == null) return;
2767
2768 mValue.setContent(o == null ? "" : o.getValue());
2769 }
2770
2771 @Override
2772 public void onNothingSelected(AdapterView<?> parent) {
2773 mValue.setContent("");
2774 }
2775 }
2776
2777 class ButtonGridFieldViewHolder extends ViewHolder<CommandButtonGridFieldBinding> {
2778 public ButtonGridFieldViewHolder(CommandButtonGridFieldBinding binding) {
2779 super(binding);
2780 options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.button_grid_item) {
2781 protected int height = 0;
2782
2783 @Override
2784 public View getView(int position, View convertView, ViewGroup parent) {
2785 Button v = (Button) super.getView(position, convertView, parent);
2786 v.setOnClickListener((view) -> {
2787 mValue.setContent(getItem(position).getValue());
2788 execute();
2789 loading = true;
2790 });
2791
2792 final SVG icon = getItem(position).getIcon();
2793 if (icon != null) {
2794 final Element iconEl = getItem(position).getIconEl();
2795 if (height < 1) {
2796 v.measure(0, 0);
2797 height = v.getMeasuredHeight();
2798 }
2799 if (height < 1) return v;
2800 if (mediaSelector) {
2801 final Drawable d = getDrawableForSVG(icon, iconEl, height * 4);
2802 if (d != null) {
2803 final int boundsHeight = 35 + (int)((height * 4) / xmppConnectionService.getResources().getDisplayMetrics().density);
2804 d.setBounds(0, 0, d.getIntrinsicWidth(), boundsHeight);
2805 }
2806 v.setCompoundDrawables(null, d, null, null);
2807 } else {
2808 v.setCompoundDrawablesRelativeWithIntrinsicBounds(getDrawableForSVG(icon, iconEl, height), null, null, null);
2809 }
2810 }
2811
2812 return v;
2813 }
2814 };
2815 }
2816 protected Element mValue = null;
2817 protected ArrayAdapter<Option> options;
2818 protected Option defaultOption = null;
2819 protected boolean mediaSelector = false;
2820 protected int textColor = -1;
2821
2822 @Override
2823 public void bind(Item item) {
2824 Field field = (Field) item;
2825 setTextOrHide(binding.label, field.getLabel());
2826 setTextOrHide(binding.desc, field.getDesc());
2827
2828 if (textColor == -1) textColor = binding.desc.getCurrentTextColor();
2829 if (field.error != null) {
2830 binding.desc.setVisibility(View.VISIBLE);
2831 binding.desc.setText(field.error);
2832 binding.desc.setTextColor(com.google.android.material.R.attr.colorError);
2833 } else {
2834 binding.desc.setTextColor(textColor);
2835 }
2836
2837 mValue = field.getValue();
2838 mediaSelector = field.el.findChild("media-selector", "https://ns.cheogram.com/") != null;
2839
2840 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2841 binding.openButton.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
2842 binding.openButton.setOnClickListener((view) -> {
2843 AlertDialog.Builder builder = new AlertDialog.Builder(binding.getRoot().getContext());
2844 DialogQuickeditBinding dialogBinding = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.dialog_quickedit, null, false);
2845 builder.setPositiveButton(R.string.action_execute, null);
2846 if (field.getDesc().isPresent()) {
2847 dialogBinding.inputLayout.setHint(field.getDesc().get());
2848 }
2849 dialogBinding.inputEditText.requestFocus();
2850 dialogBinding.inputEditText.getText().append(mValue.getContent());
2851 builder.setView(dialogBinding.getRoot());
2852 builder.setNegativeButton(R.string.cancel, null);
2853 final AlertDialog dialog = builder.create();
2854 dialog.setOnShowListener(d -> SoftKeyboardUtils.showKeyboard(dialogBinding.inputEditText));
2855 dialog.show();
2856 View.OnClickListener clickListener = v -> {
2857 String value = dialogBinding.inputEditText.getText().toString();
2858 mValue.setContent(value);
2859 SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2860 dialog.dismiss();
2861 execute();
2862 loading = true;
2863 };
2864 dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener(clickListener);
2865 dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setOnClickListener((v -> {
2866 SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2867 dialog.dismiss();
2868 }));
2869 dialog.setCanceledOnTouchOutside(false);
2870 dialog.setOnDismissListener(dialog1 -> {
2871 SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2872 });
2873 });
2874
2875 options.clear();
2876 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();
2877
2878 defaultOption = null;
2879 for (Option option : theOptions) {
2880 if (option.getValue().equals(mValue.getContent())) {
2881 defaultOption = option;
2882 break;
2883 }
2884 }
2885 if (defaultOption == null && !mValue.getContent().equals("")) {
2886 // Synthesize default option for custom value
2887 defaultOption = new Option(mValue.getContent(), mValue.getContent());
2888 }
2889 if (defaultOption == null) {
2890 binding.defaultButton.setVisibility(View.GONE);
2891 binding.defaultButtonSeperator.setVisibility(View.GONE);
2892 } else {
2893 theOptions.remove(defaultOption);
2894 binding.defaultButton.setVisibility(View.VISIBLE);
2895 binding.defaultButtonSeperator.setVisibility(View.VISIBLE);
2896
2897 final SVG defaultIcon = defaultOption.getIcon();
2898 if (defaultIcon != null) {
2899 DisplayMetrics display = mPager.get().getContext().getResources().getDisplayMetrics();
2900 int height = (int)(display.heightPixels*display.density/8);
2901 binding.defaultButton.setCompoundDrawablesRelativeWithIntrinsicBounds(null, getDrawableForSVG(defaultIcon, defaultOption.getIconEl(), height), null, null);
2902 }
2903
2904 binding.defaultButton.setText(defaultOption.toString());
2905 binding.defaultButton.setOnClickListener((view) -> {
2906 mValue.setContent(defaultOption.getValue());
2907 execute();
2908 loading = true;
2909 });
2910 }
2911
2912 options.addAll(theOptions);
2913 binding.buttons.setAdapter(options);
2914 }
2915 }
2916
2917 class TextFieldViewHolder extends ViewHolder<CommandTextFieldBinding> implements TextWatcher {
2918 public TextFieldViewHolder(CommandTextFieldBinding binding) {
2919 super(binding);
2920 binding.textinput.addTextChangedListener(this);
2921 }
2922 protected Field field = null;
2923
2924 @Override
2925 public void bind(Item item) {
2926 field = (Field) item;
2927 binding.textinputLayout.setHint(field.getLabel().or(""));
2928
2929 binding.textinputLayout.setHelperTextEnabled(field.getDesc().isPresent());
2930 for (String desc : field.getDesc().asSet()) {
2931 binding.textinputLayout.setHelperText(desc);
2932 }
2933
2934 binding.textinputLayout.setErrorEnabled(field.error != null);
2935 if (field.error != null) binding.textinputLayout.setError(field.error);
2936
2937 binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_GRAVITY);
2938 String suffixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/suffix-label");
2939 if (suffixLabel == null) {
2940 binding.textinputLayout.setSuffixText("");
2941 } else {
2942 binding.textinputLayout.setSuffixText(suffixLabel);
2943 binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_TEXT_END);
2944 }
2945
2946 String prefixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/prefix-label");
2947 binding.textinputLayout.setPrefixText(prefixLabel == null ? "" : prefixLabel);
2948
2949 binding.textinput.setText(String.join("\n", field.getValues()));
2950 setupInputType(field.el, binding.textinput, binding.textinputLayout);
2951 }
2952
2953 @Override
2954 public void afterTextChanged(Editable s) {
2955 if (field == null) return;
2956
2957 field.setValues(List.of(s.toString().split("\n")));
2958 }
2959
2960 @Override
2961 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2962
2963 @Override
2964 public void onTextChanged(CharSequence s, int start, int count, int after) { }
2965 }
2966
2967 class SliderFieldViewHolder extends ViewHolder<CommandSliderFieldBinding> {
2968 public SliderFieldViewHolder(CommandSliderFieldBinding binding) { super(binding); }
2969 protected Field field = null;
2970
2971 @Override
2972 public void bind(Item item) {
2973 field = (Field) item;
2974 setTextOrHide(binding.label, field.getLabel());
2975 setTextOrHide(binding.desc, field.getDesc());
2976 final Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2977 final String datatype = validate == null ? null : validate.getAttribute("datatype");
2978 final Element range = validate == null ? null : validate.findChild("range", "http://jabber.org/protocol/xdata-validate");
2979 // NOTE: range also implies open, so we don't have to be bound by the options strictly
2980 // Also, we don't have anywhere to put labels so we show only values, which might sometimes be bad...
2981 Float min = null;
2982 try { min = range.getAttribute("min") == null ? null : Float.valueOf(range.getAttribute("min")); } catch (NumberFormatException e) { }
2983 Float max = null;
2984 try { max = range.getAttribute("max") == null ? null : Float.valueOf(range.getAttribute("max")); } catch (NumberFormatException e) { }
2985
2986 List<Float> options = field.getOptions().stream().map(o -> Float.valueOf(o.getValue())).collect(Collectors.toList());
2987 Collections.sort(options);
2988 if (options.size() > 0) {
2989 // min/max should be on the range, but if you have options and didn't put them on the range we can imply
2990 if (min == null) min = options.get(0);
2991 if (max == null) max = options.get(options.size()-1);
2992 }
2993
2994 if (field.getValues().size() > 0) {
2995 final var val = Float.valueOf(field.getValue().getContent());
2996 if ((min == null || val >= min) && (max == null || val <= max)) {
2997 binding.slider.setValue(Float.valueOf(field.getValue().getContent()));
2998 } else {
2999 binding.slider.setValue(min == null ? Float.MIN_VALUE : min);
3000 }
3001 } else {
3002 binding.slider.setValue(min == null ? Float.MIN_VALUE : min);
3003 }
3004 binding.slider.setValueFrom(min == null ? Float.MIN_VALUE : min);
3005 binding.slider.setValueTo(max == null ? Float.MAX_VALUE : max);
3006 if ("xs:integer".equals(datatype) || "xs:int".equals(datatype) || "xs:long".equals(datatype) || "xs:short".equals(datatype) || "xs:byte".equals(datatype)) {
3007 binding.slider.setStepSize(1);
3008 } else {
3009 binding.slider.setStepSize(0);
3010 }
3011
3012 if (options.size() > 0) {
3013 float step = -1;
3014 Float prev = null;
3015 for (final Float option : options) {
3016 if (prev != null) {
3017 float nextStep = option - prev;
3018 if (step > 0 && step != nextStep) {
3019 step = -1;
3020 break;
3021 }
3022 step = nextStep;
3023 }
3024 prev = option;
3025 }
3026 if (step > 0) binding.slider.setStepSize(step);
3027 }
3028
3029 binding.slider.addOnChangeListener((slider, value, fromUser) -> {
3030 field.setValues(List.of(new DecimalFormat().format(value)));
3031 });
3032 }
3033 }
3034
3035 class WebViewHolder extends ViewHolder<CommandWebviewBinding> {
3036 public WebViewHolder(CommandWebviewBinding binding) { super(binding); }
3037 protected String boundUrl = "";
3038
3039 @Override
3040 public void bind(Item oob) {
3041 setTextOrHide(binding.desc, Optional.fromNullable(oob.el.findChildContent("desc", "jabber:x:oob")));
3042 binding.webview.getSettings().setJavaScriptEnabled(true);
3043 binding.webview.getSettings().setMediaPlaybackRequiresUserGesture(false);
3044 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");
3045 binding.webview.getSettings().setDatabaseEnabled(true);
3046 binding.webview.getSettings().setDomStorageEnabled(true);
3047 binding.webview.setWebChromeClient(new WebChromeClient() {
3048 @Override
3049 public void onProgressChanged(WebView view, int newProgress) {
3050 binding.progressbar.setVisibility(newProgress < 100 ? View.VISIBLE : View.GONE);
3051 binding.progressbar.setProgress(newProgress);
3052 }
3053
3054 @Override
3055 public void onPermissionRequest(final PermissionRequest request) {
3056 getView().post(() -> {
3057 request.grant(request.getResources());
3058 });
3059 }
3060 });
3061 binding.webview.setWebViewClient(new WebViewClient() {
3062 @Override
3063 public void onPageFinished(WebView view, String url) {
3064 super.onPageFinished(view, url);
3065 mTitle = view.getTitle();
3066 ConversationPagerAdapter.this.notifyDataSetChanged();
3067 }
3068 });
3069 final String url = oob.el.findChildContent("url", "jabber:x:oob");
3070 if (!boundUrl.equals(url)) {
3071 binding.webview.addJavascriptInterface(new JsObject(), "xmpp_xep0050");
3072 binding.webview.loadUrl(url);
3073 boundUrl = url;
3074 }
3075 }
3076
3077 class JsObject {
3078 @JavascriptInterface
3079 public void execute() { execute("execute"); }
3080
3081 @JavascriptInterface
3082 public void execute(String action) {
3083 getView().post(() -> {
3084 actionToWebview = null;
3085 if(CommandSession.this.execute(action)) {
3086 removeSession(CommandSession.this);
3087 }
3088 });
3089 }
3090
3091 @JavascriptInterface
3092 public void preventDefault() {
3093 actionToWebview = binding.webview;
3094 }
3095 }
3096 }
3097
3098 class ProgressBarViewHolder extends ViewHolder<CommandProgressBarBinding> {
3099 public ProgressBarViewHolder(CommandProgressBarBinding binding) { super(binding); }
3100
3101 @Override
3102 public void bind(Item item) {
3103 binding.text.setVisibility(loadingHasBeenLong ? View.VISIBLE : View.GONE);
3104 }
3105 }
3106
3107 class Item {
3108 protected Element el;
3109 protected int viewType;
3110 protected String error = null;
3111
3112 Item(Element el, int viewType) {
3113 this.el = el;
3114 this.viewType = viewType;
3115 }
3116
3117 public boolean validate() {
3118 error = null;
3119 return true;
3120 }
3121 }
3122
3123 class Field extends Item {
3124 Field(eu.siacs.conversations.xmpp.forms.Field el, int viewType) { super(el, viewType); }
3125
3126 @Override
3127 public boolean validate() {
3128 if (!super.validate()) return false;
3129 if (el.findChild("required", "jabber:x:data") == null) return true;
3130 if (getValue().getContent() != null && !getValue().getContent().equals("")) return true;
3131
3132 error = "this value is required";
3133 return false;
3134 }
3135
3136 public String getVar() {
3137 return el.getAttribute("var");
3138 }
3139
3140 public Optional<String> getType() {
3141 return Optional.fromNullable(el.getAttribute("type"));
3142 }
3143
3144 public Optional<String> getLabel() {
3145 String label = el.getAttribute("label");
3146 if (label == null) label = getVar();
3147 return Optional.fromNullable(label);
3148 }
3149
3150 public Optional<String> getDesc() {
3151 return Optional.fromNullable(el.findChildContent("desc", "jabber:x:data"));
3152 }
3153
3154 public Element getValue() {
3155 Element value = el.findChild("value", "jabber:x:data");
3156 if (value == null) {
3157 value = el.addChild("value", "jabber:x:data");
3158 }
3159 return value;
3160 }
3161
3162 public void setValues(Collection<String> values) {
3163 for(Element child : el.getChildren()) {
3164 if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
3165 el.removeChild(child);
3166 }
3167 }
3168
3169 for (String value : values) {
3170 el.addChild("value", "jabber:x:data").setContent(value);
3171 }
3172 }
3173
3174 public List<String> getValues() {
3175 List<String> values = new ArrayList<>();
3176 for(Element child : el.getChildren()) {
3177 if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
3178 values.add(child.getContent());
3179 }
3180 }
3181 return values;
3182 }
3183
3184 public List<Option> getOptions() {
3185 return Option.forField(el);
3186 }
3187 }
3188
3189 class Cell extends Item {
3190 protected Field reported;
3191
3192 Cell(Field reported, Element item) {
3193 super(item, TYPE_RESULT_CELL);
3194 this.reported = reported;
3195 }
3196 }
3197
3198 protected Field mkField(Element el) {
3199 int viewType = -1;
3200
3201 String formType = responseElement.getAttribute("type");
3202 if (formType != null) {
3203 String fieldType = el.getAttribute("type");
3204 if (fieldType == null) fieldType = "text-single";
3205
3206 if (formType.equals("result") || fieldType.equals("fixed")) {
3207 viewType = TYPE_RESULT_FIELD;
3208 } else if (formType.equals("form")) {
3209 final Element validate = el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
3210 final String datatype = validate == null ? null : validate.getAttribute("datatype");
3211 final Element range = validate == null ? null : validate.findChild("range", "http://jabber.org/protocol/xdata-validate");
3212 if (fieldType.equals("boolean")) {
3213 if (fillableFieldCount == 1 && actionsAdapter.countProceed() < 1) {
3214 viewType = TYPE_BUTTON_GRID_FIELD;
3215 } else {
3216 viewType = TYPE_CHECKBOX_FIELD;
3217 }
3218 } else if (
3219 range != null && range.getAttribute("min") != null && range.getAttribute("max") != null && (
3220 "xs:integer".equals(datatype) || "xs:int".equals(datatype) || "xs:long".equals(datatype) || "xs:short".equals(datatype) || "xs:byte".equals(datatype) ||
3221 "xs:decimal".equals(datatype) || "xs:double".equals(datatype)
3222 )
3223 ) {
3224 // has a range and is numeric, use a slider
3225 viewType = TYPE_SLIDER_FIELD;
3226 } else if (fieldType.equals("list-single")) {
3227 if (fillableFieldCount == 1 && actionsAdapter.countProceed() < 1 && Option.forField(el).size() < 50) {
3228 viewType = TYPE_BUTTON_GRID_FIELD;
3229 } else if (Option.forField(el).size() > 9) {
3230 viewType = TYPE_SEARCH_LIST_FIELD;
3231 } else if (el.findChild("value", "jabber:x:data") == null || (validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null)) {
3232 viewType = TYPE_RADIO_EDIT_FIELD;
3233 } else {
3234 viewType = TYPE_SPINNER_FIELD;
3235 }
3236 } else if (fieldType.equals("list-multi")) {
3237 viewType = TYPE_SEARCH_LIST_FIELD;
3238 } else {
3239 viewType = TYPE_TEXT_FIELD;
3240 }
3241 }
3242
3243 return new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), viewType);
3244 }
3245
3246 return null;
3247 }
3248
3249 protected Item mkItem(Element el, int pos) {
3250 int viewType = TYPE_ERROR;
3251
3252 if (response != null && response.getType() == Iq.Type.RESULT) {
3253 if (el.getName().equals("note")) {
3254 viewType = TYPE_NOTE;
3255 } else if (el.getNamespace().equals("jabber:x:oob")) {
3256 viewType = TYPE_WEB;
3257 } else if (el.getName().equals("instructions") && el.getNamespace().equals("jabber:x:data")) {
3258 viewType = TYPE_NOTE;
3259 } else if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data")) {
3260 Field field = mkField(el);
3261 if (field != null) {
3262 items.put(pos, field);
3263 return field;
3264 }
3265 }
3266 }
3267
3268 Item item = new Item(el, viewType);
3269 items.put(pos, item);
3270 return item;
3271 }
3272
3273 class ActionsAdapter extends ArrayAdapter<Pair<String, String>> {
3274 protected Context ctx;
3275
3276 public ActionsAdapter(Context ctx) {
3277 super(ctx, R.layout.simple_list_item);
3278 this.ctx = ctx;
3279 }
3280
3281 @Override
3282 public View getView(int position, View convertView, ViewGroup parent) {
3283 View v = super.getView(position, convertView, parent);
3284 TextView tv = (TextView) v.findViewById(android.R.id.text1);
3285 tv.setGravity(Gravity.CENTER);
3286 tv.setText(getItem(position).second);
3287 int resId = ctx.getResources().getIdentifier("action_" + getItem(position).first, "string" , ctx.getPackageName());
3288 if (resId != 0 && getItem(position).second.equals(getItem(position).first)) tv.setText(ctx.getResources().getString(resId));
3289 final var colors = MaterialColors.getColorRoles(ctx, UIHelper.getColorForName(getItem(position).first));
3290 tv.setTextColor(colors.getOnAccent());
3291 tv.setBackgroundColor(MaterialColors.harmonizeWithPrimary(ctx, colors.getAccent()));
3292 return v;
3293 }
3294
3295 public int getPosition(String s) {
3296 for(int i = 0; i < getCount(); i++) {
3297 if (getItem(i).first.equals(s)) return i;
3298 }
3299 return -1;
3300 }
3301
3302 public int countProceed() {
3303 int count = 0;
3304 for(int i = 0; i < getCount(); i++) {
3305 if (!"cancel".equals(getItem(i).first) && !"prev".equals(getItem(i).first)) count++;
3306 }
3307 return count;
3308 }
3309
3310 public int countExceptCancel() {
3311 int count = 0;
3312 for(int i = 0; i < getCount(); i++) {
3313 if (!getItem(i).first.equals("cancel")) count++;
3314 }
3315 return count;
3316 }
3317
3318 public void clearProceed() {
3319 Pair<String,String> cancelItem = null;
3320 Pair<String,String> prevItem = null;
3321 for(int i = 0; i < getCount(); i++) {
3322 if (getItem(i).first.equals("cancel")) cancelItem = getItem(i);
3323 if (getItem(i).first.equals("prev")) prevItem = getItem(i);
3324 }
3325 clear();
3326 if (cancelItem != null) add(cancelItem);
3327 if (prevItem != null) add(prevItem);
3328 }
3329 }
3330
3331 final int TYPE_ERROR = 1;
3332 final int TYPE_NOTE = 2;
3333 final int TYPE_WEB = 3;
3334 final int TYPE_RESULT_FIELD = 4;
3335 final int TYPE_TEXT_FIELD = 5;
3336 final int TYPE_CHECKBOX_FIELD = 6;
3337 final int TYPE_SPINNER_FIELD = 7;
3338 final int TYPE_RADIO_EDIT_FIELD = 8;
3339 final int TYPE_RESULT_CELL = 9;
3340 final int TYPE_PROGRESSBAR = 10;
3341 final int TYPE_SEARCH_LIST_FIELD = 11;
3342 final int TYPE_ITEM_CARD = 12;
3343 final int TYPE_BUTTON_GRID_FIELD = 13;
3344 final int TYPE_SLIDER_FIELD = 14;
3345
3346 protected boolean executing = false;
3347 protected boolean loading = false;
3348 protected boolean loadingHasBeenLong = false;
3349 protected Timer loadingTimer = new Timer();
3350 protected String mTitle;
3351 protected String mNode;
3352 protected CommandPageBinding mBinding = null;
3353 protected Iq response = null;
3354 protected Element responseElement = null;
3355 protected boolean expectingRemoval = false;
3356 protected List<Field> reported = null;
3357 protected SparseArray<Item> items = new SparseArray<>();
3358 protected XmppConnectionService xmppConnectionService;
3359 protected ActionsAdapter actionsAdapter = null;
3360 protected GridLayoutManager layoutManager;
3361 protected WebView actionToWebview = null;
3362 protected int fillableFieldCount = 0;
3363 protected Iq pendingResponsePacket = null;
3364 protected boolean waitingForRefresh = false;
3365
3366 CommandSession(String title, String node, XmppConnectionService xmppConnectionService) {
3367 loading();
3368 mTitle = title;
3369 mNode = node;
3370 this.xmppConnectionService = xmppConnectionService;
3371 ViewPager pager = mPager.get();
3372 if (pager != null) setupLayoutManager(pager.getContext());
3373 }
3374
3375 public String getTitle() {
3376 return mTitle;
3377 }
3378
3379 public String getNode() {
3380 return mNode;
3381 }
3382
3383 public void updateWithResponse(final Iq iq) {
3384 if (getView() != null && getView().isAttachedToWindow()) {
3385 getView().post(() -> updateWithResponseUiThread(iq));
3386 } else {
3387 pendingResponsePacket = iq;
3388 }
3389 }
3390
3391 protected void updateWithResponseUiThread(final Iq iq) {
3392 Timer oldTimer = this.loadingTimer;
3393 this.loadingTimer = new Timer();
3394 oldTimer.cancel();
3395 this.executing = false;
3396 this.loading = false;
3397 this.loadingHasBeenLong = false;
3398 this.responseElement = null;
3399 this.fillableFieldCount = 0;
3400 this.reported = null;
3401 this.response = iq;
3402 this.items.clear();
3403 this.actionsAdapter.clear();
3404 layoutManager.setSpanCount(1);
3405
3406 boolean actionsCleared = false;
3407 Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
3408 if (iq.getType() == Iq.Type.RESULT && command != null) {
3409 if (mNode.equals("jabber:iq:register") && command.getAttribute("status") != null && command.getAttribute("status").equals("completed")) {
3410 xmppConnectionService.createContact(getAccount().getRoster().getContact(iq.getFrom()));
3411 }
3412
3413 if (xmppConnectionService.isOnboarding() && mNode.equals("jabber:iq:register") && !"canceled".equals(command.getAttribute("status")) && xmppConnectionService.getPreferences().contains("onboarding_action")) {
3414 xmppConnectionService.getPreferences().edit().putBoolean("onboarding_continued", true).commit();
3415 }
3416
3417 Element actions = command.findChild("actions", "http://jabber.org/protocol/commands");
3418 if (actions != null) {
3419 for (Element action : actions.getChildren()) {
3420 if (!"http://jabber.org/protocol/commands".equals(action.getNamespace())) continue;
3421 if ("execute".equals(action.getName())) continue;
3422
3423 actionsAdapter.add(Pair.create(action.getName(), action.getName()));
3424 }
3425 }
3426
3427 for (Element el : command.getChildren()) {
3428 if ("x".equals(el.getName()) && "jabber:x:data".equals(el.getNamespace())) {
3429 Data form = Data.parse(el);
3430 String title = form.getTitle();
3431 if (title != null) {
3432 mTitle = title;
3433 ConversationPagerAdapter.this.notifyDataSetChanged();
3434 }
3435
3436 if ("result".equals(el.getAttribute("type")) || "form".equals(el.getAttribute("type"))) {
3437 this.responseElement = el;
3438 setupReported(el.findChild("reported", "jabber:x:data"));
3439 if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager(mBinding.getRoot().getContext()));
3440 }
3441
3442 eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
3443 if (actionList != null) {
3444 actionsAdapter.clear();
3445
3446 for (Option action : actionList.getOptions()) {
3447 actionsAdapter.add(Pair.create(action.getValue(), action.toString()));
3448 }
3449 }
3450
3451 eu.siacs.conversations.xmpp.forms.Field fillableField = null;
3452 for (eu.siacs.conversations.xmpp.forms.Field field : form.getFields()) {
3453 if ((field.getType() == null || (!field.getType().equals("hidden") && !field.getType().equals("fixed"))) && field.getFieldName() != null && !field.getFieldName().equals("http://jabber.org/protocol/commands#actions")) {
3454 final var validate = field.findChild("validate", "http://jabber.org/protocol/xdata-validate");
3455 final var range = validate == null ? null : validate.findChild("range", "http://jabber.org/protocol/xdata-validate");
3456 fillableField = range == null ? field : null;
3457 fillableFieldCount++;
3458 }
3459 }
3460
3461 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))) {
3462 actionsCleared = true;
3463 actionsAdapter.clearProceed();
3464 }
3465 break;
3466 }
3467 if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
3468 String url = el.findChildContent("url", "jabber:x:oob");
3469 if (url != null) {
3470 String scheme = Uri.parse(url).getScheme();
3471 if (scheme == null) {
3472 break;
3473 }
3474 if (scheme.equals("http") || scheme.equals("https")) {
3475 this.responseElement = el;
3476 break;
3477 }
3478 if (scheme.equals("xmpp")) {
3479 expectingRemoval = true;
3480 final Intent intent = new Intent(getView().getContext(), UriHandlerActivity.class);
3481 intent.setAction(Intent.ACTION_VIEW);
3482 intent.setData(Uri.parse(url));
3483 getView().getContext().startActivity(intent);
3484 break;
3485 }
3486 }
3487 }
3488 if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
3489 this.responseElement = el;
3490 break;
3491 }
3492 }
3493
3494 if (responseElement == null && command.getAttribute("status") != null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
3495 if ("jabber:iq:register".equals(mNode) && "canceled".equals(command.getAttribute("status"))) {
3496 if (xmppConnectionService.isOnboarding()) {
3497 if (xmppConnectionService.getPreferences().contains("onboarding_action")) {
3498 xmppConnectionService.deleteAccount(getAccount());
3499 } else {
3500 if (xmppConnectionService.getPreferences().getBoolean("onboarding_continued", false)) {
3501 removeSession(this);
3502 return;
3503 } else {
3504 xmppConnectionService.getPreferences().edit().putString("onboarding_action", "cancel").commit();
3505 xmppConnectionService.deleteAccount(getAccount());
3506 }
3507 }
3508 }
3509 xmppConnectionService.archiveConversation(Conversation.this);
3510 }
3511
3512 expectingRemoval = true;
3513 removeSession(this);
3514 return;
3515 }
3516
3517 if ("executing".equals(command.getAttribute("status")) && actionsAdapter.countExceptCancel() < 1 && !actionsCleared) {
3518 // No actions have been given, but we are not done?
3519 // This is probably a spec violation, but we should do *something*
3520 actionsAdapter.add(Pair.create("execute", "execute"));
3521 }
3522
3523 if (!actionsAdapter.isEmpty() || fillableFieldCount > 0) {
3524 if ("completed".equals(command.getAttribute("status")) || "canceled".equals(command.getAttribute("status"))) {
3525 actionsAdapter.add(Pair.create("close", "close"));
3526 } else if (actionsAdapter.getPosition("cancel") < 0 && !xmppConnectionService.isOnboarding()) {
3527 actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
3528 }
3529 }
3530 }
3531
3532 if (actionsAdapter.isEmpty()) {
3533 actionsAdapter.add(Pair.create("close", "close"));
3534 }
3535
3536 actionsAdapter.sort((x, y) -> {
3537 if (x.first.equals("cancel")) return -1;
3538 if (y.first.equals("cancel")) return 1;
3539 if (x.first.equals("prev") && xmppConnectionService.isOnboarding()) return -1;
3540 if (y.first.equals("prev") && xmppConnectionService.isOnboarding()) return 1;
3541 return 0;
3542 });
3543
3544 Data dataForm = null;
3545 if (responseElement != null && responseElement.getName().equals("x") && responseElement.getNamespace().equals("jabber:x:data")) dataForm = Data.parse(responseElement);
3546 if (mNode.equals("jabber:iq:register") &&
3547 xmppConnectionService.getPreferences().contains("onboarding_action") &&
3548 dataForm != null && dataForm.getFieldByName("gateway-jid") != null) {
3549
3550
3551 dataForm.put("gateway-jid", xmppConnectionService.getPreferences().getString("onboarding_action", ""));
3552 execute();
3553 }
3554 xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
3555 notifyDataSetChanged();
3556 }
3557
3558 protected void setupReported(Element el) {
3559 if (el == null) {
3560 reported = null;
3561 return;
3562 }
3563
3564 reported = new ArrayList<>();
3565 for (Element fieldEl : el.getChildren()) {
3566 if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
3567 reported.add(mkField(fieldEl));
3568 }
3569 }
3570
3571 @Override
3572 public int getItemCount() {
3573 if (loading) return 1;
3574 if (response == null) return 0;
3575 if (response.getType() == Iq.Type.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
3576 int i = 0;
3577 for (Element el : responseElement.getChildren()) {
3578 if (!el.getNamespace().equals("jabber:x:data")) continue;
3579 if (el.getName().equals("title")) continue;
3580 if (el.getName().equals("field")) {
3581 String type = el.getAttribute("type");
3582 if (type != null && type.equals("hidden")) continue;
3583 if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
3584 }
3585
3586 if (el.getName().equals("reported") || el.getName().equals("item")) {
3587 if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
3588 if (el.getName().equals("reported")) continue;
3589 i += 1;
3590 } else {
3591 if (reported != null) i += reported.size();
3592 }
3593 continue;
3594 }
3595
3596 i++;
3597 }
3598 return i;
3599 }
3600 return 1;
3601 }
3602
3603 public Item getItem(int position) {
3604 if (loading) return new Item(null, TYPE_PROGRESSBAR);
3605 if (items.get(position) != null) return items.get(position);
3606 if (response == null) return null;
3607
3608 if (response.getType() == Iq.Type.RESULT && responseElement != null) {
3609 if (responseElement.getNamespace().equals("jabber:x:data")) {
3610 int i = 0;
3611 for (Element el : responseElement.getChildren()) {
3612 if (!el.getNamespace().equals("jabber:x:data")) continue;
3613 if (el.getName().equals("title")) continue;
3614 if (el.getName().equals("field")) {
3615 String type = el.getAttribute("type");
3616 if (type != null && type.equals("hidden")) continue;
3617 if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
3618 }
3619
3620 if (el.getName().equals("reported") || el.getName().equals("item")) {
3621 Cell cell = null;
3622
3623 if (reported != null) {
3624 if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
3625 if (el.getName().equals("reported")) continue;
3626 if (i == position) {
3627 items.put(position, new Item(el, TYPE_ITEM_CARD));
3628 return items.get(position);
3629 }
3630 } else {
3631 if (reported.size() > position - i) {
3632 Field reportedField = reported.get(position - i);
3633 Element itemField = null;
3634 if (el.getName().equals("item")) {
3635 for (Element subel : el.getChildren()) {
3636 if (subel.getAttribute("var").equals(reportedField.getVar())) {
3637 itemField = subel;
3638 break;
3639 }
3640 }
3641 }
3642 cell = new Cell(reportedField, itemField);
3643 } else {
3644 i += reported.size();
3645 continue;
3646 }
3647 }
3648 }
3649
3650 if (cell != null) {
3651 items.put(position, cell);
3652 return cell;
3653 }
3654 }
3655
3656 if (i < position) {
3657 i++;
3658 continue;
3659 }
3660
3661 return mkItem(el, position);
3662 }
3663 }
3664 }
3665
3666 return mkItem(responseElement == null ? response : responseElement, position);
3667 }
3668
3669 @Override
3670 public int getItemViewType(int position) {
3671 return getItem(position).viewType;
3672 }
3673
3674 @Override
3675 public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
3676 switch(viewType) {
3677 case TYPE_ERROR: {
3678 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3679 return new ErrorViewHolder(binding);
3680 }
3681 case TYPE_NOTE: {
3682 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3683 return new NoteViewHolder(binding);
3684 }
3685 case TYPE_WEB: {
3686 CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
3687 return new WebViewHolder(binding);
3688 }
3689 case TYPE_RESULT_FIELD: {
3690 CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
3691 return new ResultFieldViewHolder(binding);
3692 }
3693 case TYPE_RESULT_CELL: {
3694 CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
3695 return new ResultCellViewHolder(binding);
3696 }
3697 case TYPE_ITEM_CARD: {
3698 CommandItemCardBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_item_card, container, false);
3699 return new ItemCardViewHolder(binding);
3700 }
3701 case TYPE_CHECKBOX_FIELD: {
3702 CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
3703 return new CheckboxFieldViewHolder(binding);
3704 }
3705 case TYPE_SEARCH_LIST_FIELD: {
3706 CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
3707 return new SearchListFieldViewHolder(binding);
3708 }
3709 case TYPE_RADIO_EDIT_FIELD: {
3710 CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
3711 return new RadioEditFieldViewHolder(binding);
3712 }
3713 case TYPE_SPINNER_FIELD: {
3714 CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
3715 return new SpinnerFieldViewHolder(binding);
3716 }
3717 case TYPE_BUTTON_GRID_FIELD: {
3718 CommandButtonGridFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_button_grid_field, container, false);
3719 return new ButtonGridFieldViewHolder(binding);
3720 }
3721 case TYPE_TEXT_FIELD: {
3722 CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
3723 return new TextFieldViewHolder(binding);
3724 }
3725 case TYPE_SLIDER_FIELD: {
3726 CommandSliderFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_slider_field, container, false);
3727 return new SliderFieldViewHolder(binding);
3728 }
3729 case TYPE_PROGRESSBAR: {
3730 CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
3731 return new ProgressBarViewHolder(binding);
3732 }
3733 default:
3734 if (expectingRemoval) {
3735 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3736 return new NoteViewHolder(binding);
3737 }
3738
3739 throw new IllegalArgumentException("Unknown viewType: " + viewType + " based on: " + response + ", " + responseElement + ", " + expectingRemoval);
3740 }
3741 }
3742
3743 @Override
3744 public void onBindViewHolder(ViewHolder viewHolder, int position) {
3745 viewHolder.bind(getItem(position));
3746 }
3747
3748 public View getView() {
3749 if (mBinding == null) return null;
3750 return mBinding.getRoot();
3751 }
3752
3753 public boolean validate() {
3754 int count = getItemCount();
3755 boolean isValid = true;
3756 for (int i = 0; i < count; i++) {
3757 boolean oneIsValid = getItem(i).validate();
3758 isValid = isValid && oneIsValid;
3759 }
3760 notifyDataSetChanged();
3761 return isValid;
3762 }
3763
3764 public boolean execute() {
3765 return execute("execute");
3766 }
3767
3768 public boolean execute(int actionPosition) {
3769 return execute(actionsAdapter.getItem(actionPosition).first);
3770 }
3771
3772 public synchronized boolean execute(String action) {
3773 if (!"cancel".equals(action) && executing) {
3774 loadingHasBeenLong = true;
3775 notifyDataSetChanged();
3776 return false;
3777 }
3778 if (!action.equals("cancel") && !action.equals("prev") && !validate()) return false;
3779
3780 if (response == null) return true;
3781 Element command = response.findChild("command", "http://jabber.org/protocol/commands");
3782 if (command == null) return true;
3783 String status = command.getAttribute("status");
3784 if (status == null || (!status.equals("executing") && !action.equals("prev"))) return true;
3785
3786 if (actionToWebview != null && !action.equals("cancel") && Build.VERSION.SDK_INT >= 23) {
3787 actionToWebview.postWebMessage(new WebMessage("xmpp_xep0050/" + action), Uri.parse("*"));
3788 return false;
3789 }
3790
3791 final var packet = new Iq(Iq.Type.SET);
3792 packet.setTo(response.getFrom());
3793 final Element c = packet.addChild("command", Namespace.COMMANDS);
3794 c.setAttribute("node", mNode);
3795 c.setAttribute("sessionid", command.getAttribute("sessionid"));
3796
3797 String formType = responseElement == null ? null : responseElement.getAttribute("type");
3798 if (!action.equals("cancel") &&
3799 !action.equals("prev") &&
3800 responseElement != null &&
3801 responseElement.getName().equals("x") &&
3802 responseElement.getNamespace().equals("jabber:x:data") &&
3803 formType != null && formType.equals("form")) {
3804
3805 Data form = Data.parse(responseElement);
3806 eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
3807 if (actionList != null) {
3808 actionList.setValue(action);
3809 c.setAttribute("action", "execute");
3810 }
3811
3812 if (mNode.equals("jabber:iq:register") && xmppConnectionService.isOnboarding() && form.getFieldByName("gateway-jid") != null) {
3813 if (form.getValue("gateway-jid") == null) {
3814 xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
3815 } else {
3816 xmppConnectionService.getPreferences().edit().putString("onboarding_action", form.getValue("gateway-jid")).commit();
3817 }
3818 }
3819
3820 responseElement.setAttribute("type", "submit");
3821 Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
3822 if (rsm != null) {
3823 Element max = new Element("max", "http://jabber.org/protocol/rsm");
3824 max.setContent("1000");
3825 rsm.addChild(max);
3826 }
3827
3828 c.addChild(responseElement);
3829 }
3830
3831 if (c.getAttribute("action") == null) c.setAttribute("action", action);
3832
3833 executing = true;
3834 xmppConnectionService.sendIqPacket(getAccount(), packet, (iq) -> {
3835 updateWithResponse(iq);
3836 }, 120L);
3837
3838 loading();
3839 return false;
3840 }
3841
3842 public void refresh() {
3843 synchronized(this) {
3844 if (waitingForRefresh) notifyDataSetChanged();
3845 }
3846 }
3847
3848 protected void loading() {
3849 View v = getView();
3850 try {
3851 loadingTimer.schedule(new TimerTask() {
3852 @Override
3853 public void run() {
3854 View v2 = getView();
3855 loading = true;
3856
3857 try {
3858 loadingTimer.schedule(new TimerTask() {
3859 @Override
3860 public void run() {
3861 loadingHasBeenLong = true;
3862 if (v == null && v2 == null) return;
3863 (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3864 }
3865 }, 3000);
3866 } catch (final IllegalStateException e) { }
3867
3868 if (v == null && v2 == null) return;
3869 (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3870 }
3871 }, 500);
3872 } catch (final IllegalStateException e) { }
3873 }
3874
3875 protected GridLayoutManager setupLayoutManager(final Context ctx) {
3876 int spanCount = 1;
3877
3878 if (reported != null) {
3879 float screenWidth = ctx.getResources().getDisplayMetrics().widthPixels;
3880 TextPaint paint = ((TextView) LayoutInflater.from(ctx).inflate(R.layout.command_result_cell, null)).getPaint();
3881 float tableHeaderWidth = reported.stream().reduce(
3882 0f,
3883 (total, field) -> total + StaticLayout.getDesiredWidth(field.getLabel().or("--------") + "\t", paint),
3884 (a, b) -> a + b
3885 );
3886
3887 spanCount = tableHeaderWidth > 0.59 * screenWidth ? 1 : this.reported.size();
3888 }
3889
3890 if (layoutManager != null && layoutManager.getSpanCount() != spanCount) {
3891 items.clear();
3892 notifyDataSetChanged();
3893 }
3894
3895 layoutManager = new GridLayoutManager(ctx, spanCount);
3896 layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
3897 @Override
3898 public int getSpanSize(int position) {
3899 if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
3900 return 1;
3901 }
3902 });
3903 return layoutManager;
3904 }
3905
3906 protected void setBinding(CommandPageBinding b) {
3907 mBinding = b;
3908 // https://stackoverflow.com/a/32350474/8611
3909 mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
3910 @Override
3911 public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
3912 if(rv.getChildCount() > 0) {
3913 int[] location = new int[2];
3914 rv.getLocationOnScreen(location);
3915 View childView = rv.findChildViewUnder(e.getX(), e.getY());
3916 if (childView instanceof ViewGroup) {
3917 childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
3918 }
3919 int action = e.getAction();
3920 switch (action) {
3921 case MotionEvent.ACTION_DOWN:
3922 if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(1)) || childView instanceof WebView) {
3923 rv.requestDisallowInterceptTouchEvent(true);
3924 }
3925 case MotionEvent.ACTION_UP:
3926 if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(-11)) || childView instanceof WebView) {
3927 rv.requestDisallowInterceptTouchEvent(true);
3928 }
3929 }
3930 }
3931
3932 return false;
3933 }
3934
3935 @Override
3936 public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }
3937
3938 @Override
3939 public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
3940 });
3941 mBinding.form.setLayoutManager(setupLayoutManager(mBinding.getRoot().getContext()));
3942 mBinding.form.setAdapter(this);
3943
3944 if (actionsAdapter == null) {
3945 actionsAdapter = new ActionsAdapter(mBinding.getRoot().getContext());
3946 actionsAdapter.registerDataSetObserver(new DataSetObserver() {
3947 @Override
3948 public void onChanged() {
3949 if (mBinding == null) return;
3950
3951 mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
3952 }
3953
3954 @Override
3955 public void onInvalidated() {}
3956 });
3957 }
3958
3959 mBinding.actions.setAdapter(actionsAdapter);
3960 mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
3961 if (execute(pos)) {
3962 removeSession(CommandSession.this);
3963 }
3964 });
3965
3966 actionsAdapter.notifyDataSetChanged();
3967
3968 if (pendingResponsePacket != null) {
3969 final var pending = pendingResponsePacket;
3970 pendingResponsePacket = null;
3971 updateWithResponseUiThread(pending);
3972 }
3973 }
3974
3975 private Drawable getDrawableForSVG(SVG svg, Element svgElement, int size) {
3976 if (svgElement != null && svgElement.getChildren().size() == 1 && svgElement.getChildren().get(0).getName().equals("image")) {
3977 return getDrawableForUrl(svgElement.getChildren().get(0).getAttribute("href"));
3978 } else {
3979 return xmppConnectionService.getFileBackend().drawSVG(svg, size);
3980 }
3981 }
3982
3983 private Drawable getDrawableForUrl(final String url) {
3984 final LruCache<String, Drawable> cache = xmppConnectionService.getDrawableCache();
3985 final HttpConnectionManager httpManager = xmppConnectionService.getHttpConnectionManager();
3986 final Drawable d = cache.get(url);
3987 if (Build.VERSION.SDK_INT >= 28 && d instanceof AnimatedImageDrawable) ((AnimatedImageDrawable) d).start();
3988 if (d == null) {
3989 synchronized (CommandSession.this) {
3990 waitingForRefresh = true;
3991 }
3992 int size = (int)(xmppConnectionService.getResources().getDisplayMetrics().density * 288);
3993 Message dummy = new Message(Conversation.this, url, Message.ENCRYPTION_NONE);
3994 dummy.setStatus(Message.STATUS_DUMMY);
3995 dummy.setFileParams(new Message.FileParams(url));
3996 httpManager.createNewDownloadConnection(dummy, true, (file) -> {
3997 if (file == null) {
3998 dummy.getTransferable().start();
3999 } else {
4000 try {
4001 xmppConnectionService.getFileBackend().getThumbnail(file, xmppConnectionService.getResources(), size, false, url);
4002 } catch (final Exception e) { }
4003 }
4004 });
4005 }
4006 return d;
4007 }
4008
4009 public View inflateUi(Context context, Consumer<ConversationPage> remover) {
4010 CommandPageBinding binding = DataBindingUtil.inflate(LayoutInflater.from(context), R.layout.command_page, null, false);
4011 setBinding(binding);
4012 return binding.getRoot();
4013 }
4014
4015 // https://stackoverflow.com/a/36037991/8611
4016 private View findViewAt(ViewGroup viewGroup, float x, float y) {
4017 for(int i = 0; i < viewGroup.getChildCount(); i++) {
4018 View child = viewGroup.getChildAt(i);
4019 if (child instanceof ViewGroup && !(child instanceof AbsListView) && !(child instanceof WebView)) {
4020 View foundView = findViewAt((ViewGroup) child, x, y);
4021 if (foundView != null && foundView.isShown()) {
4022 return foundView;
4023 }
4024 } else {
4025 int[] location = new int[2];
4026 child.getLocationOnScreen(location);
4027 Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
4028 if (rect.contains((int)x, (int)y)) {
4029 return child;
4030 }
4031 }
4032 }
4033
4034 return null;
4035 }
4036 }
4037
4038 class MucConfigSession extends CommandSession {
4039 MucConfigSession(XmppConnectionService xmppConnectionService) {
4040 super("Configure Channel", null, xmppConnectionService);
4041 }
4042
4043 @Override
4044 protected void updateWithResponseUiThread(final Iq iq) {
4045 Timer oldTimer = this.loadingTimer;
4046 this.loadingTimer = new Timer();
4047 oldTimer.cancel();
4048 this.executing = false;
4049 this.loading = false;
4050 this.loadingHasBeenLong = false;
4051 this.responseElement = null;
4052 this.fillableFieldCount = 0;
4053 this.reported = null;
4054 this.response = iq;
4055 this.items.clear();
4056 this.actionsAdapter.clear();
4057 layoutManager.setSpanCount(1);
4058
4059 final Element query = iq.findChild("query", "http://jabber.org/protocol/muc#owner");
4060 if (iq.getType() == Iq.Type.RESULT && query != null) {
4061 final Data form = Data.parse(query.findChild("x", "jabber:x:data"));
4062 final String title = form.getTitle();
4063 if (title != null) {
4064 mTitle = title;
4065 ConversationPagerAdapter.this.notifyDataSetChanged();
4066 }
4067
4068 this.responseElement = form;
4069 setupReported(form.findChild("reported", "jabber:x:data"));
4070 if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager(mBinding.getRoot().getContext()));
4071
4072 if (actionsAdapter.countExceptCancel() < 1) {
4073 actionsAdapter.add(Pair.create("save", "Save"));
4074 }
4075
4076 if (actionsAdapter.getPosition("cancel") < 0) {
4077 actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
4078 }
4079 } else if (iq.getType() == Iq.Type.RESULT) {
4080 expectingRemoval = true;
4081 removeSession(this);
4082 return;
4083 } else {
4084 actionsAdapter.add(Pair.create("close", "close"));
4085 }
4086
4087 notifyDataSetChanged();
4088 }
4089
4090 @Override
4091 public synchronized boolean execute(String action) {
4092 if ("cancel".equals(action)) {
4093 final var packet = new Iq(Iq.Type.SET);
4094 packet.setTo(response.getFrom());
4095 final Element form = packet
4096 .addChild("query", "http://jabber.org/protocol/muc#owner")
4097 .addChild("x", "jabber:x:data");
4098 form.setAttribute("type", "cancel");
4099 xmppConnectionService.sendIqPacket(getAccount(), packet, null);
4100 return true;
4101 }
4102
4103 if (!"save".equals(action)) return true;
4104
4105 final var packet = new Iq(Iq.Type.SET);
4106 packet.setTo(response.getFrom());
4107
4108 String formType = responseElement == null ? null : responseElement.getAttribute("type");
4109 if (responseElement != null &&
4110 responseElement.getName().equals("x") &&
4111 responseElement.getNamespace().equals("jabber:x:data") &&
4112 formType != null && formType.equals("form")) {
4113
4114 responseElement.setAttribute("type", "submit");
4115 packet
4116 .addChild("query", "http://jabber.org/protocol/muc#owner")
4117 .addChild(responseElement);
4118 }
4119
4120 executing = true;
4121 xmppConnectionService.sendIqPacket(getAccount(), packet, (iq) -> {
4122 updateWithResponse(iq);
4123 }, 120L);
4124
4125 loading();
4126
4127 return false;
4128 }
4129 }
4130 }
4131
4132 public static class Thread {
4133 protected Message subject = null;
4134 protected Message first = null;
4135 protected Message last = null;
4136 protected final String threadId;
4137
4138 protected Thread(final String threadId) {
4139 this.threadId = threadId;
4140 }
4141
4142 public String getThreadId() {
4143 return threadId;
4144 }
4145
4146 public String getSubject() {
4147 if (subject == null) return null;
4148
4149 return subject.getSubject();
4150 }
4151
4152 public String getDisplay() {
4153 final String s = getSubject();
4154 if (s != null) return s;
4155
4156 if (first != null) {
4157 return first.getBody();
4158 }
4159
4160 return "";
4161 }
4162
4163 public long getLastTime() {
4164 if (last == null) return 0;
4165
4166 return last.getTimeSent();
4167 }
4168 }
4169}