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 try {
1400 if (!"Cyrl".equals(script) && body.matches(".*\\p{IsCyrillic}.*")) {
1401 anyMatchSpam = true;
1402 return;
1403 }
1404 } catch (final java.util.regex.PatternSyntaxException e) { } // Not supported on old android
1405 if (body.length() > 320 || 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|v seti|[Pp]rii?vee?t|there\?|online\?|exploit|bit\\.ly|goo\\.gl|tinyurl\\.com|tiny\\.cc|lc\\.chat|is\\.gd|soo\\.gd|s2r\\.co|clicky\\.me|budrul\\.com|bc\\.vc|uguu\\.se).*")) {
1406 anyMatchSpam = true;
1407 return;
1408 }
1409 }
1410 }
1411
1412 public void add(Message message) {
1413 checkSpam(message);
1414 synchronized (this.messages) {
1415 this.messages.add(message);
1416 }
1417 }
1418
1419 public void prepend(int offset, Message message) {
1420 checkSpam(message);
1421 synchronized (this.messages) {
1422 this.messages.add(Math.min(offset, this.messages.size()), message);
1423 }
1424 }
1425
1426 public void addAll(int index, List<Message> messages) {
1427 checkSpam(messages.toArray(new Message[0]));
1428 synchronized (this.messages) {
1429 this.messages.addAll(index, messages);
1430 }
1431 account.getPgpDecryptionService().decrypt(messages);
1432 }
1433
1434 public void expireOldMessages(long timestamp) {
1435 synchronized (this.messages) {
1436 for (ListIterator<Message> iterator = this.messages.listIterator(); iterator.hasNext(); ) {
1437 if (iterator.next().getTimeSent() < timestamp) {
1438 iterator.remove();
1439 }
1440 }
1441 untieMessages();
1442 }
1443 }
1444
1445 public void sort() {
1446 synchronized (this.messages) {
1447 Collections.sort(this.messages, (left, right) -> {
1448 if (left.getTimeSent() < right.getTimeSent()) {
1449 return -1;
1450 } else if (left.getTimeSent() > right.getTimeSent()) {
1451 return 1;
1452 } else {
1453 return 0;
1454 }
1455 });
1456 untieMessages();
1457 }
1458 }
1459
1460 private void untieMessages() {
1461 for (Message message : this.messages) {
1462 message.untie();
1463 }
1464 }
1465
1466 public int unreadCount(XmppConnectionService xmppConnectionService) {
1467 synchronized (this.messages) {
1468 int count = 0;
1469 for (int i = messages.size() - 1; i >= 0; --i) {
1470 final Message message = messages.get(i);
1471 if (message.getSubject() != null && !message.isOOb() && (message.getRawBody() == null || message.getRawBody().length() == 0)) continue;
1472 if (asReaction(message) != null) continue;
1473 if ((message.getRawBody() == null || "".equals(message.getRawBody()) || " ".equals(message.getRawBody())) && message.getReply() != null && message.edited() && message.getHtml() != null) continue;
1474 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));
1475 if (muted) continue;
1476 if (message.isRead()) {
1477 if (message.getType() == Message.TYPE_RTP_SESSION) {
1478 continue;
1479 }
1480 return count;
1481 }
1482 ++count;
1483 }
1484 return count;
1485 }
1486 }
1487
1488 public int receivedMessagesCount() {
1489 int count = 0;
1490 synchronized (this.messages) {
1491 for (Message message : messages) {
1492 if (message.getSubject() != null && !message.isOOb() && (message.getRawBody() == null || message.getRawBody().length() == 0)) continue;
1493 if (asReaction(message) != null) continue;
1494 if ((message.getRawBody() == null || "".equals(message.getRawBody()) || " ".equals(message.getRawBody())) && message.getReply() != null && message.edited() && message.getHtml() != null) continue;
1495 if (message.getStatus() == Message.STATUS_RECEIVED) {
1496 ++count;
1497 }
1498 }
1499 }
1500 return count;
1501 }
1502
1503 public int sentMessagesCount() {
1504 int count = 0;
1505 synchronized (this.messages) {
1506 for (Message message : messages) {
1507 if (message.getStatus() != Message.STATUS_RECEIVED) {
1508 ++count;
1509 }
1510 }
1511 }
1512 return count;
1513 }
1514
1515 public boolean canInferPresence() {
1516 final Contact contact = getContact();
1517 if (contact != null && contact.canInferPresence()) return true;
1518 return sentMessagesCount() > 0;
1519 }
1520
1521 public boolean isChatRequest(final String pref) {
1522 if ("disable".equals(pref)) return false;
1523 if ("strangers".equals(pref)) return isWithStranger();
1524 if (!isWithStranger() && !strangerInvited()) return false;
1525 return anyMatchSpam;
1526 }
1527
1528 public boolean isWithStranger() {
1529 final Contact contact = getContact();
1530 return mode == MODE_SINGLE
1531 && !contact.isOwnServer()
1532 && !contact.showInContactList()
1533 && !contact.isSelf()
1534 && !(contact.getJid().isDomainJid() && JidHelper.isQuicksyDomain(contact.getJid()))
1535 && sentMessagesCount() == 0;
1536 }
1537
1538 public boolean strangerInvited() {
1539 final var inviterS = getAttribute("inviter");
1540 if (inviterS == null) return false;
1541 final var inviter = account.getRoster().getContact(Jid.of(inviterS));
1542 return getBookmark() == null && !inviter.showInContactList() && !inviter.isSelf() && sentMessagesCount() == 0;
1543 }
1544
1545 public int getReceivedMessagesCountSinceUuid(String uuid) {
1546 if (uuid == null) {
1547 return 0;
1548 }
1549 int count = 0;
1550 synchronized (this.messages) {
1551 for (int i = messages.size() - 1; i >= 0; i--) {
1552 final Message message = messages.get(i);
1553 if (uuid.equals(message.getUuid())) {
1554 return count;
1555 }
1556 if (message.getStatus() <= Message.STATUS_RECEIVED) {
1557 ++count;
1558 }
1559 }
1560 }
1561 return 0;
1562 }
1563
1564 @Override
1565 public int getAvatarBackgroundColor() {
1566 return UIHelper.getColorForName(getName().toString());
1567 }
1568
1569 @Override
1570 public String getAvatarName() {
1571 return getName().toString();
1572 }
1573
1574 public void setCurrentTab(int tab) {
1575 mCurrentTab = tab;
1576 }
1577
1578 public int getCurrentTab() {
1579 if (mCurrentTab >= 0) return mCurrentTab;
1580
1581 if (!isRead(null) || getContact().resourceWhichSupport(Namespace.COMMANDS) == null) {
1582 return 0;
1583 }
1584
1585 return 1;
1586 }
1587
1588 public void refreshSessions() {
1589 pagerAdapter.refreshSessions();
1590 }
1591
1592 public void startWebxdc(WebxdcPage page) {
1593 pagerAdapter.startWebxdc(page);
1594 }
1595
1596 public void webxdcRealtimeData(final Element thread, final String base64) {
1597 pagerAdapter.webxdcRealtimeData(thread, base64);
1598 }
1599
1600 public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
1601 pagerAdapter.startCommand(command, xmppConnectionService);
1602 }
1603
1604 public void startMucConfig(XmppConnectionService xmppConnectionService) {
1605 pagerAdapter.startMucConfig(xmppConnectionService);
1606 }
1607
1608 public boolean switchToSession(final String node) {
1609 return pagerAdapter.switchToSession(node);
1610 }
1611
1612 public void setupViewPager(ViewPager pager, TabLayout tabs, boolean onboarding, Conversation oldConversation) {
1613 pagerAdapter.setupViewPager(pager, tabs, onboarding, oldConversation);
1614 }
1615
1616 public void showViewPager() {
1617 pagerAdapter.show();
1618 }
1619
1620 public void hideViewPager() {
1621 pagerAdapter.hide();
1622 }
1623
1624 public void setDisplayState(final String stanzaId) {
1625 this.displayState = stanzaId;
1626 }
1627
1628 public String getDisplayState() {
1629 return this.displayState;
1630 }
1631
1632 public interface OnMessageFound {
1633 void onMessageFound(final Message message);
1634 }
1635
1636 public static class Draft {
1637 private final String message;
1638 private final long timestamp;
1639
1640 private Draft(String message, long timestamp) {
1641 this.message = message;
1642 this.timestamp = timestamp;
1643 }
1644
1645 public long getTimestamp() {
1646 return timestamp;
1647 }
1648
1649 public String getMessage() {
1650 return message;
1651 }
1652 }
1653
1654 public class ConversationPagerAdapter extends PagerAdapter {
1655 protected WeakReference<ViewPager> mPager = new WeakReference<>(null);
1656 protected WeakReference<TabLayout> mTabs = new WeakReference<>(null);
1657 ArrayList<ConversationPage> sessions = null;
1658 protected WeakReference<View> page1 = new WeakReference<>(null);
1659 protected WeakReference<View> page2 = new WeakReference<>(null);
1660 protected boolean mOnboarding = false;
1661
1662 public void setupViewPager(ViewPager pager, TabLayout tabs, boolean onboarding, Conversation oldConversation) {
1663 mPager = new WeakReference(pager);
1664 mTabs = new WeakReference(tabs);
1665 mOnboarding = onboarding;
1666
1667 if (oldConversation != null) {
1668 oldConversation.pagerAdapter.mPager.clear();
1669 oldConversation.pagerAdapter.mTabs.clear();
1670 }
1671
1672 if (pager == null) {
1673 page1.clear();
1674 page2.clear();
1675 return;
1676 }
1677 if (sessions != null) show();
1678
1679 if (pager.getChildAt(0) != null) page1 = new WeakReference<>(pager.getChildAt(0));
1680 if (pager.getChildAt(1) != null) page2 = new WeakReference<>(pager.getChildAt(1));
1681 if (page2.get() != null && page2.get().findViewById(R.id.commands_view) == null) {
1682 page1.clear();
1683 page2.clear();
1684 }
1685 if (page1.get() == null) page1 = oldConversation.pagerAdapter.page1;
1686 if (page2.get() == null) page2 = oldConversation.pagerAdapter.page2;
1687 if (page1.get() == null || page2.get() == null) {
1688 throw new IllegalStateException("page1 or page2 were not present as child or in model?");
1689 }
1690 pager.removeView(page1.get());
1691 pager.removeView(page2.get());
1692 pager.setAdapter(this);
1693 tabs.setupWithViewPager(pager);
1694 pager.post(() -> pager.setCurrentItem(getCurrentTab()));
1695
1696 pager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
1697 public void onPageScrollStateChanged(int state) { }
1698 public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { }
1699
1700 public void onPageSelected(int position) {
1701 setCurrentTab(position);
1702 }
1703 });
1704 }
1705
1706 public void show() {
1707 if (sessions == null) {
1708 sessions = new ArrayList<>();
1709 notifyDataSetChanged();
1710 }
1711 if (!mOnboarding && mTabs.get() != null) mTabs.get().setVisibility(View.VISIBLE);
1712 }
1713
1714 public void hide() {
1715 if (sessions != null && !sessions.isEmpty()) return; // Do not hide during active session
1716 if (mPager.get() != null) mPager.get().setCurrentItem(0);
1717 if (mTabs.get() != null) mTabs.get().setVisibility(View.GONE);
1718 sessions = null;
1719 notifyDataSetChanged();
1720 }
1721
1722 public void refreshSessions() {
1723 if (sessions == null) return;
1724
1725 for (ConversationPage session : sessions) {
1726 session.refresh();
1727 }
1728 }
1729
1730 public void webxdcRealtimeData(final Element thread, final String base64) {
1731 if (sessions == null) return;
1732
1733 for (ConversationPage session : sessions) {
1734 if (session instanceof WebxdcPage) {
1735 if (((WebxdcPage) session).threadMatches(thread)) {
1736 ((WebxdcPage) session).realtimeData(base64);
1737 }
1738 }
1739 }
1740 }
1741
1742 public void startWebxdc(WebxdcPage page) {
1743 show();
1744 sessions.add(page);
1745 notifyDataSetChanged();
1746 if (mPager.get() != null) mPager.get().setCurrentItem(getCount() - 1);
1747 }
1748
1749 public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
1750 show();
1751 CommandSession session = new CommandSession(command.getAttribute("name"), command.getAttribute("node"), xmppConnectionService);
1752
1753 final var packet = new Iq(Iq.Type.SET);
1754 packet.setTo(command.getAttributeAsJid("jid"));
1755 final Element c = packet.addChild("command", Namespace.COMMANDS);
1756 c.setAttribute("node", command.getAttribute("node"));
1757 c.setAttribute("action", "execute");
1758
1759 final TimerTask task = new TimerTask() {
1760 @Override
1761 public void run() {
1762 if (getAccount().getStatus() != Account.State.ONLINE) {
1763 final TimerTask self = this;
1764 new Timer().schedule(new TimerTask() {
1765 @Override
1766 public void run() {
1767 self.run();
1768 }
1769 }, 1000);
1770 } else {
1771 xmppConnectionService.sendIqPacket(getAccount(), packet, (iq) -> {
1772 session.updateWithResponse(iq);
1773 }, 120L);
1774 }
1775 }
1776 };
1777
1778 if (command.getAttribute("node").equals("jabber:iq:register") && packet.getTo().asBareJid().equals(Jid.of("cheogram.com"))) {
1779 new com.cheogram.android.CheogramLicenseChecker(mPager.get().getContext(), (signedData, signature) -> {
1780 if (signedData != null && signature != null) {
1781 c.addChild("license", "https://ns.cheogram.com/google-play").setContent(signedData);
1782 c.addChild("licenseSignature", "https://ns.cheogram.com/google-play").setContent(signature);
1783 }
1784
1785 task.run();
1786 }).checkLicense();
1787 } else {
1788 task.run();
1789 }
1790
1791 sessions.add(session);
1792 notifyDataSetChanged();
1793 if (mPager.get() != null) mPager.get().setCurrentItem(getCount() - 1);
1794 }
1795
1796 public void startMucConfig(XmppConnectionService xmppConnectionService) {
1797 MucConfigSession session = new MucConfigSession(xmppConnectionService);
1798 final var packet = new Iq(Iq.Type.GET);
1799 packet.setTo(Conversation.this.getJid().asBareJid());
1800 packet.addChild("query", "http://jabber.org/protocol/muc#owner");
1801
1802 final TimerTask task = new TimerTask() {
1803 @Override
1804 public void run() {
1805 if (getAccount().getStatus() != Account.State.ONLINE) {
1806 final TimerTask self = this;
1807 new Timer().schedule(new TimerTask() {
1808 @Override
1809 public void run() {
1810 self.run();
1811 }
1812 }, 1000);
1813 } else {
1814 xmppConnectionService.sendIqPacket(getAccount(), packet, (iq) -> {
1815 session.updateWithResponse(iq);
1816 }, 120L);
1817 }
1818 }
1819 };
1820 task.run();
1821
1822 sessions.add(session);
1823 notifyDataSetChanged();
1824 if (mPager.get() != null) mPager.get().setCurrentItem(getCount() - 1);
1825 }
1826
1827 public void removeSession(ConversationPage session) {
1828 sessions.remove(session);
1829 notifyDataSetChanged();
1830 if (session instanceof WebxdcPage) mPager.get().setCurrentItem(0);
1831 }
1832
1833 public boolean switchToSession(final String node) {
1834 if (sessions == null) return false;
1835
1836 int i = 0;
1837 for (ConversationPage session : sessions) {
1838 if (session.getNode().equals(node)) {
1839 if (mPager.get() != null) mPager.get().setCurrentItem(i + 2);
1840 return true;
1841 }
1842 i++;
1843 }
1844
1845 return false;
1846 }
1847
1848 @NonNull
1849 @Override
1850 public Object instantiateItem(@NonNull ViewGroup container, int position) {
1851 if (position == 0) {
1852 final var pg1 = page1.get();
1853 if (pg1 != null && pg1.getParent() != null) {
1854 ((ViewGroup) pg1.getParent()).removeView(pg1);
1855 }
1856 container.addView(pg1);
1857 return pg1;
1858 }
1859 if (position == 1) {
1860 final var pg2 = page2.get();
1861 if (pg2 != null && pg2.getParent() != null) {
1862 ((ViewGroup) pg2.getParent()).removeView(pg2);
1863 }
1864 container.addView(pg2);
1865 return pg2;
1866 }
1867
1868 if (position-2 > sessions.size()) return null;
1869 ConversationPage session = sessions.get(position-2);
1870 View v = session.inflateUi(container.getContext(), (s) -> removeSession(s));
1871 if (v != null && v.getParent() != null) {
1872 ((ViewGroup) v.getParent()).removeView(v);
1873 }
1874 container.addView(v);
1875 return session;
1876 }
1877
1878 @Override
1879 public void destroyItem(@NonNull ViewGroup container, int position, Object o) {
1880 if (position < 2) {
1881 container.removeView((View) o);
1882 return;
1883 }
1884
1885 container.removeView(((ConversationPage) o).getView());
1886 }
1887
1888 @Override
1889 public int getItemPosition(Object o) {
1890 if (mPager.get() != null) {
1891 if (o == page1.get()) return PagerAdapter.POSITION_UNCHANGED;
1892 if (o == page2.get()) return PagerAdapter.POSITION_UNCHANGED;
1893 }
1894
1895 int pos = sessions == null ? -1 : sessions.indexOf(o);
1896 if (pos < 0) return PagerAdapter.POSITION_NONE;
1897 return pos + 2;
1898 }
1899
1900 @Override
1901 public int getCount() {
1902 if (sessions == null) return 1;
1903
1904 int count = 2 + sessions.size();
1905 if (mTabs.get() == null) return count;
1906
1907 if (count > 2) {
1908 mTabs.get().setTabMode(TabLayout.MODE_SCROLLABLE);
1909 } else {
1910 mTabs.get().setTabMode(TabLayout.MODE_FIXED);
1911 }
1912 return count;
1913 }
1914
1915 @Override
1916 public boolean isViewFromObject(@NonNull View view, @NonNull Object o) {
1917 if (view == o) return true;
1918
1919 if (o instanceof ConversationPage) {
1920 return ((ConversationPage) o).getView() == view;
1921 }
1922
1923 return false;
1924 }
1925
1926 @Nullable
1927 @Override
1928 public CharSequence getPageTitle(int position) {
1929 switch (position) {
1930 case 0:
1931 return "Conversation";
1932 case 1:
1933 return "Commands";
1934 default:
1935 ConversationPage session = sessions.get(position-2);
1936 if (session == null) return super.getPageTitle(position);
1937 return session.getTitle();
1938 }
1939 }
1940
1941 class CommandSession extends RecyclerView.Adapter<CommandSession.ViewHolder> implements ConversationPage {
1942 abstract class ViewHolder<T extends ViewDataBinding> extends RecyclerView.ViewHolder {
1943 protected T binding;
1944
1945 public ViewHolder(T binding) {
1946 super(binding.getRoot());
1947 this.binding = binding;
1948 }
1949
1950 abstract public void bind(Item el);
1951
1952 protected void setTextOrHide(TextView v, Optional<String> s) {
1953 if (s == null || !s.isPresent()) {
1954 v.setVisibility(View.GONE);
1955 } else {
1956 v.setVisibility(View.VISIBLE);
1957 v.setText(s.get());
1958 }
1959 }
1960
1961 protected void setupInputType(Element field, TextView textinput, TextInputLayout layout) {
1962 int flags = 0;
1963 if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_NONE);
1964 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1965
1966 String type = field.getAttribute("type");
1967 if (type != null) {
1968 if (type.equals("text-multi") || type.equals("jid-multi")) {
1969 flags |= InputType.TYPE_TEXT_FLAG_MULTI_LINE;
1970 }
1971
1972 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1973
1974 if (type.equals("jid-single") || type.equals("jid-multi")) {
1975 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
1976 }
1977
1978 if (type.equals("text-private")) {
1979 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
1980 if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_PASSWORD_TOGGLE);
1981 }
1982 }
1983
1984 Element validate = field.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1985 if (validate == null) return;
1986 String datatype = validate.getAttribute("datatype");
1987 if (datatype == null) return;
1988
1989 if (datatype.equals("xs:integer") || datatype.equals("xs:int") || datatype.equals("xs:long") || datatype.equals("xs:short") || datatype.equals("xs:byte")) {
1990 textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED);
1991 }
1992
1993 if (datatype.equals("xs:decimal") || datatype.equals("xs:double")) {
1994 textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED | InputType.TYPE_NUMBER_FLAG_DECIMAL);
1995 }
1996
1997 if (datatype.equals("xs:date")) {
1998 textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_DATE);
1999 }
2000
2001 if (datatype.equals("xs:dateTime")) {
2002 textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_NORMAL);
2003 }
2004
2005 if (datatype.equals("xs:time")) {
2006 textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_TIME);
2007 }
2008
2009 if (datatype.equals("xs:anyURI")) {
2010 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
2011 }
2012
2013 if (datatype.equals("html:tel")) {
2014 textinput.setInputType(flags | InputType.TYPE_CLASS_PHONE);
2015 }
2016
2017 if (datatype.equals("html:email")) {
2018 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
2019 }
2020 }
2021
2022 protected String formatValue(String datatype, String value, boolean compact) {
2023 if ("xs:dateTime".equals(datatype)) {
2024 ZonedDateTime zonedDateTime = null;
2025 try {
2026 zonedDateTime = ZonedDateTime.parse(value, DateTimeFormatter.ISO_DATE_TIME);
2027 } catch (final DateTimeParseException e) {
2028 try {
2029 DateTimeFormatter almostIso = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm[:ss] X");
2030 zonedDateTime = ZonedDateTime.parse(value, almostIso);
2031 } catch (final DateTimeParseException e2) { }
2032 }
2033 if (zonedDateTime == null) return value;
2034 ZonedDateTime localZonedDateTime = zonedDateTime.withZoneSameInstant(ZoneId.systemDefault());
2035 DateTimeFormatter outputFormat = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT);
2036 return localZonedDateTime.toLocalDateTime().format(outputFormat);
2037 }
2038
2039 if ("html:tel".equals(datatype) && !compact) {
2040 return PhoneNumberUtils.formatNumber(value, value, null);
2041 }
2042
2043 return value;
2044 }
2045 }
2046
2047 class ErrorViewHolder extends ViewHolder<CommandNoteBinding> {
2048 public ErrorViewHolder(CommandNoteBinding binding) { super(binding); }
2049
2050 @Override
2051 public void bind(Item iq) {
2052 binding.errorIcon.setVisibility(View.VISIBLE);
2053
2054 if (iq == null || iq.el == null) return;
2055 Element error = iq.el.findChild("error");
2056 if (error == null) {
2057 binding.message.setText("Unexpected response: " + iq);
2058 return;
2059 }
2060 String text = error.findChildContent("text", "urn:ietf:params:xml:ns:xmpp-stanzas");
2061 if (text == null || text.equals("")) {
2062 text = error.getChildren().get(0).getName();
2063 }
2064 binding.message.setText(text);
2065 }
2066 }
2067
2068 class NoteViewHolder extends ViewHolder<CommandNoteBinding> {
2069 public NoteViewHolder(CommandNoteBinding binding) { super(binding); }
2070
2071 @Override
2072 public void bind(Item note) {
2073 binding.message.setText(note != null && note.el != null ? note.el.getContent() : "");
2074
2075 String type = note.el.getAttribute("type");
2076 if (type != null && type.equals("error")) {
2077 binding.errorIcon.setVisibility(View.VISIBLE);
2078 }
2079 }
2080 }
2081
2082 class ResultFieldViewHolder extends ViewHolder<CommandResultFieldBinding> {
2083 public ResultFieldViewHolder(CommandResultFieldBinding binding) { super(binding); }
2084
2085 @Override
2086 public void bind(Item item) {
2087 Field field = (Field) item;
2088 setTextOrHide(binding.label, field.getLabel());
2089 setTextOrHide(binding.desc, field.getDesc());
2090
2091 Element media = field.el.findChild("media", "urn:xmpp:media-element");
2092 if (media == null) {
2093 binding.mediaImage.setVisibility(View.GONE);
2094 } else {
2095 final LruCache<String, Drawable> cache = xmppConnectionService.getDrawableCache();
2096 final HttpConnectionManager httpManager = xmppConnectionService.getHttpConnectionManager();
2097 for (Element uriEl : media.getChildren()) {
2098 if (!"uri".equals(uriEl.getName())) continue;
2099 if (!"urn:xmpp:media-element".equals(uriEl.getNamespace())) continue;
2100 String mimeType = uriEl.getAttribute("type");
2101 String uriS = uriEl.getContent();
2102 if (mimeType == null || uriS == null) continue;
2103 Uri uri = Uri.parse(uriS);
2104 if (mimeType.startsWith("image/") && "https".equals(uri.getScheme())) {
2105 final Drawable d = getDrawableForUrl(uri.toString());
2106 if (d != null) {
2107 binding.mediaImage.setImageDrawable(d);
2108 binding.mediaImage.setVisibility(View.VISIBLE);
2109 }
2110 }
2111 }
2112 }
2113
2114 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2115 String datatype = validate == null ? null : validate.getAttribute("datatype");
2116
2117 ArrayAdapter<Option> values = new ArrayAdapter<>(binding.getRoot().getContext(), R.layout.simple_list_item);
2118 for (Element el : field.el.getChildren()) {
2119 if (el.getName().equals("value") && el.getNamespace().equals("jabber:x:data")) {
2120 values.add(new Option(el.getContent(), formatValue(datatype, el.getContent(), false)));
2121 }
2122 }
2123 binding.values.setAdapter(values);
2124 Util.justifyListViewHeightBasedOnChildren(binding.values);
2125
2126 if (field.getType().equals(Optional.of("jid-single")) || field.getType().equals(Optional.of("jid-multi"))) {
2127 binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
2128 new FixedURLSpan("xmpp:" + Uri.encode(Jid.ofEscaped(values.getItem(pos).getValue()).toEscapedString(), "@/+"), account).onClick(binding.values);
2129 });
2130 } else if ("xs:anyURI".equals(datatype)) {
2131 binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
2132 new FixedURLSpan(values.getItem(pos).getValue(), account).onClick(binding.values);
2133 });
2134 } else if ("html:tel".equals(datatype)) {
2135 binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
2136 try {
2137 new FixedURLSpan("tel:" + PhoneNumberUtilWrapper.normalize(binding.getRoot().getContext(), values.getItem(pos).getValue()), account).onClick(binding.values);
2138 } catch (final IllegalArgumentException | NumberParseException | NullPointerException e) { }
2139 });
2140 }
2141
2142 binding.values.setOnItemLongClickListener((arg0, arg1, pos, id) -> {
2143 if (ShareUtil.copyTextToClipboard(binding.getRoot().getContext(), values.getItem(pos).getValue(), R.string.message)) {
2144 Toast.makeText(binding.getRoot().getContext(), R.string.message_copied_to_clipboard, Toast.LENGTH_SHORT).show();
2145 }
2146 return true;
2147 });
2148 }
2149 }
2150
2151 class ResultCellViewHolder extends ViewHolder<CommandResultCellBinding> {
2152 public ResultCellViewHolder(CommandResultCellBinding binding) { super(binding); }
2153
2154 @Override
2155 public void bind(Item item) {
2156 Cell cell = (Cell) item;
2157
2158 if (cell.el == null) {
2159 binding.text.setTextAppearance(binding.getRoot().getContext(), com.google.android.material.R.style.TextAppearance_Material3_TitleMedium);
2160 setTextOrHide(binding.text, cell.reported.getLabel());
2161 } else {
2162 Element validate = cell.reported.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2163 String datatype = validate == null ? null : validate.getAttribute("datatype");
2164 String value = formatValue(datatype, cell.el.findChildContent("value", "jabber:x:data"), true);
2165 SpannableStringBuilder text = new SpannableStringBuilder(value == null ? "" : value);
2166 if (cell.reported.getType().equals(Optional.of("jid-single"))) {
2167 text.setSpan(new FixedURLSpan("xmpp:" + Jid.ofEscaped(text.toString()).toEscapedString(), account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
2168 } else if ("xs:anyURI".equals(datatype)) {
2169 text.setSpan(new FixedURLSpan(text.toString(), account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
2170 } else if ("html:tel".equals(datatype)) {
2171 try {
2172 text.setSpan(new FixedURLSpan("tel:" + PhoneNumberUtilWrapper.normalize(binding.getRoot().getContext(), text.toString()), account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
2173 } catch (final IllegalArgumentException | NumberParseException | NullPointerException e) { }
2174 }
2175
2176 binding.text.setTextAppearance(binding.getRoot().getContext(), com.google.android.material.R.style.TextAppearance_Material3_BodyMedium);
2177 binding.text.setText(text);
2178
2179 BetterLinkMovementMethod method = BetterLinkMovementMethod.newInstance();
2180 method.setOnLinkLongClickListener((tv, url) -> {
2181 tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
2182 ShareUtil.copyLinkToClipboard(binding.getRoot().getContext(), url);
2183 return true;
2184 });
2185 binding.text.setMovementMethod(method);
2186 }
2187 }
2188 }
2189
2190 class ItemCardViewHolder extends ViewHolder<CommandItemCardBinding> {
2191 public ItemCardViewHolder(CommandItemCardBinding binding) { super(binding); }
2192
2193 @Override
2194 public void bind(Item item) {
2195 binding.fields.removeAllViews();
2196
2197 for (Field field : reported) {
2198 CommandResultFieldBinding row = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.command_result_field, binding.fields, false);
2199 GridLayout.LayoutParams param = new GridLayout.LayoutParams();
2200 param.columnSpec = GridLayout.spec(GridLayout.UNDEFINED, GridLayout.FILL, 1f);
2201 param.width = 0;
2202 row.getRoot().setLayoutParams(param);
2203 binding.fields.addView(row.getRoot());
2204 for (Element el : item.el.getChildren()) {
2205 if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data") && el.getAttribute("var") != null && el.getAttribute("var").equals(field.getVar())) {
2206 for (String label : field.getLabel().asSet()) {
2207 el.setAttribute("label", label);
2208 }
2209 for (String desc : field.getDesc().asSet()) {
2210 el.setAttribute("desc", desc);
2211 }
2212 for (String type : field.getType().asSet()) {
2213 el.setAttribute("type", type);
2214 }
2215 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2216 if (validate != null) el.addChild(validate);
2217 new ResultFieldViewHolder(row).bind(new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), -1));
2218 }
2219 }
2220 }
2221 }
2222 }
2223
2224 class CheckboxFieldViewHolder extends ViewHolder<CommandCheckboxFieldBinding> implements CompoundButton.OnCheckedChangeListener {
2225 public CheckboxFieldViewHolder(CommandCheckboxFieldBinding binding) {
2226 super(binding);
2227 binding.row.setOnClickListener((v) -> {
2228 binding.checkbox.toggle();
2229 });
2230 binding.checkbox.setOnCheckedChangeListener(this);
2231 }
2232 protected Element mValue = null;
2233
2234 @Override
2235 public void bind(Item item) {
2236 Field field = (Field) item;
2237 binding.label.setText(field.getLabel().or(""));
2238 setTextOrHide(binding.desc, field.getDesc());
2239 mValue = field.getValue();
2240 final var isChecked = mValue.getContent() != null && (mValue.getContent().equals("true") || mValue.getContent().equals("1"));
2241 mValue.setContent(isChecked ? "true" : "false");
2242 binding.checkbox.setChecked(isChecked);
2243 }
2244
2245 @Override
2246 public void onCheckedChanged(CompoundButton checkbox, boolean isChecked) {
2247 if (mValue == null) return;
2248
2249 mValue.setContent(isChecked ? "true" : "false");
2250 }
2251 }
2252
2253 class SearchListFieldViewHolder extends ViewHolder<CommandSearchListFieldBinding> implements TextWatcher {
2254 public SearchListFieldViewHolder(CommandSearchListFieldBinding binding) {
2255 super(binding);
2256 binding.search.addTextChangedListener(this);
2257 }
2258 protected Field field = null;
2259 Set<String> filteredValues;
2260 List<Option> options = new ArrayList<>();
2261 protected ArrayAdapter<Option> adapter;
2262 protected boolean open;
2263 protected boolean multi;
2264 protected int textColor = -1;
2265
2266 @Override
2267 public void bind(Item item) {
2268 ViewGroup.LayoutParams layout = binding.list.getLayoutParams();
2269 final float density = xmppConnectionService.getResources().getDisplayMetrics().density;
2270 if (fillableFieldCount > 1) {
2271 layout.height = (int) (density * 200);
2272 } else {
2273 layout.height = (int) Math.max(density * 200, xmppConnectionService.getResources().getDisplayMetrics().heightPixels / 2);
2274 }
2275 binding.list.setLayoutParams(layout);
2276
2277 field = (Field) item;
2278 setTextOrHide(binding.label, field.getLabel());
2279 setTextOrHide(binding.desc, field.getDesc());
2280
2281 if (textColor == -1) textColor = binding.desc.getCurrentTextColor();
2282 if (field.error != null) {
2283 binding.desc.setVisibility(View.VISIBLE);
2284 binding.desc.setText(field.error);
2285 binding.desc.setTextColor(com.google.android.material.R.attr.colorError);
2286 } else {
2287 binding.desc.setTextColor(textColor);
2288 }
2289
2290 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2291 open = validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null;
2292 setupInputType(field.el, binding.search, null);
2293
2294 multi = field.getType().equals(Optional.of("list-multi"));
2295 if (multi) {
2296 binding.list.setChoiceMode(AbsListView.CHOICE_MODE_MULTIPLE);
2297 } else {
2298 binding.list.setChoiceMode(AbsListView.CHOICE_MODE_SINGLE);
2299 }
2300
2301 options = field.getOptions();
2302 binding.list.setOnItemClickListener((parent, view, position, id) -> {
2303 Set<String> values = new HashSet<>();
2304 if (multi) {
2305 values.addAll(field.getValues());
2306 for (final String value : field.getValues()) {
2307 if (filteredValues.contains(value)) {
2308 values.remove(value);
2309 }
2310 }
2311 }
2312
2313 SparseBooleanArray positions = binding.list.getCheckedItemPositions();
2314 for (int i = 0; i < positions.size(); i++) {
2315 if (positions.valueAt(i)) values.add(adapter.getItem(positions.keyAt(i)).getValue());
2316 }
2317 field.setValues(values);
2318
2319 if (!multi && open) binding.search.setText(String.join("\n", field.getValues()));
2320 });
2321 search("");
2322 }
2323
2324 @Override
2325 public void afterTextChanged(Editable s) {
2326 if (!multi && open) field.setValues(List.of(s.toString()));
2327 search(s.toString());
2328 }
2329
2330 @Override
2331 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2332
2333 @Override
2334 public void onTextChanged(CharSequence s, int start, int count, int after) { }
2335
2336 protected void search(String s) {
2337 List<Option> filteredOptions;
2338 final String q = s.replaceAll("\\W", "").toLowerCase();
2339 if (q == null || q.equals("")) {
2340 filteredOptions = options;
2341 } else {
2342 filteredOptions = options.stream().filter(o -> o.toString().replaceAll("\\W", "").toLowerCase().contains(q)).collect(Collectors.toList());
2343 }
2344 filteredValues = filteredOptions.stream().map(o -> o.getValue()).collect(Collectors.toSet());
2345 adapter = new ArrayAdapter(binding.getRoot().getContext(), R.layout.simple_list_item, filteredOptions);
2346 binding.list.setAdapter(adapter);
2347
2348 for (final String value : field.getValues()) {
2349 int checkedPos = filteredOptions.indexOf(new Option(value, ""));
2350 if (checkedPos >= 0) binding.list.setItemChecked(checkedPos, true);
2351 }
2352 }
2353 }
2354
2355 class RadioEditFieldViewHolder extends ViewHolder<CommandRadioEditFieldBinding> implements CompoundButton.OnCheckedChangeListener, TextWatcher {
2356 public RadioEditFieldViewHolder(CommandRadioEditFieldBinding binding) {
2357 super(binding);
2358 binding.open.addTextChangedListener(this);
2359 options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.radio_grid_item) {
2360 @Override
2361 public View getView(int position, View convertView, ViewGroup parent) {
2362 CompoundButton v = (CompoundButton) super.getView(position, convertView, parent);
2363 v.setId(position);
2364 v.setChecked(getItem(position).getValue().equals(mValue.getContent()));
2365 v.setOnCheckedChangeListener(RadioEditFieldViewHolder.this);
2366 return v;
2367 }
2368 };
2369 }
2370 protected Element mValue = null;
2371 protected ArrayAdapter<Option> options;
2372 protected int textColor = -1;
2373
2374 @Override
2375 public void bind(Item item) {
2376 Field field = (Field) item;
2377 setTextOrHide(binding.label, field.getLabel());
2378 setTextOrHide(binding.desc, field.getDesc());
2379
2380 if (textColor == -1) textColor = binding.desc.getCurrentTextColor();
2381 if (field.error != null) {
2382 binding.desc.setVisibility(View.VISIBLE);
2383 binding.desc.setText(field.error);
2384 binding.desc.setTextColor(com.google.android.material.R.attr.colorError);
2385 } else {
2386 binding.desc.setTextColor(textColor);
2387 }
2388
2389 mValue = field.getValue();
2390
2391 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2392 binding.open.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
2393 binding.open.setText(mValue.getContent());
2394 setupInputType(field.el, binding.open, null);
2395
2396 options.clear();
2397 List<Option> theOptions = field.getOptions();
2398 options.addAll(theOptions);
2399
2400 float screenWidth = binding.getRoot().getContext().getResources().getDisplayMetrics().widthPixels;
2401 TextPaint paint = ((TextView) LayoutInflater.from(binding.getRoot().getContext()).inflate(R.layout.radio_grid_item, null)).getPaint();
2402 float maxColumnWidth = theOptions.stream().map((x) ->
2403 StaticLayout.getDesiredWidth(x.toString(), paint)
2404 ).max(Float::compare).orElse(new Float(0.0));
2405 if (maxColumnWidth * theOptions.size() < 0.90 * screenWidth) {
2406 binding.radios.setNumColumns(theOptions.size());
2407 } else if (maxColumnWidth * (theOptions.size() / 2) < 0.90 * screenWidth) {
2408 binding.radios.setNumColumns(theOptions.size() / 2);
2409 } else {
2410 binding.radios.setNumColumns(1);
2411 }
2412 binding.radios.setAdapter(options);
2413 }
2414
2415 @Override
2416 public void onCheckedChanged(CompoundButton radio, boolean isChecked) {
2417 if (mValue == null) return;
2418
2419 if (isChecked) {
2420 mValue.setContent(options.getItem(radio.getId()).getValue());
2421 binding.open.setText(mValue.getContent());
2422 }
2423 options.notifyDataSetChanged();
2424 }
2425
2426 @Override
2427 public void afterTextChanged(Editable s) {
2428 if (mValue == null) return;
2429
2430 mValue.setContent(s.toString());
2431 options.notifyDataSetChanged();
2432 }
2433
2434 @Override
2435 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2436
2437 @Override
2438 public void onTextChanged(CharSequence s, int start, int count, int after) { }
2439 }
2440
2441 class SpinnerFieldViewHolder extends ViewHolder<CommandSpinnerFieldBinding> implements AdapterView.OnItemSelectedListener {
2442 public SpinnerFieldViewHolder(CommandSpinnerFieldBinding binding) {
2443 super(binding);
2444 binding.spinner.setOnItemSelectedListener(this);
2445 }
2446 protected Element mValue = null;
2447
2448 @Override
2449 public void bind(Item item) {
2450 Field field = (Field) item;
2451 setTextOrHide(binding.label, field.getLabel());
2452 binding.spinner.setPrompt(field.getLabel().or(""));
2453 setTextOrHide(binding.desc, field.getDesc());
2454
2455 mValue = field.getValue();
2456
2457 ArrayAdapter<Option> options = new ArrayAdapter<Option>(binding.getRoot().getContext(), android.R.layout.simple_spinner_item);
2458 options.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
2459 options.addAll(field.getOptions());
2460
2461 binding.spinner.setAdapter(options);
2462 binding.spinner.setSelection(options.getPosition(new Option(mValue.getContent(), null)));
2463 }
2464
2465 @Override
2466 public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
2467 Option o = (Option) parent.getItemAtPosition(pos);
2468 if (mValue == null) return;
2469
2470 mValue.setContent(o == null ? "" : o.getValue());
2471 }
2472
2473 @Override
2474 public void onNothingSelected(AdapterView<?> parent) {
2475 mValue.setContent("");
2476 }
2477 }
2478
2479 class ButtonGridFieldViewHolder extends ViewHolder<CommandButtonGridFieldBinding> {
2480 public ButtonGridFieldViewHolder(CommandButtonGridFieldBinding binding) {
2481 super(binding);
2482 options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.button_grid_item) {
2483 protected int height = 0;
2484
2485 @Override
2486 public View getView(int position, View convertView, ViewGroup parent) {
2487 Button v = (Button) super.getView(position, convertView, parent);
2488 v.setOnClickListener((view) -> {
2489 mValue.setContent(getItem(position).getValue());
2490 execute();
2491 loading = true;
2492 });
2493
2494 final SVG icon = getItem(position).getIcon();
2495 if (icon != null) {
2496 final Element iconEl = getItem(position).getIconEl();
2497 if (height < 1) {
2498 v.measure(0, 0);
2499 height = v.getMeasuredHeight();
2500 }
2501 if (height < 1) return v;
2502 if (mediaSelector) {
2503 final Drawable d = getDrawableForSVG(icon, iconEl, height * 4);
2504 if (d != null) {
2505 final int boundsHeight = 35 + (int)((height * 4) / xmppConnectionService.getResources().getDisplayMetrics().density);
2506 d.setBounds(0, 0, d.getIntrinsicWidth(), boundsHeight);
2507 }
2508 v.setCompoundDrawables(null, d, null, null);
2509 } else {
2510 v.setCompoundDrawablesRelativeWithIntrinsicBounds(getDrawableForSVG(icon, iconEl, height), null, null, null);
2511 }
2512 }
2513
2514 return v;
2515 }
2516 };
2517 }
2518 protected Element mValue = null;
2519 protected ArrayAdapter<Option> options;
2520 protected Option defaultOption = null;
2521 protected boolean mediaSelector = false;
2522 protected int textColor = -1;
2523
2524 @Override
2525 public void bind(Item item) {
2526 Field field = (Field) item;
2527 setTextOrHide(binding.label, field.getLabel());
2528 setTextOrHide(binding.desc, field.getDesc());
2529
2530 if (textColor == -1) textColor = binding.desc.getCurrentTextColor();
2531 if (field.error != null) {
2532 binding.desc.setVisibility(View.VISIBLE);
2533 binding.desc.setText(field.error);
2534 binding.desc.setTextColor(com.google.android.material.R.attr.colorError);
2535 } else {
2536 binding.desc.setTextColor(textColor);
2537 }
2538
2539 mValue = field.getValue();
2540 mediaSelector = field.el.findChild("media-selector", "https://ns.cheogram.com/") != null;
2541
2542 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2543 binding.openButton.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
2544 binding.openButton.setOnClickListener((view) -> {
2545 AlertDialog.Builder builder = new AlertDialog.Builder(binding.getRoot().getContext());
2546 DialogQuickeditBinding dialogBinding = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.dialog_quickedit, null, false);
2547 builder.setPositiveButton(R.string.action_execute, null);
2548 if (field.getDesc().isPresent()) {
2549 dialogBinding.inputLayout.setHint(field.getDesc().get());
2550 }
2551 dialogBinding.inputEditText.requestFocus();
2552 dialogBinding.inputEditText.getText().append(mValue.getContent());
2553 builder.setView(dialogBinding.getRoot());
2554 builder.setNegativeButton(R.string.cancel, null);
2555 final AlertDialog dialog = builder.create();
2556 dialog.setOnShowListener(d -> SoftKeyboardUtils.showKeyboard(dialogBinding.inputEditText));
2557 dialog.show();
2558 View.OnClickListener clickListener = v -> {
2559 String value = dialogBinding.inputEditText.getText().toString();
2560 mValue.setContent(value);
2561 SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2562 dialog.dismiss();
2563 execute();
2564 loading = true;
2565 };
2566 dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener(clickListener);
2567 dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setOnClickListener((v -> {
2568 SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2569 dialog.dismiss();
2570 }));
2571 dialog.setCanceledOnTouchOutside(false);
2572 dialog.setOnDismissListener(dialog1 -> {
2573 SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2574 });
2575 });
2576
2577 options.clear();
2578 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();
2579
2580 defaultOption = null;
2581 for (Option option : theOptions) {
2582 if (option.getValue().equals(mValue.getContent())) {
2583 defaultOption = option;
2584 break;
2585 }
2586 }
2587 if (defaultOption == null && !mValue.getContent().equals("")) {
2588 // Synthesize default option for custom value
2589 defaultOption = new Option(mValue.getContent(), mValue.getContent());
2590 }
2591 if (defaultOption == null) {
2592 binding.defaultButton.setVisibility(View.GONE);
2593 } else {
2594 theOptions.remove(defaultOption);
2595 binding.defaultButton.setVisibility(View.VISIBLE);
2596
2597 final SVG defaultIcon = defaultOption.getIcon();
2598 if (defaultIcon != null) {
2599 DisplayMetrics display = mPager.get().getContext().getResources().getDisplayMetrics();
2600 int height = (int)(display.heightPixels*display.density/4);
2601 binding.defaultButton.setCompoundDrawablesRelativeWithIntrinsicBounds(null, getDrawableForSVG(defaultIcon, defaultOption.getIconEl(), height), null, null);
2602 }
2603
2604 binding.defaultButton.setText(defaultOption.toString());
2605 binding.defaultButton.setOnClickListener((view) -> {
2606 mValue.setContent(defaultOption.getValue());
2607 execute();
2608 loading = true;
2609 });
2610 }
2611
2612 options.addAll(theOptions);
2613 binding.buttons.setAdapter(options);
2614 }
2615 }
2616
2617 class TextFieldViewHolder extends ViewHolder<CommandTextFieldBinding> implements TextWatcher {
2618 public TextFieldViewHolder(CommandTextFieldBinding binding) {
2619 super(binding);
2620 binding.textinput.addTextChangedListener(this);
2621 }
2622 protected Field field = null;
2623
2624 @Override
2625 public void bind(Item item) {
2626 field = (Field) item;
2627 binding.textinputLayout.setHint(field.getLabel().or(""));
2628
2629 binding.textinputLayout.setHelperTextEnabled(field.getDesc().isPresent());
2630 for (String desc : field.getDesc().asSet()) {
2631 binding.textinputLayout.setHelperText(desc);
2632 }
2633
2634 binding.textinputLayout.setErrorEnabled(field.error != null);
2635 if (field.error != null) binding.textinputLayout.setError(field.error);
2636
2637 binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_GRAVITY);
2638 String suffixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/suffix-label");
2639 if (suffixLabel == null) {
2640 binding.textinputLayout.setSuffixText("");
2641 } else {
2642 binding.textinputLayout.setSuffixText(suffixLabel);
2643 binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_TEXT_END);
2644 }
2645
2646 String prefixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/prefix-label");
2647 binding.textinputLayout.setPrefixText(prefixLabel == null ? "" : prefixLabel);
2648
2649 binding.textinput.setText(String.join("\n", field.getValues()));
2650 setupInputType(field.el, binding.textinput, binding.textinputLayout);
2651 }
2652
2653 @Override
2654 public void afterTextChanged(Editable s) {
2655 if (field == null) return;
2656
2657 field.setValues(List.of(s.toString().split("\n")));
2658 }
2659
2660 @Override
2661 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2662
2663 @Override
2664 public void onTextChanged(CharSequence s, int start, int count, int after) { }
2665 }
2666
2667 class SliderFieldViewHolder extends ViewHolder<CommandSliderFieldBinding> {
2668 public SliderFieldViewHolder(CommandSliderFieldBinding binding) { super(binding); }
2669 protected Field field = null;
2670
2671 @Override
2672 public void bind(Item item) {
2673 field = (Field) item;
2674 setTextOrHide(binding.label, field.getLabel());
2675 setTextOrHide(binding.desc, field.getDesc());
2676 final Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2677 final String datatype = validate == null ? null : validate.getAttribute("datatype");
2678 final Element range = validate == null ? null : validate.findChild("range", "http://jabber.org/protocol/xdata-validate");
2679 // NOTE: range also implies open, so we don't have to be bound by the options strictly
2680 // Also, we don't have anywhere to put labels so we show only values, which might sometimes be bad...
2681 Float min = null;
2682 try { min = range.getAttribute("min") == null ? null : Float.valueOf(range.getAttribute("min")); } catch (NumberFormatException e) { }
2683 Float max = null;
2684 try { max = range.getAttribute("max") == null ? null : Float.valueOf(range.getAttribute("max")); } catch (NumberFormatException e) { }
2685
2686 List<Float> options = field.getOptions().stream().map(o -> Float.valueOf(o.getValue())).collect(Collectors.toList());
2687 Collections.sort(options);
2688 if (options.size() > 0) {
2689 // min/max should be on the range, but if you have options and didn't put them on the range we can imply
2690 if (min == null) min = options.get(0);
2691 if (max == null) max = options.get(options.size()-1);
2692 }
2693
2694 if (field.getValues().size() > 0) {
2695 final var val = Float.valueOf(field.getValue().getContent());
2696 if ((min == null || val >= min) && (max == null || val <= max)) {
2697 binding.slider.setValue(Float.valueOf(field.getValue().getContent()));
2698 } else {
2699 binding.slider.setValue(min == null ? Float.MIN_VALUE : min);
2700 }
2701 } else {
2702 binding.slider.setValue(min == null ? Float.MIN_VALUE : min);
2703 }
2704 binding.slider.setValueFrom(min == null ? Float.MIN_VALUE : min);
2705 binding.slider.setValueTo(max == null ? Float.MAX_VALUE : max);
2706 if ("xs:integer".equals(datatype) || "xs:int".equals(datatype) || "xs:long".equals(datatype) || "xs:short".equals(datatype) || "xs:byte".equals(datatype)) {
2707 binding.slider.setStepSize(1);
2708 } else {
2709 binding.slider.setStepSize(0);
2710 }
2711
2712 if (options.size() > 0) {
2713 float step = -1;
2714 Float prev = null;
2715 for (final Float option : options) {
2716 if (prev != null) {
2717 float nextStep = option - prev;
2718 if (step > 0 && step != nextStep) {
2719 step = -1;
2720 break;
2721 }
2722 step = nextStep;
2723 }
2724 prev = option;
2725 }
2726 if (step > 0) binding.slider.setStepSize(step);
2727 }
2728
2729 binding.slider.addOnChangeListener((slider, value, fromUser) -> {
2730 field.setValues(List.of(new DecimalFormat().format(value)));
2731 });
2732 }
2733 }
2734
2735 class WebViewHolder extends ViewHolder<CommandWebviewBinding> {
2736 public WebViewHolder(CommandWebviewBinding binding) { super(binding); }
2737 protected String boundUrl = "";
2738
2739 @Override
2740 public void bind(Item oob) {
2741 setTextOrHide(binding.desc, Optional.fromNullable(oob.el.findChildContent("desc", "jabber:x:oob")));
2742 binding.webview.getSettings().setJavaScriptEnabled(true);
2743 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");
2744 binding.webview.getSettings().setDatabaseEnabled(true);
2745 binding.webview.getSettings().setDomStorageEnabled(true);
2746 binding.webview.setWebChromeClient(new WebChromeClient() {
2747 @Override
2748 public void onProgressChanged(WebView view, int newProgress) {
2749 binding.progressbar.setVisibility(newProgress < 100 ? View.VISIBLE : View.GONE);
2750 binding.progressbar.setProgress(newProgress);
2751 }
2752 });
2753 binding.webview.setWebViewClient(new WebViewClient() {
2754 @Override
2755 public void onPageFinished(WebView view, String url) {
2756 super.onPageFinished(view, url);
2757 mTitle = view.getTitle();
2758 ConversationPagerAdapter.this.notifyDataSetChanged();
2759 }
2760 });
2761 final String url = oob.el.findChildContent("url", "jabber:x:oob");
2762 if (!boundUrl.equals(url)) {
2763 binding.webview.addJavascriptInterface(new JsObject(), "xmpp_xep0050");
2764 binding.webview.loadUrl(url);
2765 boundUrl = url;
2766 }
2767 }
2768
2769 class JsObject {
2770 @JavascriptInterface
2771 public void execute() { execute("execute"); }
2772
2773 @JavascriptInterface
2774 public void execute(String action) {
2775 getView().post(() -> {
2776 actionToWebview = null;
2777 if(CommandSession.this.execute(action)) {
2778 removeSession(CommandSession.this);
2779 }
2780 });
2781 }
2782
2783 @JavascriptInterface
2784 public void preventDefault() {
2785 actionToWebview = binding.webview;
2786 }
2787 }
2788 }
2789
2790 class ProgressBarViewHolder extends ViewHolder<CommandProgressBarBinding> {
2791 public ProgressBarViewHolder(CommandProgressBarBinding binding) { super(binding); }
2792
2793 @Override
2794 public void bind(Item item) {
2795 binding.text.setVisibility(loadingHasBeenLong ? View.VISIBLE : View.GONE);
2796 }
2797 }
2798
2799 class Item {
2800 protected Element el;
2801 protected int viewType;
2802 protected String error = null;
2803
2804 Item(Element el, int viewType) {
2805 this.el = el;
2806 this.viewType = viewType;
2807 }
2808
2809 public boolean validate() {
2810 error = null;
2811 return true;
2812 }
2813 }
2814
2815 class Field extends Item {
2816 Field(eu.siacs.conversations.xmpp.forms.Field el, int viewType) { super(el, viewType); }
2817
2818 @Override
2819 public boolean validate() {
2820 if (!super.validate()) return false;
2821 if (el.findChild("required", "jabber:x:data") == null) return true;
2822 if (getValue().getContent() != null && !getValue().getContent().equals("")) return true;
2823
2824 error = "this value is required";
2825 return false;
2826 }
2827
2828 public String getVar() {
2829 return el.getAttribute("var");
2830 }
2831
2832 public Optional<String> getType() {
2833 return Optional.fromNullable(el.getAttribute("type"));
2834 }
2835
2836 public Optional<String> getLabel() {
2837 String label = el.getAttribute("label");
2838 if (label == null) label = getVar();
2839 return Optional.fromNullable(label);
2840 }
2841
2842 public Optional<String> getDesc() {
2843 return Optional.fromNullable(el.findChildContent("desc", "jabber:x:data"));
2844 }
2845
2846 public Element getValue() {
2847 Element value = el.findChild("value", "jabber:x:data");
2848 if (value == null) {
2849 value = el.addChild("value", "jabber:x:data");
2850 }
2851 return value;
2852 }
2853
2854 public void setValues(Collection<String> values) {
2855 for(Element child : el.getChildren()) {
2856 if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
2857 el.removeChild(child);
2858 }
2859 }
2860
2861 for (String value : values) {
2862 el.addChild("value", "jabber:x:data").setContent(value);
2863 }
2864 }
2865
2866 public List<String> getValues() {
2867 List<String> values = new ArrayList<>();
2868 for(Element child : el.getChildren()) {
2869 if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
2870 values.add(child.getContent());
2871 }
2872 }
2873 return values;
2874 }
2875
2876 public List<Option> getOptions() {
2877 return Option.forField(el);
2878 }
2879 }
2880
2881 class Cell extends Item {
2882 protected Field reported;
2883
2884 Cell(Field reported, Element item) {
2885 super(item, TYPE_RESULT_CELL);
2886 this.reported = reported;
2887 }
2888 }
2889
2890 protected Field mkField(Element el) {
2891 int viewType = -1;
2892
2893 String formType = responseElement.getAttribute("type");
2894 if (formType != null) {
2895 String fieldType = el.getAttribute("type");
2896 if (fieldType == null) fieldType = "text-single";
2897
2898 if (formType.equals("result") || fieldType.equals("fixed")) {
2899 viewType = TYPE_RESULT_FIELD;
2900 } else if (formType.equals("form")) {
2901 final Element validate = el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2902 final String datatype = validate == null ? null : validate.getAttribute("datatype");
2903 final Element range = validate == null ? null : validate.findChild("range", "http://jabber.org/protocol/xdata-validate");
2904 if (fieldType.equals("boolean")) {
2905 if (fillableFieldCount == 1 && actionsAdapter.countProceed() < 1) {
2906 viewType = TYPE_BUTTON_GRID_FIELD;
2907 } else {
2908 viewType = TYPE_CHECKBOX_FIELD;
2909 }
2910 } else if (
2911 range != null && range.getAttribute("min") != null && range.getAttribute("max") != null && (
2912 "xs:integer".equals(datatype) || "xs:int".equals(datatype) || "xs:long".equals(datatype) || "xs:short".equals(datatype) || "xs:byte".equals(datatype) ||
2913 "xs:decimal".equals(datatype) || "xs:double".equals(datatype)
2914 )
2915 ) {
2916 // has a range and is numeric, use a slider
2917 viewType = TYPE_SLIDER_FIELD;
2918 } else if (fieldType.equals("list-single")) {
2919 if (fillableFieldCount == 1 && actionsAdapter.countProceed() < 1 && Option.forField(el).size() < 50) {
2920 viewType = TYPE_BUTTON_GRID_FIELD;
2921 } else if (Option.forField(el).size() > 9) {
2922 viewType = TYPE_SEARCH_LIST_FIELD;
2923 } else if (el.findChild("value", "jabber:x:data") == null || (validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null)) {
2924 viewType = TYPE_RADIO_EDIT_FIELD;
2925 } else {
2926 viewType = TYPE_SPINNER_FIELD;
2927 }
2928 } else if (fieldType.equals("list-multi")) {
2929 viewType = TYPE_SEARCH_LIST_FIELD;
2930 } else {
2931 viewType = TYPE_TEXT_FIELD;
2932 }
2933 }
2934
2935 return new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), viewType);
2936 }
2937
2938 return null;
2939 }
2940
2941 protected Item mkItem(Element el, int pos) {
2942 int viewType = TYPE_ERROR;
2943
2944 if (response != null && response.getType() == Iq.Type.RESULT) {
2945 if (el.getName().equals("note")) {
2946 viewType = TYPE_NOTE;
2947 } else if (el.getNamespace().equals("jabber:x:oob")) {
2948 viewType = TYPE_WEB;
2949 } else if (el.getName().equals("instructions") && el.getNamespace().equals("jabber:x:data")) {
2950 viewType = TYPE_NOTE;
2951 } else if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data")) {
2952 Field field = mkField(el);
2953 if (field != null) {
2954 items.put(pos, field);
2955 return field;
2956 }
2957 }
2958 }
2959
2960 Item item = new Item(el, viewType);
2961 items.put(pos, item);
2962 return item;
2963 }
2964
2965 class ActionsAdapter extends ArrayAdapter<Pair<String, String>> {
2966 protected Context ctx;
2967
2968 public ActionsAdapter(Context ctx) {
2969 super(ctx, R.layout.simple_list_item);
2970 this.ctx = ctx;
2971 }
2972
2973 @Override
2974 public View getView(int position, View convertView, ViewGroup parent) {
2975 View v = super.getView(position, convertView, parent);
2976 TextView tv = (TextView) v.findViewById(android.R.id.text1);
2977 tv.setGravity(Gravity.CENTER);
2978 tv.setText(getItem(position).second);
2979 int resId = ctx.getResources().getIdentifier("action_" + getItem(position).first, "string" , ctx.getPackageName());
2980 if (resId != 0 && getItem(position).second.equals(getItem(position).first)) tv.setText(ctx.getResources().getString(resId));
2981 final var colors = MaterialColors.getColorRoles(ctx, UIHelper.getColorForName(getItem(position).first));
2982 tv.setTextColor(colors.getOnAccent());
2983 tv.setBackgroundColor(MaterialColors.harmonizeWithPrimary(ctx, colors.getAccent()));
2984 return v;
2985 }
2986
2987 public int getPosition(String s) {
2988 for(int i = 0; i < getCount(); i++) {
2989 if (getItem(i).first.equals(s)) return i;
2990 }
2991 return -1;
2992 }
2993
2994 public int countProceed() {
2995 int count = 0;
2996 for(int i = 0; i < getCount(); i++) {
2997 if (!"cancel".equals(getItem(i).first) && !"prev".equals(getItem(i).first)) count++;
2998 }
2999 return count;
3000 }
3001
3002 public int countExceptCancel() {
3003 int count = 0;
3004 for(int i = 0; i < getCount(); i++) {
3005 if (!getItem(i).first.equals("cancel")) count++;
3006 }
3007 return count;
3008 }
3009
3010 public void clearProceed() {
3011 Pair<String,String> cancelItem = null;
3012 Pair<String,String> prevItem = null;
3013 for(int i = 0; i < getCount(); i++) {
3014 if (getItem(i).first.equals("cancel")) cancelItem = getItem(i);
3015 if (getItem(i).first.equals("prev")) prevItem = getItem(i);
3016 }
3017 clear();
3018 if (cancelItem != null) add(cancelItem);
3019 if (prevItem != null) add(prevItem);
3020 }
3021 }
3022
3023 final int TYPE_ERROR = 1;
3024 final int TYPE_NOTE = 2;
3025 final int TYPE_WEB = 3;
3026 final int TYPE_RESULT_FIELD = 4;
3027 final int TYPE_TEXT_FIELD = 5;
3028 final int TYPE_CHECKBOX_FIELD = 6;
3029 final int TYPE_SPINNER_FIELD = 7;
3030 final int TYPE_RADIO_EDIT_FIELD = 8;
3031 final int TYPE_RESULT_CELL = 9;
3032 final int TYPE_PROGRESSBAR = 10;
3033 final int TYPE_SEARCH_LIST_FIELD = 11;
3034 final int TYPE_ITEM_CARD = 12;
3035 final int TYPE_BUTTON_GRID_FIELD = 13;
3036 final int TYPE_SLIDER_FIELD = 14;
3037
3038 protected boolean executing = false;
3039 protected boolean loading = false;
3040 protected boolean loadingHasBeenLong = false;
3041 protected Timer loadingTimer = new Timer();
3042 protected String mTitle;
3043 protected String mNode;
3044 protected CommandPageBinding mBinding = null;
3045 protected Iq response = null;
3046 protected Element responseElement = null;
3047 protected boolean expectingRemoval = false;
3048 protected List<Field> reported = null;
3049 protected SparseArray<Item> items = new SparseArray<>();
3050 protected XmppConnectionService xmppConnectionService;
3051 protected ActionsAdapter actionsAdapter = null;
3052 protected GridLayoutManager layoutManager;
3053 protected WebView actionToWebview = null;
3054 protected int fillableFieldCount = 0;
3055 protected Iq pendingResponsePacket = null;
3056 protected boolean waitingForRefresh = false;
3057
3058 CommandSession(String title, String node, XmppConnectionService xmppConnectionService) {
3059 loading();
3060 mTitle = title;
3061 mNode = node;
3062 this.xmppConnectionService = xmppConnectionService;
3063 if (mPager.get() != null) setupLayoutManager(mPager.get().getContext());
3064 }
3065
3066 public String getTitle() {
3067 return mTitle;
3068 }
3069
3070 public String getNode() {
3071 return mNode;
3072 }
3073
3074 public void updateWithResponse(final Iq iq) {
3075 if (getView() != null && getView().isAttachedToWindow()) {
3076 getView().post(() -> updateWithResponseUiThread(iq));
3077 } else {
3078 pendingResponsePacket = iq;
3079 }
3080 }
3081
3082 protected void updateWithResponseUiThread(final Iq iq) {
3083 Timer oldTimer = this.loadingTimer;
3084 this.loadingTimer = new Timer();
3085 oldTimer.cancel();
3086 this.executing = false;
3087 this.loading = false;
3088 this.loadingHasBeenLong = false;
3089 this.responseElement = null;
3090 this.fillableFieldCount = 0;
3091 this.reported = null;
3092 this.response = iq;
3093 this.items.clear();
3094 this.actionsAdapter.clear();
3095 layoutManager.setSpanCount(1);
3096
3097 boolean actionsCleared = false;
3098 Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
3099 if (iq.getType() == Iq.Type.RESULT && command != null) {
3100 if (mNode.equals("jabber:iq:register") && command.getAttribute("status") != null && command.getAttribute("status").equals("completed")) {
3101 xmppConnectionService.createContact(getAccount().getRoster().getContact(iq.getFrom()), true);
3102 }
3103
3104 if (xmppConnectionService.isOnboarding() && mNode.equals("jabber:iq:register") && !"canceled".equals(command.getAttribute("status")) && xmppConnectionService.getPreferences().contains("onboarding_action")) {
3105 xmppConnectionService.getPreferences().edit().putBoolean("onboarding_continued", true).commit();
3106 }
3107
3108 Element actions = command.findChild("actions", "http://jabber.org/protocol/commands");
3109 if (actions != null) {
3110 for (Element action : actions.getChildren()) {
3111 if (!"http://jabber.org/protocol/commands".equals(action.getNamespace())) continue;
3112 if ("execute".equals(action.getName())) continue;
3113
3114 actionsAdapter.add(Pair.create(action.getName(), action.getName()));
3115 }
3116 }
3117
3118 for (Element el : command.getChildren()) {
3119 if ("x".equals(el.getName()) && "jabber:x:data".equals(el.getNamespace())) {
3120 Data form = Data.parse(el);
3121 String title = form.getTitle();
3122 if (title != null) {
3123 mTitle = title;
3124 ConversationPagerAdapter.this.notifyDataSetChanged();
3125 }
3126
3127 if ("result".equals(el.getAttribute("type")) || "form".equals(el.getAttribute("type"))) {
3128 this.responseElement = el;
3129 setupReported(el.findChild("reported", "jabber:x:data"));
3130 if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager(mBinding.getRoot().getContext()));
3131 }
3132
3133 eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
3134 if (actionList != null) {
3135 actionsAdapter.clear();
3136
3137 for (Option action : actionList.getOptions()) {
3138 actionsAdapter.add(Pair.create(action.getValue(), action.toString()));
3139 }
3140 }
3141
3142 eu.siacs.conversations.xmpp.forms.Field fillableField = null;
3143 for (eu.siacs.conversations.xmpp.forms.Field field : form.getFields()) {
3144 if ((field.getType() == null || (!field.getType().equals("hidden") && !field.getType().equals("fixed"))) && field.getFieldName() != null && !field.getFieldName().equals("http://jabber.org/protocol/commands#actions")) {
3145 final var validate = field.findChild("validate", "http://jabber.org/protocol/xdata-validate");
3146 final var range = validate == null ? null : validate.findChild("range", "http://jabber.org/protocol/xdata-validate");
3147 fillableField = range == null ? field : null;
3148 fillableFieldCount++;
3149 }
3150 }
3151
3152 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))) {
3153 actionsCleared = true;
3154 actionsAdapter.clearProceed();
3155 }
3156 break;
3157 }
3158 if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
3159 String url = el.findChildContent("url", "jabber:x:oob");
3160 if (url != null) {
3161 String scheme = Uri.parse(url).getScheme();
3162 if (scheme.equals("http") || scheme.equals("https")) {
3163 this.responseElement = el;
3164 break;
3165 }
3166 if (scheme.equals("xmpp")) {
3167 expectingRemoval = true;
3168 final Intent intent = new Intent(getView().getContext(), UriHandlerActivity.class);
3169 intent.setAction(Intent.ACTION_VIEW);
3170 intent.setData(Uri.parse(url));
3171 getView().getContext().startActivity(intent);
3172 break;
3173 }
3174 }
3175 }
3176 if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
3177 this.responseElement = el;
3178 break;
3179 }
3180 }
3181
3182 if (responseElement == null && command.getAttribute("status") != null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
3183 if ("jabber:iq:register".equals(mNode) && "canceled".equals(command.getAttribute("status"))) {
3184 if (xmppConnectionService.isOnboarding()) {
3185 if (xmppConnectionService.getPreferences().contains("onboarding_action")) {
3186 xmppConnectionService.deleteAccount(getAccount());
3187 } else {
3188 if (xmppConnectionService.getPreferences().getBoolean("onboarding_continued", false)) {
3189 removeSession(this);
3190 return;
3191 } else {
3192 xmppConnectionService.getPreferences().edit().putString("onboarding_action", "cancel").commit();
3193 xmppConnectionService.deleteAccount(getAccount());
3194 }
3195 }
3196 }
3197 xmppConnectionService.archiveConversation(Conversation.this);
3198 }
3199
3200 expectingRemoval = true;
3201 removeSession(this);
3202 return;
3203 }
3204
3205 if ("executing".equals(command.getAttribute("status")) && actionsAdapter.countExceptCancel() < 1 && !actionsCleared) {
3206 // No actions have been given, but we are not done?
3207 // This is probably a spec violation, but we should do *something*
3208 actionsAdapter.add(Pair.create("execute", "execute"));
3209 }
3210
3211 if (!actionsAdapter.isEmpty() || fillableFieldCount > 0) {
3212 if ("completed".equals(command.getAttribute("status")) || "canceled".equals(command.getAttribute("status"))) {
3213 actionsAdapter.add(Pair.create("close", "close"));
3214 } else if (actionsAdapter.getPosition("cancel") < 0 && !xmppConnectionService.isOnboarding()) {
3215 actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
3216 }
3217 }
3218 }
3219
3220 if (actionsAdapter.isEmpty()) {
3221 actionsAdapter.add(Pair.create("close", "close"));
3222 }
3223
3224 actionsAdapter.sort((x, y) -> {
3225 if (x.first.equals("cancel")) return -1;
3226 if (y.first.equals("cancel")) return 1;
3227 if (x.first.equals("prev") && xmppConnectionService.isOnboarding()) return -1;
3228 if (y.first.equals("prev") && xmppConnectionService.isOnboarding()) return 1;
3229 return 0;
3230 });
3231
3232 Data dataForm = null;
3233 if (responseElement != null && responseElement.getName().equals("x") && responseElement.getNamespace().equals("jabber:x:data")) dataForm = Data.parse(responseElement);
3234 if (mNode.equals("jabber:iq:register") &&
3235 xmppConnectionService.getPreferences().contains("onboarding_action") &&
3236 dataForm != null && dataForm.getFieldByName("gateway-jid") != null) {
3237
3238
3239 dataForm.put("gateway-jid", xmppConnectionService.getPreferences().getString("onboarding_action", ""));
3240 execute();
3241 }
3242 xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
3243 notifyDataSetChanged();
3244 }
3245
3246 protected void setupReported(Element el) {
3247 if (el == null) {
3248 reported = null;
3249 return;
3250 }
3251
3252 reported = new ArrayList<>();
3253 for (Element fieldEl : el.getChildren()) {
3254 if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
3255 reported.add(mkField(fieldEl));
3256 }
3257 }
3258
3259 @Override
3260 public int getItemCount() {
3261 if (loading) return 1;
3262 if (response == null) return 0;
3263 if (response.getType() == Iq.Type.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
3264 int i = 0;
3265 for (Element el : responseElement.getChildren()) {
3266 if (!el.getNamespace().equals("jabber:x:data")) continue;
3267 if (el.getName().equals("title")) continue;
3268 if (el.getName().equals("field")) {
3269 String type = el.getAttribute("type");
3270 if (type != null && type.equals("hidden")) continue;
3271 if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
3272 }
3273
3274 if (el.getName().equals("reported") || el.getName().equals("item")) {
3275 if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
3276 if (el.getName().equals("reported")) continue;
3277 i += 1;
3278 } else {
3279 if (reported != null) i += reported.size();
3280 }
3281 continue;
3282 }
3283
3284 i++;
3285 }
3286 return i;
3287 }
3288 return 1;
3289 }
3290
3291 public Item getItem(int position) {
3292 if (loading) return new Item(null, TYPE_PROGRESSBAR);
3293 if (items.get(position) != null) return items.get(position);
3294 if (response == null) return null;
3295
3296 if (response.getType() == Iq.Type.RESULT && responseElement != null) {
3297 if (responseElement.getNamespace().equals("jabber:x:data")) {
3298 int i = 0;
3299 for (Element el : responseElement.getChildren()) {
3300 if (!el.getNamespace().equals("jabber:x:data")) continue;
3301 if (el.getName().equals("title")) continue;
3302 if (el.getName().equals("field")) {
3303 String type = el.getAttribute("type");
3304 if (type != null && type.equals("hidden")) continue;
3305 if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
3306 }
3307
3308 if (el.getName().equals("reported") || el.getName().equals("item")) {
3309 Cell cell = null;
3310
3311 if (reported != null) {
3312 if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
3313 if (el.getName().equals("reported")) continue;
3314 if (i == position) {
3315 items.put(position, new Item(el, TYPE_ITEM_CARD));
3316 return items.get(position);
3317 }
3318 } else {
3319 if (reported.size() > position - i) {
3320 Field reportedField = reported.get(position - i);
3321 Element itemField = null;
3322 if (el.getName().equals("item")) {
3323 for (Element subel : el.getChildren()) {
3324 if (subel.getAttribute("var").equals(reportedField.getVar())) {
3325 itemField = subel;
3326 break;
3327 }
3328 }
3329 }
3330 cell = new Cell(reportedField, itemField);
3331 } else {
3332 i += reported.size();
3333 continue;
3334 }
3335 }
3336 }
3337
3338 if (cell != null) {
3339 items.put(position, cell);
3340 return cell;
3341 }
3342 }
3343
3344 if (i < position) {
3345 i++;
3346 continue;
3347 }
3348
3349 return mkItem(el, position);
3350 }
3351 }
3352 }
3353
3354 return mkItem(responseElement == null ? response : responseElement, position);
3355 }
3356
3357 @Override
3358 public int getItemViewType(int position) {
3359 return getItem(position).viewType;
3360 }
3361
3362 @Override
3363 public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
3364 switch(viewType) {
3365 case TYPE_ERROR: {
3366 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3367 return new ErrorViewHolder(binding);
3368 }
3369 case TYPE_NOTE: {
3370 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3371 return new NoteViewHolder(binding);
3372 }
3373 case TYPE_WEB: {
3374 CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
3375 return new WebViewHolder(binding);
3376 }
3377 case TYPE_RESULT_FIELD: {
3378 CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
3379 return new ResultFieldViewHolder(binding);
3380 }
3381 case TYPE_RESULT_CELL: {
3382 CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
3383 return new ResultCellViewHolder(binding);
3384 }
3385 case TYPE_ITEM_CARD: {
3386 CommandItemCardBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_item_card, container, false);
3387 return new ItemCardViewHolder(binding);
3388 }
3389 case TYPE_CHECKBOX_FIELD: {
3390 CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
3391 return new CheckboxFieldViewHolder(binding);
3392 }
3393 case TYPE_SEARCH_LIST_FIELD: {
3394 CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
3395 return new SearchListFieldViewHolder(binding);
3396 }
3397 case TYPE_RADIO_EDIT_FIELD: {
3398 CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
3399 return new RadioEditFieldViewHolder(binding);
3400 }
3401 case TYPE_SPINNER_FIELD: {
3402 CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
3403 return new SpinnerFieldViewHolder(binding);
3404 }
3405 case TYPE_BUTTON_GRID_FIELD: {
3406 CommandButtonGridFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_button_grid_field, container, false);
3407 return new ButtonGridFieldViewHolder(binding);
3408 }
3409 case TYPE_TEXT_FIELD: {
3410 CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
3411 return new TextFieldViewHolder(binding);
3412 }
3413 case TYPE_SLIDER_FIELD: {
3414 CommandSliderFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_slider_field, container, false);
3415 return new SliderFieldViewHolder(binding);
3416 }
3417 case TYPE_PROGRESSBAR: {
3418 CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
3419 return new ProgressBarViewHolder(binding);
3420 }
3421 default:
3422 if (expectingRemoval) {
3423 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3424 return new NoteViewHolder(binding);
3425 }
3426
3427 throw new IllegalArgumentException("Unknown viewType: " + viewType + " based on: " + response + ", " + responseElement + ", " + expectingRemoval);
3428 }
3429 }
3430
3431 @Override
3432 public void onBindViewHolder(ViewHolder viewHolder, int position) {
3433 viewHolder.bind(getItem(position));
3434 }
3435
3436 public View getView() {
3437 if (mBinding == null) return null;
3438 return mBinding.getRoot();
3439 }
3440
3441 public boolean validate() {
3442 int count = getItemCount();
3443 boolean isValid = true;
3444 for (int i = 0; i < count; i++) {
3445 boolean oneIsValid = getItem(i).validate();
3446 isValid = isValid && oneIsValid;
3447 }
3448 notifyDataSetChanged();
3449 return isValid;
3450 }
3451
3452 public boolean execute() {
3453 return execute("execute");
3454 }
3455
3456 public boolean execute(int actionPosition) {
3457 return execute(actionsAdapter.getItem(actionPosition).first);
3458 }
3459
3460 public synchronized boolean execute(String action) {
3461 if (!"cancel".equals(action) && executing) {
3462 loadingHasBeenLong = true;
3463 notifyDataSetChanged();
3464 return false;
3465 }
3466 if (!action.equals("cancel") && !action.equals("prev") && !validate()) return false;
3467
3468 if (response == null) return true;
3469 Element command = response.findChild("command", "http://jabber.org/protocol/commands");
3470 if (command == null) return true;
3471 String status = command.getAttribute("status");
3472 if (status == null || (!status.equals("executing") && !action.equals("prev"))) return true;
3473
3474 if (actionToWebview != null && !action.equals("cancel") && Build.VERSION.SDK_INT >= 23) {
3475 actionToWebview.postWebMessage(new WebMessage("xmpp_xep0050/" + action), Uri.parse("*"));
3476 return false;
3477 }
3478
3479 final var packet = new Iq(Iq.Type.SET);
3480 packet.setTo(response.getFrom());
3481 final Element c = packet.addChild("command", Namespace.COMMANDS);
3482 c.setAttribute("node", mNode);
3483 c.setAttribute("sessionid", command.getAttribute("sessionid"));
3484
3485 String formType = responseElement == null ? null : responseElement.getAttribute("type");
3486 if (!action.equals("cancel") &&
3487 !action.equals("prev") &&
3488 responseElement != null &&
3489 responseElement.getName().equals("x") &&
3490 responseElement.getNamespace().equals("jabber:x:data") &&
3491 formType != null && formType.equals("form")) {
3492
3493 Data form = Data.parse(responseElement);
3494 eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
3495 if (actionList != null) {
3496 actionList.setValue(action);
3497 c.setAttribute("action", "execute");
3498 }
3499
3500 if (mNode.equals("jabber:iq:register") && xmppConnectionService.isOnboarding() && form.getFieldByName("gateway-jid") != null) {
3501 if (form.getValue("gateway-jid") == null) {
3502 xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
3503 } else {
3504 xmppConnectionService.getPreferences().edit().putString("onboarding_action", form.getValue("gateway-jid")).commit();
3505 }
3506 }
3507
3508 responseElement.setAttribute("type", "submit");
3509 Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
3510 if (rsm != null) {
3511 Element max = new Element("max", "http://jabber.org/protocol/rsm");
3512 max.setContent("1000");
3513 rsm.addChild(max);
3514 }
3515
3516 c.addChild(responseElement);
3517 }
3518
3519 if (c.getAttribute("action") == null) c.setAttribute("action", action);
3520
3521 executing = true;
3522 xmppConnectionService.sendIqPacket(getAccount(), packet, (iq) -> {
3523 updateWithResponse(iq);
3524 }, 120L);
3525
3526 loading();
3527 return false;
3528 }
3529
3530 public void refresh() {
3531 synchronized(this) {
3532 if (waitingForRefresh) notifyDataSetChanged();
3533 }
3534 }
3535
3536 protected void loading() {
3537 View v = getView();
3538 try {
3539 loadingTimer.schedule(new TimerTask() {
3540 @Override
3541 public void run() {
3542 View v2 = getView();
3543 loading = true;
3544
3545 try {
3546 loadingTimer.schedule(new TimerTask() {
3547 @Override
3548 public void run() {
3549 loadingHasBeenLong = true;
3550 if (v == null && v2 == null) return;
3551 (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3552 }
3553 }, 3000);
3554 } catch (final IllegalStateException e) { }
3555
3556 if (v == null && v2 == null) return;
3557 (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3558 }
3559 }, 500);
3560 } catch (final IllegalStateException e) { }
3561 }
3562
3563 protected GridLayoutManager setupLayoutManager(final Context ctx) {
3564 int spanCount = 1;
3565
3566 if (reported != null) {
3567 float screenWidth = ctx.getResources().getDisplayMetrics().widthPixels;
3568 TextPaint paint = ((TextView) LayoutInflater.from(mPager.get().getContext()).inflate(R.layout.command_result_cell, null)).getPaint();
3569 float tableHeaderWidth = reported.stream().reduce(
3570 0f,
3571 (total, field) -> total + StaticLayout.getDesiredWidth(field.getLabel().or("--------") + "\t", paint),
3572 (a, b) -> a + b
3573 );
3574
3575 spanCount = tableHeaderWidth > 0.59 * screenWidth ? 1 : this.reported.size();
3576 }
3577
3578 if (layoutManager != null && layoutManager.getSpanCount() != spanCount) {
3579 items.clear();
3580 notifyDataSetChanged();
3581 }
3582
3583 layoutManager = new GridLayoutManager(ctx, spanCount);
3584 layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
3585 @Override
3586 public int getSpanSize(int position) {
3587 if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
3588 return 1;
3589 }
3590 });
3591 return layoutManager;
3592 }
3593
3594 protected void setBinding(CommandPageBinding b) {
3595 mBinding = b;
3596 // https://stackoverflow.com/a/32350474/8611
3597 mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
3598 @Override
3599 public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
3600 if(rv.getChildCount() > 0) {
3601 int[] location = new int[2];
3602 rv.getLocationOnScreen(location);
3603 View childView = rv.findChildViewUnder(e.getX(), e.getY());
3604 if (childView instanceof ViewGroup) {
3605 childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
3606 }
3607 int action = e.getAction();
3608 switch (action) {
3609 case MotionEvent.ACTION_DOWN:
3610 if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(1)) || childView instanceof WebView) {
3611 rv.requestDisallowInterceptTouchEvent(true);
3612 }
3613 case MotionEvent.ACTION_UP:
3614 if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(-11)) || childView instanceof WebView) {
3615 rv.requestDisallowInterceptTouchEvent(true);
3616 }
3617 }
3618 }
3619
3620 return false;
3621 }
3622
3623 @Override
3624 public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }
3625
3626 @Override
3627 public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
3628 });
3629 mBinding.form.setLayoutManager(setupLayoutManager(mBinding.getRoot().getContext()));
3630 mBinding.form.setAdapter(this);
3631
3632 if (actionsAdapter == null) {
3633 actionsAdapter = new ActionsAdapter(mBinding.getRoot().getContext());
3634 actionsAdapter.registerDataSetObserver(new DataSetObserver() {
3635 @Override
3636 public void onChanged() {
3637 if (mBinding == null) return;
3638
3639 mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
3640 }
3641
3642 @Override
3643 public void onInvalidated() {}
3644 });
3645 }
3646
3647 mBinding.actions.setAdapter(actionsAdapter);
3648 mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
3649 if (execute(pos)) {
3650 removeSession(CommandSession.this);
3651 }
3652 });
3653
3654 actionsAdapter.notifyDataSetChanged();
3655
3656 if (pendingResponsePacket != null) {
3657 final var pending = pendingResponsePacket;
3658 pendingResponsePacket = null;
3659 updateWithResponseUiThread(pending);
3660 }
3661 }
3662
3663 private Drawable getDrawableForSVG(SVG svg, Element svgElement, int size) {
3664 if (svgElement != null && svgElement.getChildren().size() == 1 && svgElement.getChildren().get(0).getName().equals("image")) {
3665 return getDrawableForUrl(svgElement.getChildren().get(0).getAttribute("href"));
3666 } else {
3667 return xmppConnectionService.getFileBackend().drawSVG(svg, size);
3668 }
3669 }
3670
3671 private Drawable getDrawableForUrl(final String url) {
3672 final LruCache<String, Drawable> cache = xmppConnectionService.getDrawableCache();
3673 final HttpConnectionManager httpManager = xmppConnectionService.getHttpConnectionManager();
3674 final Drawable d = cache.get(url);
3675 if (Build.VERSION.SDK_INT >= 28 && d instanceof AnimatedImageDrawable) ((AnimatedImageDrawable) d).start();
3676 if (d == null) {
3677 synchronized (CommandSession.this) {
3678 waitingForRefresh = true;
3679 }
3680 int size = (int)(xmppConnectionService.getResources().getDisplayMetrics().density * 288);
3681 Message dummy = new Message(Conversation.this, url, Message.ENCRYPTION_NONE);
3682 dummy.setStatus(Message.STATUS_DUMMY);
3683 dummy.setFileParams(new Message.FileParams(url));
3684 httpManager.createNewDownloadConnection(dummy, true, (file) -> {
3685 if (file == null) {
3686 dummy.getTransferable().start();
3687 } else {
3688 try {
3689 xmppConnectionService.getFileBackend().getThumbnail(file, xmppConnectionService.getResources(), size, false, url);
3690 } catch (final Exception e) { }
3691 }
3692 });
3693 }
3694 return d;
3695 }
3696
3697 public View inflateUi(Context context, Consumer<ConversationPage> remover) {
3698 CommandPageBinding binding = DataBindingUtil.inflate(LayoutInflater.from(context), R.layout.command_page, null, false);
3699 setBinding(binding);
3700 return binding.getRoot();
3701 }
3702
3703 // https://stackoverflow.com/a/36037991/8611
3704 private View findViewAt(ViewGroup viewGroup, float x, float y) {
3705 for(int i = 0; i < viewGroup.getChildCount(); i++) {
3706 View child = viewGroup.getChildAt(i);
3707 if (child instanceof ViewGroup && !(child instanceof AbsListView) && !(child instanceof WebView)) {
3708 View foundView = findViewAt((ViewGroup) child, x, y);
3709 if (foundView != null && foundView.isShown()) {
3710 return foundView;
3711 }
3712 } else {
3713 int[] location = new int[2];
3714 child.getLocationOnScreen(location);
3715 Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
3716 if (rect.contains((int)x, (int)y)) {
3717 return child;
3718 }
3719 }
3720 }
3721
3722 return null;
3723 }
3724 }
3725
3726 class MucConfigSession extends CommandSession {
3727 MucConfigSession(XmppConnectionService xmppConnectionService) {
3728 super("Configure Channel", null, xmppConnectionService);
3729 }
3730
3731 @Override
3732 protected void updateWithResponseUiThread(final Iq iq) {
3733 Timer oldTimer = this.loadingTimer;
3734 this.loadingTimer = new Timer();
3735 oldTimer.cancel();
3736 this.executing = false;
3737 this.loading = false;
3738 this.loadingHasBeenLong = false;
3739 this.responseElement = null;
3740 this.fillableFieldCount = 0;
3741 this.reported = null;
3742 this.response = iq;
3743 this.items.clear();
3744 this.actionsAdapter.clear();
3745 layoutManager.setSpanCount(1);
3746
3747 final Element query = iq.findChild("query", "http://jabber.org/protocol/muc#owner");
3748 if (iq.getType() == Iq.Type.RESULT && query != null) {
3749 final Data form = Data.parse(query.findChild("x", "jabber:x:data"));
3750 final String title = form.getTitle();
3751 if (title != null) {
3752 mTitle = title;
3753 ConversationPagerAdapter.this.notifyDataSetChanged();
3754 }
3755
3756 this.responseElement = form;
3757 setupReported(form.findChild("reported", "jabber:x:data"));
3758 if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager(mBinding.getRoot().getContext()));
3759
3760 if (actionsAdapter.countExceptCancel() < 1) {
3761 actionsAdapter.add(Pair.create("save", "Save"));
3762 }
3763
3764 if (actionsAdapter.getPosition("cancel") < 0) {
3765 actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
3766 }
3767 } else if (iq.getType() == Iq.Type.RESULT) {
3768 expectingRemoval = true;
3769 removeSession(this);
3770 return;
3771 } else {
3772 actionsAdapter.add(Pair.create("close", "close"));
3773 }
3774
3775 notifyDataSetChanged();
3776 }
3777
3778 @Override
3779 public synchronized boolean execute(String action) {
3780 if ("cancel".equals(action)) {
3781 final var packet = new Iq(Iq.Type.SET);
3782 packet.setTo(response.getFrom());
3783 final Element form = packet
3784 .addChild("query", "http://jabber.org/protocol/muc#owner")
3785 .addChild("x", "jabber:x:data");
3786 form.setAttribute("type", "cancel");
3787 xmppConnectionService.sendIqPacket(getAccount(), packet, null);
3788 return true;
3789 }
3790
3791 if (!"save".equals(action)) return true;
3792
3793 final var packet = new Iq(Iq.Type.SET);
3794 packet.setTo(response.getFrom());
3795
3796 String formType = responseElement == null ? null : responseElement.getAttribute("type");
3797 if (responseElement != null &&
3798 responseElement.getName().equals("x") &&
3799 responseElement.getNamespace().equals("jabber:x:data") &&
3800 formType != null && formType.equals("form")) {
3801
3802 responseElement.setAttribute("type", "submit");
3803 packet
3804 .addChild("query", "http://jabber.org/protocol/muc#owner")
3805 .addChild(responseElement);
3806 }
3807
3808 executing = true;
3809 xmppConnectionService.sendIqPacket(getAccount(), packet, (iq) -> {
3810 updateWithResponse(iq);
3811 }, 120L);
3812
3813 loading();
3814
3815 return false;
3816 }
3817 }
3818 }
3819
3820 public static class Thread {
3821 protected Message subject = null;
3822 protected Message first = null;
3823 protected Message last = null;
3824 protected final String threadId;
3825
3826 protected Thread(final String threadId) {
3827 this.threadId = threadId;
3828 }
3829
3830 public String getThreadId() {
3831 return threadId;
3832 }
3833
3834 public String getSubject() {
3835 if (subject == null) return null;
3836
3837 return subject.getSubject();
3838 }
3839
3840 public String getDisplay() {
3841 final String s = getSubject();
3842 if (s != null) return s;
3843
3844 if (first != null) {
3845 return first.getBody();
3846 }
3847
3848 return "";
3849 }
3850
3851 public long getLastTime() {
3852 if (last == null) return 0;
3853
3854 return last.getTimeSent();
3855 }
3856 }
3857}