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