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