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