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