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