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