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