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