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