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 JidHelper.parseOrFallbackToInvalid(
291 cursor.getString(cursor.getColumnIndexOrThrow(CONTACTJID))),
292 cursor.getLong(cursor.getColumnIndexOrThrow(CREATED)),
293 cursor.getInt(cursor.getColumnIndexOrThrow(STATUS)),
294 cursor.getInt(cursor.getColumnIndexOrThrow(MODE)),
295 cursor.getString(cursor.getColumnIndexOrThrow(ATTRIBUTES)));
296 }
297
298 public static Message getLatestMarkableMessage(
299 final List<Message> messages, boolean isPrivateAndNonAnonymousMuc) {
300 for (int i = messages.size() - 1; i >= 0; --i) {
301 final Message message = messages.get(i);
302 if (message.getStatus() <= Message.STATUS_RECEIVED
303 && (message.markable || isPrivateAndNonAnonymousMuc)
304 && !message.isPrivateMessage()) {
305 return message;
306 }
307 }
308 return null;
309 }
310
311 private static boolean suitableForOmemoByDefault(final Conversation conversation) {
312 if (conversation.getJid().asBareJid().equals(Config.BUG_REPORTS)) {
313 return false;
314 }
315 if (conversation.getContact().isOwnServer()) {
316 return false;
317 }
318 final String contact = conversation.getJid().getDomain().toEscapedString();
319 final String account = conversation.getAccount().getServer();
320 if (Config.OMEMO_EXCEPTIONS.matchesContactDomain(contact)
321 || Config.OMEMO_EXCEPTIONS.ACCOUNT_DOMAINS.contains(account)) {
322 return false;
323 }
324 return conversation.isSingleOrPrivateAndNonAnonymous()
325 || conversation.getBooleanAttribute(
326 ATTRIBUTE_FORMERLY_PRIVATE_NON_ANONYMOUS, false);
327 }
328
329 public boolean hasMessagesLeftOnServer() {
330 return messagesLeftOnServer;
331 }
332
333 public void setHasMessagesLeftOnServer(boolean value) {
334 this.messagesLeftOnServer = value;
335 }
336
337 public Message getFirstUnreadMessage() {
338 Message first = null;
339 synchronized (this.messages) {
340 for (int i = messages.size() - 1; i >= 0; --i) {
341 final Message message = messages.get(i);
342 if (message.getSubject() != null && !message.isOOb() && (message.getRawBody() == null || message.getRawBody().length() == 0)) continue;
343 if ((message.getRawBody() == null || "".equals(message.getRawBody()) || " ".equals(message.getRawBody())) && message.getReply() != null && message.edited() && message.getHtml() != null) continue;
344 if (asReaction(message) != null) continue;
345 if (message.isRead()) {
346 return first;
347 } else {
348 first = message;
349 }
350 }
351 }
352 return first;
353 }
354
355 public String findMostRecentRemoteDisplayableId() {
356 final boolean multi = mode == Conversation.MODE_MULTI;
357 synchronized (this.messages) {
358 for (int i = messages.size() - 1; i >= 0; --i) {
359 final Message message = messages.get(i);
360 if (message.getSubject() != null && !message.isOOb() && (message.getRawBody() == null || message.getRawBody().length() == 0)) continue;
361 if ((message.getRawBody() == null || "".equals(message.getRawBody()) || " ".equals(message.getRawBody())) && message.getReply() != null && message.edited() && message.getHtml() != null) continue;
362 if (asReaction(message) != null) continue;
363 if (message.getStatus() == Message.STATUS_RECEIVED) {
364 final String serverMsgId = message.getServerMsgId();
365 if (serverMsgId != null && multi) {
366 return serverMsgId;
367 }
368 return message.getRemoteMsgId();
369 }
370 }
371 }
372 return null;
373 }
374
375 public int countFailedDeliveries() {
376 int count = 0;
377 synchronized (this.messages) {
378 for (final Message message : this.messages) {
379 if (message.getStatus() == Message.STATUS_SEND_FAILED) {
380 ++count;
381 }
382 }
383 }
384 return count;
385 }
386
387 public Message getLastEditableMessage() {
388 synchronized (this.messages) {
389 for (int i = messages.size() - 1; i >= 0; --i) {
390 final Message message = messages.get(i);
391 if (message.isEditable()) {
392 if (message.isGeoUri() || message.getType() != Message.TYPE_TEXT) {
393 return null;
394 }
395 return message;
396 }
397 }
398 }
399 return null;
400 }
401
402 public Message findUnsentMessageWithUuid(String uuid) {
403 synchronized (this.messages) {
404 for (final Message message : this.messages) {
405 final int s = message.getStatus();
406 if ((s == Message.STATUS_UNSEND || s == Message.STATUS_WAITING)
407 && message.getUuid().equals(uuid)) {
408 return message;
409 }
410 }
411 }
412 return null;
413 }
414
415 public void findWaitingMessages(OnMessageFound onMessageFound) {
416 final ArrayList<Message> results = new ArrayList<>();
417 synchronized (this.messages) {
418 for (Message message : this.messages) {
419 if (message.getStatus() == Message.STATUS_WAITING) {
420 results.add(message);
421 }
422 }
423 }
424 for (Message result : results) {
425 onMessageFound.onMessageFound(result);
426 }
427 }
428
429 public void findMessagesAndCallsToNotify(OnMessageFound onMessageFound) {
430 final ArrayList<Message> results = new ArrayList<>();
431 synchronized (this.messages) {
432 for (final Message message : this.messages) {
433 if (message.isRead() || message.notificationWasDismissed()) {
434 continue;
435 }
436 results.add(message);
437 }
438 }
439 for (final Message result : results) {
440 onMessageFound.onMessageFound(result);
441 }
442 }
443
444 public Message findMessageWithFileAndUuid(final String uuid) {
445 synchronized (this.messages) {
446 for (final Message message : this.messages) {
447 final Transferable transferable = message.getTransferable();
448 final boolean unInitiatedButKnownSize =
449 MessageUtils.unInitiatedButKnownSize(message);
450 if (message.getUuid().equals(uuid)
451 && message.getEncryption() != Message.ENCRYPTION_PGP
452 && (message.isFileOrImage()
453 || message.treatAsDownloadable()
454 || unInitiatedButKnownSize
455 || (transferable != null
456 && transferable.getStatus()
457 != Transferable.STATUS_UPLOADING))) {
458 return message;
459 }
460 }
461 }
462 return null;
463 }
464
465 public Message findMessageWithUuid(final String uuid) {
466 synchronized (this.messages) {
467 for (final Message message : this.messages) {
468 if (message.getUuid().equals(uuid)) {
469 return message;
470 }
471 }
472 }
473 return null;
474 }
475
476 public boolean markAsDeleted(final List<String> uuids) {
477 boolean deleted = false;
478 final PgpDecryptionService pgpDecryptionService = account.getPgpDecryptionService();
479 synchronized (this.messages) {
480 for (Message message : this.messages) {
481 if (uuids.contains(message.getUuid())) {
482 message.setDeleted(true);
483 deleted = true;
484 if (message.getEncryption() == Message.ENCRYPTION_PGP
485 && pgpDecryptionService != null) {
486 pgpDecryptionService.discard(message);
487 }
488 }
489 }
490 }
491 return deleted;
492 }
493
494 public boolean markAsChanged(final List<DatabaseBackend.FilePathInfo> files) {
495 boolean changed = false;
496 final PgpDecryptionService pgpDecryptionService = account.getPgpDecryptionService();
497 synchronized (this.messages) {
498 for (Message message : this.messages) {
499 for (final DatabaseBackend.FilePathInfo file : files)
500 if (file.uuid.toString().equals(message.getUuid())) {
501 message.setDeleted(file.deleted);
502 changed = true;
503 if (file.deleted
504 && message.getEncryption() == Message.ENCRYPTION_PGP
505 && pgpDecryptionService != null) {
506 pgpDecryptionService.discard(message);
507 }
508 }
509 }
510 }
511 return changed;
512 }
513
514 public void clearMessages() {
515 synchronized (this.messages) {
516 this.messages.clear();
517 }
518 }
519
520 public boolean setIncomingChatState(ChatState state) {
521 if (this.mIncomingChatState == state) {
522 return false;
523 }
524 this.mIncomingChatState = state;
525 return true;
526 }
527
528 public ChatState getIncomingChatState() {
529 return this.mIncomingChatState;
530 }
531
532 public boolean setOutgoingChatState(ChatState state) {
533 if (mode == MODE_SINGLE && !getContact().isSelf()
534 || (isPrivateAndNonAnonymous() && getNextCounterpart() == null)) {
535 if (this.mOutgoingChatState != state) {
536 this.mOutgoingChatState = state;
537 return true;
538 }
539 }
540 return false;
541 }
542
543 public ChatState getOutgoingChatState() {
544 return this.mOutgoingChatState;
545 }
546
547 public void trim() {
548 synchronized (this.messages) {
549 final int size = messages.size();
550 final int maxsize = Config.PAGE_SIZE * Config.MAX_NUM_PAGES;
551 if (size > maxsize) {
552 List<Message> discards = this.messages.subList(0, size - maxsize);
553 final PgpDecryptionService pgpDecryptionService = account.getPgpDecryptionService();
554 if (pgpDecryptionService != null) {
555 pgpDecryptionService.discard(discards);
556 }
557 discards.clear();
558 untieMessages();
559 }
560 }
561 }
562
563 public void findUnsentTextMessages(OnMessageFound onMessageFound) {
564 final ArrayList<Message> results = new ArrayList<>();
565 synchronized (this.messages) {
566 for (Message message : this.messages) {
567 if ((message.getType() == Message.TYPE_TEXT || message.hasFileOnRemoteHost())
568 && message.getStatus() == Message.STATUS_UNSEND) {
569 results.add(message);
570 }
571 }
572 }
573 for (Message result : results) {
574 onMessageFound.onMessageFound(result);
575 }
576 }
577
578 public Message findSentMessageWithUuidOrRemoteId(String id) {
579 synchronized (this.messages) {
580 for (Message message : this.messages) {
581 if (id.equals(message.getUuid())
582 || (message.getStatus() >= Message.STATUS_SEND
583 && id.equals(message.getRemoteMsgId()))) {
584 return message;
585 }
586 }
587 }
588 return null;
589 }
590
591 public Message findMessageWithUuidOrRemoteId(final String id) {
592 synchronized (this.messages) {
593 for (final Message message : this.messages) {
594 if (id.equals(message.getUuid()) || id.equals(message.getRemoteMsgId())) {
595 return message;
596 }
597 }
598 }
599 return null;
600 }
601
602 public Message findMessageWithRemoteIdAndCounterpart(String id, Jid counterpart) {
603 synchronized (this.messages) {
604 for (int i = this.messages.size() - 1; i >= 0; --i) {
605 final Message message = messages.get(i);
606 final Jid mcp = message.getCounterpart();
607 if (mcp == null && counterpart != null) {
608 continue;
609 }
610 if (counterpart == null || mcp.equals(counterpart) || mcp.asBareJid().equals(counterpart)) {
611 final boolean idMatch = id.equals(message.getUuid()) || id.equals(message.getRemoteMsgId()) || (getMode() == MODE_MULTI && id.equals(message.getServerMsgId()));
612 if (idMatch) return message;
613 }
614 }
615 }
616 return null;
617 }
618
619 public Message findSentMessageWithUuid(String id) {
620 synchronized (this.messages) {
621 for (Message message : this.messages) {
622 if (id.equals(message.getUuid())) {
623 return message;
624 }
625 }
626 }
627 return null;
628 }
629
630 public Message findMessageWithRemoteId(String id, Jid counterpart) {
631 synchronized (this.messages) {
632 for (Message message : this.messages) {
633 if (counterpart.equals(message.getCounterpart())
634 && (id.equals(message.getRemoteMsgId()) || id.equals(message.getUuid()))) {
635 return message;
636 }
637 }
638 }
639 return null;
640 }
641
642 public Message findReceivedWithRemoteId(final String id) {
643 synchronized (this.messages) {
644 for (final Message message : this.messages) {
645 if (message.getStatus() == Message.STATUS_RECEIVED
646 && id.equals(message.getRemoteMsgId())) {
647 return message;
648 }
649 }
650 }
651 return null;
652 }
653
654 public Message findMessageWithServerMsgId(String id) {
655 synchronized (this.messages) {
656 for (Message message : this.messages) {
657 if (id != null && id.equals(message.getServerMsgId())) {
658 return message;
659 }
660 }
661 }
662 return null;
663 }
664
665 public boolean hasMessageWithCounterpart(Jid counterpart) {
666 synchronized (this.messages) {
667 for (Message message : this.messages) {
668 if (counterpart.equals(message.getCounterpart())) {
669 return true;
670 }
671 }
672 }
673 return false;
674 }
675
676 public Message findMessageReactingTo(String id, Jid reactor) {
677 if (id == null) return null;
678
679 synchronized (this.messages) {
680 for (int i = this.messages.size() - 1; i >= 0; --i) {
681 final Message message = messages.get(i);
682 if (reactor == null && message.getStatus() < Message.STATUS_SEND) continue;
683 if (reactor != null && message.getCounterpart() == null) continue;
684 if (reactor != null && !(message.getCounterpart().equals(reactor) || message.getCounterpart().asBareJid().equals(reactor))) continue;
685
686 final Element r = message.getReactionsEl();
687 if (r != null && r.getAttribute("id") != null && id.equals(r.getAttribute("id"))) {
688 return message;
689 }
690 }
691 }
692 return null;
693 }
694
695 public List<Message> findMessagesBy(MucOptions.User user) {
696 List<Message> result = new ArrayList<>();
697 synchronized (this.messages) {
698 for (Message m : this.messages) {
699 // occupant id?
700 final Jid trueCp = m.getTrueCounterpart();
701 if (m.getCounterpart().equals(user.getFullJid()) || (trueCp != null && trueCp.equals(user.getRealJid()))) {
702 result.add(m);
703 }
704 }
705 }
706 return result;
707 }
708
709 public Set<String> findReactionsTo(String id, Jid reactor) {
710 Set<String> reactionEmoji = new HashSet<>();
711 Message reactM = findMessageReactingTo(id, reactor);
712 Element reactions = reactM == null ? null : reactM.getReactionsEl();
713 if (reactions != null) {
714 for (Element el : reactions.getChildren()) {
715 if (el.getName().equals("reaction") && el.getNamespace().equals("urn:xmpp:reactions:0")) {
716 reactionEmoji.add(el.getContent());
717 }
718 }
719 }
720 return reactionEmoji;
721 }
722
723 public Set<Message> findReplies(String id) {
724 Set<Message> replies = new HashSet<>();
725 if (id == null) return replies;
726
727 synchronized (this.messages) {
728 for (int i = this.messages.size() - 1; i >= 0; --i) {
729 final Message message = messages.get(i);
730 if (id.equals(message.getServerMsgId())) break;
731 if (id.equals(message.getUuid())) break;
732 final Element r = message.getReply();
733 if (r != null && r.getAttribute("id") != null && id.equals(r.getAttribute("id"))) {
734 replies.add(message);
735 }
736 }
737 }
738 return replies;
739 }
740
741 public long loadMoreTimestamp() {
742 if (messages.size() < 1) return 0;
743 if (getLockThread() && messages.size() > 5000) return 0;
744
745 if (messages.get(0).getType() == Message.TYPE_STATUS && messages.size() >= 2) {
746 return messages.get(1).getTimeSent();
747 } else {
748 return messages.get(0).getTimeSent();
749 }
750 }
751
752 public void populateWithMessages(final List<Message> messages, XmppConnectionService xmppConnectionService) {
753 synchronized (this.messages) {
754 messages.clear();
755 messages.addAll(this.messages);
756 threads.clear();
757 reactions.clear();
758 }
759 Set<String> extraIds = new HashSet<>();
760 for (ListIterator<Message> iterator = messages.listIterator(messages.size()); iterator.hasPrevious(); ) {
761 Message m = iterator.previous();
762 final Element mthread = m.getThread();
763 if (mthread != null) {
764 Thread thread = threads.get(mthread.getContent());
765 if (thread == null) {
766 thread = new Thread(mthread.getContent());
767 threads.put(mthread.getContent(), thread);
768 }
769 if (thread.subject == null && (m.getSubject() != null && !m.isOOb() && (m.getRawBody() == null || m.getRawBody().length() == 0))) {
770 thread.subject = m;
771 } else {
772 if (thread.last == null) thread.last = m;
773 thread.first = m;
774 }
775 }
776
777 if ((m.getRawBody() == null || "".equals(m.getRawBody()) || " ".equals(m.getRawBody())) && m.getReply() != null && m.edited() && m.getHtml() != null) {
778 iterator.remove();
779 continue;
780 }
781
782 final var asReaction = asReaction(m);
783 if (asReaction != null) {
784 reactions.put(asReaction.first, asReaction.second);
785 iterator.remove();
786 } 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())))) {
787 iterator.remove();
788 } else if (getLockThread() && mthread != null) {
789 final var reply = m.getReply();
790 if (reply != null && reply.getAttribute("id") != null) extraIds.add(reply.getAttribute("id"));
791 Element reactions = m.getReactionsEl();
792 if (reactions != null && reactions.getAttribute("id") != null) extraIds.add(reactions.getAttribute("id"));
793 }
794 }
795 }
796
797 protected Pair<String, Reaction> asReaction(Message m) {
798 final var reply = m.getReply();
799 if (reply != null && reply.getAttribute("id") != null) {
800 final String envelopeId;
801 if (m.isCarbon() || m.getStatus() == Message.STATUS_RECEIVED) {
802 envelopeId = m.getRemoteMsgId();
803 } else {
804 envelopeId = m.getUuid();
805 }
806
807 final var body = m.getBody(true).toString().replaceAll("\\s", "");
808 if (Emoticons.isEmoji(body)) {
809 return new Pair<>(reply.getAttribute("id"), new Reaction(body, null, m.getStatus() <= Message.STATUS_RECEIVED, m.getCounterpart(), m.getTrueCounterpart(), m.getOccupantId(), envelopeId));
810 } else {
811 final var html = m.getHtml();
812 if (html == null) return null;
813
814 SpannableStringBuilder spannable = m.getSpannableBody(null, null, false);
815 ImageSpan[] imageSpans = spannable.getSpans(0, spannable.length(), ImageSpan.class);
816 for (ImageSpan span : imageSpans) {
817 final int start = spannable.getSpanStart(span);
818 final int end = spannable.getSpanEnd(span);
819 spannable.delete(start, end);
820 }
821 if (imageSpans.length == 1 && spannable.toString().replaceAll("\\s", "").length() < 1) {
822 // Only one inline image, so it's a custom emoji by itself as a reply/reaction
823 final var source = imageSpans[0].getSource();
824 var shortcode = "";
825 final var img = html.findChild("img");
826 if (img != null) {
827 shortcode = img.getAttribute("alt").replaceAll("(^:)|(:$)", "");
828 }
829 if (source != null && source.length() > 0 && source.substring(0, 4).equals("cid:")) {
830 final Cid cid = BobTransfer.cid(Uri.parse(source));
831 return new Pair<>(reply.getAttribute("id"), new Reaction(shortcode, cid, m.getStatus() <= Message.STATUS_RECEIVED, m.getCounterpart(), m.getTrueCounterpart(), m.getOccupantId(), envelopeId));
832 }
833 }
834 }
835 }
836 return null;
837 }
838
839 public Reaction.Aggregated aggregatedReactionsFor(Message m, Function<Reaction, GetThumbnailForCid> thumbnailer) {
840 Set<Reaction> result = new HashSet<>();
841 if (getMode() == MODE_MULTI && !m.isPrivateMessage()) {
842 result.addAll(reactions.get(m.getServerMsgId()));
843 } else if (m.getStatus() > Message.STATUS_RECEIVED) {
844 result.addAll(reactions.get(m.getUuid()));
845 } else {
846 result.addAll(reactions.get(m.getRemoteMsgId()));
847 }
848 result.addAll(m.getReactions());
849 return Reaction.aggregated(result, thumbnailer);
850 }
851
852 public Thread getThread(String id) {
853 return threads.get(id);
854 }
855
856 public List<Thread> recentThreads() {
857 final ArrayList<Thread> recent = new ArrayList<>();
858 recent.addAll(threads.values());
859 recent.sort((a, b) -> b.getLastTime() == a.getLastTime() ? 0 : (b.getLastTime() > a.getLastTime() ? 1 : -1));
860 return recent.size() < 5 ? recent : recent.subList(0, 5);
861 }
862
863 @Override
864 public boolean isBlocked() {
865 return getContact().isBlocked();
866 }
867
868 @Override
869 public boolean isDomainBlocked() {
870 return getContact().isDomainBlocked();
871 }
872
873 @Override
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 new FixedURLSpan("xmpp:" + Uri.encode(Jid.ofEscaped(values.getItem(pos).getValue()).toEscapedString(), "@/+"), account).onClick(binding.values);
2210 });
2211 } else if ("xs:anyURI".equals(datatype)) {
2212 binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
2213 new FixedURLSpan(values.getItem(pos).getValue(), account).onClick(binding.values);
2214 });
2215 } else if ("html:tel".equals(datatype)) {
2216 binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
2217 try {
2218 new FixedURLSpan("tel:" + PhoneNumberUtilWrapper.normalize(binding.getRoot().getContext(), values.getItem(pos).getValue()), account).onClick(binding.values);
2219 } catch (final IllegalArgumentException | NumberParseException | NullPointerException e) { }
2220 });
2221 }
2222
2223 binding.values.setOnItemLongClickListener((arg0, arg1, pos, id) -> {
2224 if (ShareUtil.copyTextToClipboard(binding.getRoot().getContext(), values.getItem(pos).getValue(), R.string.message)) {
2225 Toast.makeText(binding.getRoot().getContext(), R.string.message_copied_to_clipboard, Toast.LENGTH_SHORT).show();
2226 }
2227 return true;
2228 });
2229 }
2230 }
2231
2232 class ResultCellViewHolder extends ViewHolder<CommandResultCellBinding> {
2233 public ResultCellViewHolder(CommandResultCellBinding binding) { super(binding); }
2234
2235 @Override
2236 public void bind(Item item) {
2237 Cell cell = (Cell) item;
2238
2239 if (cell.el == null) {
2240 binding.text.setTextAppearance(binding.getRoot().getContext(), com.google.android.material.R.style.TextAppearance_Material3_TitleMedium);
2241 setTextOrHide(binding.text, cell.reported.getLabel());
2242 } else {
2243 Element validate = cell.reported.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2244 String datatype = validate == null ? null : validate.getAttribute("datatype");
2245 String value = formatValue(datatype, cell.el.findChildContent("value", "jabber:x:data"), true);
2246 SpannableStringBuilder text = new SpannableStringBuilder(value == null ? "" : value);
2247 if (cell.reported.getType().equals(Optional.of("jid-single"))) {
2248 text.setSpan(new FixedURLSpan("xmpp:" + Jid.ofEscaped(text.toString()).toEscapedString(), account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
2249 } else if ("xs:anyURI".equals(datatype)) {
2250 text.setSpan(new FixedURLSpan(text.toString(), account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
2251 } else if ("html:tel".equals(datatype)) {
2252 try {
2253 text.setSpan(new FixedURLSpan("tel:" + PhoneNumberUtilWrapper.normalize(binding.getRoot().getContext(), text.toString()), account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
2254 } catch (final IllegalArgumentException | NumberParseException | NullPointerException e) { }
2255 }
2256
2257 binding.text.setTextAppearance(binding.getRoot().getContext(), com.google.android.material.R.style.TextAppearance_Material3_BodyMedium);
2258 binding.text.setText(text);
2259
2260 BetterLinkMovementMethod method = BetterLinkMovementMethod.newInstance();
2261 method.setOnLinkLongClickListener((tv, url) -> {
2262 tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
2263 ShareUtil.copyLinkToClipboard(binding.getRoot().getContext(), url);
2264 return true;
2265 });
2266 binding.text.setMovementMethod(method);
2267 }
2268 }
2269 }
2270
2271 class ItemCardViewHolder extends ViewHolder<CommandItemCardBinding> {
2272 public ItemCardViewHolder(CommandItemCardBinding binding) { super(binding); }
2273
2274 @Override
2275 public void bind(Item item) {
2276 binding.fields.removeAllViews();
2277
2278 for (Field field : reported) {
2279 CommandResultFieldBinding row = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.command_result_field, binding.fields, false);
2280 GridLayout.LayoutParams param = new GridLayout.LayoutParams();
2281 param.columnSpec = GridLayout.spec(GridLayout.UNDEFINED, GridLayout.FILL, 1f);
2282 param.width = 0;
2283 row.getRoot().setLayoutParams(param);
2284 binding.fields.addView(row.getRoot());
2285 for (Element el : item.el.getChildren()) {
2286 if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data") && el.getAttribute("var") != null && el.getAttribute("var").equals(field.getVar())) {
2287 for (String label : field.getLabel().asSet()) {
2288 el.setAttribute("label", label);
2289 }
2290 for (String desc : field.getDesc().asSet()) {
2291 el.setAttribute("desc", desc);
2292 }
2293 for (String type : field.getType().asSet()) {
2294 el.setAttribute("type", type);
2295 }
2296 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2297 if (validate != null) el.addChild(validate);
2298 new ResultFieldViewHolder(row).bind(new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), -1));
2299 }
2300 }
2301 }
2302 }
2303 }
2304
2305 class CheckboxFieldViewHolder extends ViewHolder<CommandCheckboxFieldBinding> implements CompoundButton.OnCheckedChangeListener {
2306 public CheckboxFieldViewHolder(CommandCheckboxFieldBinding binding) {
2307 super(binding);
2308 binding.row.setOnClickListener((v) -> {
2309 binding.checkbox.toggle();
2310 });
2311 binding.checkbox.setOnCheckedChangeListener(this);
2312 }
2313 protected Element mValue = null;
2314
2315 @Override
2316 public void bind(Item item) {
2317 Field field = (Field) item;
2318 binding.label.setText(field.getLabel().or(""));
2319 setTextOrHide(binding.desc, field.getDesc());
2320 mValue = field.getValue();
2321 final var isChecked = mValue.getContent() != null && (mValue.getContent().equals("true") || mValue.getContent().equals("1"));
2322 mValue.setContent(isChecked ? "true" : "false");
2323 binding.checkbox.setChecked(isChecked);
2324 }
2325
2326 @Override
2327 public void onCheckedChanged(CompoundButton checkbox, boolean isChecked) {
2328 if (mValue == null) return;
2329
2330 mValue.setContent(isChecked ? "true" : "false");
2331 }
2332 }
2333
2334 class SearchListFieldViewHolder extends ViewHolder<CommandSearchListFieldBinding> implements TextWatcher {
2335 public SearchListFieldViewHolder(CommandSearchListFieldBinding binding) {
2336 super(binding);
2337 binding.search.addTextChangedListener(this);
2338 }
2339 protected Field field = null;
2340 Set<String> filteredValues;
2341 List<Option> options = new ArrayList<>();
2342 protected ArrayAdapter<Option> adapter;
2343 protected boolean open;
2344 protected boolean multi;
2345 protected int textColor = -1;
2346
2347 @Override
2348 public void bind(Item item) {
2349 ViewGroup.LayoutParams layout = binding.list.getLayoutParams();
2350 final float density = xmppConnectionService.getResources().getDisplayMetrics().density;
2351 if (fillableFieldCount > 1) {
2352 layout.height = (int) (density * 200);
2353 } else {
2354 layout.height = (int) Math.max(density * 200, xmppConnectionService.getResources().getDisplayMetrics().heightPixels / 2);
2355 }
2356 binding.list.setLayoutParams(layout);
2357
2358 field = (Field) item;
2359 setTextOrHide(binding.label, field.getLabel());
2360 setTextOrHide(binding.desc, field.getDesc());
2361
2362 if (textColor == -1) textColor = binding.desc.getCurrentTextColor();
2363 if (field.error != null) {
2364 binding.desc.setVisibility(View.VISIBLE);
2365 binding.desc.setText(field.error);
2366 binding.desc.setTextColor(com.google.android.material.R.attr.colorError);
2367 } else {
2368 binding.desc.setTextColor(textColor);
2369 }
2370
2371 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2372 open = validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null;
2373 setupInputType(field.el, binding.search, null);
2374
2375 multi = field.getType().equals(Optional.of("list-multi"));
2376 if (multi) {
2377 binding.list.setChoiceMode(AbsListView.CHOICE_MODE_MULTIPLE);
2378 } else {
2379 binding.list.setChoiceMode(AbsListView.CHOICE_MODE_SINGLE);
2380 }
2381
2382 options = field.getOptions();
2383 binding.list.setOnItemClickListener((parent, view, position, id) -> {
2384 Set<String> values = new HashSet<>();
2385 if (multi) {
2386 values.addAll(field.getValues());
2387 for (final String value : field.getValues()) {
2388 if (filteredValues.contains(value)) {
2389 values.remove(value);
2390 }
2391 }
2392 }
2393
2394 SparseBooleanArray positions = binding.list.getCheckedItemPositions();
2395 for (int i = 0; i < positions.size(); i++) {
2396 if (positions.valueAt(i)) values.add(adapter.getItem(positions.keyAt(i)).getValue());
2397 }
2398 field.setValues(values);
2399
2400 if (!multi && open) binding.search.setText(String.join("\n", field.getValues()));
2401 });
2402 search("");
2403 }
2404
2405 @Override
2406 public void afterTextChanged(Editable s) {
2407 if (!multi && open) field.setValues(List.of(s.toString()));
2408 search(s.toString());
2409 }
2410
2411 @Override
2412 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2413
2414 @Override
2415 public void onTextChanged(CharSequence s, int start, int count, int after) { }
2416
2417 protected void search(String s) {
2418 List<Option> filteredOptions;
2419 final String q = s.replaceAll("\\W", "").toLowerCase();
2420 if (q == null || q.equals("")) {
2421 filteredOptions = options;
2422 } else {
2423 filteredOptions = options.stream().filter(o -> o.toString().replaceAll("\\W", "").toLowerCase().contains(q)).collect(Collectors.toList());
2424 }
2425 filteredValues = filteredOptions.stream().map(o -> o.getValue()).collect(Collectors.toSet());
2426 adapter = new ArrayAdapter(binding.getRoot().getContext(), R.layout.simple_list_item, filteredOptions);
2427 binding.list.setAdapter(adapter);
2428
2429 for (final String value : field.getValues()) {
2430 int checkedPos = filteredOptions.indexOf(new Option(value, ""));
2431 if (checkedPos >= 0) binding.list.setItemChecked(checkedPos, true);
2432 }
2433 }
2434 }
2435
2436 class RadioEditFieldViewHolder extends ViewHolder<CommandRadioEditFieldBinding> implements CompoundButton.OnCheckedChangeListener, TextWatcher {
2437 public RadioEditFieldViewHolder(CommandRadioEditFieldBinding binding) {
2438 super(binding);
2439 binding.open.addTextChangedListener(this);
2440 options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.radio_grid_item) {
2441 @Override
2442 public View getView(int position, View convertView, ViewGroup parent) {
2443 CompoundButton v = (CompoundButton) super.getView(position, convertView, parent);
2444 v.setId(position);
2445 v.setChecked(getItem(position).getValue().equals(mValue.getContent()));
2446 v.setOnCheckedChangeListener(RadioEditFieldViewHolder.this);
2447 return v;
2448 }
2449 };
2450 }
2451 protected Element mValue = null;
2452 protected ArrayAdapter<Option> options;
2453 protected int textColor = -1;
2454
2455 @Override
2456 public void bind(Item item) {
2457 Field field = (Field) item;
2458 setTextOrHide(binding.label, field.getLabel());
2459 setTextOrHide(binding.desc, field.getDesc());
2460
2461 if (textColor == -1) textColor = binding.desc.getCurrentTextColor();
2462 if (field.error != null) {
2463 binding.desc.setVisibility(View.VISIBLE);
2464 binding.desc.setText(field.error);
2465 binding.desc.setTextColor(com.google.android.material.R.attr.colorError);
2466 } else {
2467 binding.desc.setTextColor(textColor);
2468 }
2469
2470 mValue = field.getValue();
2471
2472 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2473 binding.open.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
2474 binding.open.setText(mValue.getContent());
2475 setupInputType(field.el, binding.open, null);
2476
2477 options.clear();
2478 List<Option> theOptions = field.getOptions();
2479 options.addAll(theOptions);
2480
2481 float screenWidth = binding.getRoot().getContext().getResources().getDisplayMetrics().widthPixels;
2482 TextPaint paint = ((TextView) LayoutInflater.from(binding.getRoot().getContext()).inflate(R.layout.radio_grid_item, null)).getPaint();
2483 float maxColumnWidth = theOptions.stream().map((x) ->
2484 StaticLayout.getDesiredWidth(x.toString(), paint)
2485 ).max(Float::compare).orElse(new Float(0.0));
2486 if (maxColumnWidth * theOptions.size() < 0.90 * screenWidth) {
2487 binding.radios.setNumColumns(theOptions.size());
2488 } else if (maxColumnWidth * (theOptions.size() / 2) < 0.90 * screenWidth) {
2489 binding.radios.setNumColumns(theOptions.size() / 2);
2490 } else {
2491 binding.radios.setNumColumns(1);
2492 }
2493 binding.radios.setAdapter(options);
2494 }
2495
2496 @Override
2497 public void onCheckedChanged(CompoundButton radio, boolean isChecked) {
2498 if (mValue == null) return;
2499
2500 if (isChecked) {
2501 mValue.setContent(options.getItem(radio.getId()).getValue());
2502 binding.open.setText(mValue.getContent());
2503 }
2504 options.notifyDataSetChanged();
2505 }
2506
2507 @Override
2508 public void afterTextChanged(Editable s) {
2509 if (mValue == null) return;
2510
2511 mValue.setContent(s.toString());
2512 options.notifyDataSetChanged();
2513 }
2514
2515 @Override
2516 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2517
2518 @Override
2519 public void onTextChanged(CharSequence s, int start, int count, int after) { }
2520 }
2521
2522 class SpinnerFieldViewHolder extends ViewHolder<CommandSpinnerFieldBinding> implements AdapterView.OnItemSelectedListener {
2523 public SpinnerFieldViewHolder(CommandSpinnerFieldBinding binding) {
2524 super(binding);
2525 binding.spinner.setOnItemSelectedListener(this);
2526 }
2527 protected Element mValue = null;
2528
2529 @Override
2530 public void bind(Item item) {
2531 Field field = (Field) item;
2532 setTextOrHide(binding.label, field.getLabel());
2533 binding.spinner.setPrompt(field.getLabel().or(""));
2534 setTextOrHide(binding.desc, field.getDesc());
2535
2536 mValue = field.getValue();
2537
2538 ArrayAdapter<Option> options = new ArrayAdapter<Option>(binding.getRoot().getContext(), android.R.layout.simple_spinner_item);
2539 options.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
2540 options.addAll(field.getOptions());
2541
2542 binding.spinner.setAdapter(options);
2543 binding.spinner.setSelection(options.getPosition(new Option(mValue.getContent(), null)));
2544 }
2545
2546 @Override
2547 public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
2548 Option o = (Option) parent.getItemAtPosition(pos);
2549 if (mValue == null) return;
2550
2551 mValue.setContent(o == null ? "" : o.getValue());
2552 }
2553
2554 @Override
2555 public void onNothingSelected(AdapterView<?> parent) {
2556 mValue.setContent("");
2557 }
2558 }
2559
2560 class ButtonGridFieldViewHolder extends ViewHolder<CommandButtonGridFieldBinding> {
2561 public ButtonGridFieldViewHolder(CommandButtonGridFieldBinding binding) {
2562 super(binding);
2563 options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.button_grid_item) {
2564 protected int height = 0;
2565
2566 @Override
2567 public View getView(int position, View convertView, ViewGroup parent) {
2568 Button v = (Button) super.getView(position, convertView, parent);
2569 v.setOnClickListener((view) -> {
2570 mValue.setContent(getItem(position).getValue());
2571 execute();
2572 loading = true;
2573 });
2574
2575 final SVG icon = getItem(position).getIcon();
2576 if (icon != null) {
2577 final Element iconEl = getItem(position).getIconEl();
2578 if (height < 1) {
2579 v.measure(0, 0);
2580 height = v.getMeasuredHeight();
2581 }
2582 if (height < 1) return v;
2583 if (mediaSelector) {
2584 final Drawable d = getDrawableForSVG(icon, iconEl, height * 4);
2585 if (d != null) {
2586 final int boundsHeight = 35 + (int)((height * 4) / xmppConnectionService.getResources().getDisplayMetrics().density);
2587 d.setBounds(0, 0, d.getIntrinsicWidth(), boundsHeight);
2588 }
2589 v.setCompoundDrawables(null, d, null, null);
2590 } else {
2591 v.setCompoundDrawablesRelativeWithIntrinsicBounds(getDrawableForSVG(icon, iconEl, height), null, null, null);
2592 }
2593 }
2594
2595 return v;
2596 }
2597 };
2598 }
2599 protected Element mValue = null;
2600 protected ArrayAdapter<Option> options;
2601 protected Option defaultOption = null;
2602 protected boolean mediaSelector = false;
2603 protected int textColor = -1;
2604
2605 @Override
2606 public void bind(Item item) {
2607 Field field = (Field) item;
2608 setTextOrHide(binding.label, field.getLabel());
2609 setTextOrHide(binding.desc, field.getDesc());
2610
2611 if (textColor == -1) textColor = binding.desc.getCurrentTextColor();
2612 if (field.error != null) {
2613 binding.desc.setVisibility(View.VISIBLE);
2614 binding.desc.setText(field.error);
2615 binding.desc.setTextColor(com.google.android.material.R.attr.colorError);
2616 } else {
2617 binding.desc.setTextColor(textColor);
2618 }
2619
2620 mValue = field.getValue();
2621 mediaSelector = field.el.findChild("media-selector", "https://ns.cheogram.com/") != null;
2622
2623 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2624 binding.openButton.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
2625 binding.openButton.setOnClickListener((view) -> {
2626 AlertDialog.Builder builder = new AlertDialog.Builder(binding.getRoot().getContext());
2627 DialogQuickeditBinding dialogBinding = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.dialog_quickedit, null, false);
2628 builder.setPositiveButton(R.string.action_execute, null);
2629 if (field.getDesc().isPresent()) {
2630 dialogBinding.inputLayout.setHint(field.getDesc().get());
2631 }
2632 dialogBinding.inputEditText.requestFocus();
2633 dialogBinding.inputEditText.getText().append(mValue.getContent());
2634 builder.setView(dialogBinding.getRoot());
2635 builder.setNegativeButton(R.string.cancel, null);
2636 final AlertDialog dialog = builder.create();
2637 dialog.setOnShowListener(d -> SoftKeyboardUtils.showKeyboard(dialogBinding.inputEditText));
2638 dialog.show();
2639 View.OnClickListener clickListener = v -> {
2640 String value = dialogBinding.inputEditText.getText().toString();
2641 mValue.setContent(value);
2642 SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2643 dialog.dismiss();
2644 execute();
2645 loading = true;
2646 };
2647 dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener(clickListener);
2648 dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setOnClickListener((v -> {
2649 SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2650 dialog.dismiss();
2651 }));
2652 dialog.setCanceledOnTouchOutside(false);
2653 dialog.setOnDismissListener(dialog1 -> {
2654 SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2655 });
2656 });
2657
2658 options.clear();
2659 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();
2660
2661 defaultOption = null;
2662 for (Option option : theOptions) {
2663 if (option.getValue().equals(mValue.getContent())) {
2664 defaultOption = option;
2665 break;
2666 }
2667 }
2668 if (defaultOption == null && !mValue.getContent().equals("")) {
2669 // Synthesize default option for custom value
2670 defaultOption = new Option(mValue.getContent(), mValue.getContent());
2671 }
2672 if (defaultOption == null) {
2673 binding.defaultButton.setVisibility(View.GONE);
2674 } else {
2675 theOptions.remove(defaultOption);
2676 binding.defaultButton.setVisibility(View.VISIBLE);
2677
2678 final SVG defaultIcon = defaultOption.getIcon();
2679 if (defaultIcon != null) {
2680 DisplayMetrics display = mPager.get().getContext().getResources().getDisplayMetrics();
2681 int height = (int)(display.heightPixels*display.density/4);
2682 binding.defaultButton.setCompoundDrawablesRelativeWithIntrinsicBounds(null, getDrawableForSVG(defaultIcon, defaultOption.getIconEl(), height), null, null);
2683 }
2684
2685 binding.defaultButton.setText(defaultOption.toString());
2686 binding.defaultButton.setOnClickListener((view) -> {
2687 mValue.setContent(defaultOption.getValue());
2688 execute();
2689 loading = true;
2690 });
2691 }
2692
2693 options.addAll(theOptions);
2694 binding.buttons.setAdapter(options);
2695 }
2696 }
2697
2698 class TextFieldViewHolder extends ViewHolder<CommandTextFieldBinding> implements TextWatcher {
2699 public TextFieldViewHolder(CommandTextFieldBinding binding) {
2700 super(binding);
2701 binding.textinput.addTextChangedListener(this);
2702 }
2703 protected Field field = null;
2704
2705 @Override
2706 public void bind(Item item) {
2707 field = (Field) item;
2708 binding.textinputLayout.setHint(field.getLabel().or(""));
2709
2710 binding.textinputLayout.setHelperTextEnabled(field.getDesc().isPresent());
2711 for (String desc : field.getDesc().asSet()) {
2712 binding.textinputLayout.setHelperText(desc);
2713 }
2714
2715 binding.textinputLayout.setErrorEnabled(field.error != null);
2716 if (field.error != null) binding.textinputLayout.setError(field.error);
2717
2718 binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_GRAVITY);
2719 String suffixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/suffix-label");
2720 if (suffixLabel == null) {
2721 binding.textinputLayout.setSuffixText("");
2722 } else {
2723 binding.textinputLayout.setSuffixText(suffixLabel);
2724 binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_TEXT_END);
2725 }
2726
2727 String prefixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/prefix-label");
2728 binding.textinputLayout.setPrefixText(prefixLabel == null ? "" : prefixLabel);
2729
2730 binding.textinput.setText(String.join("\n", field.getValues()));
2731 setupInputType(field.el, binding.textinput, binding.textinputLayout);
2732 }
2733
2734 @Override
2735 public void afterTextChanged(Editable s) {
2736 if (field == null) return;
2737
2738 field.setValues(List.of(s.toString().split("\n")));
2739 }
2740
2741 @Override
2742 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2743
2744 @Override
2745 public void onTextChanged(CharSequence s, int start, int count, int after) { }
2746 }
2747
2748 class SliderFieldViewHolder extends ViewHolder<CommandSliderFieldBinding> {
2749 public SliderFieldViewHolder(CommandSliderFieldBinding binding) { super(binding); }
2750 protected Field field = null;
2751
2752 @Override
2753 public void bind(Item item) {
2754 field = (Field) item;
2755 setTextOrHide(binding.label, field.getLabel());
2756 setTextOrHide(binding.desc, field.getDesc());
2757 final Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2758 final String datatype = validate == null ? null : validate.getAttribute("datatype");
2759 final Element range = validate == null ? null : validate.findChild("range", "http://jabber.org/protocol/xdata-validate");
2760 // NOTE: range also implies open, so we don't have to be bound by the options strictly
2761 // Also, we don't have anywhere to put labels so we show only values, which might sometimes be bad...
2762 Float min = null;
2763 try { min = range.getAttribute("min") == null ? null : Float.valueOf(range.getAttribute("min")); } catch (NumberFormatException e) { }
2764 Float max = null;
2765 try { max = range.getAttribute("max") == null ? null : Float.valueOf(range.getAttribute("max")); } catch (NumberFormatException e) { }
2766
2767 List<Float> options = field.getOptions().stream().map(o -> Float.valueOf(o.getValue())).collect(Collectors.toList());
2768 Collections.sort(options);
2769 if (options.size() > 0) {
2770 // min/max should be on the range, but if you have options and didn't put them on the range we can imply
2771 if (min == null) min = options.get(0);
2772 if (max == null) max = options.get(options.size()-1);
2773 }
2774
2775 if (field.getValues().size() > 0) {
2776 final var val = Float.valueOf(field.getValue().getContent());
2777 if ((min == null || val >= min) && (max == null || val <= max)) {
2778 binding.slider.setValue(Float.valueOf(field.getValue().getContent()));
2779 } else {
2780 binding.slider.setValue(min == null ? Float.MIN_VALUE : min);
2781 }
2782 } else {
2783 binding.slider.setValue(min == null ? Float.MIN_VALUE : min);
2784 }
2785 binding.slider.setValueFrom(min == null ? Float.MIN_VALUE : min);
2786 binding.slider.setValueTo(max == null ? Float.MAX_VALUE : max);
2787 if ("xs:integer".equals(datatype) || "xs:int".equals(datatype) || "xs:long".equals(datatype) || "xs:short".equals(datatype) || "xs:byte".equals(datatype)) {
2788 binding.slider.setStepSize(1);
2789 } else {
2790 binding.slider.setStepSize(0);
2791 }
2792
2793 if (options.size() > 0) {
2794 float step = -1;
2795 Float prev = null;
2796 for (final Float option : options) {
2797 if (prev != null) {
2798 float nextStep = option - prev;
2799 if (step > 0 && step != nextStep) {
2800 step = -1;
2801 break;
2802 }
2803 step = nextStep;
2804 }
2805 prev = option;
2806 }
2807 if (step > 0) binding.slider.setStepSize(step);
2808 }
2809
2810 binding.slider.addOnChangeListener((slider, value, fromUser) -> {
2811 field.setValues(List.of(new DecimalFormat().format(value)));
2812 });
2813 }
2814 }
2815
2816 class WebViewHolder extends ViewHolder<CommandWebviewBinding> {
2817 public WebViewHolder(CommandWebviewBinding binding) { super(binding); }
2818 protected String boundUrl = "";
2819
2820 @Override
2821 public void bind(Item oob) {
2822 setTextOrHide(binding.desc, Optional.fromNullable(oob.el.findChildContent("desc", "jabber:x:oob")));
2823 binding.webview.getSettings().setJavaScriptEnabled(true);
2824 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");
2825 binding.webview.getSettings().setDatabaseEnabled(true);
2826 binding.webview.getSettings().setDomStorageEnabled(true);
2827 binding.webview.setWebChromeClient(new WebChromeClient() {
2828 @Override
2829 public void onProgressChanged(WebView view, int newProgress) {
2830 binding.progressbar.setVisibility(newProgress < 100 ? View.VISIBLE : View.GONE);
2831 binding.progressbar.setProgress(newProgress);
2832 }
2833 });
2834 binding.webview.setWebViewClient(new WebViewClient() {
2835 @Override
2836 public void onPageFinished(WebView view, String url) {
2837 super.onPageFinished(view, url);
2838 mTitle = view.getTitle();
2839 ConversationPagerAdapter.this.notifyDataSetChanged();
2840 }
2841 });
2842 final String url = oob.el.findChildContent("url", "jabber:x:oob");
2843 if (!boundUrl.equals(url)) {
2844 binding.webview.addJavascriptInterface(new JsObject(), "xmpp_xep0050");
2845 binding.webview.loadUrl(url);
2846 boundUrl = url;
2847 }
2848 }
2849
2850 class JsObject {
2851 @JavascriptInterface
2852 public void execute() { execute("execute"); }
2853
2854 @JavascriptInterface
2855 public void execute(String action) {
2856 getView().post(() -> {
2857 actionToWebview = null;
2858 if(CommandSession.this.execute(action)) {
2859 removeSession(CommandSession.this);
2860 }
2861 });
2862 }
2863
2864 @JavascriptInterface
2865 public void preventDefault() {
2866 actionToWebview = binding.webview;
2867 }
2868 }
2869 }
2870
2871 class ProgressBarViewHolder extends ViewHolder<CommandProgressBarBinding> {
2872 public ProgressBarViewHolder(CommandProgressBarBinding binding) { super(binding); }
2873
2874 @Override
2875 public void bind(Item item) {
2876 binding.text.setVisibility(loadingHasBeenLong ? View.VISIBLE : View.GONE);
2877 }
2878 }
2879
2880 class Item {
2881 protected Element el;
2882 protected int viewType;
2883 protected String error = null;
2884
2885 Item(Element el, int viewType) {
2886 this.el = el;
2887 this.viewType = viewType;
2888 }
2889
2890 public boolean validate() {
2891 error = null;
2892 return true;
2893 }
2894 }
2895
2896 class Field extends Item {
2897 Field(eu.siacs.conversations.xmpp.forms.Field el, int viewType) { super(el, viewType); }
2898
2899 @Override
2900 public boolean validate() {
2901 if (!super.validate()) return false;
2902 if (el.findChild("required", "jabber:x:data") == null) return true;
2903 if (getValue().getContent() != null && !getValue().getContent().equals("")) return true;
2904
2905 error = "this value is required";
2906 return false;
2907 }
2908
2909 public String getVar() {
2910 return el.getAttribute("var");
2911 }
2912
2913 public Optional<String> getType() {
2914 return Optional.fromNullable(el.getAttribute("type"));
2915 }
2916
2917 public Optional<String> getLabel() {
2918 String label = el.getAttribute("label");
2919 if (label == null) label = getVar();
2920 return Optional.fromNullable(label);
2921 }
2922
2923 public Optional<String> getDesc() {
2924 return Optional.fromNullable(el.findChildContent("desc", "jabber:x:data"));
2925 }
2926
2927 public Element getValue() {
2928 Element value = el.findChild("value", "jabber:x:data");
2929 if (value == null) {
2930 value = el.addChild("value", "jabber:x:data");
2931 }
2932 return value;
2933 }
2934
2935 public void setValues(Collection<String> values) {
2936 for(Element child : el.getChildren()) {
2937 if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
2938 el.removeChild(child);
2939 }
2940 }
2941
2942 for (String value : values) {
2943 el.addChild("value", "jabber:x:data").setContent(value);
2944 }
2945 }
2946
2947 public List<String> getValues() {
2948 List<String> values = new ArrayList<>();
2949 for(Element child : el.getChildren()) {
2950 if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
2951 values.add(child.getContent());
2952 }
2953 }
2954 return values;
2955 }
2956
2957 public List<Option> getOptions() {
2958 return Option.forField(el);
2959 }
2960 }
2961
2962 class Cell extends Item {
2963 protected Field reported;
2964
2965 Cell(Field reported, Element item) {
2966 super(item, TYPE_RESULT_CELL);
2967 this.reported = reported;
2968 }
2969 }
2970
2971 protected Field mkField(Element el) {
2972 int viewType = -1;
2973
2974 String formType = responseElement.getAttribute("type");
2975 if (formType != null) {
2976 String fieldType = el.getAttribute("type");
2977 if (fieldType == null) fieldType = "text-single";
2978
2979 if (formType.equals("result") || fieldType.equals("fixed")) {
2980 viewType = TYPE_RESULT_FIELD;
2981 } else if (formType.equals("form")) {
2982 final Element validate = el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2983 final String datatype = validate == null ? null : validate.getAttribute("datatype");
2984 final Element range = validate == null ? null : validate.findChild("range", "http://jabber.org/protocol/xdata-validate");
2985 if (fieldType.equals("boolean")) {
2986 if (fillableFieldCount == 1 && actionsAdapter.countProceed() < 1) {
2987 viewType = TYPE_BUTTON_GRID_FIELD;
2988 } else {
2989 viewType = TYPE_CHECKBOX_FIELD;
2990 }
2991 } else if (
2992 range != null && range.getAttribute("min") != null && range.getAttribute("max") != null && (
2993 "xs:integer".equals(datatype) || "xs:int".equals(datatype) || "xs:long".equals(datatype) || "xs:short".equals(datatype) || "xs:byte".equals(datatype) ||
2994 "xs:decimal".equals(datatype) || "xs:double".equals(datatype)
2995 )
2996 ) {
2997 // has a range and is numeric, use a slider
2998 viewType = TYPE_SLIDER_FIELD;
2999 } else if (fieldType.equals("list-single")) {
3000 if (fillableFieldCount == 1 && actionsAdapter.countProceed() < 1 && Option.forField(el).size() < 50) {
3001 viewType = TYPE_BUTTON_GRID_FIELD;
3002 } else if (Option.forField(el).size() > 9) {
3003 viewType = TYPE_SEARCH_LIST_FIELD;
3004 } else if (el.findChild("value", "jabber:x:data") == null || (validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null)) {
3005 viewType = TYPE_RADIO_EDIT_FIELD;
3006 } else {
3007 viewType = TYPE_SPINNER_FIELD;
3008 }
3009 } else if (fieldType.equals("list-multi")) {
3010 viewType = TYPE_SEARCH_LIST_FIELD;
3011 } else {
3012 viewType = TYPE_TEXT_FIELD;
3013 }
3014 }
3015
3016 return new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), viewType);
3017 }
3018
3019 return null;
3020 }
3021
3022 protected Item mkItem(Element el, int pos) {
3023 int viewType = TYPE_ERROR;
3024
3025 if (response != null && response.getType() == Iq.Type.RESULT) {
3026 if (el.getName().equals("note")) {
3027 viewType = TYPE_NOTE;
3028 } else if (el.getNamespace().equals("jabber:x:oob")) {
3029 viewType = TYPE_WEB;
3030 } else if (el.getName().equals("instructions") && el.getNamespace().equals("jabber:x:data")) {
3031 viewType = TYPE_NOTE;
3032 } else if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data")) {
3033 Field field = mkField(el);
3034 if (field != null) {
3035 items.put(pos, field);
3036 return field;
3037 }
3038 }
3039 }
3040
3041 Item item = new Item(el, viewType);
3042 items.put(pos, item);
3043 return item;
3044 }
3045
3046 class ActionsAdapter extends ArrayAdapter<Pair<String, String>> {
3047 protected Context ctx;
3048
3049 public ActionsAdapter(Context ctx) {
3050 super(ctx, R.layout.simple_list_item);
3051 this.ctx = ctx;
3052 }
3053
3054 @Override
3055 public View getView(int position, View convertView, ViewGroup parent) {
3056 View v = super.getView(position, convertView, parent);
3057 TextView tv = (TextView) v.findViewById(android.R.id.text1);
3058 tv.setGravity(Gravity.CENTER);
3059 tv.setText(getItem(position).second);
3060 int resId = ctx.getResources().getIdentifier("action_" + getItem(position).first, "string" , ctx.getPackageName());
3061 if (resId != 0 && getItem(position).second.equals(getItem(position).first)) tv.setText(ctx.getResources().getString(resId));
3062 final var colors = MaterialColors.getColorRoles(ctx, UIHelper.getColorForName(getItem(position).first));
3063 tv.setTextColor(colors.getOnAccent());
3064 tv.setBackgroundColor(MaterialColors.harmonizeWithPrimary(ctx, colors.getAccent()));
3065 return v;
3066 }
3067
3068 public int getPosition(String s) {
3069 for(int i = 0; i < getCount(); i++) {
3070 if (getItem(i).first.equals(s)) return i;
3071 }
3072 return -1;
3073 }
3074
3075 public int countProceed() {
3076 int count = 0;
3077 for(int i = 0; i < getCount(); i++) {
3078 if (!"cancel".equals(getItem(i).first) && !"prev".equals(getItem(i).first)) count++;
3079 }
3080 return count;
3081 }
3082
3083 public int countExceptCancel() {
3084 int count = 0;
3085 for(int i = 0; i < getCount(); i++) {
3086 if (!getItem(i).first.equals("cancel")) count++;
3087 }
3088 return count;
3089 }
3090
3091 public void clearProceed() {
3092 Pair<String,String> cancelItem = null;
3093 Pair<String,String> prevItem = null;
3094 for(int i = 0; i < getCount(); i++) {
3095 if (getItem(i).first.equals("cancel")) cancelItem = getItem(i);
3096 if (getItem(i).first.equals("prev")) prevItem = getItem(i);
3097 }
3098 clear();
3099 if (cancelItem != null) add(cancelItem);
3100 if (prevItem != null) add(prevItem);
3101 }
3102 }
3103
3104 final int TYPE_ERROR = 1;
3105 final int TYPE_NOTE = 2;
3106 final int TYPE_WEB = 3;
3107 final int TYPE_RESULT_FIELD = 4;
3108 final int TYPE_TEXT_FIELD = 5;
3109 final int TYPE_CHECKBOX_FIELD = 6;
3110 final int TYPE_SPINNER_FIELD = 7;
3111 final int TYPE_RADIO_EDIT_FIELD = 8;
3112 final int TYPE_RESULT_CELL = 9;
3113 final int TYPE_PROGRESSBAR = 10;
3114 final int TYPE_SEARCH_LIST_FIELD = 11;
3115 final int TYPE_ITEM_CARD = 12;
3116 final int TYPE_BUTTON_GRID_FIELD = 13;
3117 final int TYPE_SLIDER_FIELD = 14;
3118
3119 protected boolean executing = false;
3120 protected boolean loading = false;
3121 protected boolean loadingHasBeenLong = false;
3122 protected Timer loadingTimer = new Timer();
3123 protected String mTitle;
3124 protected String mNode;
3125 protected CommandPageBinding mBinding = null;
3126 protected Iq response = null;
3127 protected Element responseElement = null;
3128 protected boolean expectingRemoval = false;
3129 protected List<Field> reported = null;
3130 protected SparseArray<Item> items = new SparseArray<>();
3131 protected XmppConnectionService xmppConnectionService;
3132 protected ActionsAdapter actionsAdapter = null;
3133 protected GridLayoutManager layoutManager;
3134 protected WebView actionToWebview = null;
3135 protected int fillableFieldCount = 0;
3136 protected Iq pendingResponsePacket = null;
3137 protected boolean waitingForRefresh = false;
3138
3139 CommandSession(String title, String node, XmppConnectionService xmppConnectionService) {
3140 loading();
3141 mTitle = title;
3142 mNode = node;
3143 this.xmppConnectionService = xmppConnectionService;
3144 if (mPager.get() != null) setupLayoutManager(mPager.get().getContext());
3145 }
3146
3147 public String getTitle() {
3148 return mTitle;
3149 }
3150
3151 public String getNode() {
3152 return mNode;
3153 }
3154
3155 public void updateWithResponse(final Iq iq) {
3156 if (getView() != null && getView().isAttachedToWindow()) {
3157 getView().post(() -> updateWithResponseUiThread(iq));
3158 } else {
3159 pendingResponsePacket = iq;
3160 }
3161 }
3162
3163 protected void updateWithResponseUiThread(final Iq iq) {
3164 Timer oldTimer = this.loadingTimer;
3165 this.loadingTimer = new Timer();
3166 oldTimer.cancel();
3167 this.executing = false;
3168 this.loading = false;
3169 this.loadingHasBeenLong = false;
3170 this.responseElement = null;
3171 this.fillableFieldCount = 0;
3172 this.reported = null;
3173 this.response = iq;
3174 this.items.clear();
3175 this.actionsAdapter.clear();
3176 layoutManager.setSpanCount(1);
3177
3178 boolean actionsCleared = false;
3179 Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
3180 if (iq.getType() == Iq.Type.RESULT && command != null) {
3181 if (mNode.equals("jabber:iq:register") && command.getAttribute("status") != null && command.getAttribute("status").equals("completed")) {
3182 xmppConnectionService.createContact(getAccount().getRoster().getContact(iq.getFrom()), true);
3183 }
3184
3185 if (xmppConnectionService.isOnboarding() && mNode.equals("jabber:iq:register") && !"canceled".equals(command.getAttribute("status")) && xmppConnectionService.getPreferences().contains("onboarding_action")) {
3186 xmppConnectionService.getPreferences().edit().putBoolean("onboarding_continued", true).commit();
3187 }
3188
3189 Element actions = command.findChild("actions", "http://jabber.org/protocol/commands");
3190 if (actions != null) {
3191 for (Element action : actions.getChildren()) {
3192 if (!"http://jabber.org/protocol/commands".equals(action.getNamespace())) continue;
3193 if ("execute".equals(action.getName())) continue;
3194
3195 actionsAdapter.add(Pair.create(action.getName(), action.getName()));
3196 }
3197 }
3198
3199 for (Element el : command.getChildren()) {
3200 if ("x".equals(el.getName()) && "jabber:x:data".equals(el.getNamespace())) {
3201 Data form = Data.parse(el);
3202 String title = form.getTitle();
3203 if (title != null) {
3204 mTitle = title;
3205 ConversationPagerAdapter.this.notifyDataSetChanged();
3206 }
3207
3208 if ("result".equals(el.getAttribute("type")) || "form".equals(el.getAttribute("type"))) {
3209 this.responseElement = el;
3210 setupReported(el.findChild("reported", "jabber:x:data"));
3211 if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager(mBinding.getRoot().getContext()));
3212 }
3213
3214 eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
3215 if (actionList != null) {
3216 actionsAdapter.clear();
3217
3218 for (Option action : actionList.getOptions()) {
3219 actionsAdapter.add(Pair.create(action.getValue(), action.toString()));
3220 }
3221 }
3222
3223 eu.siacs.conversations.xmpp.forms.Field fillableField = null;
3224 for (eu.siacs.conversations.xmpp.forms.Field field : form.getFields()) {
3225 if ((field.getType() == null || (!field.getType().equals("hidden") && !field.getType().equals("fixed"))) && field.getFieldName() != null && !field.getFieldName().equals("http://jabber.org/protocol/commands#actions")) {
3226 final var validate = field.findChild("validate", "http://jabber.org/protocol/xdata-validate");
3227 final var range = validate == null ? null : validate.findChild("range", "http://jabber.org/protocol/xdata-validate");
3228 fillableField = range == null ? field : null;
3229 fillableFieldCount++;
3230 }
3231 }
3232
3233 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))) {
3234 actionsCleared = true;
3235 actionsAdapter.clearProceed();
3236 }
3237 break;
3238 }
3239 if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
3240 String url = el.findChildContent("url", "jabber:x:oob");
3241 if (url != null) {
3242 String scheme = Uri.parse(url).getScheme();
3243 if (scheme.equals("http") || scheme.equals("https")) {
3244 this.responseElement = el;
3245 break;
3246 }
3247 if (scheme.equals("xmpp")) {
3248 expectingRemoval = true;
3249 final Intent intent = new Intent(getView().getContext(), UriHandlerActivity.class);
3250 intent.setAction(Intent.ACTION_VIEW);
3251 intent.setData(Uri.parse(url));
3252 getView().getContext().startActivity(intent);
3253 break;
3254 }
3255 }
3256 }
3257 if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
3258 this.responseElement = el;
3259 break;
3260 }
3261 }
3262
3263 if (responseElement == null && command.getAttribute("status") != null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
3264 if ("jabber:iq:register".equals(mNode) && "canceled".equals(command.getAttribute("status"))) {
3265 if (xmppConnectionService.isOnboarding()) {
3266 if (xmppConnectionService.getPreferences().contains("onboarding_action")) {
3267 xmppConnectionService.deleteAccount(getAccount());
3268 } else {
3269 if (xmppConnectionService.getPreferences().getBoolean("onboarding_continued", false)) {
3270 removeSession(this);
3271 return;
3272 } else {
3273 xmppConnectionService.getPreferences().edit().putString("onboarding_action", "cancel").commit();
3274 xmppConnectionService.deleteAccount(getAccount());
3275 }
3276 }
3277 }
3278 xmppConnectionService.archiveConversation(Conversation.this);
3279 }
3280
3281 expectingRemoval = true;
3282 removeSession(this);
3283 return;
3284 }
3285
3286 if ("executing".equals(command.getAttribute("status")) && actionsAdapter.countExceptCancel() < 1 && !actionsCleared) {
3287 // No actions have been given, but we are not done?
3288 // This is probably a spec violation, but we should do *something*
3289 actionsAdapter.add(Pair.create("execute", "execute"));
3290 }
3291
3292 if (!actionsAdapter.isEmpty() || fillableFieldCount > 0) {
3293 if ("completed".equals(command.getAttribute("status")) || "canceled".equals(command.getAttribute("status"))) {
3294 actionsAdapter.add(Pair.create("close", "close"));
3295 } else if (actionsAdapter.getPosition("cancel") < 0 && !xmppConnectionService.isOnboarding()) {
3296 actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
3297 }
3298 }
3299 }
3300
3301 if (actionsAdapter.isEmpty()) {
3302 actionsAdapter.add(Pair.create("close", "close"));
3303 }
3304
3305 actionsAdapter.sort((x, y) -> {
3306 if (x.first.equals("cancel")) return -1;
3307 if (y.first.equals("cancel")) return 1;
3308 if (x.first.equals("prev") && xmppConnectionService.isOnboarding()) return -1;
3309 if (y.first.equals("prev") && xmppConnectionService.isOnboarding()) return 1;
3310 return 0;
3311 });
3312
3313 Data dataForm = null;
3314 if (responseElement != null && responseElement.getName().equals("x") && responseElement.getNamespace().equals("jabber:x:data")) dataForm = Data.parse(responseElement);
3315 if (mNode.equals("jabber:iq:register") &&
3316 xmppConnectionService.getPreferences().contains("onboarding_action") &&
3317 dataForm != null && dataForm.getFieldByName("gateway-jid") != null) {
3318
3319
3320 dataForm.put("gateway-jid", xmppConnectionService.getPreferences().getString("onboarding_action", ""));
3321 execute();
3322 }
3323 xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
3324 notifyDataSetChanged();
3325 }
3326
3327 protected void setupReported(Element el) {
3328 if (el == null) {
3329 reported = null;
3330 return;
3331 }
3332
3333 reported = new ArrayList<>();
3334 for (Element fieldEl : el.getChildren()) {
3335 if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
3336 reported.add(mkField(fieldEl));
3337 }
3338 }
3339
3340 @Override
3341 public int getItemCount() {
3342 if (loading) return 1;
3343 if (response == null) return 0;
3344 if (response.getType() == Iq.Type.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
3345 int i = 0;
3346 for (Element el : responseElement.getChildren()) {
3347 if (!el.getNamespace().equals("jabber:x:data")) continue;
3348 if (el.getName().equals("title")) continue;
3349 if (el.getName().equals("field")) {
3350 String type = el.getAttribute("type");
3351 if (type != null && type.equals("hidden")) continue;
3352 if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
3353 }
3354
3355 if (el.getName().equals("reported") || el.getName().equals("item")) {
3356 if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
3357 if (el.getName().equals("reported")) continue;
3358 i += 1;
3359 } else {
3360 if (reported != null) i += reported.size();
3361 }
3362 continue;
3363 }
3364
3365 i++;
3366 }
3367 return i;
3368 }
3369 return 1;
3370 }
3371
3372 public Item getItem(int position) {
3373 if (loading) return new Item(null, TYPE_PROGRESSBAR);
3374 if (items.get(position) != null) return items.get(position);
3375 if (response == null) return null;
3376
3377 if (response.getType() == Iq.Type.RESULT && responseElement != null) {
3378 if (responseElement.getNamespace().equals("jabber:x:data")) {
3379 int i = 0;
3380 for (Element el : responseElement.getChildren()) {
3381 if (!el.getNamespace().equals("jabber:x:data")) continue;
3382 if (el.getName().equals("title")) continue;
3383 if (el.getName().equals("field")) {
3384 String type = el.getAttribute("type");
3385 if (type != null && type.equals("hidden")) continue;
3386 if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
3387 }
3388
3389 if (el.getName().equals("reported") || el.getName().equals("item")) {
3390 Cell cell = null;
3391
3392 if (reported != null) {
3393 if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
3394 if (el.getName().equals("reported")) continue;
3395 if (i == position) {
3396 items.put(position, new Item(el, TYPE_ITEM_CARD));
3397 return items.get(position);
3398 }
3399 } else {
3400 if (reported.size() > position - i) {
3401 Field reportedField = reported.get(position - i);
3402 Element itemField = null;
3403 if (el.getName().equals("item")) {
3404 for (Element subel : el.getChildren()) {
3405 if (subel.getAttribute("var").equals(reportedField.getVar())) {
3406 itemField = subel;
3407 break;
3408 }
3409 }
3410 }
3411 cell = new Cell(reportedField, itemField);
3412 } else {
3413 i += reported.size();
3414 continue;
3415 }
3416 }
3417 }
3418
3419 if (cell != null) {
3420 items.put(position, cell);
3421 return cell;
3422 }
3423 }
3424
3425 if (i < position) {
3426 i++;
3427 continue;
3428 }
3429
3430 return mkItem(el, position);
3431 }
3432 }
3433 }
3434
3435 return mkItem(responseElement == null ? response : responseElement, position);
3436 }
3437
3438 @Override
3439 public int getItemViewType(int position) {
3440 return getItem(position).viewType;
3441 }
3442
3443 @Override
3444 public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
3445 switch(viewType) {
3446 case TYPE_ERROR: {
3447 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3448 return new ErrorViewHolder(binding);
3449 }
3450 case TYPE_NOTE: {
3451 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3452 return new NoteViewHolder(binding);
3453 }
3454 case TYPE_WEB: {
3455 CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
3456 return new WebViewHolder(binding);
3457 }
3458 case TYPE_RESULT_FIELD: {
3459 CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
3460 return new ResultFieldViewHolder(binding);
3461 }
3462 case TYPE_RESULT_CELL: {
3463 CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
3464 return new ResultCellViewHolder(binding);
3465 }
3466 case TYPE_ITEM_CARD: {
3467 CommandItemCardBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_item_card, container, false);
3468 return new ItemCardViewHolder(binding);
3469 }
3470 case TYPE_CHECKBOX_FIELD: {
3471 CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
3472 return new CheckboxFieldViewHolder(binding);
3473 }
3474 case TYPE_SEARCH_LIST_FIELD: {
3475 CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
3476 return new SearchListFieldViewHolder(binding);
3477 }
3478 case TYPE_RADIO_EDIT_FIELD: {
3479 CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
3480 return new RadioEditFieldViewHolder(binding);
3481 }
3482 case TYPE_SPINNER_FIELD: {
3483 CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
3484 return new SpinnerFieldViewHolder(binding);
3485 }
3486 case TYPE_BUTTON_GRID_FIELD: {
3487 CommandButtonGridFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_button_grid_field, container, false);
3488 return new ButtonGridFieldViewHolder(binding);
3489 }
3490 case TYPE_TEXT_FIELD: {
3491 CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
3492 return new TextFieldViewHolder(binding);
3493 }
3494 case TYPE_SLIDER_FIELD: {
3495 CommandSliderFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_slider_field, container, false);
3496 return new SliderFieldViewHolder(binding);
3497 }
3498 case TYPE_PROGRESSBAR: {
3499 CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
3500 return new ProgressBarViewHolder(binding);
3501 }
3502 default:
3503 if (expectingRemoval) {
3504 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3505 return new NoteViewHolder(binding);
3506 }
3507
3508 throw new IllegalArgumentException("Unknown viewType: " + viewType + " based on: " + response + ", " + responseElement + ", " + expectingRemoval);
3509 }
3510 }
3511
3512 @Override
3513 public void onBindViewHolder(ViewHolder viewHolder, int position) {
3514 viewHolder.bind(getItem(position));
3515 }
3516
3517 public View getView() {
3518 if (mBinding == null) return null;
3519 return mBinding.getRoot();
3520 }
3521
3522 public boolean validate() {
3523 int count = getItemCount();
3524 boolean isValid = true;
3525 for (int i = 0; i < count; i++) {
3526 boolean oneIsValid = getItem(i).validate();
3527 isValid = isValid && oneIsValid;
3528 }
3529 notifyDataSetChanged();
3530 return isValid;
3531 }
3532
3533 public boolean execute() {
3534 return execute("execute");
3535 }
3536
3537 public boolean execute(int actionPosition) {
3538 return execute(actionsAdapter.getItem(actionPosition).first);
3539 }
3540
3541 public synchronized boolean execute(String action) {
3542 if (!"cancel".equals(action) && executing) {
3543 loadingHasBeenLong = true;
3544 notifyDataSetChanged();
3545 return false;
3546 }
3547 if (!action.equals("cancel") && !action.equals("prev") && !validate()) return false;
3548
3549 if (response == null) return true;
3550 Element command = response.findChild("command", "http://jabber.org/protocol/commands");
3551 if (command == null) return true;
3552 String status = command.getAttribute("status");
3553 if (status == null || (!status.equals("executing") && !action.equals("prev"))) return true;
3554
3555 if (actionToWebview != null && !action.equals("cancel") && Build.VERSION.SDK_INT >= 23) {
3556 actionToWebview.postWebMessage(new WebMessage("xmpp_xep0050/" + action), Uri.parse("*"));
3557 return false;
3558 }
3559
3560 final var packet = new Iq(Iq.Type.SET);
3561 packet.setTo(response.getFrom());
3562 final Element c = packet.addChild("command", Namespace.COMMANDS);
3563 c.setAttribute("node", mNode);
3564 c.setAttribute("sessionid", command.getAttribute("sessionid"));
3565
3566 String formType = responseElement == null ? null : responseElement.getAttribute("type");
3567 if (!action.equals("cancel") &&
3568 !action.equals("prev") &&
3569 responseElement != null &&
3570 responseElement.getName().equals("x") &&
3571 responseElement.getNamespace().equals("jabber:x:data") &&
3572 formType != null && formType.equals("form")) {
3573
3574 Data form = Data.parse(responseElement);
3575 eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
3576 if (actionList != null) {
3577 actionList.setValue(action);
3578 c.setAttribute("action", "execute");
3579 }
3580
3581 if (mNode.equals("jabber:iq:register") && xmppConnectionService.isOnboarding() && form.getFieldByName("gateway-jid") != null) {
3582 if (form.getValue("gateway-jid") == null) {
3583 xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
3584 } else {
3585 xmppConnectionService.getPreferences().edit().putString("onboarding_action", form.getValue("gateway-jid")).commit();
3586 }
3587 }
3588
3589 responseElement.setAttribute("type", "submit");
3590 Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
3591 if (rsm != null) {
3592 Element max = new Element("max", "http://jabber.org/protocol/rsm");
3593 max.setContent("1000");
3594 rsm.addChild(max);
3595 }
3596
3597 c.addChild(responseElement);
3598 }
3599
3600 if (c.getAttribute("action") == null) c.setAttribute("action", action);
3601
3602 executing = true;
3603 xmppConnectionService.sendIqPacket(getAccount(), packet, (iq) -> {
3604 updateWithResponse(iq);
3605 }, 120L);
3606
3607 loading();
3608 return false;
3609 }
3610
3611 public void refresh() {
3612 synchronized(this) {
3613 if (waitingForRefresh) notifyDataSetChanged();
3614 }
3615 }
3616
3617 protected void loading() {
3618 View v = getView();
3619 try {
3620 loadingTimer.schedule(new TimerTask() {
3621 @Override
3622 public void run() {
3623 View v2 = getView();
3624 loading = true;
3625
3626 try {
3627 loadingTimer.schedule(new TimerTask() {
3628 @Override
3629 public void run() {
3630 loadingHasBeenLong = true;
3631 if (v == null && v2 == null) return;
3632 (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3633 }
3634 }, 3000);
3635 } catch (final IllegalStateException e) { }
3636
3637 if (v == null && v2 == null) return;
3638 (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3639 }
3640 }, 500);
3641 } catch (final IllegalStateException e) { }
3642 }
3643
3644 protected GridLayoutManager setupLayoutManager(final Context ctx) {
3645 int spanCount = 1;
3646
3647 if (reported != null) {
3648 float screenWidth = ctx.getResources().getDisplayMetrics().widthPixels;
3649 TextPaint paint = ((TextView) LayoutInflater.from(mPager.get().getContext()).inflate(R.layout.command_result_cell, null)).getPaint();
3650 float tableHeaderWidth = reported.stream().reduce(
3651 0f,
3652 (total, field) -> total + StaticLayout.getDesiredWidth(field.getLabel().or("--------") + "\t", paint),
3653 (a, b) -> a + b
3654 );
3655
3656 spanCount = tableHeaderWidth > 0.59 * screenWidth ? 1 : this.reported.size();
3657 }
3658
3659 if (layoutManager != null && layoutManager.getSpanCount() != spanCount) {
3660 items.clear();
3661 notifyDataSetChanged();
3662 }
3663
3664 layoutManager = new GridLayoutManager(ctx, spanCount);
3665 layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
3666 @Override
3667 public int getSpanSize(int position) {
3668 if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
3669 return 1;
3670 }
3671 });
3672 return layoutManager;
3673 }
3674
3675 protected void setBinding(CommandPageBinding b) {
3676 mBinding = b;
3677 // https://stackoverflow.com/a/32350474/8611
3678 mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
3679 @Override
3680 public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
3681 if(rv.getChildCount() > 0) {
3682 int[] location = new int[2];
3683 rv.getLocationOnScreen(location);
3684 View childView = rv.findChildViewUnder(e.getX(), e.getY());
3685 if (childView instanceof ViewGroup) {
3686 childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
3687 }
3688 int action = e.getAction();
3689 switch (action) {
3690 case MotionEvent.ACTION_DOWN:
3691 if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(1)) || childView instanceof WebView) {
3692 rv.requestDisallowInterceptTouchEvent(true);
3693 }
3694 case MotionEvent.ACTION_UP:
3695 if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(-11)) || childView instanceof WebView) {
3696 rv.requestDisallowInterceptTouchEvent(true);
3697 }
3698 }
3699 }
3700
3701 return false;
3702 }
3703
3704 @Override
3705 public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }
3706
3707 @Override
3708 public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
3709 });
3710 mBinding.form.setLayoutManager(setupLayoutManager(mBinding.getRoot().getContext()));
3711 mBinding.form.setAdapter(this);
3712
3713 if (actionsAdapter == null) {
3714 actionsAdapter = new ActionsAdapter(mBinding.getRoot().getContext());
3715 actionsAdapter.registerDataSetObserver(new DataSetObserver() {
3716 @Override
3717 public void onChanged() {
3718 if (mBinding == null) return;
3719
3720 mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
3721 }
3722
3723 @Override
3724 public void onInvalidated() {}
3725 });
3726 }
3727
3728 mBinding.actions.setAdapter(actionsAdapter);
3729 mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
3730 if (execute(pos)) {
3731 removeSession(CommandSession.this);
3732 }
3733 });
3734
3735 actionsAdapter.notifyDataSetChanged();
3736
3737 if (pendingResponsePacket != null) {
3738 final var pending = pendingResponsePacket;
3739 pendingResponsePacket = null;
3740 updateWithResponseUiThread(pending);
3741 }
3742 }
3743
3744 private Drawable getDrawableForSVG(SVG svg, Element svgElement, int size) {
3745 if (svgElement != null && svgElement.getChildren().size() == 1 && svgElement.getChildren().get(0).getName().equals("image")) {
3746 return getDrawableForUrl(svgElement.getChildren().get(0).getAttribute("href"));
3747 } else {
3748 return xmppConnectionService.getFileBackend().drawSVG(svg, size);
3749 }
3750 }
3751
3752 private Drawable getDrawableForUrl(final String url) {
3753 final LruCache<String, Drawable> cache = xmppConnectionService.getDrawableCache();
3754 final HttpConnectionManager httpManager = xmppConnectionService.getHttpConnectionManager();
3755 final Drawable d = cache.get(url);
3756 if (Build.VERSION.SDK_INT >= 28 && d instanceof AnimatedImageDrawable) ((AnimatedImageDrawable) d).start();
3757 if (d == null) {
3758 synchronized (CommandSession.this) {
3759 waitingForRefresh = true;
3760 }
3761 int size = (int)(xmppConnectionService.getResources().getDisplayMetrics().density * 288);
3762 Message dummy = new Message(Conversation.this, url, Message.ENCRYPTION_NONE);
3763 dummy.setStatus(Message.STATUS_DUMMY);
3764 dummy.setFileParams(new Message.FileParams(url));
3765 httpManager.createNewDownloadConnection(dummy, true, (file) -> {
3766 if (file == null) {
3767 dummy.getTransferable().start();
3768 } else {
3769 try {
3770 xmppConnectionService.getFileBackend().getThumbnail(file, xmppConnectionService.getResources(), size, false, url);
3771 } catch (final Exception e) { }
3772 }
3773 });
3774 }
3775 return d;
3776 }
3777
3778 public View inflateUi(Context context, Consumer<ConversationPage> remover) {
3779 CommandPageBinding binding = DataBindingUtil.inflate(LayoutInflater.from(context), R.layout.command_page, null, false);
3780 setBinding(binding);
3781 return binding.getRoot();
3782 }
3783
3784 // https://stackoverflow.com/a/36037991/8611
3785 private View findViewAt(ViewGroup viewGroup, float x, float y) {
3786 for(int i = 0; i < viewGroup.getChildCount(); i++) {
3787 View child = viewGroup.getChildAt(i);
3788 if (child instanceof ViewGroup && !(child instanceof AbsListView) && !(child instanceof WebView)) {
3789 View foundView = findViewAt((ViewGroup) child, x, y);
3790 if (foundView != null && foundView.isShown()) {
3791 return foundView;
3792 }
3793 } else {
3794 int[] location = new int[2];
3795 child.getLocationOnScreen(location);
3796 Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
3797 if (rect.contains((int)x, (int)y)) {
3798 return child;
3799 }
3800 }
3801 }
3802
3803 return null;
3804 }
3805 }
3806
3807 class MucConfigSession extends CommandSession {
3808 MucConfigSession(XmppConnectionService xmppConnectionService) {
3809 super("Configure Channel", null, xmppConnectionService);
3810 }
3811
3812 @Override
3813 protected void updateWithResponseUiThread(final Iq iq) {
3814 Timer oldTimer = this.loadingTimer;
3815 this.loadingTimer = new Timer();
3816 oldTimer.cancel();
3817 this.executing = false;
3818 this.loading = false;
3819 this.loadingHasBeenLong = false;
3820 this.responseElement = null;
3821 this.fillableFieldCount = 0;
3822 this.reported = null;
3823 this.response = iq;
3824 this.items.clear();
3825 this.actionsAdapter.clear();
3826 layoutManager.setSpanCount(1);
3827
3828 final Element query = iq.findChild("query", "http://jabber.org/protocol/muc#owner");
3829 if (iq.getType() == Iq.Type.RESULT && query != null) {
3830 final Data form = Data.parse(query.findChild("x", "jabber:x:data"));
3831 final String title = form.getTitle();
3832 if (title != null) {
3833 mTitle = title;
3834 ConversationPagerAdapter.this.notifyDataSetChanged();
3835 }
3836
3837 this.responseElement = form;
3838 setupReported(form.findChild("reported", "jabber:x:data"));
3839 if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager(mBinding.getRoot().getContext()));
3840
3841 if (actionsAdapter.countExceptCancel() < 1) {
3842 actionsAdapter.add(Pair.create("save", "Save"));
3843 }
3844
3845 if (actionsAdapter.getPosition("cancel") < 0) {
3846 actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
3847 }
3848 } else if (iq.getType() == Iq.Type.RESULT) {
3849 expectingRemoval = true;
3850 removeSession(this);
3851 return;
3852 } else {
3853 actionsAdapter.add(Pair.create("close", "close"));
3854 }
3855
3856 notifyDataSetChanged();
3857 }
3858
3859 @Override
3860 public synchronized boolean execute(String action) {
3861 if ("cancel".equals(action)) {
3862 final var packet = new Iq(Iq.Type.SET);
3863 packet.setTo(response.getFrom());
3864 final Element form = packet
3865 .addChild("query", "http://jabber.org/protocol/muc#owner")
3866 .addChild("x", "jabber:x:data");
3867 form.setAttribute("type", "cancel");
3868 xmppConnectionService.sendIqPacket(getAccount(), packet, null);
3869 return true;
3870 }
3871
3872 if (!"save".equals(action)) return true;
3873
3874 final var packet = new Iq(Iq.Type.SET);
3875 packet.setTo(response.getFrom());
3876
3877 String formType = responseElement == null ? null : responseElement.getAttribute("type");
3878 if (responseElement != null &&
3879 responseElement.getName().equals("x") &&
3880 responseElement.getNamespace().equals("jabber:x:data") &&
3881 formType != null && formType.equals("form")) {
3882
3883 responseElement.setAttribute("type", "submit");
3884 packet
3885 .addChild("query", "http://jabber.org/protocol/muc#owner")
3886 .addChild(responseElement);
3887 }
3888
3889 executing = true;
3890 xmppConnectionService.sendIqPacket(getAccount(), packet, (iq) -> {
3891 updateWithResponse(iq);
3892 }, 120L);
3893
3894 loading();
3895
3896 return false;
3897 }
3898 }
3899 }
3900
3901 public static class Thread {
3902 protected Message subject = null;
3903 protected Message first = null;
3904 protected Message last = null;
3905 protected final String threadId;
3906
3907 protected Thread(final String threadId) {
3908 this.threadId = threadId;
3909 }
3910
3911 public String getThreadId() {
3912 return threadId;
3913 }
3914
3915 public String getSubject() {
3916 if (subject == null) return null;
3917
3918 return subject.getSubject();
3919 }
3920
3921 public String getDisplay() {
3922 final String s = getSubject();
3923 if (s != null) return s;
3924
3925 if (first != null) {
3926 return first.getBody();
3927 }
3928
3929 return "";
3930 }
3931
3932 public long getLastTime() {
3933 if (last == null) return 0;
3934
3935 return last.getTimeSent();
3936 }
3937 }
3938}