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