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