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