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