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(XmppConnectionService xmppConnectionService) {
881 return unreadCount(xmppConnectionService) < 1;
882 }
883
884 public List<Message> markRead(final String upToUuid) {
885 final ImmutableList.Builder<Message> unread = new ImmutableList.Builder<>();
886 synchronized (this.messages) {
887 for (final Message message : this.messages) {
888 if (!message.isRead()) {
889 message.markRead();
890 unread.add(message);
891 }
892 if (message.getUuid().equals(upToUuid)) {
893 return unread.build();
894 }
895 }
896 }
897 return unread.build();
898 }
899
900 public Message getLatestMessage() {
901 synchronized (this.messages) {
902 if (this.messages.size() == 0) {
903 Message message = new Message(this, "", Message.ENCRYPTION_NONE);
904 message.setType(Message.TYPE_STATUS);
905 message.setTime(Math.max(getCreated(), getLastClearHistory().getTimestamp()));
906 message.setTimeReceived(Math.max(getCreated(), getLastClearHistory().getTimestamp()));
907 return message;
908 } else {
909 return this.messages.get(this.messages.size() - 1);
910 }
911 }
912 }
913
914 public @NonNull
915 CharSequence getName() {
916 if (getMode() == MODE_MULTI) {
917 final String roomName = getMucOptions().getName();
918 final String subject = getMucOptions().getSubject();
919 final Bookmark bookmark = getBookmark();
920 final String bookmarkName = bookmark != null ? bookmark.getBookmarkName() : null;
921 if (printableValue(roomName)) {
922 return roomName;
923 } else if (printableValue(subject)) {
924 return subject;
925 } else if (printableValue(bookmarkName, false)) {
926 return bookmarkName;
927 } else {
928 final String generatedName = getMucOptions().createNameFromParticipants();
929 if (printableValue(generatedName)) {
930 return generatedName;
931 } else {
932 return contactJid.getLocal() != null ? contactJid.getLocal() : contactJid;
933 }
934 }
935 } else {
936 return this.getContact().getDisplayName();
937 }
938 }
939
940 public List<Tag> getTags(final Context ctx) {
941 if (getMode() == MODE_MULTI) {
942 if (getBookmark() == null) return new ArrayList<>();
943 return getBookmark().getTags(ctx);
944 } else {
945 return getContact().getTags(ctx);
946 }
947 }
948
949 public String getAccountUuid() {
950 return this.accountUuid;
951 }
952
953 public Account getAccount() {
954 return this.account;
955 }
956
957 public void setAccount(final Account account) {
958 this.account = account;
959 }
960
961 public Contact getContact() {
962 return this.account.getRoster().getContact(this.contactJid);
963 }
964
965 @Override
966 public Jid getJid() {
967 return this.contactJid;
968 }
969
970 public int getStatus() {
971 return this.status;
972 }
973
974 public void setStatus(int status) {
975 this.status = status;
976 }
977
978 public long getCreated() {
979 return this.created;
980 }
981
982 public ContentValues getContentValues() {
983 ContentValues values = new ContentValues();
984 values.put(UUID, uuid);
985 values.put(NAME, name);
986 values.put(CONTACT, contactUuid);
987 values.put(ACCOUNT, accountUuid);
988 values.put(CONTACTJID, contactJid.toString());
989 values.put(CREATED, created);
990 values.put(STATUS, status);
991 values.put(MODE, mode);
992 synchronized (this.attributes) {
993 values.put(ATTRIBUTES, attributes.toString());
994 }
995 return values;
996 }
997
998 public int getMode() {
999 return this.mode;
1000 }
1001
1002 public void setMode(int mode) {
1003 this.mode = mode;
1004 }
1005
1006 /**
1007 * short for is Private and Non-anonymous
1008 */
1009 public boolean isSingleOrPrivateAndNonAnonymous() {
1010 return mode == MODE_SINGLE || isPrivateAndNonAnonymous();
1011 }
1012
1013 public boolean isPrivateAndNonAnonymous() {
1014 return getMucOptions().isPrivateAndNonAnonymous();
1015 }
1016
1017 public synchronized MucOptions getMucOptions() {
1018 if (this.mucOptions == null) {
1019 this.mucOptions = new MucOptions(this);
1020 }
1021 return this.mucOptions;
1022 }
1023
1024 public void resetMucOptions() {
1025 this.mucOptions = null;
1026 }
1027
1028 public void setContactJid(final Jid jid) {
1029 this.contactJid = jid;
1030 }
1031
1032 public Jid getNextCounterpart() {
1033 return this.nextCounterpart;
1034 }
1035
1036 public void setNextCounterpart(Jid jid) {
1037 this.nextCounterpart = jid;
1038 }
1039
1040 public int getNextEncryption() {
1041 if (!Config.supportOmemo() && !Config.supportOpenPgp()) {
1042 return Message.ENCRYPTION_NONE;
1043 }
1044 if (OmemoSetting.isAlways()) {
1045 return suitableForOmemoByDefault(this) ? Message.ENCRYPTION_AXOLOTL : Message.ENCRYPTION_NONE;
1046 }
1047 final int defaultEncryption;
1048 if (suitableForOmemoByDefault(this)) {
1049 defaultEncryption = OmemoSetting.getEncryption();
1050 } else {
1051 defaultEncryption = Message.ENCRYPTION_NONE;
1052 }
1053 int encryption = this.getIntAttribute(ATTRIBUTE_NEXT_ENCRYPTION, defaultEncryption);
1054 if (encryption == Message.ENCRYPTION_OTR || encryption < 0) {
1055 return defaultEncryption;
1056 } else {
1057 return encryption;
1058 }
1059 }
1060
1061 public boolean setNextEncryption(int encryption) {
1062 return this.setAttribute(ATTRIBUTE_NEXT_ENCRYPTION, encryption);
1063 }
1064
1065 public String getNextMessage() {
1066 final String nextMessage = getAttribute(ATTRIBUTE_NEXT_MESSAGE);
1067 return nextMessage == null ? "" : nextMessage;
1068 }
1069
1070 public @Nullable
1071 Draft getDraft() {
1072 long timestamp = getLongAttribute(ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP, 0);
1073 if (timestamp > getLatestMessage().getTimeSent()) {
1074 String message = getAttribute(ATTRIBUTE_NEXT_MESSAGE);
1075 if (!TextUtils.isEmpty(message) && timestamp != 0) {
1076 return new Draft(message, timestamp);
1077 }
1078 }
1079 return null;
1080 }
1081
1082 public boolean setNextMessage(final String input) {
1083 final String message = input == null || input.trim().isEmpty() ? null : input;
1084 boolean changed = !getNextMessage().equals(message);
1085 this.setAttribute(ATTRIBUTE_NEXT_MESSAGE, message);
1086 if (changed) {
1087 this.setAttribute(ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP, message == null ? 0 : System.currentTimeMillis());
1088 }
1089 return changed;
1090 }
1091
1092 public Bookmark getBookmark() {
1093 return this.account.getBookmark(this.contactJid);
1094 }
1095
1096 public Message findDuplicateMessage(Message message) {
1097 synchronized (this.messages) {
1098 for (int i = this.messages.size() - 1; i >= 0; --i) {
1099 if (this.messages.get(i).similar(message)) {
1100 return this.messages.get(i);
1101 }
1102 }
1103 }
1104 return null;
1105 }
1106
1107 public boolean hasDuplicateMessage(Message message) {
1108 return findDuplicateMessage(message) != null;
1109 }
1110
1111 public Message findSentMessageWithBody(String body) {
1112 synchronized (this.messages) {
1113 for (int i = this.messages.size() - 1; i >= 0; --i) {
1114 Message message = this.messages.get(i);
1115 if (message.getStatus() == Message.STATUS_UNSEND || message.getStatus() == Message.STATUS_SEND) {
1116 String otherBody;
1117 if (message.hasFileOnRemoteHost()) {
1118 otherBody = message.getFileParams().url;
1119 } else {
1120 otherBody = message.body;
1121 }
1122 if (otherBody != null && otherBody.equals(body)) {
1123 return message;
1124 }
1125 }
1126 }
1127 return null;
1128 }
1129 }
1130
1131 public Message findRtpSession(final String sessionId, final int s) {
1132 synchronized (this.messages) {
1133 for (int i = this.messages.size() - 1; i >= 0; --i) {
1134 final Message message = this.messages.get(i);
1135 if ((message.getStatus() == s) && (message.getType() == Message.TYPE_RTP_SESSION) && sessionId.equals(message.getRemoteMsgId())) {
1136 return message;
1137 }
1138 }
1139 }
1140 return null;
1141 }
1142
1143 public boolean possibleDuplicate(final String serverMsgId, final String remoteMsgId) {
1144 if (serverMsgId == null || remoteMsgId == null) {
1145 return false;
1146 }
1147 synchronized (this.messages) {
1148 for (Message message : this.messages) {
1149 if (serverMsgId.equals(message.getServerMsgId()) || remoteMsgId.equals(message.getRemoteMsgId())) {
1150 return true;
1151 }
1152 }
1153 }
1154 return false;
1155 }
1156
1157 public MamReference getLastMessageTransmitted() {
1158 final MamReference lastClear = getLastClearHistory();
1159 MamReference lastReceived = new MamReference(0);
1160 synchronized (this.messages) {
1161 for (int i = this.messages.size() - 1; i >= 0; --i) {
1162 final Message message = this.messages.get(i);
1163 if (message.isPrivateMessage()) {
1164 continue; //it's unsafe to use private messages as anchor. They could be coming from user archive
1165 }
1166 if (message.getStatus() == Message.STATUS_RECEIVED || message.isCarbon() || message.getServerMsgId() != null) {
1167 lastReceived = new MamReference(message.getTimeSent(), message.getServerMsgId());
1168 break;
1169 }
1170 }
1171 }
1172 return MamReference.max(lastClear, lastReceived);
1173 }
1174
1175 public void setMutedTill(long value) {
1176 this.setAttribute(ATTRIBUTE_MUTED_TILL, String.valueOf(value));
1177 }
1178
1179 public boolean isMuted() {
1180 return System.currentTimeMillis() < this.getLongAttribute(ATTRIBUTE_MUTED_TILL, 0);
1181 }
1182
1183 public boolean alwaysNotify() {
1184 return mode == MODE_SINGLE || getBooleanAttribute(ATTRIBUTE_ALWAYS_NOTIFY, Config.ALWAYS_NOTIFY_BY_DEFAULT || isPrivateAndNonAnonymous());
1185 }
1186
1187 public boolean notifyReplies() {
1188 return alwaysNotify() || getBooleanAttribute(ATTRIBUTE_NOTIFY_REPLIES, false);
1189 }
1190
1191 public void setStoreInCache(final boolean cache) {
1192 setAttribute("storeMedia", cache ? "cache" : "shared");
1193 }
1194
1195 public boolean storeInCache() {
1196 if ("cache".equals(getAttribute("storeMedia"))) return true;
1197 if ("shared".equals(getAttribute("storeMedia"))) return false;
1198 if (mode == Conversation.MODE_MULTI && !mucOptions.isPrivateAndNonAnonymous()) return true;
1199 return false;
1200 }
1201
1202 public boolean setAttribute(String key, boolean value) {
1203 return setAttribute(key, String.valueOf(value));
1204 }
1205
1206 private boolean setAttribute(String key, long value) {
1207 return setAttribute(key, Long.toString(value));
1208 }
1209
1210 private boolean setAttribute(String key, int value) {
1211 return setAttribute(key, String.valueOf(value));
1212 }
1213
1214 public boolean setAttribute(String key, String value) {
1215 synchronized (this.attributes) {
1216 try {
1217 if (value == null) {
1218 if (this.attributes.has(key)) {
1219 this.attributes.remove(key);
1220 return true;
1221 } else {
1222 return false;
1223 }
1224 } else {
1225 final String prev = this.attributes.optString(key, null);
1226 this.attributes.put(key, value);
1227 return !value.equals(prev);
1228 }
1229 } catch (JSONException e) {
1230 throw new AssertionError(e);
1231 }
1232 }
1233 }
1234
1235 public boolean setAttribute(String key, List<Jid> jids) {
1236 JSONArray array = new JSONArray();
1237 for (Jid jid : jids) {
1238 array.put(jid.asBareJid().toString());
1239 }
1240 synchronized (this.attributes) {
1241 try {
1242 this.attributes.put(key, array);
1243 return true;
1244 } catch (JSONException e) {
1245 return false;
1246 }
1247 }
1248 }
1249
1250 public String getAttribute(String key) {
1251 synchronized (this.attributes) {
1252 return this.attributes.optString(key, null);
1253 }
1254 }
1255
1256 private List<Jid> getJidListAttribute(String key) {
1257 ArrayList<Jid> list = new ArrayList<>();
1258 synchronized (this.attributes) {
1259 try {
1260 JSONArray array = this.attributes.getJSONArray(key);
1261 for (int i = 0; i < array.length(); ++i) {
1262 try {
1263 list.add(Jid.of(array.getString(i)));
1264 } catch (IllegalArgumentException e) {
1265 //ignored
1266 }
1267 }
1268 } catch (JSONException e) {
1269 //ignored
1270 }
1271 }
1272 return list;
1273 }
1274
1275 private int getIntAttribute(String key, int defaultValue) {
1276 String value = this.getAttribute(key);
1277 if (value == null) {
1278 return defaultValue;
1279 } else {
1280 try {
1281 return Integer.parseInt(value);
1282 } catch (NumberFormatException e) {
1283 return defaultValue;
1284 }
1285 }
1286 }
1287
1288 public long getLongAttribute(String key, long defaultValue) {
1289 String value = this.getAttribute(key);
1290 if (value == null) {
1291 return defaultValue;
1292 } else {
1293 try {
1294 return Long.parseLong(value);
1295 } catch (NumberFormatException e) {
1296 return defaultValue;
1297 }
1298 }
1299 }
1300
1301 public boolean getBooleanAttribute(String key, boolean defaultValue) {
1302 String value = this.getAttribute(key);
1303 if (value == null) {
1304 return defaultValue;
1305 } else {
1306 return Boolean.parseBoolean(value);
1307 }
1308 }
1309
1310 public void remove(Message message) {
1311 synchronized (this.messages) {
1312 this.messages.remove(message);
1313 }
1314 }
1315
1316 public void add(Message message) {
1317 synchronized (this.messages) {
1318 this.messages.add(message);
1319 }
1320 }
1321
1322 public void prepend(int offset, Message message) {
1323 synchronized (this.messages) {
1324 this.messages.add(Math.min(offset, this.messages.size()), message);
1325 }
1326 }
1327
1328 public void addAll(int index, List<Message> messages) {
1329 synchronized (this.messages) {
1330 this.messages.addAll(index, messages);
1331 }
1332 account.getPgpDecryptionService().decrypt(messages);
1333 }
1334
1335 public void expireOldMessages(long timestamp) {
1336 synchronized (this.messages) {
1337 for (ListIterator<Message> iterator = this.messages.listIterator(); iterator.hasNext(); ) {
1338 if (iterator.next().getTimeSent() < timestamp) {
1339 iterator.remove();
1340 }
1341 }
1342 untieMessages();
1343 }
1344 }
1345
1346 public void sort() {
1347 synchronized (this.messages) {
1348 Collections.sort(this.messages, (left, right) -> {
1349 if (left.getTimeSent() < right.getTimeSent()) {
1350 return -1;
1351 } else if (left.getTimeSent() > right.getTimeSent()) {
1352 return 1;
1353 } else {
1354 return 0;
1355 }
1356 });
1357 untieMessages();
1358 }
1359 }
1360
1361 private void untieMessages() {
1362 for (Message message : this.messages) {
1363 message.untie();
1364 }
1365 }
1366
1367 public int unreadCount(XmppConnectionService xmppConnectionService) {
1368 synchronized (this.messages) {
1369 int count = 0;
1370 for(final Message message : Lists.reverse(this.messages)) {
1371 if (message.getSubject() != null && !message.isOOb() && (message.getRawBody() == null || message.getRawBody().length() == 0)) continue;
1372 final boolean muted = xmppConnectionService != null && message.getStatus() == Message.STATUS_RECEIVED && getMode() == Conversation.MODE_MULTI && xmppConnectionService.isMucUserMuted(new MucOptions.User(null, getJid(), message.getOccupantId(), null, null));
1373 if (muted) continue;
1374 if (message.isRead()) {
1375 if (message.getType() == Message.TYPE_RTP_SESSION) {
1376 continue;
1377 }
1378 return count;
1379 }
1380 ++count;
1381 }
1382 return count;
1383 }
1384 }
1385
1386 public int receivedMessagesCount() {
1387 int count = 0;
1388 synchronized (this.messages) {
1389 for (Message message : messages) {
1390 if (message.getSubject() != null && !message.isOOb() && (message.getRawBody() == null || message.getRawBody().length() == 0)) continue;
1391 if (message.getStatus() == Message.STATUS_RECEIVED) {
1392 ++count;
1393 }
1394 }
1395 }
1396 return count;
1397 }
1398
1399 public int sentMessagesCount() {
1400 int count = 0;
1401 synchronized (this.messages) {
1402 for (Message message : messages) {
1403 if (message.getStatus() != Message.STATUS_RECEIVED) {
1404 ++count;
1405 }
1406 }
1407 }
1408 return count;
1409 }
1410
1411 public boolean canInferPresence() {
1412 final Contact contact = getContact();
1413 if (contact != null && contact.canInferPresence()) return true;
1414 return sentMessagesCount() > 0;
1415 }
1416
1417 public boolean isWithStranger() {
1418 final Contact contact = getContact();
1419 return mode == MODE_SINGLE
1420 && !contact.isOwnServer()
1421 && !contact.showInContactList()
1422 && !contact.isSelf()
1423 && !(contact.getJid().isDomainJid() && JidHelper.isQuicksyDomain(contact.getJid()))
1424 && sentMessagesCount() == 0;
1425 }
1426
1427 public boolean strangerInvited() {
1428 final var inviterS = getAttribute("inviter");
1429 if (inviterS == null) return false;
1430 final var inviter = account.getRoster().getContact(Jid.of(inviterS));
1431 return getBookmark() == null && !inviter.showInContactList() && !inviter.isSelf() && sentMessagesCount() == 0;
1432 }
1433
1434 public int getReceivedMessagesCountSinceUuid(String uuid) {
1435 if (uuid == null) {
1436 return 0;
1437 }
1438 int count = 0;
1439 synchronized (this.messages) {
1440 for (int i = messages.size() - 1; i >= 0; i--) {
1441 final Message message = messages.get(i);
1442 if (uuid.equals(message.getUuid())) {
1443 return count;
1444 }
1445 if (message.getStatus() <= Message.STATUS_RECEIVED) {
1446 ++count;
1447 }
1448 }
1449 }
1450 return 0;
1451 }
1452
1453 @Override
1454 public int getAvatarBackgroundColor() {
1455 return UIHelper.getColorForName(getName().toString());
1456 }
1457
1458 @Override
1459 public String getAvatarName() {
1460 return getName().toString();
1461 }
1462
1463 public void setCurrentTab(int tab) {
1464 mCurrentTab = tab;
1465 }
1466
1467 public int getCurrentTab() {
1468 if (mCurrentTab >= 0) return mCurrentTab;
1469
1470 if (!isRead(null) || getContact().resourceWhichSupport(Namespace.COMMANDS) == null) {
1471 return 0;
1472 }
1473
1474 return 1;
1475 }
1476
1477 public void refreshSessions() {
1478 pagerAdapter.refreshSessions();
1479 }
1480
1481 public void startWebxdc(WebxdcPage page) {
1482 pagerAdapter.startWebxdc(page);
1483 }
1484
1485 public void webxdcRealtimeData(final Element thread, final String base64) {
1486 pagerAdapter.webxdcRealtimeData(thread, base64);
1487 }
1488
1489 public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
1490 pagerAdapter.startCommand(command, xmppConnectionService);
1491 }
1492
1493 public void startMucConfig(XmppConnectionService xmppConnectionService) {
1494 pagerAdapter.startMucConfig(xmppConnectionService);
1495 }
1496
1497 public boolean switchToSession(final String node) {
1498 return pagerAdapter.switchToSession(node);
1499 }
1500
1501 public void setupViewPager(ViewPager pager, TabLayout tabs, boolean onboarding, Conversation oldConversation) {
1502 pagerAdapter.setupViewPager(pager, tabs, onboarding, oldConversation);
1503 }
1504
1505 public void showViewPager() {
1506 pagerAdapter.show();
1507 }
1508
1509 public void hideViewPager() {
1510 pagerAdapter.hide();
1511 }
1512
1513 public void setDisplayState(final String stanzaId) {
1514 this.displayState = stanzaId;
1515 }
1516
1517 public String getDisplayState() {
1518 return this.displayState;
1519 }
1520
1521 public interface OnMessageFound {
1522 void onMessageFound(final Message message);
1523 }
1524
1525 public static class Draft {
1526 private final String message;
1527 private final long timestamp;
1528
1529 private Draft(String message, long timestamp) {
1530 this.message = message;
1531 this.timestamp = timestamp;
1532 }
1533
1534 public long getTimestamp() {
1535 return timestamp;
1536 }
1537
1538 public String getMessage() {
1539 return message;
1540 }
1541 }
1542
1543 public class ConversationPagerAdapter extends PagerAdapter {
1544 protected ViewPager mPager = null;
1545 protected TabLayout mTabs = null;
1546 ArrayList<ConversationPage> sessions = null;
1547 protected View page1 = null;
1548 protected View page2 = null;
1549 protected boolean mOnboarding = false;
1550
1551 public void setupViewPager(ViewPager pager, TabLayout tabs, boolean onboarding, Conversation oldConversation) {
1552 mPager = pager;
1553 mTabs = tabs;
1554 mOnboarding = onboarding;
1555
1556 if (oldConversation != null) {
1557 oldConversation.pagerAdapter.mPager = null;
1558 oldConversation.pagerAdapter.mTabs = null;
1559 }
1560
1561 if (mPager == null) {
1562 page1 = null;
1563 page2 = null;
1564 return;
1565 }
1566 if (sessions != null) show();
1567
1568 if (pager.getChildAt(0) != null) page1 = pager.getChildAt(0);
1569 if (pager.getChildAt(1) != null) page2 = pager.getChildAt(1);
1570 if (page2 != null && page2.findViewById(R.id.commands_view) == null) {
1571 page1 = null;
1572 page2 = null;
1573 }
1574 if (page1 == null) page1 = oldConversation.pagerAdapter.page1;
1575 if (page2 == null) page2 = oldConversation.pagerAdapter.page2;
1576 if (page1 == null || page2 == null) {
1577 throw new IllegalStateException("page1 or page2 were not present as child or in model?");
1578 }
1579 pager.removeView(page1);
1580 pager.removeView(page2);
1581 pager.setAdapter(this);
1582 tabs.setupWithViewPager(mPager);
1583 pager.post(() -> pager.setCurrentItem(getCurrentTab()));
1584
1585 mPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
1586 public void onPageScrollStateChanged(int state) { }
1587 public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { }
1588
1589 public void onPageSelected(int position) {
1590 setCurrentTab(position);
1591 }
1592 });
1593 }
1594
1595 public void show() {
1596 if (sessions == null) {
1597 sessions = new ArrayList<>();
1598 notifyDataSetChanged();
1599 }
1600 if (!mOnboarding && mTabs != null) mTabs.setVisibility(View.VISIBLE);
1601 }
1602
1603 public void hide() {
1604 if (sessions != null && !sessions.isEmpty()) return; // Do not hide during active session
1605 if (mPager != null) mPager.setCurrentItem(0);
1606 if (mTabs != null) mTabs.setVisibility(View.GONE);
1607 sessions = null;
1608 notifyDataSetChanged();
1609 }
1610
1611 public void refreshSessions() {
1612 if (sessions == null) return;
1613
1614 for (ConversationPage session : sessions) {
1615 session.refresh();
1616 }
1617 }
1618
1619 public void webxdcRealtimeData(final Element thread, final String base64) {
1620 if (sessions == null) return;
1621
1622 for (ConversationPage session : sessions) {
1623 if (session instanceof WebxdcPage) {
1624 if (((WebxdcPage) session).threadMatches(thread)) {
1625 ((WebxdcPage) session).realtimeData(base64);
1626 }
1627 }
1628 }
1629 }
1630
1631 public void startWebxdc(WebxdcPage page) {
1632 show();
1633 sessions.add(page);
1634 notifyDataSetChanged();
1635 if (mPager != null) mPager.setCurrentItem(getCount() - 1);
1636 }
1637
1638 public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
1639 show();
1640 CommandSession session = new CommandSession(command.getAttribute("name"), command.getAttribute("node"), xmppConnectionService);
1641
1642 final var packet = new Iq(Iq.Type.SET);
1643 packet.setTo(command.getAttributeAsJid("jid"));
1644 final Element c = packet.addChild("command", Namespace.COMMANDS);
1645 c.setAttribute("node", command.getAttribute("node"));
1646 c.setAttribute("action", "execute");
1647
1648 final TimerTask task = new TimerTask() {
1649 @Override
1650 public void run() {
1651 if (getAccount().getStatus() != Account.State.ONLINE) {
1652 final TimerTask self = this;
1653 new Timer().schedule(new TimerTask() {
1654 @Override
1655 public void run() {
1656 self.run();
1657 }
1658 }, 1000);
1659 } else {
1660 xmppConnectionService.sendIqPacket(getAccount(), packet, (iq) -> {
1661 session.updateWithResponse(iq);
1662 }, 120L);
1663 }
1664 }
1665 };
1666
1667 if (command.getAttribute("node").equals("jabber:iq:register") && packet.getTo().asBareJid().equals(Jid.of("cheogram.com"))) {
1668 new com.cheogram.android.CheogramLicenseChecker(mPager.getContext(), (signedData, signature) -> {
1669 if (signedData != null && signature != null) {
1670 c.addChild("license", "https://ns.cheogram.com/google-play").setContent(signedData);
1671 c.addChild("licenseSignature", "https://ns.cheogram.com/google-play").setContent(signature);
1672 }
1673
1674 task.run();
1675 }).checkLicense();
1676 } else {
1677 task.run();
1678 }
1679
1680 sessions.add(session);
1681 notifyDataSetChanged();
1682 if (mPager != null) mPager.setCurrentItem(getCount() - 1);
1683 }
1684
1685 public void startMucConfig(XmppConnectionService xmppConnectionService) {
1686 MucConfigSession session = new MucConfigSession(xmppConnectionService);
1687 final var packet = new Iq(Iq.Type.GET);
1688 packet.setTo(Conversation.this.getJid().asBareJid());
1689 packet.addChild("query", "http://jabber.org/protocol/muc#owner");
1690
1691 final TimerTask task = new TimerTask() {
1692 @Override
1693 public void run() {
1694 if (getAccount().getStatus() != Account.State.ONLINE) {
1695 final TimerTask self = this;
1696 new Timer().schedule(new TimerTask() {
1697 @Override
1698 public void run() {
1699 self.run();
1700 }
1701 }, 1000);
1702 } else {
1703 xmppConnectionService.sendIqPacket(getAccount(), packet, (iq) -> {
1704 session.updateWithResponse(iq);
1705 }, 120L);
1706 }
1707 }
1708 };
1709 task.run();
1710
1711 sessions.add(session);
1712 notifyDataSetChanged();
1713 if (mPager != null) mPager.setCurrentItem(getCount() - 1);
1714 }
1715
1716 public void removeSession(ConversationPage session) {
1717 sessions.remove(session);
1718 notifyDataSetChanged();
1719 if (session instanceof WebxdcPage) mPager.setCurrentItem(0);
1720 }
1721
1722 public boolean switchToSession(final String node) {
1723 if (sessions == null) return false;
1724
1725 int i = 0;
1726 for (ConversationPage session : sessions) {
1727 if (session.getNode().equals(node)) {
1728 if (mPager != null) mPager.setCurrentItem(i + 2);
1729 return true;
1730 }
1731 i++;
1732 }
1733
1734 return false;
1735 }
1736
1737 @NonNull
1738 @Override
1739 public Object instantiateItem(@NonNull ViewGroup container, int position) {
1740 if (position == 0) {
1741 if (page1 != null && page1.getParent() != null) {
1742 ((ViewGroup) page1.getParent()).removeView(page1);
1743 }
1744 container.addView(page1);
1745 return page1;
1746 }
1747 if (position == 1) {
1748 if (page2 != null && page2.getParent() != null) {
1749 ((ViewGroup) page2.getParent()).removeView(page2);
1750 }
1751 container.addView(page2);
1752 return page2;
1753 }
1754
1755 if (position-2 > sessions.size()) return null;
1756 ConversationPage session = sessions.get(position-2);
1757 View v = session.inflateUi(container.getContext(), (s) -> removeSession(s));
1758 if (v != null && v.getParent() != null) {
1759 ((ViewGroup) v.getParent()).removeView(v);
1760 }
1761 container.addView(v);
1762 return session;
1763 }
1764
1765 @Override
1766 public void destroyItem(@NonNull ViewGroup container, int position, Object o) {
1767 if (position < 2) {
1768 container.removeView((View) o);
1769 return;
1770 }
1771
1772 container.removeView(((ConversationPage) o).getView());
1773 }
1774
1775 @Override
1776 public int getItemPosition(Object o) {
1777 if (mPager != null) {
1778 if (o == page1) return PagerAdapter.POSITION_UNCHANGED;
1779 if (o == page2) return PagerAdapter.POSITION_UNCHANGED;
1780 }
1781
1782 int pos = sessions == null ? -1 : sessions.indexOf(o);
1783 if (pos < 0) return PagerAdapter.POSITION_NONE;
1784 return pos + 2;
1785 }
1786
1787 @Override
1788 public int getCount() {
1789 if (sessions == null) return 1;
1790
1791 int count = 2 + sessions.size();
1792 if (mTabs == null) return count;
1793
1794 if (count > 2) {
1795 mTabs.setTabMode(TabLayout.MODE_SCROLLABLE);
1796 } else {
1797 mTabs.setTabMode(TabLayout.MODE_FIXED);
1798 }
1799 return count;
1800 }
1801
1802 @Override
1803 public boolean isViewFromObject(@NonNull View view, @NonNull Object o) {
1804 if (view == o) return true;
1805
1806 if (o instanceof ConversationPage) {
1807 return ((ConversationPage) o).getView() == view;
1808 }
1809
1810 return false;
1811 }
1812
1813 @Nullable
1814 @Override
1815 public CharSequence getPageTitle(int position) {
1816 switch (position) {
1817 case 0:
1818 return "Conversation";
1819 case 1:
1820 return "Commands";
1821 default:
1822 ConversationPage session = sessions.get(position-2);
1823 if (session == null) return super.getPageTitle(position);
1824 return session.getTitle();
1825 }
1826 }
1827
1828 class CommandSession extends RecyclerView.Adapter<CommandSession.ViewHolder> implements ConversationPage {
1829 abstract class ViewHolder<T extends ViewDataBinding> extends RecyclerView.ViewHolder {
1830 protected T binding;
1831
1832 public ViewHolder(T binding) {
1833 super(binding.getRoot());
1834 this.binding = binding;
1835 }
1836
1837 abstract public void bind(Item el);
1838
1839 protected void setTextOrHide(TextView v, Optional<String> s) {
1840 if (s == null || !s.isPresent()) {
1841 v.setVisibility(View.GONE);
1842 } else {
1843 v.setVisibility(View.VISIBLE);
1844 v.setText(s.get());
1845 }
1846 }
1847
1848 protected void setupInputType(Element field, TextView textinput, TextInputLayout layout) {
1849 int flags = 0;
1850 if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_NONE);
1851 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1852
1853 String type = field.getAttribute("type");
1854 if (type != null) {
1855 if (type.equals("text-multi") || type.equals("jid-multi")) {
1856 flags |= InputType.TYPE_TEXT_FLAG_MULTI_LINE;
1857 }
1858
1859 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1860
1861 if (type.equals("jid-single") || type.equals("jid-multi")) {
1862 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
1863 }
1864
1865 if (type.equals("text-private")) {
1866 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
1867 if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_PASSWORD_TOGGLE);
1868 }
1869 }
1870
1871 Element validate = field.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1872 if (validate == null) return;
1873 String datatype = validate.getAttribute("datatype");
1874 if (datatype == null) return;
1875
1876 if (datatype.equals("xs:integer") || datatype.equals("xs:int") || datatype.equals("xs:long") || datatype.equals("xs:short") || datatype.equals("xs:byte")) {
1877 textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED);
1878 }
1879
1880 if (datatype.equals("xs:decimal") || datatype.equals("xs:double")) {
1881 textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED | InputType.TYPE_NUMBER_FLAG_DECIMAL);
1882 }
1883
1884 if (datatype.equals("xs:date")) {
1885 textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_DATE);
1886 }
1887
1888 if (datatype.equals("xs:dateTime")) {
1889 textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_NORMAL);
1890 }
1891
1892 if (datatype.equals("xs:time")) {
1893 textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_TIME);
1894 }
1895
1896 if (datatype.equals("xs:anyURI")) {
1897 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
1898 }
1899
1900 if (datatype.equals("html:tel")) {
1901 textinput.setInputType(flags | InputType.TYPE_CLASS_PHONE);
1902 }
1903
1904 if (datatype.equals("html:email")) {
1905 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
1906 }
1907 }
1908
1909 protected String formatValue(String datatype, String value, boolean compact) {
1910 if ("xs:dateTime".equals(datatype)) {
1911 ZonedDateTime zonedDateTime = null;
1912 try {
1913 zonedDateTime = ZonedDateTime.parse(value, DateTimeFormatter.ISO_DATE_TIME);
1914 } catch (final DateTimeParseException e) {
1915 try {
1916 DateTimeFormatter almostIso = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm[:ss] X");
1917 zonedDateTime = ZonedDateTime.parse(value, almostIso);
1918 } catch (final DateTimeParseException e2) { }
1919 }
1920 if (zonedDateTime == null) return value;
1921 ZonedDateTime localZonedDateTime = zonedDateTime.withZoneSameInstant(ZoneId.systemDefault());
1922 DateTimeFormatter outputFormat = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT);
1923 return localZonedDateTime.toLocalDateTime().format(outputFormat);
1924 }
1925
1926 if ("html:tel".equals(datatype) && !compact) {
1927 return PhoneNumberUtils.formatNumber(value, value, null);
1928 }
1929
1930 return value;
1931 }
1932 }
1933
1934 class ErrorViewHolder extends ViewHolder<CommandNoteBinding> {
1935 public ErrorViewHolder(CommandNoteBinding binding) { super(binding); }
1936
1937 @Override
1938 public void bind(Item iq) {
1939 binding.errorIcon.setVisibility(View.VISIBLE);
1940
1941 if (iq == null || iq.el == null) return;
1942 Element error = iq.el.findChild("error");
1943 if (error == null) {
1944 binding.message.setText("Unexpected response: " + iq);
1945 return;
1946 }
1947 String text = error.findChildContent("text", "urn:ietf:params:xml:ns:xmpp-stanzas");
1948 if (text == null || text.equals("")) {
1949 text = error.getChildren().get(0).getName();
1950 }
1951 binding.message.setText(text);
1952 }
1953 }
1954
1955 class NoteViewHolder extends ViewHolder<CommandNoteBinding> {
1956 public NoteViewHolder(CommandNoteBinding binding) { super(binding); }
1957
1958 @Override
1959 public void bind(Item note) {
1960 binding.message.setText(note != null && note.el != null ? note.el.getContent() : "");
1961
1962 String type = note.el.getAttribute("type");
1963 if (type != null && type.equals("error")) {
1964 binding.errorIcon.setVisibility(View.VISIBLE);
1965 }
1966 }
1967 }
1968
1969 class ResultFieldViewHolder extends ViewHolder<CommandResultFieldBinding> {
1970 public ResultFieldViewHolder(CommandResultFieldBinding binding) { super(binding); }
1971
1972 @Override
1973 public void bind(Item item) {
1974 Field field = (Field) item;
1975 setTextOrHide(binding.label, field.getLabel());
1976 setTextOrHide(binding.desc, field.getDesc());
1977
1978 Element media = field.el.findChild("media", "urn:xmpp:media-element");
1979 if (media == null) {
1980 binding.mediaImage.setVisibility(View.GONE);
1981 } else {
1982 final LruCache<String, Drawable> cache = xmppConnectionService.getDrawableCache();
1983 final HttpConnectionManager httpManager = xmppConnectionService.getHttpConnectionManager();
1984 for (Element uriEl : media.getChildren()) {
1985 if (!"uri".equals(uriEl.getName())) continue;
1986 if (!"urn:xmpp:media-element".equals(uriEl.getNamespace())) continue;
1987 String mimeType = uriEl.getAttribute("type");
1988 String uriS = uriEl.getContent();
1989 if (mimeType == null || uriS == null) continue;
1990 Uri uri = Uri.parse(uriS);
1991 if (mimeType.startsWith("image/") && "https".equals(uri.getScheme())) {
1992 final Drawable d = getDrawableForUrl(uri.toString());
1993 if (d != null) {
1994 binding.mediaImage.setImageDrawable(d);
1995 binding.mediaImage.setVisibility(View.VISIBLE);
1996 }
1997 }
1998 }
1999 }
2000
2001 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2002 String datatype = validate == null ? null : validate.getAttribute("datatype");
2003
2004 ArrayAdapter<Option> values = new ArrayAdapter<>(binding.getRoot().getContext(), R.layout.simple_list_item);
2005 for (Element el : field.el.getChildren()) {
2006 if (el.getName().equals("value") && el.getNamespace().equals("jabber:x:data")) {
2007 values.add(new Option(el.getContent(), formatValue(datatype, el.getContent(), false)));
2008 }
2009 }
2010 binding.values.setAdapter(values);
2011 Util.justifyListViewHeightBasedOnChildren(binding.values);
2012
2013 if (field.getType().equals(Optional.of("jid-single")) || field.getType().equals(Optional.of("jid-multi"))) {
2014 binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
2015 new FixedURLSpan("xmpp:" + Uri.encode(Jid.ofEscaped(values.getItem(pos).getValue()).toEscapedString(), "@/+"), account).onClick(binding.values);
2016 });
2017 } else if ("xs:anyURI".equals(datatype)) {
2018 binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
2019 new FixedURLSpan(values.getItem(pos).getValue(), account).onClick(binding.values);
2020 });
2021 } else if ("html:tel".equals(datatype)) {
2022 binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
2023 try {
2024 new FixedURLSpan("tel:" + PhoneNumberUtilWrapper.normalize(binding.getRoot().getContext(), values.getItem(pos).getValue()), account).onClick(binding.values);
2025 } catch (final IllegalArgumentException | NumberParseException | NullPointerException e) { }
2026 });
2027 }
2028
2029 binding.values.setOnItemLongClickListener((arg0, arg1, pos, id) -> {
2030 if (ShareUtil.copyTextToClipboard(binding.getRoot().getContext(), values.getItem(pos).getValue(), R.string.message)) {
2031 Toast.makeText(binding.getRoot().getContext(), R.string.message_copied_to_clipboard, Toast.LENGTH_SHORT).show();
2032 }
2033 return true;
2034 });
2035 }
2036 }
2037
2038 class ResultCellViewHolder extends ViewHolder<CommandResultCellBinding> {
2039 public ResultCellViewHolder(CommandResultCellBinding binding) { super(binding); }
2040
2041 @Override
2042 public void bind(Item item) {
2043 Cell cell = (Cell) item;
2044
2045 if (cell.el == null) {
2046 binding.text.setTextAppearance(binding.getRoot().getContext(), com.google.android.material.R.style.TextAppearance_Material3_TitleMedium);
2047 setTextOrHide(binding.text, cell.reported.getLabel());
2048 } else {
2049 Element validate = cell.reported.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2050 String datatype = validate == null ? null : validate.getAttribute("datatype");
2051 String value = formatValue(datatype, cell.el.findChildContent("value", "jabber:x:data"), true);
2052 SpannableStringBuilder text = new SpannableStringBuilder(value == null ? "" : value);
2053 if (cell.reported.getType().equals(Optional.of("jid-single"))) {
2054 text.setSpan(new FixedURLSpan("xmpp:" + Jid.ofEscaped(text.toString()).toEscapedString(), account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
2055 } else if ("xs:anyURI".equals(datatype)) {
2056 text.setSpan(new FixedURLSpan(text.toString(), account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
2057 } else if ("html:tel".equals(datatype)) {
2058 try {
2059 text.setSpan(new FixedURLSpan("tel:" + PhoneNumberUtilWrapper.normalize(binding.getRoot().getContext(), text.toString()), account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
2060 } catch (final IllegalArgumentException | NumberParseException | NullPointerException e) { }
2061 }
2062
2063 binding.text.setTextAppearance(binding.getRoot().getContext(), com.google.android.material.R.style.TextAppearance_Material3_BodyMedium);
2064 binding.text.setText(text);
2065
2066 BetterLinkMovementMethod method = BetterLinkMovementMethod.newInstance();
2067 method.setOnLinkLongClickListener((tv, url) -> {
2068 tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
2069 ShareUtil.copyLinkToClipboard(binding.getRoot().getContext(), url);
2070 return true;
2071 });
2072 binding.text.setMovementMethod(method);
2073 }
2074 }
2075 }
2076
2077 class ItemCardViewHolder extends ViewHolder<CommandItemCardBinding> {
2078 public ItemCardViewHolder(CommandItemCardBinding binding) { super(binding); }
2079
2080 @Override
2081 public void bind(Item item) {
2082 binding.fields.removeAllViews();
2083
2084 for (Field field : reported) {
2085 CommandResultFieldBinding row = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.command_result_field, binding.fields, false);
2086 GridLayout.LayoutParams param = new GridLayout.LayoutParams();
2087 param.columnSpec = GridLayout.spec(GridLayout.UNDEFINED, GridLayout.FILL, 1f);
2088 param.width = 0;
2089 row.getRoot().setLayoutParams(param);
2090 binding.fields.addView(row.getRoot());
2091 for (Element el : item.el.getChildren()) {
2092 if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data") && el.getAttribute("var") != null && el.getAttribute("var").equals(field.getVar())) {
2093 for (String label : field.getLabel().asSet()) {
2094 el.setAttribute("label", label);
2095 }
2096 for (String desc : field.getDesc().asSet()) {
2097 el.setAttribute("desc", desc);
2098 }
2099 for (String type : field.getType().asSet()) {
2100 el.setAttribute("type", type);
2101 }
2102 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2103 if (validate != null) el.addChild(validate);
2104 new ResultFieldViewHolder(row).bind(new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), -1));
2105 }
2106 }
2107 }
2108 }
2109 }
2110
2111 class CheckboxFieldViewHolder extends ViewHolder<CommandCheckboxFieldBinding> implements CompoundButton.OnCheckedChangeListener {
2112 public CheckboxFieldViewHolder(CommandCheckboxFieldBinding binding) {
2113 super(binding);
2114 binding.row.setOnClickListener((v) -> {
2115 binding.checkbox.toggle();
2116 });
2117 binding.checkbox.setOnCheckedChangeListener(this);
2118 }
2119 protected Element mValue = null;
2120
2121 @Override
2122 public void bind(Item item) {
2123 Field field = (Field) item;
2124 binding.label.setText(field.getLabel().or(""));
2125 setTextOrHide(binding.desc, field.getDesc());
2126 mValue = field.getValue();
2127 final var isChecked = mValue.getContent() != null && (mValue.getContent().equals("true") || mValue.getContent().equals("1"));
2128 mValue.setContent(isChecked ? "true" : "false");
2129 binding.checkbox.setChecked(isChecked);
2130 }
2131
2132 @Override
2133 public void onCheckedChanged(CompoundButton checkbox, boolean isChecked) {
2134 if (mValue == null) return;
2135
2136 mValue.setContent(isChecked ? "true" : "false");
2137 }
2138 }
2139
2140 class SearchListFieldViewHolder extends ViewHolder<CommandSearchListFieldBinding> implements TextWatcher {
2141 public SearchListFieldViewHolder(CommandSearchListFieldBinding binding) {
2142 super(binding);
2143 binding.search.addTextChangedListener(this);
2144 }
2145 protected Field field = null;
2146 Set<String> filteredValues;
2147 List<Option> options = new ArrayList<>();
2148 protected ArrayAdapter<Option> adapter;
2149 protected boolean open;
2150 protected boolean multi;
2151 protected int textColor = -1;
2152
2153 @Override
2154 public void bind(Item item) {
2155 ViewGroup.LayoutParams layout = binding.list.getLayoutParams();
2156 final float density = xmppConnectionService.getResources().getDisplayMetrics().density;
2157 if (fillableFieldCount > 1) {
2158 layout.height = (int) (density * 200);
2159 } else {
2160 layout.height = (int) Math.max(density * 200, xmppConnectionService.getResources().getDisplayMetrics().heightPixels / 2);
2161 }
2162 binding.list.setLayoutParams(layout);
2163
2164 field = (Field) item;
2165 setTextOrHide(binding.label, field.getLabel());
2166 setTextOrHide(binding.desc, field.getDesc());
2167
2168 if (textColor == -1) textColor = binding.desc.getCurrentTextColor();
2169 if (field.error != null) {
2170 binding.desc.setVisibility(View.VISIBLE);
2171 binding.desc.setText(field.error);
2172 binding.desc.setTextColor(com.google.android.material.R.attr.colorError);
2173 } else {
2174 binding.desc.setTextColor(textColor);
2175 }
2176
2177 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2178 open = validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null;
2179 setupInputType(field.el, binding.search, null);
2180
2181 multi = field.getType().equals(Optional.of("list-multi"));
2182 if (multi) {
2183 binding.list.setChoiceMode(AbsListView.CHOICE_MODE_MULTIPLE);
2184 } else {
2185 binding.list.setChoiceMode(AbsListView.CHOICE_MODE_SINGLE);
2186 }
2187
2188 options = field.getOptions();
2189 binding.list.setOnItemClickListener((parent, view, position, id) -> {
2190 Set<String> values = new HashSet<>();
2191 if (multi) {
2192 values.addAll(field.getValues());
2193 for (final String value : field.getValues()) {
2194 if (filteredValues.contains(value)) {
2195 values.remove(value);
2196 }
2197 }
2198 }
2199
2200 SparseBooleanArray positions = binding.list.getCheckedItemPositions();
2201 for (int i = 0; i < positions.size(); i++) {
2202 if (positions.valueAt(i)) values.add(adapter.getItem(positions.keyAt(i)).getValue());
2203 }
2204 field.setValues(values);
2205
2206 if (!multi && open) binding.search.setText(String.join("\n", field.getValues()));
2207 });
2208 search("");
2209 }
2210
2211 @Override
2212 public void afterTextChanged(Editable s) {
2213 if (!multi && open) field.setValues(List.of(s.toString()));
2214 search(s.toString());
2215 }
2216
2217 @Override
2218 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2219
2220 @Override
2221 public void onTextChanged(CharSequence s, int start, int count, int after) { }
2222
2223 protected void search(String s) {
2224 List<Option> filteredOptions;
2225 final String q = s.replaceAll("\\W", "").toLowerCase();
2226 if (q == null || q.equals("")) {
2227 filteredOptions = options;
2228 } else {
2229 filteredOptions = options.stream().filter(o -> o.toString().replaceAll("\\W", "").toLowerCase().contains(q)).collect(Collectors.toList());
2230 }
2231 filteredValues = filteredOptions.stream().map(o -> o.getValue()).collect(Collectors.toSet());
2232 adapter = new ArrayAdapter(binding.getRoot().getContext(), R.layout.simple_list_item, filteredOptions);
2233 binding.list.setAdapter(adapter);
2234
2235 for (final String value : field.getValues()) {
2236 int checkedPos = filteredOptions.indexOf(new Option(value, ""));
2237 if (checkedPos >= 0) binding.list.setItemChecked(checkedPos, true);
2238 }
2239 }
2240 }
2241
2242 class RadioEditFieldViewHolder extends ViewHolder<CommandRadioEditFieldBinding> implements CompoundButton.OnCheckedChangeListener, TextWatcher {
2243 public RadioEditFieldViewHolder(CommandRadioEditFieldBinding binding) {
2244 super(binding);
2245 binding.open.addTextChangedListener(this);
2246 options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.radio_grid_item) {
2247 @Override
2248 public View getView(int position, View convertView, ViewGroup parent) {
2249 CompoundButton v = (CompoundButton) super.getView(position, convertView, parent);
2250 v.setId(position);
2251 v.setChecked(getItem(position).getValue().equals(mValue.getContent()));
2252 v.setOnCheckedChangeListener(RadioEditFieldViewHolder.this);
2253 return v;
2254 }
2255 };
2256 }
2257 protected Element mValue = null;
2258 protected ArrayAdapter<Option> options;
2259 protected int textColor = -1;
2260
2261 @Override
2262 public void bind(Item item) {
2263 Field field = (Field) item;
2264 setTextOrHide(binding.label, field.getLabel());
2265 setTextOrHide(binding.desc, field.getDesc());
2266
2267 if (textColor == -1) textColor = binding.desc.getCurrentTextColor();
2268 if (field.error != null) {
2269 binding.desc.setVisibility(View.VISIBLE);
2270 binding.desc.setText(field.error);
2271 binding.desc.setTextColor(com.google.android.material.R.attr.colorError);
2272 } else {
2273 binding.desc.setTextColor(textColor);
2274 }
2275
2276 mValue = field.getValue();
2277
2278 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2279 binding.open.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
2280 binding.open.setText(mValue.getContent());
2281 setupInputType(field.el, binding.open, null);
2282
2283 options.clear();
2284 List<Option> theOptions = field.getOptions();
2285 options.addAll(theOptions);
2286
2287 float screenWidth = binding.getRoot().getContext().getResources().getDisplayMetrics().widthPixels;
2288 TextPaint paint = ((TextView) LayoutInflater.from(binding.getRoot().getContext()).inflate(R.layout.radio_grid_item, null)).getPaint();
2289 float maxColumnWidth = theOptions.stream().map((x) ->
2290 StaticLayout.getDesiredWidth(x.toString(), paint)
2291 ).max(Float::compare).orElse(new Float(0.0));
2292 if (maxColumnWidth * theOptions.size() < 0.90 * screenWidth) {
2293 binding.radios.setNumColumns(theOptions.size());
2294 } else if (maxColumnWidth * (theOptions.size() / 2) < 0.90 * screenWidth) {
2295 binding.radios.setNumColumns(theOptions.size() / 2);
2296 } else {
2297 binding.radios.setNumColumns(1);
2298 }
2299 binding.radios.setAdapter(options);
2300 }
2301
2302 @Override
2303 public void onCheckedChanged(CompoundButton radio, boolean isChecked) {
2304 if (mValue == null) return;
2305
2306 if (isChecked) {
2307 mValue.setContent(options.getItem(radio.getId()).getValue());
2308 binding.open.setText(mValue.getContent());
2309 }
2310 options.notifyDataSetChanged();
2311 }
2312
2313 @Override
2314 public void afterTextChanged(Editable s) {
2315 if (mValue == null) return;
2316
2317 mValue.setContent(s.toString());
2318 options.notifyDataSetChanged();
2319 }
2320
2321 @Override
2322 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2323
2324 @Override
2325 public void onTextChanged(CharSequence s, int start, int count, int after) { }
2326 }
2327
2328 class SpinnerFieldViewHolder extends ViewHolder<CommandSpinnerFieldBinding> implements AdapterView.OnItemSelectedListener {
2329 public SpinnerFieldViewHolder(CommandSpinnerFieldBinding binding) {
2330 super(binding);
2331 binding.spinner.setOnItemSelectedListener(this);
2332 }
2333 protected Element mValue = null;
2334
2335 @Override
2336 public void bind(Item item) {
2337 Field field = (Field) item;
2338 setTextOrHide(binding.label, field.getLabel());
2339 binding.spinner.setPrompt(field.getLabel().or(""));
2340 setTextOrHide(binding.desc, field.getDesc());
2341
2342 mValue = field.getValue();
2343
2344 ArrayAdapter<Option> options = new ArrayAdapter<Option>(binding.getRoot().getContext(), android.R.layout.simple_spinner_item);
2345 options.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
2346 options.addAll(field.getOptions());
2347
2348 binding.spinner.setAdapter(options);
2349 binding.spinner.setSelection(options.getPosition(new Option(mValue.getContent(), null)));
2350 }
2351
2352 @Override
2353 public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
2354 Option o = (Option) parent.getItemAtPosition(pos);
2355 if (mValue == null) return;
2356
2357 mValue.setContent(o == null ? "" : o.getValue());
2358 }
2359
2360 @Override
2361 public void onNothingSelected(AdapterView<?> parent) {
2362 mValue.setContent("");
2363 }
2364 }
2365
2366 class ButtonGridFieldViewHolder extends ViewHolder<CommandButtonGridFieldBinding> {
2367 public ButtonGridFieldViewHolder(CommandButtonGridFieldBinding binding) {
2368 super(binding);
2369 options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.button_grid_item) {
2370 protected int height = 0;
2371
2372 @Override
2373 public View getView(int position, View convertView, ViewGroup parent) {
2374 Button v = (Button) super.getView(position, convertView, parent);
2375 v.setOnClickListener((view) -> {
2376 mValue.setContent(getItem(position).getValue());
2377 execute();
2378 loading = true;
2379 });
2380
2381 final SVG icon = getItem(position).getIcon();
2382 if (icon != null) {
2383 final Element iconEl = getItem(position).getIconEl();
2384 if (height < 1) {
2385 v.measure(0, 0);
2386 height = v.getMeasuredHeight();
2387 }
2388 if (height < 1) return v;
2389 if (mediaSelector) {
2390 final Drawable d = getDrawableForSVG(icon, iconEl, height * 4);
2391 if (d != null) {
2392 final int boundsHeight = 35 + (int)((height * 4) / xmppConnectionService.getResources().getDisplayMetrics().density);
2393 d.setBounds(0, 0, d.getIntrinsicWidth(), boundsHeight);
2394 }
2395 v.setCompoundDrawables(null, d, null, null);
2396 } else {
2397 v.setCompoundDrawablesRelativeWithIntrinsicBounds(getDrawableForSVG(icon, iconEl, height), null, null, null);
2398 }
2399 }
2400
2401 return v;
2402 }
2403 };
2404 }
2405 protected Element mValue = null;
2406 protected ArrayAdapter<Option> options;
2407 protected Option defaultOption = null;
2408 protected boolean mediaSelector = false;
2409 protected int textColor = -1;
2410
2411 @Override
2412 public void bind(Item item) {
2413 Field field = (Field) item;
2414 setTextOrHide(binding.label, field.getLabel());
2415 setTextOrHide(binding.desc, field.getDesc());
2416
2417 if (textColor == -1) textColor = binding.desc.getCurrentTextColor();
2418 if (field.error != null) {
2419 binding.desc.setVisibility(View.VISIBLE);
2420 binding.desc.setText(field.error);
2421 binding.desc.setTextColor(com.google.android.material.R.attr.colorError);
2422 } else {
2423 binding.desc.setTextColor(textColor);
2424 }
2425
2426 mValue = field.getValue();
2427 mediaSelector = field.el.findChild("media-selector", "https://ns.cheogram.com/") != null;
2428
2429 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2430 binding.openButton.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
2431 binding.openButton.setOnClickListener((view) -> {
2432 AlertDialog.Builder builder = new AlertDialog.Builder(binding.getRoot().getContext());
2433 DialogQuickeditBinding dialogBinding = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.dialog_quickedit, null, false);
2434 builder.setPositiveButton(R.string.action_execute, null);
2435 if (field.getDesc().isPresent()) {
2436 dialogBinding.inputLayout.setHint(field.getDesc().get());
2437 }
2438 dialogBinding.inputEditText.requestFocus();
2439 dialogBinding.inputEditText.getText().append(mValue.getContent());
2440 builder.setView(dialogBinding.getRoot());
2441 builder.setNegativeButton(R.string.cancel, null);
2442 final AlertDialog dialog = builder.create();
2443 dialog.setOnShowListener(d -> SoftKeyboardUtils.showKeyboard(dialogBinding.inputEditText));
2444 dialog.show();
2445 View.OnClickListener clickListener = v -> {
2446 String value = dialogBinding.inputEditText.getText().toString();
2447 mValue.setContent(value);
2448 SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2449 dialog.dismiss();
2450 execute();
2451 loading = true;
2452 };
2453 dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener(clickListener);
2454 dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setOnClickListener((v -> {
2455 SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2456 dialog.dismiss();
2457 }));
2458 dialog.setCanceledOnTouchOutside(false);
2459 dialog.setOnDismissListener(dialog1 -> {
2460 SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2461 });
2462 });
2463
2464 options.clear();
2465 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();
2466
2467 defaultOption = null;
2468 for (Option option : theOptions) {
2469 if (option.getValue().equals(mValue.getContent())) {
2470 defaultOption = option;
2471 break;
2472 }
2473 }
2474 if (defaultOption == null && !mValue.getContent().equals("")) {
2475 // Synthesize default option for custom value
2476 defaultOption = new Option(mValue.getContent(), mValue.getContent());
2477 }
2478 if (defaultOption == null) {
2479 binding.defaultButton.setVisibility(View.GONE);
2480 } else {
2481 theOptions.remove(defaultOption);
2482 binding.defaultButton.setVisibility(View.VISIBLE);
2483
2484 final SVG defaultIcon = defaultOption.getIcon();
2485 if (defaultIcon != null) {
2486 DisplayMetrics display = mPager.getContext().getResources().getDisplayMetrics();
2487 int height = (int)(display.heightPixels*display.density/4);
2488 binding.defaultButton.setCompoundDrawablesRelativeWithIntrinsicBounds(null, getDrawableForSVG(defaultIcon, defaultOption.getIconEl(), height), null, null);
2489 }
2490
2491 binding.defaultButton.setText(defaultOption.toString());
2492 binding.defaultButton.setOnClickListener((view) -> {
2493 mValue.setContent(defaultOption.getValue());
2494 execute();
2495 loading = true;
2496 });
2497 }
2498
2499 options.addAll(theOptions);
2500 binding.buttons.setAdapter(options);
2501 }
2502 }
2503
2504 class TextFieldViewHolder extends ViewHolder<CommandTextFieldBinding> implements TextWatcher {
2505 public TextFieldViewHolder(CommandTextFieldBinding binding) {
2506 super(binding);
2507 binding.textinput.addTextChangedListener(this);
2508 }
2509 protected Field field = null;
2510
2511 @Override
2512 public void bind(Item item) {
2513 field = (Field) item;
2514 binding.textinputLayout.setHint(field.getLabel().or(""));
2515
2516 binding.textinputLayout.setHelperTextEnabled(field.getDesc().isPresent());
2517 for (String desc : field.getDesc().asSet()) {
2518 binding.textinputLayout.setHelperText(desc);
2519 }
2520
2521 binding.textinputLayout.setErrorEnabled(field.error != null);
2522 if (field.error != null) binding.textinputLayout.setError(field.error);
2523
2524 binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_GRAVITY);
2525 String suffixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/suffix-label");
2526 if (suffixLabel == null) {
2527 binding.textinputLayout.setSuffixText("");
2528 } else {
2529 binding.textinputLayout.setSuffixText(suffixLabel);
2530 binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_TEXT_END);
2531 }
2532
2533 String prefixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/prefix-label");
2534 binding.textinputLayout.setPrefixText(prefixLabel == null ? "" : prefixLabel);
2535
2536 binding.textinput.setText(String.join("\n", field.getValues()));
2537 setupInputType(field.el, binding.textinput, binding.textinputLayout);
2538 }
2539
2540 @Override
2541 public void afterTextChanged(Editable s) {
2542 if (field == null) return;
2543
2544 field.setValues(List.of(s.toString().split("\n")));
2545 }
2546
2547 @Override
2548 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2549
2550 @Override
2551 public void onTextChanged(CharSequence s, int start, int count, int after) { }
2552 }
2553
2554 class SliderFieldViewHolder extends ViewHolder<CommandSliderFieldBinding> {
2555 public SliderFieldViewHolder(CommandSliderFieldBinding binding) { super(binding); }
2556 protected Field field = null;
2557
2558 @Override
2559 public void bind(Item item) {
2560 field = (Field) item;
2561 setTextOrHide(binding.label, field.getLabel());
2562 setTextOrHide(binding.desc, field.getDesc());
2563 final Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2564 final String datatype = validate == null ? null : validate.getAttribute("datatype");
2565 final Element range = validate == null ? null : validate.findChild("range", "http://jabber.org/protocol/xdata-validate");
2566 // NOTE: range also implies open, so we don't have to be bound by the options strictly
2567 // Also, we don't have anywhere to put labels so we show only values, which might sometimes be bad...
2568 Float min = null;
2569 try { min = range.getAttribute("min") == null ? null : Float.valueOf(range.getAttribute("min")); } catch (NumberFormatException e) { }
2570 Float max = null;
2571 try { max = range.getAttribute("max") == null ? null : Float.valueOf(range.getAttribute("max")); } catch (NumberFormatException e) { }
2572
2573 List<Float> options = field.getOptions().stream().map(o -> Float.valueOf(o.getValue())).collect(Collectors.toList());
2574 Collections.sort(options);
2575 if (options.size() > 0) {
2576 // min/max should be on the range, but if you have options and didn't put them on the range we can imply
2577 if (min == null) min = options.get(0);
2578 if (max == null) max = options.get(options.size()-1);
2579 }
2580
2581 if (field.getValues().size() > 0) {
2582 final var val = Float.valueOf(field.getValue().getContent());
2583 if ((min == null || val >= min) && (max == null || val <= max)) {
2584 binding.slider.setValue(Float.valueOf(field.getValue().getContent()));
2585 } else {
2586 binding.slider.setValue(min == null ? Float.MIN_VALUE : min);
2587 }
2588 } else {
2589 binding.slider.setValue(min == null ? Float.MIN_VALUE : min);
2590 }
2591 binding.slider.setValueFrom(min == null ? Float.MIN_VALUE : min);
2592 binding.slider.setValueTo(max == null ? Float.MAX_VALUE : max);
2593 if ("xs:integer".equals(datatype) || "xs:int".equals(datatype) || "xs:long".equals(datatype) || "xs:short".equals(datatype) || "xs:byte".equals(datatype)) {
2594 binding.slider.setStepSize(1);
2595 } else {
2596 binding.slider.setStepSize(0);
2597 }
2598
2599 if (options.size() > 0) {
2600 float step = -1;
2601 Float prev = null;
2602 for (final Float option : options) {
2603 if (prev != null) {
2604 float nextStep = option - prev;
2605 if (step > 0 && step != nextStep) {
2606 step = -1;
2607 break;
2608 }
2609 step = nextStep;
2610 }
2611 prev = option;
2612 }
2613 if (step > 0) binding.slider.setStepSize(step);
2614 }
2615
2616 binding.slider.addOnChangeListener((slider, value, fromUser) -> {
2617 field.setValues(List.of(new DecimalFormat().format(value)));
2618 });
2619 }
2620 }
2621
2622 class WebViewHolder extends ViewHolder<CommandWebviewBinding> {
2623 public WebViewHolder(CommandWebviewBinding binding) { super(binding); }
2624 protected String boundUrl = "";
2625
2626 @Override
2627 public void bind(Item oob) {
2628 setTextOrHide(binding.desc, Optional.fromNullable(oob.el.findChildContent("desc", "jabber:x:oob")));
2629 binding.webview.getSettings().setJavaScriptEnabled(true);
2630 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");
2631 binding.webview.getSettings().setDatabaseEnabled(true);
2632 binding.webview.getSettings().setDomStorageEnabled(true);
2633 binding.webview.setWebChromeClient(new WebChromeClient() {
2634 @Override
2635 public void onProgressChanged(WebView view, int newProgress) {
2636 binding.progressbar.setVisibility(newProgress < 100 ? View.VISIBLE : View.GONE);
2637 binding.progressbar.setProgress(newProgress);
2638 }
2639 });
2640 binding.webview.setWebViewClient(new WebViewClient() {
2641 @Override
2642 public void onPageFinished(WebView view, String url) {
2643 super.onPageFinished(view, url);
2644 mTitle = view.getTitle();
2645 ConversationPagerAdapter.this.notifyDataSetChanged();
2646 }
2647 });
2648 final String url = oob.el.findChildContent("url", "jabber:x:oob");
2649 if (!boundUrl.equals(url)) {
2650 binding.webview.addJavascriptInterface(new JsObject(), "xmpp_xep0050");
2651 binding.webview.loadUrl(url);
2652 boundUrl = url;
2653 }
2654 }
2655
2656 class JsObject {
2657 @JavascriptInterface
2658 public void execute() { execute("execute"); }
2659
2660 @JavascriptInterface
2661 public void execute(String action) {
2662 getView().post(() -> {
2663 actionToWebview = null;
2664 if(CommandSession.this.execute(action)) {
2665 removeSession(CommandSession.this);
2666 }
2667 });
2668 }
2669
2670 @JavascriptInterface
2671 public void preventDefault() {
2672 actionToWebview = binding.webview;
2673 }
2674 }
2675 }
2676
2677 class ProgressBarViewHolder extends ViewHolder<CommandProgressBarBinding> {
2678 public ProgressBarViewHolder(CommandProgressBarBinding binding) { super(binding); }
2679
2680 @Override
2681 public void bind(Item item) {
2682 binding.text.setVisibility(loadingHasBeenLong ? View.VISIBLE : View.GONE);
2683 }
2684 }
2685
2686 class Item {
2687 protected Element el;
2688 protected int viewType;
2689 protected String error = null;
2690
2691 Item(Element el, int viewType) {
2692 this.el = el;
2693 this.viewType = viewType;
2694 }
2695
2696 public boolean validate() {
2697 error = null;
2698 return true;
2699 }
2700 }
2701
2702 class Field extends Item {
2703 Field(eu.siacs.conversations.xmpp.forms.Field el, int viewType) { super(el, viewType); }
2704
2705 @Override
2706 public boolean validate() {
2707 if (!super.validate()) return false;
2708 if (el.findChild("required", "jabber:x:data") == null) return true;
2709 if (getValue().getContent() != null && !getValue().getContent().equals("")) return true;
2710
2711 error = "this value is required";
2712 return false;
2713 }
2714
2715 public String getVar() {
2716 return el.getAttribute("var");
2717 }
2718
2719 public Optional<String> getType() {
2720 return Optional.fromNullable(el.getAttribute("type"));
2721 }
2722
2723 public Optional<String> getLabel() {
2724 String label = el.getAttribute("label");
2725 if (label == null) label = getVar();
2726 return Optional.fromNullable(label);
2727 }
2728
2729 public Optional<String> getDesc() {
2730 return Optional.fromNullable(el.findChildContent("desc", "jabber:x:data"));
2731 }
2732
2733 public Element getValue() {
2734 Element value = el.findChild("value", "jabber:x:data");
2735 if (value == null) {
2736 value = el.addChild("value", "jabber:x:data");
2737 }
2738 return value;
2739 }
2740
2741 public void setValues(Collection<String> values) {
2742 for(Element child : el.getChildren()) {
2743 if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
2744 el.removeChild(child);
2745 }
2746 }
2747
2748 for (String value : values) {
2749 el.addChild("value", "jabber:x:data").setContent(value);
2750 }
2751 }
2752
2753 public List<String> getValues() {
2754 List<String> values = new ArrayList<>();
2755 for(Element child : el.getChildren()) {
2756 if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
2757 values.add(child.getContent());
2758 }
2759 }
2760 return values;
2761 }
2762
2763 public List<Option> getOptions() {
2764 return Option.forField(el);
2765 }
2766 }
2767
2768 class Cell extends Item {
2769 protected Field reported;
2770
2771 Cell(Field reported, Element item) {
2772 super(item, TYPE_RESULT_CELL);
2773 this.reported = reported;
2774 }
2775 }
2776
2777 protected Field mkField(Element el) {
2778 int viewType = -1;
2779
2780 String formType = responseElement.getAttribute("type");
2781 if (formType != null) {
2782 String fieldType = el.getAttribute("type");
2783 if (fieldType == null) fieldType = "text-single";
2784
2785 if (formType.equals("result") || fieldType.equals("fixed")) {
2786 viewType = TYPE_RESULT_FIELD;
2787 } else if (formType.equals("form")) {
2788 final Element validate = el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2789 final String datatype = validate == null ? null : validate.getAttribute("datatype");
2790 final Element range = validate == null ? null : validate.findChild("range", "http://jabber.org/protocol/xdata-validate");
2791 if (fieldType.equals("boolean")) {
2792 if (fillableFieldCount == 1 && actionsAdapter.countProceed() < 1) {
2793 viewType = TYPE_BUTTON_GRID_FIELD;
2794 } else {
2795 viewType = TYPE_CHECKBOX_FIELD;
2796 }
2797 } else if (
2798 range != null && range.getAttribute("min") != null && range.getAttribute("max") != null && (
2799 "xs:integer".equals(datatype) || "xs:int".equals(datatype) || "xs:long".equals(datatype) || "xs:short".equals(datatype) || "xs:byte".equals(datatype) ||
2800 "xs:decimal".equals(datatype) || "xs:double".equals(datatype)
2801 )
2802 ) {
2803 // has a range and is numeric, use a slider
2804 viewType = TYPE_SLIDER_FIELD;
2805 } else if (fieldType.equals("list-single")) {
2806 if (fillableFieldCount == 1 && actionsAdapter.countProceed() < 1 && Option.forField(el).size() < 50) {
2807 viewType = TYPE_BUTTON_GRID_FIELD;
2808 } else if (Option.forField(el).size() > 9) {
2809 viewType = TYPE_SEARCH_LIST_FIELD;
2810 } else if (el.findChild("value", "jabber:x:data") == null || (validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null)) {
2811 viewType = TYPE_RADIO_EDIT_FIELD;
2812 } else {
2813 viewType = TYPE_SPINNER_FIELD;
2814 }
2815 } else if (fieldType.equals("list-multi")) {
2816 viewType = TYPE_SEARCH_LIST_FIELD;
2817 } else {
2818 viewType = TYPE_TEXT_FIELD;
2819 }
2820 }
2821
2822 return new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), viewType);
2823 }
2824
2825 return null;
2826 }
2827
2828 protected Item mkItem(Element el, int pos) {
2829 int viewType = TYPE_ERROR;
2830
2831 if (response != null && response.getType() == Iq.Type.RESULT) {
2832 if (el.getName().equals("note")) {
2833 viewType = TYPE_NOTE;
2834 } else if (el.getNamespace().equals("jabber:x:oob")) {
2835 viewType = TYPE_WEB;
2836 } else if (el.getName().equals("instructions") && el.getNamespace().equals("jabber:x:data")) {
2837 viewType = TYPE_NOTE;
2838 } else if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data")) {
2839 Field field = mkField(el);
2840 if (field != null) {
2841 items.put(pos, field);
2842 return field;
2843 }
2844 }
2845 }
2846
2847 Item item = new Item(el, viewType);
2848 items.put(pos, item);
2849 return item;
2850 }
2851
2852 class ActionsAdapter extends ArrayAdapter<Pair<String, String>> {
2853 protected Context ctx;
2854
2855 public ActionsAdapter(Context ctx) {
2856 super(ctx, R.layout.simple_list_item);
2857 this.ctx = ctx;
2858 }
2859
2860 @Override
2861 public View getView(int position, View convertView, ViewGroup parent) {
2862 View v = super.getView(position, convertView, parent);
2863 TextView tv = (TextView) v.findViewById(android.R.id.text1);
2864 tv.setGravity(Gravity.CENTER);
2865 tv.setText(getItem(position).second);
2866 int resId = ctx.getResources().getIdentifier("action_" + getItem(position).first, "string" , ctx.getPackageName());
2867 if (resId != 0 && getItem(position).second.equals(getItem(position).first)) tv.setText(ctx.getResources().getString(resId));
2868 final var colors = MaterialColors.getColorRoles(ctx, UIHelper.getColorForName(getItem(position).first));
2869 tv.setTextColor(colors.getOnAccent());
2870 tv.setBackgroundColor(MaterialColors.harmonizeWithPrimary(ctx, colors.getAccent()));
2871 return v;
2872 }
2873
2874 public int getPosition(String s) {
2875 for(int i = 0; i < getCount(); i++) {
2876 if (getItem(i).first.equals(s)) return i;
2877 }
2878 return -1;
2879 }
2880
2881 public int countProceed() {
2882 int count = 0;
2883 for(int i = 0; i < getCount(); i++) {
2884 if (!"cancel".equals(getItem(i).first) && !"prev".equals(getItem(i).first)) count++;
2885 }
2886 return count;
2887 }
2888
2889 public int countExceptCancel() {
2890 int count = 0;
2891 for(int i = 0; i < getCount(); i++) {
2892 if (!getItem(i).first.equals("cancel")) count++;
2893 }
2894 return count;
2895 }
2896
2897 public void clearProceed() {
2898 Pair<String,String> cancelItem = null;
2899 Pair<String,String> prevItem = null;
2900 for(int i = 0; i < getCount(); i++) {
2901 if (getItem(i).first.equals("cancel")) cancelItem = getItem(i);
2902 if (getItem(i).first.equals("prev")) prevItem = getItem(i);
2903 }
2904 clear();
2905 if (cancelItem != null) add(cancelItem);
2906 if (prevItem != null) add(prevItem);
2907 }
2908 }
2909
2910 final int TYPE_ERROR = 1;
2911 final int TYPE_NOTE = 2;
2912 final int TYPE_WEB = 3;
2913 final int TYPE_RESULT_FIELD = 4;
2914 final int TYPE_TEXT_FIELD = 5;
2915 final int TYPE_CHECKBOX_FIELD = 6;
2916 final int TYPE_SPINNER_FIELD = 7;
2917 final int TYPE_RADIO_EDIT_FIELD = 8;
2918 final int TYPE_RESULT_CELL = 9;
2919 final int TYPE_PROGRESSBAR = 10;
2920 final int TYPE_SEARCH_LIST_FIELD = 11;
2921 final int TYPE_ITEM_CARD = 12;
2922 final int TYPE_BUTTON_GRID_FIELD = 13;
2923 final int TYPE_SLIDER_FIELD = 14;
2924
2925 protected boolean executing = false;
2926 protected boolean loading = false;
2927 protected boolean loadingHasBeenLong = false;
2928 protected Timer loadingTimer = new Timer();
2929 protected String mTitle;
2930 protected String mNode;
2931 protected CommandPageBinding mBinding = null;
2932 protected Iq response = null;
2933 protected Element responseElement = null;
2934 protected boolean expectingRemoval = false;
2935 protected List<Field> reported = null;
2936 protected SparseArray<Item> items = new SparseArray<>();
2937 protected XmppConnectionService xmppConnectionService;
2938 protected ActionsAdapter actionsAdapter = null;
2939 protected GridLayoutManager layoutManager;
2940 protected WebView actionToWebview = null;
2941 protected int fillableFieldCount = 0;
2942 protected Iq pendingResponsePacket = null;
2943 protected boolean waitingForRefresh = false;
2944
2945 CommandSession(String title, String node, XmppConnectionService xmppConnectionService) {
2946 loading();
2947 mTitle = title;
2948 mNode = node;
2949 this.xmppConnectionService = xmppConnectionService;
2950 if (mPager != null) setupLayoutManager(mPager.getContext());
2951 }
2952
2953 public String getTitle() {
2954 return mTitle;
2955 }
2956
2957 public String getNode() {
2958 return mNode;
2959 }
2960
2961 public void updateWithResponse(final Iq iq) {
2962 if (getView() != null && getView().isAttachedToWindow()) {
2963 getView().post(() -> updateWithResponseUiThread(iq));
2964 } else {
2965 pendingResponsePacket = iq;
2966 }
2967 }
2968
2969 protected void updateWithResponseUiThread(final Iq iq) {
2970 Timer oldTimer = this.loadingTimer;
2971 this.loadingTimer = new Timer();
2972 oldTimer.cancel();
2973 this.executing = false;
2974 this.loading = false;
2975 this.loadingHasBeenLong = false;
2976 this.responseElement = null;
2977 this.fillableFieldCount = 0;
2978 this.reported = null;
2979 this.response = iq;
2980 this.items.clear();
2981 this.actionsAdapter.clear();
2982 layoutManager.setSpanCount(1);
2983
2984 boolean actionsCleared = false;
2985 Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
2986 if (iq.getType() == Iq.Type.RESULT && command != null) {
2987 if (mNode.equals("jabber:iq:register") && command.getAttribute("status") != null && command.getAttribute("status").equals("completed")) {
2988 xmppConnectionService.createContact(getAccount().getRoster().getContact(iq.getFrom()), true);
2989 }
2990
2991 if (xmppConnectionService.isOnboarding() && mNode.equals("jabber:iq:register") && !"canceled".equals(command.getAttribute("status")) && xmppConnectionService.getPreferences().contains("onboarding_action")) {
2992 xmppConnectionService.getPreferences().edit().putBoolean("onboarding_continued", true).commit();
2993 }
2994
2995 Element actions = command.findChild("actions", "http://jabber.org/protocol/commands");
2996 if (actions != null) {
2997 for (Element action : actions.getChildren()) {
2998 if (!"http://jabber.org/protocol/commands".equals(action.getNamespace())) continue;
2999 if ("execute".equals(action.getName())) continue;
3000
3001 actionsAdapter.add(Pair.create(action.getName(), action.getName()));
3002 }
3003 }
3004
3005 for (Element el : command.getChildren()) {
3006 if ("x".equals(el.getName()) && "jabber:x:data".equals(el.getNamespace())) {
3007 Data form = Data.parse(el);
3008 String title = form.getTitle();
3009 if (title != null) {
3010 mTitle = title;
3011 ConversationPagerAdapter.this.notifyDataSetChanged();
3012 }
3013
3014 if ("result".equals(el.getAttribute("type")) || "form".equals(el.getAttribute("type"))) {
3015 this.responseElement = el;
3016 setupReported(el.findChild("reported", "jabber:x:data"));
3017 if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager(mBinding.getRoot().getContext()));
3018 }
3019
3020 eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
3021 if (actionList != null) {
3022 actionsAdapter.clear();
3023
3024 for (Option action : actionList.getOptions()) {
3025 actionsAdapter.add(Pair.create(action.getValue(), action.toString()));
3026 }
3027 }
3028
3029 eu.siacs.conversations.xmpp.forms.Field fillableField = null;
3030 for (eu.siacs.conversations.xmpp.forms.Field field : form.getFields()) {
3031 if ((field.getType() == null || (!field.getType().equals("hidden") && !field.getType().equals("fixed"))) && field.getFieldName() != null && !field.getFieldName().equals("http://jabber.org/protocol/commands#actions")) {
3032 final var validate = field.findChild("validate", "http://jabber.org/protocol/xdata-validate");
3033 final var range = validate == null ? null : validate.findChild("range", "http://jabber.org/protocol/xdata-validate");
3034 fillableField = range == null ? field : null;
3035 fillableFieldCount++;
3036 }
3037 }
3038
3039 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))) {
3040 actionsCleared = true;
3041 actionsAdapter.clearProceed();
3042 }
3043 break;
3044 }
3045 if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
3046 String url = el.findChildContent("url", "jabber:x:oob");
3047 if (url != null) {
3048 String scheme = Uri.parse(url).getScheme();
3049 if (scheme.equals("http") || scheme.equals("https")) {
3050 this.responseElement = el;
3051 break;
3052 }
3053 if (scheme.equals("xmpp")) {
3054 expectingRemoval = true;
3055 final Intent intent = new Intent(getView().getContext(), UriHandlerActivity.class);
3056 intent.setAction(Intent.ACTION_VIEW);
3057 intent.setData(Uri.parse(url));
3058 getView().getContext().startActivity(intent);
3059 break;
3060 }
3061 }
3062 }
3063 if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
3064 this.responseElement = el;
3065 break;
3066 }
3067 }
3068
3069 if (responseElement == null && command.getAttribute("status") != null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
3070 if ("jabber:iq:register".equals(mNode) && "canceled".equals(command.getAttribute("status"))) {
3071 if (xmppConnectionService.isOnboarding()) {
3072 if (xmppConnectionService.getPreferences().contains("onboarding_action")) {
3073 xmppConnectionService.deleteAccount(getAccount());
3074 } else {
3075 if (xmppConnectionService.getPreferences().getBoolean("onboarding_continued", false)) {
3076 removeSession(this);
3077 return;
3078 } else {
3079 xmppConnectionService.getPreferences().edit().putString("onboarding_action", "cancel").commit();
3080 xmppConnectionService.deleteAccount(getAccount());
3081 }
3082 }
3083 }
3084 xmppConnectionService.archiveConversation(Conversation.this);
3085 }
3086
3087 expectingRemoval = true;
3088 removeSession(this);
3089 return;
3090 }
3091
3092 if ("executing".equals(command.getAttribute("status")) && actionsAdapter.countExceptCancel() < 1 && !actionsCleared) {
3093 // No actions have been given, but we are not done?
3094 // This is probably a spec violation, but we should do *something*
3095 actionsAdapter.add(Pair.create("execute", "execute"));
3096 }
3097
3098 if (!actionsAdapter.isEmpty() || fillableFieldCount > 0) {
3099 if ("completed".equals(command.getAttribute("status")) || "canceled".equals(command.getAttribute("status"))) {
3100 actionsAdapter.add(Pair.create("close", "close"));
3101 } else if (actionsAdapter.getPosition("cancel") < 0 && !xmppConnectionService.isOnboarding()) {
3102 actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
3103 }
3104 }
3105 }
3106
3107 if (actionsAdapter.isEmpty()) {
3108 actionsAdapter.add(Pair.create("close", "close"));
3109 }
3110
3111 actionsAdapter.sort((x, y) -> {
3112 if (x.first.equals("cancel")) return -1;
3113 if (y.first.equals("cancel")) return 1;
3114 if (x.first.equals("prev") && xmppConnectionService.isOnboarding()) return -1;
3115 if (y.first.equals("prev") && xmppConnectionService.isOnboarding()) return 1;
3116 return 0;
3117 });
3118
3119 Data dataForm = null;
3120 if (responseElement != null && responseElement.getName().equals("x") && responseElement.getNamespace().equals("jabber:x:data")) dataForm = Data.parse(responseElement);
3121 if (mNode.equals("jabber:iq:register") &&
3122 xmppConnectionService.getPreferences().contains("onboarding_action") &&
3123 dataForm != null && dataForm.getFieldByName("gateway-jid") != null) {
3124
3125
3126 dataForm.put("gateway-jid", xmppConnectionService.getPreferences().getString("onboarding_action", ""));
3127 execute();
3128 }
3129 xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
3130 notifyDataSetChanged();
3131 }
3132
3133 protected void setupReported(Element el) {
3134 if (el == null) {
3135 reported = null;
3136 return;
3137 }
3138
3139 reported = new ArrayList<>();
3140 for (Element fieldEl : el.getChildren()) {
3141 if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
3142 reported.add(mkField(fieldEl));
3143 }
3144 }
3145
3146 @Override
3147 public int getItemCount() {
3148 if (loading) return 1;
3149 if (response == null) return 0;
3150 if (response.getType() == Iq.Type.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
3151 int i = 0;
3152 for (Element el : responseElement.getChildren()) {
3153 if (!el.getNamespace().equals("jabber:x:data")) continue;
3154 if (el.getName().equals("title")) continue;
3155 if (el.getName().equals("field")) {
3156 String type = el.getAttribute("type");
3157 if (type != null && type.equals("hidden")) continue;
3158 if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
3159 }
3160
3161 if (el.getName().equals("reported") || el.getName().equals("item")) {
3162 if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
3163 if (el.getName().equals("reported")) continue;
3164 i += 1;
3165 } else {
3166 if (reported != null) i += reported.size();
3167 }
3168 continue;
3169 }
3170
3171 i++;
3172 }
3173 return i;
3174 }
3175 return 1;
3176 }
3177
3178 public Item getItem(int position) {
3179 if (loading) return new Item(null, TYPE_PROGRESSBAR);
3180 if (items.get(position) != null) return items.get(position);
3181 if (response == null) return null;
3182
3183 if (response.getType() == Iq.Type.RESULT && responseElement != null) {
3184 if (responseElement.getNamespace().equals("jabber:x:data")) {
3185 int i = 0;
3186 for (Element el : responseElement.getChildren()) {
3187 if (!el.getNamespace().equals("jabber:x:data")) continue;
3188 if (el.getName().equals("title")) continue;
3189 if (el.getName().equals("field")) {
3190 String type = el.getAttribute("type");
3191 if (type != null && type.equals("hidden")) continue;
3192 if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
3193 }
3194
3195 if (el.getName().equals("reported") || el.getName().equals("item")) {
3196 Cell cell = null;
3197
3198 if (reported != null) {
3199 if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
3200 if (el.getName().equals("reported")) continue;
3201 if (i == position) {
3202 items.put(position, new Item(el, TYPE_ITEM_CARD));
3203 return items.get(position);
3204 }
3205 } else {
3206 if (reported.size() > position - i) {
3207 Field reportedField = reported.get(position - i);
3208 Element itemField = null;
3209 if (el.getName().equals("item")) {
3210 for (Element subel : el.getChildren()) {
3211 if (subel.getAttribute("var").equals(reportedField.getVar())) {
3212 itemField = subel;
3213 break;
3214 }
3215 }
3216 }
3217 cell = new Cell(reportedField, itemField);
3218 } else {
3219 i += reported.size();
3220 continue;
3221 }
3222 }
3223 }
3224
3225 if (cell != null) {
3226 items.put(position, cell);
3227 return cell;
3228 }
3229 }
3230
3231 if (i < position) {
3232 i++;
3233 continue;
3234 }
3235
3236 return mkItem(el, position);
3237 }
3238 }
3239 }
3240
3241 return mkItem(responseElement == null ? response : responseElement, position);
3242 }
3243
3244 @Override
3245 public int getItemViewType(int position) {
3246 return getItem(position).viewType;
3247 }
3248
3249 @Override
3250 public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
3251 switch(viewType) {
3252 case TYPE_ERROR: {
3253 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3254 return new ErrorViewHolder(binding);
3255 }
3256 case TYPE_NOTE: {
3257 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3258 return new NoteViewHolder(binding);
3259 }
3260 case TYPE_WEB: {
3261 CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
3262 return new WebViewHolder(binding);
3263 }
3264 case TYPE_RESULT_FIELD: {
3265 CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
3266 return new ResultFieldViewHolder(binding);
3267 }
3268 case TYPE_RESULT_CELL: {
3269 CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
3270 return new ResultCellViewHolder(binding);
3271 }
3272 case TYPE_ITEM_CARD: {
3273 CommandItemCardBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_item_card, container, false);
3274 return new ItemCardViewHolder(binding);
3275 }
3276 case TYPE_CHECKBOX_FIELD: {
3277 CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
3278 return new CheckboxFieldViewHolder(binding);
3279 }
3280 case TYPE_SEARCH_LIST_FIELD: {
3281 CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
3282 return new SearchListFieldViewHolder(binding);
3283 }
3284 case TYPE_RADIO_EDIT_FIELD: {
3285 CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
3286 return new RadioEditFieldViewHolder(binding);
3287 }
3288 case TYPE_SPINNER_FIELD: {
3289 CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
3290 return new SpinnerFieldViewHolder(binding);
3291 }
3292 case TYPE_BUTTON_GRID_FIELD: {
3293 CommandButtonGridFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_button_grid_field, container, false);
3294 return new ButtonGridFieldViewHolder(binding);
3295 }
3296 case TYPE_TEXT_FIELD: {
3297 CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
3298 return new TextFieldViewHolder(binding);
3299 }
3300 case TYPE_SLIDER_FIELD: {
3301 CommandSliderFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_slider_field, container, false);
3302 return new SliderFieldViewHolder(binding);
3303 }
3304 case TYPE_PROGRESSBAR: {
3305 CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
3306 return new ProgressBarViewHolder(binding);
3307 }
3308 default:
3309 if (expectingRemoval) {
3310 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3311 return new NoteViewHolder(binding);
3312 }
3313
3314 throw new IllegalArgumentException("Unknown viewType: " + viewType + " based on: " + response + ", " + responseElement + ", " + expectingRemoval);
3315 }
3316 }
3317
3318 @Override
3319 public void onBindViewHolder(ViewHolder viewHolder, int position) {
3320 viewHolder.bind(getItem(position));
3321 }
3322
3323 public View getView() {
3324 if (mBinding == null) return null;
3325 return mBinding.getRoot();
3326 }
3327
3328 public boolean validate() {
3329 int count = getItemCount();
3330 boolean isValid = true;
3331 for (int i = 0; i < count; i++) {
3332 boolean oneIsValid = getItem(i).validate();
3333 isValid = isValid && oneIsValid;
3334 }
3335 notifyDataSetChanged();
3336 return isValid;
3337 }
3338
3339 public boolean execute() {
3340 return execute("execute");
3341 }
3342
3343 public boolean execute(int actionPosition) {
3344 return execute(actionsAdapter.getItem(actionPosition).first);
3345 }
3346
3347 public synchronized boolean execute(String action) {
3348 if (!"cancel".equals(action) && executing) {
3349 loadingHasBeenLong = true;
3350 notifyDataSetChanged();
3351 return false;
3352 }
3353 if (!action.equals("cancel") && !action.equals("prev") && !validate()) return false;
3354
3355 if (response == null) return true;
3356 Element command = response.findChild("command", "http://jabber.org/protocol/commands");
3357 if (command == null) return true;
3358 String status = command.getAttribute("status");
3359 if (status == null || (!status.equals("executing") && !action.equals("prev"))) return true;
3360
3361 if (actionToWebview != null && !action.equals("cancel") && Build.VERSION.SDK_INT >= 23) {
3362 actionToWebview.postWebMessage(new WebMessage("xmpp_xep0050/" + action), Uri.parse("*"));
3363 return false;
3364 }
3365
3366 final var packet = new Iq(Iq.Type.SET);
3367 packet.setTo(response.getFrom());
3368 final Element c = packet.addChild("command", Namespace.COMMANDS);
3369 c.setAttribute("node", mNode);
3370 c.setAttribute("sessionid", command.getAttribute("sessionid"));
3371
3372 String formType = responseElement == null ? null : responseElement.getAttribute("type");
3373 if (!action.equals("cancel") &&
3374 !action.equals("prev") &&
3375 responseElement != null &&
3376 responseElement.getName().equals("x") &&
3377 responseElement.getNamespace().equals("jabber:x:data") &&
3378 formType != null && formType.equals("form")) {
3379
3380 Data form = Data.parse(responseElement);
3381 eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
3382 if (actionList != null) {
3383 actionList.setValue(action);
3384 c.setAttribute("action", "execute");
3385 }
3386
3387 if (mNode.equals("jabber:iq:register") && xmppConnectionService.isOnboarding() && form.getFieldByName("gateway-jid") != null) {
3388 if (form.getValue("gateway-jid") == null) {
3389 xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
3390 } else {
3391 xmppConnectionService.getPreferences().edit().putString("onboarding_action", form.getValue("gateway-jid")).commit();
3392 }
3393 }
3394
3395 responseElement.setAttribute("type", "submit");
3396 Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
3397 if (rsm != null) {
3398 Element max = new Element("max", "http://jabber.org/protocol/rsm");
3399 max.setContent("1000");
3400 rsm.addChild(max);
3401 }
3402
3403 c.addChild(responseElement);
3404 }
3405
3406 if (c.getAttribute("action") == null) c.setAttribute("action", action);
3407
3408 executing = true;
3409 xmppConnectionService.sendIqPacket(getAccount(), packet, (iq) -> {
3410 updateWithResponse(iq);
3411 }, 120L);
3412
3413 loading();
3414 return false;
3415 }
3416
3417 public void refresh() {
3418 synchronized(this) {
3419 if (waitingForRefresh) notifyDataSetChanged();
3420 }
3421 }
3422
3423 protected void loading() {
3424 View v = getView();
3425 try {
3426 loadingTimer.schedule(new TimerTask() {
3427 @Override
3428 public void run() {
3429 View v2 = getView();
3430 loading = true;
3431
3432 try {
3433 loadingTimer.schedule(new TimerTask() {
3434 @Override
3435 public void run() {
3436 loadingHasBeenLong = true;
3437 if (v == null && v2 == null) return;
3438 (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3439 }
3440 }, 3000);
3441 } catch (final IllegalStateException e) { }
3442
3443 if (v == null && v2 == null) return;
3444 (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3445 }
3446 }, 500);
3447 } catch (final IllegalStateException e) { }
3448 }
3449
3450 protected GridLayoutManager setupLayoutManager(final Context ctx) {
3451 int spanCount = 1;
3452
3453 if (reported != null) {
3454 float screenWidth = ctx.getResources().getDisplayMetrics().widthPixels;
3455 TextPaint paint = ((TextView) LayoutInflater.from(mPager.getContext()).inflate(R.layout.command_result_cell, null)).getPaint();
3456 float tableHeaderWidth = reported.stream().reduce(
3457 0f,
3458 (total, field) -> total + StaticLayout.getDesiredWidth(field.getLabel().or("--------") + "\t", paint),
3459 (a, b) -> a + b
3460 );
3461
3462 spanCount = tableHeaderWidth > 0.59 * screenWidth ? 1 : this.reported.size();
3463 }
3464
3465 if (layoutManager != null && layoutManager.getSpanCount() != spanCount) {
3466 items.clear();
3467 notifyDataSetChanged();
3468 }
3469
3470 layoutManager = new GridLayoutManager(ctx, spanCount);
3471 layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
3472 @Override
3473 public int getSpanSize(int position) {
3474 if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
3475 return 1;
3476 }
3477 });
3478 return layoutManager;
3479 }
3480
3481 protected void setBinding(CommandPageBinding b) {
3482 mBinding = b;
3483 // https://stackoverflow.com/a/32350474/8611
3484 mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
3485 @Override
3486 public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
3487 if(rv.getChildCount() > 0) {
3488 int[] location = new int[2];
3489 rv.getLocationOnScreen(location);
3490 View childView = rv.findChildViewUnder(e.getX(), e.getY());
3491 if (childView instanceof ViewGroup) {
3492 childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
3493 }
3494 int action = e.getAction();
3495 switch (action) {
3496 case MotionEvent.ACTION_DOWN:
3497 if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(1)) || childView instanceof WebView) {
3498 rv.requestDisallowInterceptTouchEvent(true);
3499 }
3500 case MotionEvent.ACTION_UP:
3501 if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(-11)) || childView instanceof WebView) {
3502 rv.requestDisallowInterceptTouchEvent(true);
3503 }
3504 }
3505 }
3506
3507 return false;
3508 }
3509
3510 @Override
3511 public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }
3512
3513 @Override
3514 public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
3515 });
3516 mBinding.form.setLayoutManager(setupLayoutManager(mBinding.getRoot().getContext()));
3517 mBinding.form.setAdapter(this);
3518
3519 if (actionsAdapter == null) {
3520 actionsAdapter = new ActionsAdapter(mBinding.getRoot().getContext());
3521 actionsAdapter.registerDataSetObserver(new DataSetObserver() {
3522 @Override
3523 public void onChanged() {
3524 if (mBinding == null) return;
3525
3526 mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
3527 }
3528
3529 @Override
3530 public void onInvalidated() {}
3531 });
3532 }
3533
3534 mBinding.actions.setAdapter(actionsAdapter);
3535 mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
3536 if (execute(pos)) {
3537 removeSession(CommandSession.this);
3538 }
3539 });
3540
3541 actionsAdapter.notifyDataSetChanged();
3542
3543 if (pendingResponsePacket != null) {
3544 final var pending = pendingResponsePacket;
3545 pendingResponsePacket = null;
3546 updateWithResponseUiThread(pending);
3547 }
3548 }
3549
3550 private Drawable getDrawableForSVG(SVG svg, Element svgElement, int size) {
3551 if (svgElement != null && svgElement.getChildren().size() == 1 && svgElement.getChildren().get(0).getName().equals("image")) {
3552 return getDrawableForUrl(svgElement.getChildren().get(0).getAttribute("href"));
3553 } else {
3554 return xmppConnectionService.getFileBackend().drawSVG(svg, size);
3555 }
3556 }
3557
3558 private Drawable getDrawableForUrl(final String url) {
3559 final LruCache<String, Drawable> cache = xmppConnectionService.getDrawableCache();
3560 final HttpConnectionManager httpManager = xmppConnectionService.getHttpConnectionManager();
3561 final Drawable d = cache.get(url);
3562 if (Build.VERSION.SDK_INT >= 28 && d instanceof AnimatedImageDrawable) ((AnimatedImageDrawable) d).start();
3563 if (d == null) {
3564 synchronized (CommandSession.this) {
3565 waitingForRefresh = true;
3566 }
3567 int size = (int)(xmppConnectionService.getResources().getDisplayMetrics().density * 288);
3568 Message dummy = new Message(Conversation.this, url, Message.ENCRYPTION_NONE);
3569 dummy.setStatus(Message.STATUS_DUMMY);
3570 dummy.setFileParams(new Message.FileParams(url));
3571 httpManager.createNewDownloadConnection(dummy, true, (file) -> {
3572 if (file == null) {
3573 dummy.getTransferable().start();
3574 } else {
3575 try {
3576 xmppConnectionService.getFileBackend().getThumbnail(file, xmppConnectionService.getResources(), size, false, url);
3577 } catch (final Exception e) { }
3578 }
3579 });
3580 }
3581 return d;
3582 }
3583
3584 public View inflateUi(Context context, Consumer<ConversationPage> remover) {
3585 CommandPageBinding binding = DataBindingUtil.inflate(LayoutInflater.from(context), R.layout.command_page, null, false);
3586 setBinding(binding);
3587 return binding.getRoot();
3588 }
3589
3590 // https://stackoverflow.com/a/36037991/8611
3591 private View findViewAt(ViewGroup viewGroup, float x, float y) {
3592 for(int i = 0; i < viewGroup.getChildCount(); i++) {
3593 View child = viewGroup.getChildAt(i);
3594 if (child instanceof ViewGroup && !(child instanceof AbsListView) && !(child instanceof WebView)) {
3595 View foundView = findViewAt((ViewGroup) child, x, y);
3596 if (foundView != null && foundView.isShown()) {
3597 return foundView;
3598 }
3599 } else {
3600 int[] location = new int[2];
3601 child.getLocationOnScreen(location);
3602 Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
3603 if (rect.contains((int)x, (int)y)) {
3604 return child;
3605 }
3606 }
3607 }
3608
3609 return null;
3610 }
3611 }
3612
3613 class MucConfigSession extends CommandSession {
3614 MucConfigSession(XmppConnectionService xmppConnectionService) {
3615 super("Configure Channel", null, xmppConnectionService);
3616 }
3617
3618 @Override
3619 protected void updateWithResponseUiThread(final Iq iq) {
3620 Timer oldTimer = this.loadingTimer;
3621 this.loadingTimer = new Timer();
3622 oldTimer.cancel();
3623 this.executing = false;
3624 this.loading = false;
3625 this.loadingHasBeenLong = false;
3626 this.responseElement = null;
3627 this.fillableFieldCount = 0;
3628 this.reported = null;
3629 this.response = iq;
3630 this.items.clear();
3631 this.actionsAdapter.clear();
3632 layoutManager.setSpanCount(1);
3633
3634 final Element query = iq.findChild("query", "http://jabber.org/protocol/muc#owner");
3635 if (iq.getType() == Iq.Type.RESULT && query != null) {
3636 final Data form = Data.parse(query.findChild("x", "jabber:x:data"));
3637 final String title = form.getTitle();
3638 if (title != null) {
3639 mTitle = title;
3640 ConversationPagerAdapter.this.notifyDataSetChanged();
3641 }
3642
3643 this.responseElement = form;
3644 setupReported(form.findChild("reported", "jabber:x:data"));
3645 if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager(mBinding.getRoot().getContext()));
3646
3647 if (actionsAdapter.countExceptCancel() < 1) {
3648 actionsAdapter.add(Pair.create("save", "Save"));
3649 }
3650
3651 if (actionsAdapter.getPosition("cancel") < 0) {
3652 actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
3653 }
3654 } else if (iq.getType() == Iq.Type.RESULT) {
3655 expectingRemoval = true;
3656 removeSession(this);
3657 return;
3658 } else {
3659 actionsAdapter.add(Pair.create("close", "close"));
3660 }
3661
3662 notifyDataSetChanged();
3663 }
3664
3665 @Override
3666 public synchronized boolean execute(String action) {
3667 if ("cancel".equals(action)) {
3668 final var packet = new Iq(Iq.Type.SET);
3669 packet.setTo(response.getFrom());
3670 final Element form = packet
3671 .addChild("query", "http://jabber.org/protocol/muc#owner")
3672 .addChild("x", "jabber:x:data");
3673 form.setAttribute("type", "cancel");
3674 xmppConnectionService.sendIqPacket(getAccount(), packet, null);
3675 return true;
3676 }
3677
3678 if (!"save".equals(action)) return true;
3679
3680 final var packet = new Iq(Iq.Type.SET);
3681 packet.setTo(response.getFrom());
3682
3683 String formType = responseElement == null ? null : responseElement.getAttribute("type");
3684 if (responseElement != null &&
3685 responseElement.getName().equals("x") &&
3686 responseElement.getNamespace().equals("jabber:x:data") &&
3687 formType != null && formType.equals("form")) {
3688
3689 responseElement.setAttribute("type", "submit");
3690 packet
3691 .addChild("query", "http://jabber.org/protocol/muc#owner")
3692 .addChild(responseElement);
3693 }
3694
3695 executing = true;
3696 xmppConnectionService.sendIqPacket(getAccount(), packet, (iq) -> {
3697 updateWithResponse(iq);
3698 }, 120L);
3699
3700 loading();
3701
3702 return false;
3703 }
3704 }
3705 }
3706
3707 public static class Thread {
3708 protected Message subject = null;
3709 protected Message first = null;
3710 protected Message last = null;
3711 protected final String threadId;
3712
3713 protected Thread(final String threadId) {
3714 this.threadId = threadId;
3715 }
3716
3717 public String getThreadId() {
3718 return threadId;
3719 }
3720
3721 public String getSubject() {
3722 if (subject == null) return null;
3723
3724 return subject.getSubject();
3725 }
3726
3727 public String getDisplay() {
3728 final String s = getSubject();
3729 if (s != null) return s;
3730
3731 if (first != null) {
3732 return first.getBody();
3733 }
3734
3735 return "";
3736 }
3737
3738 public long getLastTime() {
3739 if (last == null) return 0;
3740
3741 return last.getTimeSent();
3742 }
3743 }
3744}