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