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