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 binding.defaultButtonSeperator.setVisibility(View.GONE);
2678 } else {
2679 theOptions.remove(defaultOption);
2680 binding.defaultButton.setVisibility(View.VISIBLE);
2681 binding.defaultButtonSeperator.setVisibility(View.VISIBLE);
2682
2683 final SVG defaultIcon = defaultOption.getIcon();
2684 if (defaultIcon != null) {
2685 DisplayMetrics display = mPager.get().getContext().getResources().getDisplayMetrics();
2686 int height = (int)(display.heightPixels*display.density/8);
2687 binding.defaultButton.setCompoundDrawablesRelativeWithIntrinsicBounds(null, getDrawableForSVG(defaultIcon, defaultOption.getIconEl(), height), null, null);
2688 }
2689
2690 binding.defaultButton.setText(defaultOption.toString());
2691 binding.defaultButton.setOnClickListener((view) -> {
2692 mValue.setContent(defaultOption.getValue());
2693 execute();
2694 loading = true;
2695 });
2696 }
2697
2698 options.addAll(theOptions);
2699 binding.buttons.setAdapter(options);
2700 }
2701 }
2702
2703 class TextFieldViewHolder extends ViewHolder<CommandTextFieldBinding> implements TextWatcher {
2704 public TextFieldViewHolder(CommandTextFieldBinding binding) {
2705 super(binding);
2706 binding.textinput.addTextChangedListener(this);
2707 }
2708 protected Field field = null;
2709
2710 @Override
2711 public void bind(Item item) {
2712 field = (Field) item;
2713 binding.textinputLayout.setHint(field.getLabel().or(""));
2714
2715 binding.textinputLayout.setHelperTextEnabled(field.getDesc().isPresent());
2716 for (String desc : field.getDesc().asSet()) {
2717 binding.textinputLayout.setHelperText(desc);
2718 }
2719
2720 binding.textinputLayout.setErrorEnabled(field.error != null);
2721 if (field.error != null) binding.textinputLayout.setError(field.error);
2722
2723 binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_GRAVITY);
2724 String suffixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/suffix-label");
2725 if (suffixLabel == null) {
2726 binding.textinputLayout.setSuffixText("");
2727 } else {
2728 binding.textinputLayout.setSuffixText(suffixLabel);
2729 binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_TEXT_END);
2730 }
2731
2732 String prefixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/prefix-label");
2733 binding.textinputLayout.setPrefixText(prefixLabel == null ? "" : prefixLabel);
2734
2735 binding.textinput.setText(String.join("\n", field.getValues()));
2736 setupInputType(field.el, binding.textinput, binding.textinputLayout);
2737 }
2738
2739 @Override
2740 public void afterTextChanged(Editable s) {
2741 if (field == null) return;
2742
2743 field.setValues(List.of(s.toString().split("\n")));
2744 }
2745
2746 @Override
2747 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2748
2749 @Override
2750 public void onTextChanged(CharSequence s, int start, int count, int after) { }
2751 }
2752
2753 class SliderFieldViewHolder extends ViewHolder<CommandSliderFieldBinding> {
2754 public SliderFieldViewHolder(CommandSliderFieldBinding binding) { super(binding); }
2755 protected Field field = null;
2756
2757 @Override
2758 public void bind(Item item) {
2759 field = (Field) item;
2760 setTextOrHide(binding.label, field.getLabel());
2761 setTextOrHide(binding.desc, field.getDesc());
2762 final Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2763 final String datatype = validate == null ? null : validate.getAttribute("datatype");
2764 final Element range = validate == null ? null : validate.findChild("range", "http://jabber.org/protocol/xdata-validate");
2765 // NOTE: range also implies open, so we don't have to be bound by the options strictly
2766 // Also, we don't have anywhere to put labels so we show only values, which might sometimes be bad...
2767 Float min = null;
2768 try { min = range.getAttribute("min") == null ? null : Float.valueOf(range.getAttribute("min")); } catch (NumberFormatException e) { }
2769 Float max = null;
2770 try { max = range.getAttribute("max") == null ? null : Float.valueOf(range.getAttribute("max")); } catch (NumberFormatException e) { }
2771
2772 List<Float> options = field.getOptions().stream().map(o -> Float.valueOf(o.getValue())).collect(Collectors.toList());
2773 Collections.sort(options);
2774 if (options.size() > 0) {
2775 // min/max should be on the range, but if you have options and didn't put them on the range we can imply
2776 if (min == null) min = options.get(0);
2777 if (max == null) max = options.get(options.size()-1);
2778 }
2779
2780 if (field.getValues().size() > 0) {
2781 final var val = Float.valueOf(field.getValue().getContent());
2782 if ((min == null || val >= min) && (max == null || val <= max)) {
2783 binding.slider.setValue(Float.valueOf(field.getValue().getContent()));
2784 } else {
2785 binding.slider.setValue(min == null ? Float.MIN_VALUE : min);
2786 }
2787 } else {
2788 binding.slider.setValue(min == null ? Float.MIN_VALUE : min);
2789 }
2790 binding.slider.setValueFrom(min == null ? Float.MIN_VALUE : min);
2791 binding.slider.setValueTo(max == null ? Float.MAX_VALUE : max);
2792 if ("xs:integer".equals(datatype) || "xs:int".equals(datatype) || "xs:long".equals(datatype) || "xs:short".equals(datatype) || "xs:byte".equals(datatype)) {
2793 binding.slider.setStepSize(1);
2794 } else {
2795 binding.slider.setStepSize(0);
2796 }
2797
2798 if (options.size() > 0) {
2799 float step = -1;
2800 Float prev = null;
2801 for (final Float option : options) {
2802 if (prev != null) {
2803 float nextStep = option - prev;
2804 if (step > 0 && step != nextStep) {
2805 step = -1;
2806 break;
2807 }
2808 step = nextStep;
2809 }
2810 prev = option;
2811 }
2812 if (step > 0) binding.slider.setStepSize(step);
2813 }
2814
2815 binding.slider.addOnChangeListener((slider, value, fromUser) -> {
2816 field.setValues(List.of(new DecimalFormat().format(value)));
2817 });
2818 }
2819 }
2820
2821 class WebViewHolder extends ViewHolder<CommandWebviewBinding> {
2822 public WebViewHolder(CommandWebviewBinding binding) { super(binding); }
2823 protected String boundUrl = "";
2824
2825 @Override
2826 public void bind(Item oob) {
2827 setTextOrHide(binding.desc, Optional.fromNullable(oob.el.findChildContent("desc", "jabber:x:oob")));
2828 binding.webview.getSettings().setJavaScriptEnabled(true);
2829 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");
2830 binding.webview.getSettings().setDatabaseEnabled(true);
2831 binding.webview.getSettings().setDomStorageEnabled(true);
2832 binding.webview.setWebChromeClient(new WebChromeClient() {
2833 @Override
2834 public void onProgressChanged(WebView view, int newProgress) {
2835 binding.progressbar.setVisibility(newProgress < 100 ? View.VISIBLE : View.GONE);
2836 binding.progressbar.setProgress(newProgress);
2837 }
2838 });
2839 binding.webview.setWebViewClient(new WebViewClient() {
2840 @Override
2841 public void onPageFinished(WebView view, String url) {
2842 super.onPageFinished(view, url);
2843 mTitle = view.getTitle();
2844 ConversationPagerAdapter.this.notifyDataSetChanged();
2845 }
2846 });
2847 final String url = oob.el.findChildContent("url", "jabber:x:oob");
2848 if (!boundUrl.equals(url)) {
2849 binding.webview.addJavascriptInterface(new JsObject(), "xmpp_xep0050");
2850 binding.webview.loadUrl(url);
2851 boundUrl = url;
2852 }
2853 }
2854
2855 class JsObject {
2856 @JavascriptInterface
2857 public void execute() { execute("execute"); }
2858
2859 @JavascriptInterface
2860 public void execute(String action) {
2861 getView().post(() -> {
2862 actionToWebview = null;
2863 if(CommandSession.this.execute(action)) {
2864 removeSession(CommandSession.this);
2865 }
2866 });
2867 }
2868
2869 @JavascriptInterface
2870 public void preventDefault() {
2871 actionToWebview = binding.webview;
2872 }
2873 }
2874 }
2875
2876 class ProgressBarViewHolder extends ViewHolder<CommandProgressBarBinding> {
2877 public ProgressBarViewHolder(CommandProgressBarBinding binding) { super(binding); }
2878
2879 @Override
2880 public void bind(Item item) {
2881 binding.text.setVisibility(loadingHasBeenLong ? View.VISIBLE : View.GONE);
2882 }
2883 }
2884
2885 class Item {
2886 protected Element el;
2887 protected int viewType;
2888 protected String error = null;
2889
2890 Item(Element el, int viewType) {
2891 this.el = el;
2892 this.viewType = viewType;
2893 }
2894
2895 public boolean validate() {
2896 error = null;
2897 return true;
2898 }
2899 }
2900
2901 class Field extends Item {
2902 Field(eu.siacs.conversations.xmpp.forms.Field el, int viewType) { super(el, viewType); }
2903
2904 @Override
2905 public boolean validate() {
2906 if (!super.validate()) return false;
2907 if (el.findChild("required", "jabber:x:data") == null) return true;
2908 if (getValue().getContent() != null && !getValue().getContent().equals("")) return true;
2909
2910 error = "this value is required";
2911 return false;
2912 }
2913
2914 public String getVar() {
2915 return el.getAttribute("var");
2916 }
2917
2918 public Optional<String> getType() {
2919 return Optional.fromNullable(el.getAttribute("type"));
2920 }
2921
2922 public Optional<String> getLabel() {
2923 String label = el.getAttribute("label");
2924 if (label == null) label = getVar();
2925 return Optional.fromNullable(label);
2926 }
2927
2928 public Optional<String> getDesc() {
2929 return Optional.fromNullable(el.findChildContent("desc", "jabber:x:data"));
2930 }
2931
2932 public Element getValue() {
2933 Element value = el.findChild("value", "jabber:x:data");
2934 if (value == null) {
2935 value = el.addChild("value", "jabber:x:data");
2936 }
2937 return value;
2938 }
2939
2940 public void setValues(Collection<String> values) {
2941 for(Element child : el.getChildren()) {
2942 if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
2943 el.removeChild(child);
2944 }
2945 }
2946
2947 for (String value : values) {
2948 el.addChild("value", "jabber:x:data").setContent(value);
2949 }
2950 }
2951
2952 public List<String> getValues() {
2953 List<String> values = new ArrayList<>();
2954 for(Element child : el.getChildren()) {
2955 if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
2956 values.add(child.getContent());
2957 }
2958 }
2959 return values;
2960 }
2961
2962 public List<Option> getOptions() {
2963 return Option.forField(el);
2964 }
2965 }
2966
2967 class Cell extends Item {
2968 protected Field reported;
2969
2970 Cell(Field reported, Element item) {
2971 super(item, TYPE_RESULT_CELL);
2972 this.reported = reported;
2973 }
2974 }
2975
2976 protected Field mkField(Element el) {
2977 int viewType = -1;
2978
2979 String formType = responseElement.getAttribute("type");
2980 if (formType != null) {
2981 String fieldType = el.getAttribute("type");
2982 if (fieldType == null) fieldType = "text-single";
2983
2984 if (formType.equals("result") || fieldType.equals("fixed")) {
2985 viewType = TYPE_RESULT_FIELD;
2986 } else if (formType.equals("form")) {
2987 final Element validate = el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2988 final String datatype = validate == null ? null : validate.getAttribute("datatype");
2989 final Element range = validate == null ? null : validate.findChild("range", "http://jabber.org/protocol/xdata-validate");
2990 if (fieldType.equals("boolean")) {
2991 if (fillableFieldCount == 1 && actionsAdapter.countProceed() < 1) {
2992 viewType = TYPE_BUTTON_GRID_FIELD;
2993 } else {
2994 viewType = TYPE_CHECKBOX_FIELD;
2995 }
2996 } else if (
2997 range != null && range.getAttribute("min") != null && range.getAttribute("max") != null && (
2998 "xs:integer".equals(datatype) || "xs:int".equals(datatype) || "xs:long".equals(datatype) || "xs:short".equals(datatype) || "xs:byte".equals(datatype) ||
2999 "xs:decimal".equals(datatype) || "xs:double".equals(datatype)
3000 )
3001 ) {
3002 // has a range and is numeric, use a slider
3003 viewType = TYPE_SLIDER_FIELD;
3004 } else if (fieldType.equals("list-single")) {
3005 if (fillableFieldCount == 1 && actionsAdapter.countProceed() < 1 && Option.forField(el).size() < 50) {
3006 viewType = TYPE_BUTTON_GRID_FIELD;
3007 } else if (Option.forField(el).size() > 9) {
3008 viewType = TYPE_SEARCH_LIST_FIELD;
3009 } else if (el.findChild("value", "jabber:x:data") == null || (validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null)) {
3010 viewType = TYPE_RADIO_EDIT_FIELD;
3011 } else {
3012 viewType = TYPE_SPINNER_FIELD;
3013 }
3014 } else if (fieldType.equals("list-multi")) {
3015 viewType = TYPE_SEARCH_LIST_FIELD;
3016 } else {
3017 viewType = TYPE_TEXT_FIELD;
3018 }
3019 }
3020
3021 return new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), viewType);
3022 }
3023
3024 return null;
3025 }
3026
3027 protected Item mkItem(Element el, int pos) {
3028 int viewType = TYPE_ERROR;
3029
3030 if (response != null && response.getType() == Iq.Type.RESULT) {
3031 if (el.getName().equals("note")) {
3032 viewType = TYPE_NOTE;
3033 } else if (el.getNamespace().equals("jabber:x:oob")) {
3034 viewType = TYPE_WEB;
3035 } else if (el.getName().equals("instructions") && el.getNamespace().equals("jabber:x:data")) {
3036 viewType = TYPE_NOTE;
3037 } else if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data")) {
3038 Field field = mkField(el);
3039 if (field != null) {
3040 items.put(pos, field);
3041 return field;
3042 }
3043 }
3044 }
3045
3046 Item item = new Item(el, viewType);
3047 items.put(pos, item);
3048 return item;
3049 }
3050
3051 class ActionsAdapter extends ArrayAdapter<Pair<String, String>> {
3052 protected Context ctx;
3053
3054 public ActionsAdapter(Context ctx) {
3055 super(ctx, R.layout.simple_list_item);
3056 this.ctx = ctx;
3057 }
3058
3059 @Override
3060 public View getView(int position, View convertView, ViewGroup parent) {
3061 View v = super.getView(position, convertView, parent);
3062 TextView tv = (TextView) v.findViewById(android.R.id.text1);
3063 tv.setGravity(Gravity.CENTER);
3064 tv.setText(getItem(position).second);
3065 int resId = ctx.getResources().getIdentifier("action_" + getItem(position).first, "string" , ctx.getPackageName());
3066 if (resId != 0 && getItem(position).second.equals(getItem(position).first)) tv.setText(ctx.getResources().getString(resId));
3067 final var colors = MaterialColors.getColorRoles(ctx, UIHelper.getColorForName(getItem(position).first));
3068 tv.setTextColor(colors.getOnAccent());
3069 tv.setBackgroundColor(MaterialColors.harmonizeWithPrimary(ctx, colors.getAccent()));
3070 return v;
3071 }
3072
3073 public int getPosition(String s) {
3074 for(int i = 0; i < getCount(); i++) {
3075 if (getItem(i).first.equals(s)) return i;
3076 }
3077 return -1;
3078 }
3079
3080 public int countProceed() {
3081 int count = 0;
3082 for(int i = 0; i < getCount(); i++) {
3083 if (!"cancel".equals(getItem(i).first) && !"prev".equals(getItem(i).first)) count++;
3084 }
3085 return count;
3086 }
3087
3088 public int countExceptCancel() {
3089 int count = 0;
3090 for(int i = 0; i < getCount(); i++) {
3091 if (!getItem(i).first.equals("cancel")) count++;
3092 }
3093 return count;
3094 }
3095
3096 public void clearProceed() {
3097 Pair<String,String> cancelItem = null;
3098 Pair<String,String> prevItem = null;
3099 for(int i = 0; i < getCount(); i++) {
3100 if (getItem(i).first.equals("cancel")) cancelItem = getItem(i);
3101 if (getItem(i).first.equals("prev")) prevItem = getItem(i);
3102 }
3103 clear();
3104 if (cancelItem != null) add(cancelItem);
3105 if (prevItem != null) add(prevItem);
3106 }
3107 }
3108
3109 final int TYPE_ERROR = 1;
3110 final int TYPE_NOTE = 2;
3111 final int TYPE_WEB = 3;
3112 final int TYPE_RESULT_FIELD = 4;
3113 final int TYPE_TEXT_FIELD = 5;
3114 final int TYPE_CHECKBOX_FIELD = 6;
3115 final int TYPE_SPINNER_FIELD = 7;
3116 final int TYPE_RADIO_EDIT_FIELD = 8;
3117 final int TYPE_RESULT_CELL = 9;
3118 final int TYPE_PROGRESSBAR = 10;
3119 final int TYPE_SEARCH_LIST_FIELD = 11;
3120 final int TYPE_ITEM_CARD = 12;
3121 final int TYPE_BUTTON_GRID_FIELD = 13;
3122 final int TYPE_SLIDER_FIELD = 14;
3123
3124 protected boolean executing = false;
3125 protected boolean loading = false;
3126 protected boolean loadingHasBeenLong = false;
3127 protected Timer loadingTimer = new Timer();
3128 protected String mTitle;
3129 protected String mNode;
3130 protected CommandPageBinding mBinding = null;
3131 protected Iq response = null;
3132 protected Element responseElement = null;
3133 protected boolean expectingRemoval = false;
3134 protected List<Field> reported = null;
3135 protected SparseArray<Item> items = new SparseArray<>();
3136 protected XmppConnectionService xmppConnectionService;
3137 protected ActionsAdapter actionsAdapter = null;
3138 protected GridLayoutManager layoutManager;
3139 protected WebView actionToWebview = null;
3140 protected int fillableFieldCount = 0;
3141 protected Iq pendingResponsePacket = null;
3142 protected boolean waitingForRefresh = false;
3143
3144 CommandSession(String title, String node, XmppConnectionService xmppConnectionService) {
3145 loading();
3146 mTitle = title;
3147 mNode = node;
3148 this.xmppConnectionService = xmppConnectionService;
3149 ViewPager pager = mPager.get();
3150 if (pager != null) setupLayoutManager(pager.getContext());
3151 }
3152
3153 public String getTitle() {
3154 return mTitle;
3155 }
3156
3157 public String getNode() {
3158 return mNode;
3159 }
3160
3161 public void updateWithResponse(final Iq iq) {
3162 if (getView() != null && getView().isAttachedToWindow()) {
3163 getView().post(() -> updateWithResponseUiThread(iq));
3164 } else {
3165 pendingResponsePacket = iq;
3166 }
3167 }
3168
3169 protected void updateWithResponseUiThread(final Iq iq) {
3170 Timer oldTimer = this.loadingTimer;
3171 this.loadingTimer = new Timer();
3172 oldTimer.cancel();
3173 this.executing = false;
3174 this.loading = false;
3175 this.loadingHasBeenLong = false;
3176 this.responseElement = null;
3177 this.fillableFieldCount = 0;
3178 this.reported = null;
3179 this.response = iq;
3180 this.items.clear();
3181 this.actionsAdapter.clear();
3182 layoutManager.setSpanCount(1);
3183
3184 boolean actionsCleared = false;
3185 Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
3186 if (iq.getType() == Iq.Type.RESULT && command != null) {
3187 if (mNode.equals("jabber:iq:register") && command.getAttribute("status") != null && command.getAttribute("status").equals("completed")) {
3188 xmppConnectionService.createContact(getAccount().getRoster().getContact(iq.getFrom()));
3189 }
3190
3191 if (xmppConnectionService.isOnboarding() && mNode.equals("jabber:iq:register") && !"canceled".equals(command.getAttribute("status")) && xmppConnectionService.getPreferences().contains("onboarding_action")) {
3192 xmppConnectionService.getPreferences().edit().putBoolean("onboarding_continued", true).commit();
3193 }
3194
3195 Element actions = command.findChild("actions", "http://jabber.org/protocol/commands");
3196 if (actions != null) {
3197 for (Element action : actions.getChildren()) {
3198 if (!"http://jabber.org/protocol/commands".equals(action.getNamespace())) continue;
3199 if ("execute".equals(action.getName())) continue;
3200
3201 actionsAdapter.add(Pair.create(action.getName(), action.getName()));
3202 }
3203 }
3204
3205 for (Element el : command.getChildren()) {
3206 if ("x".equals(el.getName()) && "jabber:x:data".equals(el.getNamespace())) {
3207 Data form = Data.parse(el);
3208 String title = form.getTitle();
3209 if (title != null) {
3210 mTitle = title;
3211 ConversationPagerAdapter.this.notifyDataSetChanged();
3212 }
3213
3214 if ("result".equals(el.getAttribute("type")) || "form".equals(el.getAttribute("type"))) {
3215 this.responseElement = el;
3216 setupReported(el.findChild("reported", "jabber:x:data"));
3217 if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager(mBinding.getRoot().getContext()));
3218 }
3219
3220 eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
3221 if (actionList != null) {
3222 actionsAdapter.clear();
3223
3224 for (Option action : actionList.getOptions()) {
3225 actionsAdapter.add(Pair.create(action.getValue(), action.toString()));
3226 }
3227 }
3228
3229 eu.siacs.conversations.xmpp.forms.Field fillableField = null;
3230 for (eu.siacs.conversations.xmpp.forms.Field field : form.getFields()) {
3231 if ((field.getType() == null || (!field.getType().equals("hidden") && !field.getType().equals("fixed"))) && field.getFieldName() != null && !field.getFieldName().equals("http://jabber.org/protocol/commands#actions")) {
3232 final var validate = field.findChild("validate", "http://jabber.org/protocol/xdata-validate");
3233 final var range = validate == null ? null : validate.findChild("range", "http://jabber.org/protocol/xdata-validate");
3234 fillableField = range == null ? field : null;
3235 fillableFieldCount++;
3236 }
3237 }
3238
3239 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))) {
3240 actionsCleared = true;
3241 actionsAdapter.clearProceed();
3242 }
3243 break;
3244 }
3245 if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
3246 String url = el.findChildContent("url", "jabber:x:oob");
3247 if (url != null) {
3248 String scheme = Uri.parse(url).getScheme();
3249 if (scheme == null) {
3250 break;
3251 }
3252 if (scheme.equals("http") || scheme.equals("https")) {
3253 this.responseElement = el;
3254 break;
3255 }
3256 if (scheme.equals("xmpp")) {
3257 expectingRemoval = true;
3258 final Intent intent = new Intent(getView().getContext(), UriHandlerActivity.class);
3259 intent.setAction(Intent.ACTION_VIEW);
3260 intent.setData(Uri.parse(url));
3261 getView().getContext().startActivity(intent);
3262 break;
3263 }
3264 }
3265 }
3266 if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
3267 this.responseElement = el;
3268 break;
3269 }
3270 }
3271
3272 if (responseElement == null && command.getAttribute("status") != null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
3273 if ("jabber:iq:register".equals(mNode) && "canceled".equals(command.getAttribute("status"))) {
3274 if (xmppConnectionService.isOnboarding()) {
3275 if (xmppConnectionService.getPreferences().contains("onboarding_action")) {
3276 xmppConnectionService.deleteAccount(getAccount());
3277 } else {
3278 if (xmppConnectionService.getPreferences().getBoolean("onboarding_continued", false)) {
3279 removeSession(this);
3280 return;
3281 } else {
3282 xmppConnectionService.getPreferences().edit().putString("onboarding_action", "cancel").commit();
3283 xmppConnectionService.deleteAccount(getAccount());
3284 }
3285 }
3286 }
3287 xmppConnectionService.archiveConversation(Conversation.this);
3288 }
3289
3290 expectingRemoval = true;
3291 removeSession(this);
3292 return;
3293 }
3294
3295 if ("executing".equals(command.getAttribute("status")) && actionsAdapter.countExceptCancel() < 1 && !actionsCleared) {
3296 // No actions have been given, but we are not done?
3297 // This is probably a spec violation, but we should do *something*
3298 actionsAdapter.add(Pair.create("execute", "execute"));
3299 }
3300
3301 if (!actionsAdapter.isEmpty() || fillableFieldCount > 0) {
3302 if ("completed".equals(command.getAttribute("status")) || "canceled".equals(command.getAttribute("status"))) {
3303 actionsAdapter.add(Pair.create("close", "close"));
3304 } else if (actionsAdapter.getPosition("cancel") < 0 && !xmppConnectionService.isOnboarding()) {
3305 actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
3306 }
3307 }
3308 }
3309
3310 if (actionsAdapter.isEmpty()) {
3311 actionsAdapter.add(Pair.create("close", "close"));
3312 }
3313
3314 actionsAdapter.sort((x, y) -> {
3315 if (x.first.equals("cancel")) return -1;
3316 if (y.first.equals("cancel")) return 1;
3317 if (x.first.equals("prev") && xmppConnectionService.isOnboarding()) return -1;
3318 if (y.first.equals("prev") && xmppConnectionService.isOnboarding()) return 1;
3319 return 0;
3320 });
3321
3322 Data dataForm = null;
3323 if (responseElement != null && responseElement.getName().equals("x") && responseElement.getNamespace().equals("jabber:x:data")) dataForm = Data.parse(responseElement);
3324 if (mNode.equals("jabber:iq:register") &&
3325 xmppConnectionService.getPreferences().contains("onboarding_action") &&
3326 dataForm != null && dataForm.getFieldByName("gateway-jid") != null) {
3327
3328
3329 dataForm.put("gateway-jid", xmppConnectionService.getPreferences().getString("onboarding_action", ""));
3330 execute();
3331 }
3332 xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
3333 notifyDataSetChanged();
3334 }
3335
3336 protected void setupReported(Element el) {
3337 if (el == null) {
3338 reported = null;
3339 return;
3340 }
3341
3342 reported = new ArrayList<>();
3343 for (Element fieldEl : el.getChildren()) {
3344 if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
3345 reported.add(mkField(fieldEl));
3346 }
3347 }
3348
3349 @Override
3350 public int getItemCount() {
3351 if (loading) return 1;
3352 if (response == null) return 0;
3353 if (response.getType() == Iq.Type.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
3354 int i = 0;
3355 for (Element el : responseElement.getChildren()) {
3356 if (!el.getNamespace().equals("jabber:x:data")) continue;
3357 if (el.getName().equals("title")) continue;
3358 if (el.getName().equals("field")) {
3359 String type = el.getAttribute("type");
3360 if (type != null && type.equals("hidden")) continue;
3361 if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
3362 }
3363
3364 if (el.getName().equals("reported") || el.getName().equals("item")) {
3365 if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
3366 if (el.getName().equals("reported")) continue;
3367 i += 1;
3368 } else {
3369 if (reported != null) i += reported.size();
3370 }
3371 continue;
3372 }
3373
3374 i++;
3375 }
3376 return i;
3377 }
3378 return 1;
3379 }
3380
3381 public Item getItem(int position) {
3382 if (loading) return new Item(null, TYPE_PROGRESSBAR);
3383 if (items.get(position) != null) return items.get(position);
3384 if (response == null) return null;
3385
3386 if (response.getType() == Iq.Type.RESULT && responseElement != null) {
3387 if (responseElement.getNamespace().equals("jabber:x:data")) {
3388 int i = 0;
3389 for (Element el : responseElement.getChildren()) {
3390 if (!el.getNamespace().equals("jabber:x:data")) continue;
3391 if (el.getName().equals("title")) continue;
3392 if (el.getName().equals("field")) {
3393 String type = el.getAttribute("type");
3394 if (type != null && type.equals("hidden")) continue;
3395 if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
3396 }
3397
3398 if (el.getName().equals("reported") || el.getName().equals("item")) {
3399 Cell cell = null;
3400
3401 if (reported != null) {
3402 if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
3403 if (el.getName().equals("reported")) continue;
3404 if (i == position) {
3405 items.put(position, new Item(el, TYPE_ITEM_CARD));
3406 return items.get(position);
3407 }
3408 } else {
3409 if (reported.size() > position - i) {
3410 Field reportedField = reported.get(position - i);
3411 Element itemField = null;
3412 if (el.getName().equals("item")) {
3413 for (Element subel : el.getChildren()) {
3414 if (subel.getAttribute("var").equals(reportedField.getVar())) {
3415 itemField = subel;
3416 break;
3417 }
3418 }
3419 }
3420 cell = new Cell(reportedField, itemField);
3421 } else {
3422 i += reported.size();
3423 continue;
3424 }
3425 }
3426 }
3427
3428 if (cell != null) {
3429 items.put(position, cell);
3430 return cell;
3431 }
3432 }
3433
3434 if (i < position) {
3435 i++;
3436 continue;
3437 }
3438
3439 return mkItem(el, position);
3440 }
3441 }
3442 }
3443
3444 return mkItem(responseElement == null ? response : responseElement, position);
3445 }
3446
3447 @Override
3448 public int getItemViewType(int position) {
3449 return getItem(position).viewType;
3450 }
3451
3452 @Override
3453 public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
3454 switch(viewType) {
3455 case TYPE_ERROR: {
3456 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3457 return new ErrorViewHolder(binding);
3458 }
3459 case TYPE_NOTE: {
3460 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3461 return new NoteViewHolder(binding);
3462 }
3463 case TYPE_WEB: {
3464 CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
3465 return new WebViewHolder(binding);
3466 }
3467 case TYPE_RESULT_FIELD: {
3468 CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
3469 return new ResultFieldViewHolder(binding);
3470 }
3471 case TYPE_RESULT_CELL: {
3472 CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
3473 return new ResultCellViewHolder(binding);
3474 }
3475 case TYPE_ITEM_CARD: {
3476 CommandItemCardBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_item_card, container, false);
3477 return new ItemCardViewHolder(binding);
3478 }
3479 case TYPE_CHECKBOX_FIELD: {
3480 CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
3481 return new CheckboxFieldViewHolder(binding);
3482 }
3483 case TYPE_SEARCH_LIST_FIELD: {
3484 CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
3485 return new SearchListFieldViewHolder(binding);
3486 }
3487 case TYPE_RADIO_EDIT_FIELD: {
3488 CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
3489 return new RadioEditFieldViewHolder(binding);
3490 }
3491 case TYPE_SPINNER_FIELD: {
3492 CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
3493 return new SpinnerFieldViewHolder(binding);
3494 }
3495 case TYPE_BUTTON_GRID_FIELD: {
3496 CommandButtonGridFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_button_grid_field, container, false);
3497 return new ButtonGridFieldViewHolder(binding);
3498 }
3499 case TYPE_TEXT_FIELD: {
3500 CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
3501 return new TextFieldViewHolder(binding);
3502 }
3503 case TYPE_SLIDER_FIELD: {
3504 CommandSliderFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_slider_field, container, false);
3505 return new SliderFieldViewHolder(binding);
3506 }
3507 case TYPE_PROGRESSBAR: {
3508 CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
3509 return new ProgressBarViewHolder(binding);
3510 }
3511 default:
3512 if (expectingRemoval) {
3513 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3514 return new NoteViewHolder(binding);
3515 }
3516
3517 throw new IllegalArgumentException("Unknown viewType: " + viewType + " based on: " + response + ", " + responseElement + ", " + expectingRemoval);
3518 }
3519 }
3520
3521 @Override
3522 public void onBindViewHolder(ViewHolder viewHolder, int position) {
3523 viewHolder.bind(getItem(position));
3524 }
3525
3526 public View getView() {
3527 if (mBinding == null) return null;
3528 return mBinding.getRoot();
3529 }
3530
3531 public boolean validate() {
3532 int count = getItemCount();
3533 boolean isValid = true;
3534 for (int i = 0; i < count; i++) {
3535 boolean oneIsValid = getItem(i).validate();
3536 isValid = isValid && oneIsValid;
3537 }
3538 notifyDataSetChanged();
3539 return isValid;
3540 }
3541
3542 public boolean execute() {
3543 return execute("execute");
3544 }
3545
3546 public boolean execute(int actionPosition) {
3547 return execute(actionsAdapter.getItem(actionPosition).first);
3548 }
3549
3550 public synchronized boolean execute(String action) {
3551 if (!"cancel".equals(action) && executing) {
3552 loadingHasBeenLong = true;
3553 notifyDataSetChanged();
3554 return false;
3555 }
3556 if (!action.equals("cancel") && !action.equals("prev") && !validate()) return false;
3557
3558 if (response == null) return true;
3559 Element command = response.findChild("command", "http://jabber.org/protocol/commands");
3560 if (command == null) return true;
3561 String status = command.getAttribute("status");
3562 if (status == null || (!status.equals("executing") && !action.equals("prev"))) return true;
3563
3564 if (actionToWebview != null && !action.equals("cancel") && Build.VERSION.SDK_INT >= 23) {
3565 actionToWebview.postWebMessage(new WebMessage("xmpp_xep0050/" + action), Uri.parse("*"));
3566 return false;
3567 }
3568
3569 final var packet = new Iq(Iq.Type.SET);
3570 packet.setTo(response.getFrom());
3571 final Element c = packet.addChild("command", Namespace.COMMANDS);
3572 c.setAttribute("node", mNode);
3573 c.setAttribute("sessionid", command.getAttribute("sessionid"));
3574
3575 String formType = responseElement == null ? null : responseElement.getAttribute("type");
3576 if (!action.equals("cancel") &&
3577 !action.equals("prev") &&
3578 responseElement != null &&
3579 responseElement.getName().equals("x") &&
3580 responseElement.getNamespace().equals("jabber:x:data") &&
3581 formType != null && formType.equals("form")) {
3582
3583 Data form = Data.parse(responseElement);
3584 eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
3585 if (actionList != null) {
3586 actionList.setValue(action);
3587 c.setAttribute("action", "execute");
3588 }
3589
3590 if (mNode.equals("jabber:iq:register") && xmppConnectionService.isOnboarding() && form.getFieldByName("gateway-jid") != null) {
3591 if (form.getValue("gateway-jid") == null) {
3592 xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
3593 } else {
3594 xmppConnectionService.getPreferences().edit().putString("onboarding_action", form.getValue("gateway-jid")).commit();
3595 }
3596 }
3597
3598 responseElement.setAttribute("type", "submit");
3599 Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
3600 if (rsm != null) {
3601 Element max = new Element("max", "http://jabber.org/protocol/rsm");
3602 max.setContent("1000");
3603 rsm.addChild(max);
3604 }
3605
3606 c.addChild(responseElement);
3607 }
3608
3609 if (c.getAttribute("action") == null) c.setAttribute("action", action);
3610
3611 executing = true;
3612 xmppConnectionService.sendIqPacket(getAccount(), packet, (iq) -> {
3613 updateWithResponse(iq);
3614 }, 120L);
3615
3616 loading();
3617 return false;
3618 }
3619
3620 public void refresh() {
3621 synchronized(this) {
3622 if (waitingForRefresh) notifyDataSetChanged();
3623 }
3624 }
3625
3626 protected void loading() {
3627 View v = getView();
3628 try {
3629 loadingTimer.schedule(new TimerTask() {
3630 @Override
3631 public void run() {
3632 View v2 = getView();
3633 loading = true;
3634
3635 try {
3636 loadingTimer.schedule(new TimerTask() {
3637 @Override
3638 public void run() {
3639 loadingHasBeenLong = true;
3640 if (v == null && v2 == null) return;
3641 (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3642 }
3643 }, 3000);
3644 } catch (final IllegalStateException e) { }
3645
3646 if (v == null && v2 == null) return;
3647 (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3648 }
3649 }, 500);
3650 } catch (final IllegalStateException e) { }
3651 }
3652
3653 protected GridLayoutManager setupLayoutManager(final Context ctx) {
3654 int spanCount = 1;
3655
3656 if (reported != null) {
3657 float screenWidth = ctx.getResources().getDisplayMetrics().widthPixels;
3658 TextPaint paint = ((TextView) LayoutInflater.from(ctx).inflate(R.layout.command_result_cell, null)).getPaint();
3659 float tableHeaderWidth = reported.stream().reduce(
3660 0f,
3661 (total, field) -> total + StaticLayout.getDesiredWidth(field.getLabel().or("--------") + "\t", paint),
3662 (a, b) -> a + b
3663 );
3664
3665 spanCount = tableHeaderWidth > 0.59 * screenWidth ? 1 : this.reported.size();
3666 }
3667
3668 if (layoutManager != null && layoutManager.getSpanCount() != spanCount) {
3669 items.clear();
3670 notifyDataSetChanged();
3671 }
3672
3673 layoutManager = new GridLayoutManager(ctx, spanCount);
3674 layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
3675 @Override
3676 public int getSpanSize(int position) {
3677 if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
3678 return 1;
3679 }
3680 });
3681 return layoutManager;
3682 }
3683
3684 protected void setBinding(CommandPageBinding b) {
3685 mBinding = b;
3686 // https://stackoverflow.com/a/32350474/8611
3687 mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
3688 @Override
3689 public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
3690 if(rv.getChildCount() > 0) {
3691 int[] location = new int[2];
3692 rv.getLocationOnScreen(location);
3693 View childView = rv.findChildViewUnder(e.getX(), e.getY());
3694 if (childView instanceof ViewGroup) {
3695 childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
3696 }
3697 int action = e.getAction();
3698 switch (action) {
3699 case MotionEvent.ACTION_DOWN:
3700 if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(1)) || childView instanceof WebView) {
3701 rv.requestDisallowInterceptTouchEvent(true);
3702 }
3703 case MotionEvent.ACTION_UP:
3704 if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(-11)) || childView instanceof WebView) {
3705 rv.requestDisallowInterceptTouchEvent(true);
3706 }
3707 }
3708 }
3709
3710 return false;
3711 }
3712
3713 @Override
3714 public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }
3715
3716 @Override
3717 public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
3718 });
3719 mBinding.form.setLayoutManager(setupLayoutManager(mBinding.getRoot().getContext()));
3720 mBinding.form.setAdapter(this);
3721
3722 if (actionsAdapter == null) {
3723 actionsAdapter = new ActionsAdapter(mBinding.getRoot().getContext());
3724 actionsAdapter.registerDataSetObserver(new DataSetObserver() {
3725 @Override
3726 public void onChanged() {
3727 if (mBinding == null) return;
3728
3729 mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
3730 }
3731
3732 @Override
3733 public void onInvalidated() {}
3734 });
3735 }
3736
3737 mBinding.actions.setAdapter(actionsAdapter);
3738 mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
3739 if (execute(pos)) {
3740 removeSession(CommandSession.this);
3741 }
3742 });
3743
3744 actionsAdapter.notifyDataSetChanged();
3745
3746 if (pendingResponsePacket != null) {
3747 final var pending = pendingResponsePacket;
3748 pendingResponsePacket = null;
3749 updateWithResponseUiThread(pending);
3750 }
3751 }
3752
3753 private Drawable getDrawableForSVG(SVG svg, Element svgElement, int size) {
3754 if (svgElement != null && svgElement.getChildren().size() == 1 && svgElement.getChildren().get(0).getName().equals("image")) {
3755 return getDrawableForUrl(svgElement.getChildren().get(0).getAttribute("href"));
3756 } else {
3757 return xmppConnectionService.getFileBackend().drawSVG(svg, size);
3758 }
3759 }
3760
3761 private Drawable getDrawableForUrl(final String url) {
3762 final LruCache<String, Drawable> cache = xmppConnectionService.getDrawableCache();
3763 final HttpConnectionManager httpManager = xmppConnectionService.getHttpConnectionManager();
3764 final Drawable d = cache.get(url);
3765 if (Build.VERSION.SDK_INT >= 28 && d instanceof AnimatedImageDrawable) ((AnimatedImageDrawable) d).start();
3766 if (d == null) {
3767 synchronized (CommandSession.this) {
3768 waitingForRefresh = true;
3769 }
3770 int size = (int)(xmppConnectionService.getResources().getDisplayMetrics().density * 288);
3771 Message dummy = new Message(Conversation.this, url, Message.ENCRYPTION_NONE);
3772 dummy.setStatus(Message.STATUS_DUMMY);
3773 dummy.setFileParams(new Message.FileParams(url));
3774 httpManager.createNewDownloadConnection(dummy, true, (file) -> {
3775 if (file == null) {
3776 dummy.getTransferable().start();
3777 } else {
3778 try {
3779 xmppConnectionService.getFileBackend().getThumbnail(file, xmppConnectionService.getResources(), size, false, url);
3780 } catch (final Exception e) { }
3781 }
3782 });
3783 }
3784 return d;
3785 }
3786
3787 public View inflateUi(Context context, Consumer<ConversationPage> remover) {
3788 CommandPageBinding binding = DataBindingUtil.inflate(LayoutInflater.from(context), R.layout.command_page, null, false);
3789 setBinding(binding);
3790 return binding.getRoot();
3791 }
3792
3793 // https://stackoverflow.com/a/36037991/8611
3794 private View findViewAt(ViewGroup viewGroup, float x, float y) {
3795 for(int i = 0; i < viewGroup.getChildCount(); i++) {
3796 View child = viewGroup.getChildAt(i);
3797 if (child instanceof ViewGroup && !(child instanceof AbsListView) && !(child instanceof WebView)) {
3798 View foundView = findViewAt((ViewGroup) child, x, y);
3799 if (foundView != null && foundView.isShown()) {
3800 return foundView;
3801 }
3802 } else {
3803 int[] location = new int[2];
3804 child.getLocationOnScreen(location);
3805 Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
3806 if (rect.contains((int)x, (int)y)) {
3807 return child;
3808 }
3809 }
3810 }
3811
3812 return null;
3813 }
3814 }
3815
3816 class MucConfigSession extends CommandSession {
3817 MucConfigSession(XmppConnectionService xmppConnectionService) {
3818 super("Configure Channel", null, xmppConnectionService);
3819 }
3820
3821 @Override
3822 protected void updateWithResponseUiThread(final Iq iq) {
3823 Timer oldTimer = this.loadingTimer;
3824 this.loadingTimer = new Timer();
3825 oldTimer.cancel();
3826 this.executing = false;
3827 this.loading = false;
3828 this.loadingHasBeenLong = false;
3829 this.responseElement = null;
3830 this.fillableFieldCount = 0;
3831 this.reported = null;
3832 this.response = iq;
3833 this.items.clear();
3834 this.actionsAdapter.clear();
3835 layoutManager.setSpanCount(1);
3836
3837 final Element query = iq.findChild("query", "http://jabber.org/protocol/muc#owner");
3838 if (iq.getType() == Iq.Type.RESULT && query != null) {
3839 final Data form = Data.parse(query.findChild("x", "jabber:x:data"));
3840 final String title = form.getTitle();
3841 if (title != null) {
3842 mTitle = title;
3843 ConversationPagerAdapter.this.notifyDataSetChanged();
3844 }
3845
3846 this.responseElement = form;
3847 setupReported(form.findChild("reported", "jabber:x:data"));
3848 if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager(mBinding.getRoot().getContext()));
3849
3850 if (actionsAdapter.countExceptCancel() < 1) {
3851 actionsAdapter.add(Pair.create("save", "Save"));
3852 }
3853
3854 if (actionsAdapter.getPosition("cancel") < 0) {
3855 actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
3856 }
3857 } else if (iq.getType() == Iq.Type.RESULT) {
3858 expectingRemoval = true;
3859 removeSession(this);
3860 return;
3861 } else {
3862 actionsAdapter.add(Pair.create("close", "close"));
3863 }
3864
3865 notifyDataSetChanged();
3866 }
3867
3868 @Override
3869 public synchronized boolean execute(String action) {
3870 if ("cancel".equals(action)) {
3871 final var packet = new Iq(Iq.Type.SET);
3872 packet.setTo(response.getFrom());
3873 final Element form = packet
3874 .addChild("query", "http://jabber.org/protocol/muc#owner")
3875 .addChild("x", "jabber:x:data");
3876 form.setAttribute("type", "cancel");
3877 xmppConnectionService.sendIqPacket(getAccount(), packet, null);
3878 return true;
3879 }
3880
3881 if (!"save".equals(action)) return true;
3882
3883 final var packet = new Iq(Iq.Type.SET);
3884 packet.setTo(response.getFrom());
3885
3886 String formType = responseElement == null ? null : responseElement.getAttribute("type");
3887 if (responseElement != null &&
3888 responseElement.getName().equals("x") &&
3889 responseElement.getNamespace().equals("jabber:x:data") &&
3890 formType != null && formType.equals("form")) {
3891
3892 responseElement.setAttribute("type", "submit");
3893 packet
3894 .addChild("query", "http://jabber.org/protocol/muc#owner")
3895 .addChild(responseElement);
3896 }
3897
3898 executing = true;
3899 xmppConnectionService.sendIqPacket(getAccount(), packet, (iq) -> {
3900 updateWithResponse(iq);
3901 }, 120L);
3902
3903 loading();
3904
3905 return false;
3906 }
3907 }
3908 }
3909
3910 public static class Thread {
3911 protected Message subject = null;
3912 protected Message first = null;
3913 protected Message last = null;
3914 protected final String threadId;
3915
3916 protected Thread(final String threadId) {
3917 this.threadId = threadId;
3918 }
3919
3920 public String getThreadId() {
3921 return threadId;
3922 }
3923
3924 public String getSubject() {
3925 if (subject == null) return null;
3926
3927 return subject.getSubject();
3928 }
3929
3930 public String getDisplay() {
3931 final String s = getSubject();
3932 if (s != null) return s;
3933
3934 if (first != null) {
3935 return first.getBody();
3936 }
3937
3938 return "";
3939 }
3940
3941 public long getLastTime() {
3942 if (last == null) return 0;
3943
3944 return last.getTimeSent();
3945 }
3946 }
3947}