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