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 startCommand(Element command, XmppConnectionService xmppConnectionService) {
1444 pagerAdapter.startCommand(command, xmppConnectionService);
1445 }
1446
1447 public void startMucConfig(XmppConnectionService xmppConnectionService) {
1448 pagerAdapter.startMucConfig(xmppConnectionService);
1449 }
1450
1451 public boolean switchToSession(final String node) {
1452 return pagerAdapter.switchToSession(node);
1453 }
1454
1455 public void setupViewPager(ViewPager pager, TabLayout tabs, boolean onboarding, Conversation oldConversation) {
1456 pagerAdapter.setupViewPager(pager, tabs, onboarding, oldConversation);
1457 }
1458
1459 public void showViewPager() {
1460 pagerAdapter.show();
1461 }
1462
1463 public void hideViewPager() {
1464 pagerAdapter.hide();
1465 }
1466
1467 public void setDisplayState(final String stanzaId) {
1468 this.displayState = stanzaId;
1469 }
1470
1471 public String getDisplayState() {
1472 return this.displayState;
1473 }
1474
1475 public interface OnMessageFound {
1476 void onMessageFound(final Message message);
1477 }
1478
1479 public static class Draft {
1480 private final String message;
1481 private final long timestamp;
1482
1483 private Draft(String message, long timestamp) {
1484 this.message = message;
1485 this.timestamp = timestamp;
1486 }
1487
1488 public long getTimestamp() {
1489 return timestamp;
1490 }
1491
1492 public String getMessage() {
1493 return message;
1494 }
1495 }
1496
1497 public class ConversationPagerAdapter extends PagerAdapter {
1498 protected ViewPager mPager = null;
1499 protected TabLayout mTabs = null;
1500 ArrayList<ConversationPage> sessions = null;
1501 protected View page1 = null;
1502 protected View page2 = null;
1503 protected boolean mOnboarding = false;
1504
1505 public void setupViewPager(ViewPager pager, TabLayout tabs, boolean onboarding, Conversation oldConversation) {
1506 mPager = pager;
1507 mTabs = tabs;
1508 mOnboarding = onboarding;
1509
1510 if (oldConversation != null) {
1511 oldConversation.pagerAdapter.mPager = null;
1512 oldConversation.pagerAdapter.mTabs = null;
1513 }
1514
1515 if (mPager == null) {
1516 page1 = null;
1517 page2 = null;
1518 return;
1519 }
1520 if (sessions != null) show();
1521
1522 if (pager.getChildAt(0) != null) page1 = pager.getChildAt(0);
1523 if (pager.getChildAt(1) != null) page2 = pager.getChildAt(1);
1524 if (page2 != null && page2.findViewById(R.id.commands_view) == null) {
1525 page1 = null;
1526 page2 = null;
1527 }
1528 if (page1 == null) page1 = oldConversation.pagerAdapter.page1;
1529 if (page2 == null) page2 = oldConversation.pagerAdapter.page2;
1530 if (page1 == null || page2 == null) {
1531 throw new IllegalStateException("page1 or page2 were not present as child or in model?");
1532 }
1533 pager.removeView(page1);
1534 pager.removeView(page2);
1535 pager.setAdapter(this);
1536 tabs.setupWithViewPager(mPager);
1537 pager.post(() -> pager.setCurrentItem(getCurrentTab()));
1538
1539 mPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
1540 public void onPageScrollStateChanged(int state) { }
1541 public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { }
1542
1543 public void onPageSelected(int position) {
1544 setCurrentTab(position);
1545 }
1546 });
1547 }
1548
1549 public void show() {
1550 if (sessions == null) {
1551 sessions = new ArrayList<>();
1552 notifyDataSetChanged();
1553 }
1554 if (!mOnboarding && mTabs != null) mTabs.setVisibility(View.VISIBLE);
1555 }
1556
1557 public void hide() {
1558 if (sessions != null && !sessions.isEmpty()) return; // Do not hide during active session
1559 if (mPager != null) mPager.setCurrentItem(0);
1560 if (mTabs != null) mTabs.setVisibility(View.GONE);
1561 sessions = null;
1562 notifyDataSetChanged();
1563 }
1564
1565 public void refreshSessions() {
1566 if (sessions == null) return;
1567
1568 for (ConversationPage session : sessions) {
1569 session.refresh();
1570 }
1571 }
1572
1573 public void startWebxdc(WebxdcPage page) {
1574 show();
1575 sessions.add(page);
1576 notifyDataSetChanged();
1577 if (mPager != null) mPager.setCurrentItem(getCount() - 1);
1578 }
1579
1580 public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
1581 show();
1582 CommandSession session = new CommandSession(command.getAttribute("name"), command.getAttribute("node"), xmppConnectionService);
1583
1584 final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
1585 packet.setTo(command.getAttributeAsJid("jid"));
1586 final Element c = packet.addChild("command", Namespace.COMMANDS);
1587 c.setAttribute("node", command.getAttribute("node"));
1588 c.setAttribute("action", "execute");
1589
1590 final TimerTask task = new TimerTask() {
1591 @Override
1592 public void run() {
1593 if (getAccount().getStatus() != Account.State.ONLINE) {
1594 final TimerTask self = this;
1595 new Timer().schedule(new TimerTask() {
1596 @Override
1597 public void run() {
1598 self.run();
1599 }
1600 }, 1000);
1601 } else {
1602 xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
1603 session.updateWithResponse(iq);
1604 }, 120L);
1605 }
1606 }
1607 };
1608
1609 if (command.getAttribute("node").equals("jabber:iq:register") && packet.getTo().asBareJid().equals(Jid.of("cheogram.com"))) {
1610 new com.cheogram.android.CheogramLicenseChecker(mPager.getContext(), (signedData, signature) -> {
1611 if (signedData != null && signature != null) {
1612 c.addChild("license", "https://ns.cheogram.com/google-play").setContent(signedData);
1613 c.addChild("licenseSignature", "https://ns.cheogram.com/google-play").setContent(signature);
1614 }
1615
1616 task.run();
1617 }).checkLicense();
1618 } else {
1619 task.run();
1620 }
1621
1622 sessions.add(session);
1623 notifyDataSetChanged();
1624 if (mPager != null) mPager.setCurrentItem(getCount() - 1);
1625 }
1626
1627 public void startMucConfig(XmppConnectionService xmppConnectionService) {
1628 MucConfigSession session = new MucConfigSession(xmppConnectionService);
1629 final IqPacket packet = new IqPacket(IqPacket.TYPE.GET);
1630 packet.setTo(Conversation.this.getJid().asBareJid());
1631 packet.addChild("query", "http://jabber.org/protocol/muc#owner");
1632
1633 final TimerTask task = new TimerTask() {
1634 @Override
1635 public void run() {
1636 if (getAccount().getStatus() != Account.State.ONLINE) {
1637 final TimerTask self = this;
1638 new Timer().schedule(new TimerTask() {
1639 @Override
1640 public void run() {
1641 self.run();
1642 }
1643 }, 1000);
1644 } else {
1645 xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
1646 session.updateWithResponse(iq);
1647 }, 120L);
1648 }
1649 }
1650 };
1651 task.run();
1652
1653 sessions.add(session);
1654 notifyDataSetChanged();
1655 if (mPager != null) mPager.setCurrentItem(getCount() - 1);
1656 }
1657
1658 public void removeSession(ConversationPage session) {
1659 sessions.remove(session);
1660 notifyDataSetChanged();
1661 if (session instanceof WebxdcPage) mPager.setCurrentItem(0);
1662 }
1663
1664 public boolean switchToSession(final String node) {
1665 if (sessions == null) return false;
1666
1667 int i = 0;
1668 for (ConversationPage session : sessions) {
1669 if (session.getNode().equals(node)) {
1670 if (mPager != null) mPager.setCurrentItem(i + 2);
1671 return true;
1672 }
1673 i++;
1674 }
1675
1676 return false;
1677 }
1678
1679 @NonNull
1680 @Override
1681 public Object instantiateItem(@NonNull ViewGroup container, int position) {
1682 if (position == 0) {
1683 if (page1 != null && page1.getParent() != null) {
1684 ((ViewGroup) page1.getParent()).removeView(page1);
1685 }
1686 container.addView(page1);
1687 return page1;
1688 }
1689 if (position == 1) {
1690 if (page2 != null && page2.getParent() != null) {
1691 ((ViewGroup) page2.getParent()).removeView(page2);
1692 }
1693 container.addView(page2);
1694 return page2;
1695 }
1696
1697 if (position-2 > sessions.size()) return null;
1698 ConversationPage session = sessions.get(position-2);
1699 View v = session.inflateUi(container.getContext(), (s) -> removeSession(s));
1700 if (v != null && v.getParent() != null) {
1701 ((ViewGroup) v.getParent()).removeView(v);
1702 }
1703 container.addView(v);
1704 return session;
1705 }
1706
1707 @Override
1708 public void destroyItem(@NonNull ViewGroup container, int position, Object o) {
1709 if (position < 2) {
1710 container.removeView((View) o);
1711 return;
1712 }
1713
1714 container.removeView(((ConversationPage) o).getView());
1715 }
1716
1717 @Override
1718 public int getItemPosition(Object o) {
1719 if (mPager != null) {
1720 if (o == page1) return PagerAdapter.POSITION_UNCHANGED;
1721 if (o == page2) return PagerAdapter.POSITION_UNCHANGED;
1722 }
1723
1724 int pos = sessions == null ? -1 : sessions.indexOf(o);
1725 if (pos < 0) return PagerAdapter.POSITION_NONE;
1726 return pos + 2;
1727 }
1728
1729 @Override
1730 public int getCount() {
1731 if (sessions == null) return 1;
1732
1733 int count = 2 + sessions.size();
1734 if (mTabs == null) return count;
1735
1736 if (count > 2) {
1737 mTabs.setTabMode(TabLayout.MODE_SCROLLABLE);
1738 } else {
1739 mTabs.setTabMode(TabLayout.MODE_FIXED);
1740 }
1741 return count;
1742 }
1743
1744 @Override
1745 public boolean isViewFromObject(@NonNull View view, @NonNull Object o) {
1746 if (view == o) return true;
1747
1748 if (o instanceof ConversationPage) {
1749 return ((ConversationPage) o).getView() == view;
1750 }
1751
1752 return false;
1753 }
1754
1755 @Nullable
1756 @Override
1757 public CharSequence getPageTitle(int position) {
1758 switch (position) {
1759 case 0:
1760 return "Conversation";
1761 case 1:
1762 return "Commands";
1763 default:
1764 ConversationPage session = sessions.get(position-2);
1765 if (session == null) return super.getPageTitle(position);
1766 return session.getTitle();
1767 }
1768 }
1769
1770 class CommandSession extends RecyclerView.Adapter<CommandSession.ViewHolder> implements ConversationPage {
1771 abstract class ViewHolder<T extends ViewDataBinding> extends RecyclerView.ViewHolder {
1772 protected T binding;
1773
1774 public ViewHolder(T binding) {
1775 super(binding.getRoot());
1776 this.binding = binding;
1777 }
1778
1779 abstract public void bind(Item el);
1780
1781 protected void setTextOrHide(TextView v, Optional<String> s) {
1782 if (s == null || !s.isPresent()) {
1783 v.setVisibility(View.GONE);
1784 } else {
1785 v.setVisibility(View.VISIBLE);
1786 v.setText(s.get());
1787 }
1788 }
1789
1790 protected void setupInputType(Element field, TextView textinput, TextInputLayout layout) {
1791 int flags = 0;
1792 if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_NONE);
1793 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1794
1795 String type = field.getAttribute("type");
1796 if (type != null) {
1797 if (type.equals("text-multi") || type.equals("jid-multi")) {
1798 flags |= InputType.TYPE_TEXT_FLAG_MULTI_LINE;
1799 }
1800
1801 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1802
1803 if (type.equals("jid-single") || type.equals("jid-multi")) {
1804 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
1805 }
1806
1807 if (type.equals("text-private")) {
1808 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
1809 if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_PASSWORD_TOGGLE);
1810 }
1811 }
1812
1813 Element validate = field.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1814 if (validate == null) return;
1815 String datatype = validate.getAttribute("datatype");
1816 if (datatype == null) return;
1817
1818 if (datatype.equals("xs:integer") || datatype.equals("xs:int") || datatype.equals("xs:long") || datatype.equals("xs:short") || datatype.equals("xs:byte")) {
1819 textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED);
1820 }
1821
1822 if (datatype.equals("xs:decimal") || datatype.equals("xs:double")) {
1823 textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED | InputType.TYPE_NUMBER_FLAG_DECIMAL);
1824 }
1825
1826 if (datatype.equals("xs:date")) {
1827 textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_DATE);
1828 }
1829
1830 if (datatype.equals("xs:dateTime")) {
1831 textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_NORMAL);
1832 }
1833
1834 if (datatype.equals("xs:time")) {
1835 textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_TIME);
1836 }
1837
1838 if (datatype.equals("xs:anyURI")) {
1839 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
1840 }
1841
1842 if (datatype.equals("html:tel")) {
1843 textinput.setInputType(flags | InputType.TYPE_CLASS_PHONE);
1844 }
1845
1846 if (datatype.equals("html:email")) {
1847 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
1848 }
1849 }
1850
1851 protected String formatValue(String datatype, String value, boolean compact) {
1852 if ("xs:dateTime".equals(datatype)) {
1853 ZonedDateTime zonedDateTime = null;
1854 try {
1855 zonedDateTime = ZonedDateTime.parse(value, DateTimeFormatter.ISO_DATE_TIME);
1856 } catch (final DateTimeParseException e) {
1857 try {
1858 DateTimeFormatter almostIso = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm[:ss] X");
1859 zonedDateTime = ZonedDateTime.parse(value, almostIso);
1860 } catch (final DateTimeParseException e2) { }
1861 }
1862 if (zonedDateTime == null) return value;
1863 ZonedDateTime localZonedDateTime = zonedDateTime.withZoneSameInstant(ZoneId.systemDefault());
1864 DateTimeFormatter outputFormat = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT);
1865 return localZonedDateTime.toLocalDateTime().format(outputFormat);
1866 }
1867
1868 if ("html:tel".equals(datatype) && !compact) {
1869 return PhoneNumberUtils.formatNumber(value, value, null);
1870 }
1871
1872 return value;
1873 }
1874 }
1875
1876 class ErrorViewHolder extends ViewHolder<CommandNoteBinding> {
1877 public ErrorViewHolder(CommandNoteBinding binding) { super(binding); }
1878
1879 @Override
1880 public void bind(Item iq) {
1881 binding.errorIcon.setVisibility(View.VISIBLE);
1882
1883 if (iq == null || iq.el == null) return;
1884 Element error = iq.el.findChild("error");
1885 if (error == null) {
1886 binding.message.setText("Unexpected response: " + iq);
1887 return;
1888 }
1889 String text = error.findChildContent("text", "urn:ietf:params:xml:ns:xmpp-stanzas");
1890 if (text == null || text.equals("")) {
1891 text = error.getChildren().get(0).getName();
1892 }
1893 binding.message.setText(text);
1894 }
1895 }
1896
1897 class NoteViewHolder extends ViewHolder<CommandNoteBinding> {
1898 public NoteViewHolder(CommandNoteBinding binding) { super(binding); }
1899
1900 @Override
1901 public void bind(Item note) {
1902 binding.message.setText(note != null && note.el != null ? note.el.getContent() : "");
1903
1904 String type = note.el.getAttribute("type");
1905 if (type != null && type.equals("error")) {
1906 binding.errorIcon.setVisibility(View.VISIBLE);
1907 }
1908 }
1909 }
1910
1911 class ResultFieldViewHolder extends ViewHolder<CommandResultFieldBinding> {
1912 public ResultFieldViewHolder(CommandResultFieldBinding binding) { super(binding); }
1913
1914 @Override
1915 public void bind(Item item) {
1916 Field field = (Field) item;
1917 setTextOrHide(binding.label, field.getLabel());
1918 setTextOrHide(binding.desc, field.getDesc());
1919
1920 Element media = field.el.findChild("media", "urn:xmpp:media-element");
1921 if (media == null) {
1922 binding.mediaImage.setVisibility(View.GONE);
1923 } else {
1924 final LruCache<String, Drawable> cache = xmppConnectionService.getDrawableCache();
1925 final HttpConnectionManager httpManager = xmppConnectionService.getHttpConnectionManager();
1926 for (Element uriEl : media.getChildren()) {
1927 if (!"uri".equals(uriEl.getName())) continue;
1928 if (!"urn:xmpp:media-element".equals(uriEl.getNamespace())) continue;
1929 String mimeType = uriEl.getAttribute("type");
1930 String uriS = uriEl.getContent();
1931 if (mimeType == null || uriS == null) continue;
1932 Uri uri = Uri.parse(uriS);
1933 if (mimeType.startsWith("image/") && "https".equals(uri.getScheme())) {
1934 final Drawable d = getDrawableForUrl(uri.toString());
1935 if (d != null) {
1936 binding.mediaImage.setImageDrawable(d);
1937 binding.mediaImage.setVisibility(View.VISIBLE);
1938 }
1939 }
1940 }
1941 }
1942
1943 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1944 String datatype = validate == null ? null : validate.getAttribute("datatype");
1945
1946 ArrayAdapter<Option> values = new ArrayAdapter<>(binding.getRoot().getContext(), R.layout.simple_list_item);
1947 for (Element el : field.el.getChildren()) {
1948 if (el.getName().equals("value") && el.getNamespace().equals("jabber:x:data")) {
1949 values.add(new Option(el.getContent(), formatValue(datatype, el.getContent(), false)));
1950 }
1951 }
1952 binding.values.setAdapter(values);
1953 Util.justifyListViewHeightBasedOnChildren(binding.values);
1954
1955 if (field.getType().equals(Optional.of("jid-single")) || field.getType().equals(Optional.of("jid-multi"))) {
1956 binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
1957 new FixedURLSpan("xmpp:" + Uri.encode(Jid.ofEscaped(values.getItem(pos).getValue()).toEscapedString(), "@/+"), account).onClick(binding.values);
1958 });
1959 } else if ("xs:anyURI".equals(datatype)) {
1960 binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
1961 new FixedURLSpan(values.getItem(pos).getValue(), account).onClick(binding.values);
1962 });
1963 } else if ("html:tel".equals(datatype)) {
1964 binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
1965 try {
1966 new FixedURLSpan("tel:" + PhoneNumberUtilWrapper.normalize(binding.getRoot().getContext(), values.getItem(pos).getValue()), account).onClick(binding.values);
1967 } catch (final IllegalArgumentException | NumberParseException | NullPointerException e) { }
1968 });
1969 }
1970
1971 binding.values.setOnItemLongClickListener((arg0, arg1, pos, id) -> {
1972 if (ShareUtil.copyTextToClipboard(binding.getRoot().getContext(), values.getItem(pos).getValue(), R.string.message)) {
1973 Toast.makeText(binding.getRoot().getContext(), R.string.message_copied_to_clipboard, Toast.LENGTH_SHORT).show();
1974 }
1975 return true;
1976 });
1977 }
1978 }
1979
1980 class ResultCellViewHolder extends ViewHolder<CommandResultCellBinding> {
1981 public ResultCellViewHolder(CommandResultCellBinding binding) { super(binding); }
1982
1983 @Override
1984 public void bind(Item item) {
1985 Cell cell = (Cell) item;
1986
1987 if (cell.el == null) {
1988 binding.text.setTextAppearance(binding.getRoot().getContext(), com.google.android.material.R.style.TextAppearance_Material3_TitleMedium);
1989 setTextOrHide(binding.text, cell.reported.getLabel());
1990 } else {
1991 Element validate = cell.reported.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1992 String datatype = validate == null ? null : validate.getAttribute("datatype");
1993 String value = formatValue(datatype, cell.el.findChildContent("value", "jabber:x:data"), true);
1994 SpannableStringBuilder text = new SpannableStringBuilder(value == null ? "" : value);
1995 if (cell.reported.getType().equals(Optional.of("jid-single"))) {
1996 text.setSpan(new FixedURLSpan("xmpp:" + Jid.ofEscaped(text.toString()).toEscapedString(), account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1997 } else if ("xs:anyURI".equals(datatype)) {
1998 text.setSpan(new FixedURLSpan(text.toString(), account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1999 } else if ("html:tel".equals(datatype)) {
2000 try {
2001 text.setSpan(new FixedURLSpan("tel:" + PhoneNumberUtilWrapper.normalize(binding.getRoot().getContext(), text.toString()), account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
2002 } catch (final IllegalArgumentException | NumberParseException | NullPointerException e) { }
2003 }
2004
2005 binding.text.setTextAppearance(binding.getRoot().getContext(), com.google.android.material.R.style.TextAppearance_Material3_BodyMedium);
2006 binding.text.setText(text);
2007
2008 BetterLinkMovementMethod method = BetterLinkMovementMethod.newInstance();
2009 method.setOnLinkLongClickListener((tv, url) -> {
2010 tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
2011 ShareUtil.copyLinkToClipboard(binding.getRoot().getContext(), url);
2012 return true;
2013 });
2014 binding.text.setMovementMethod(method);
2015 }
2016 }
2017 }
2018
2019 class ItemCardViewHolder extends ViewHolder<CommandItemCardBinding> {
2020 public ItemCardViewHolder(CommandItemCardBinding binding) { super(binding); }
2021
2022 @Override
2023 public void bind(Item item) {
2024 binding.fields.removeAllViews();
2025
2026 for (Field field : reported) {
2027 CommandResultFieldBinding row = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.command_result_field, binding.fields, false);
2028 GridLayout.LayoutParams param = new GridLayout.LayoutParams();
2029 param.columnSpec = GridLayout.spec(GridLayout.UNDEFINED, GridLayout.FILL, 1f);
2030 param.width = 0;
2031 row.getRoot().setLayoutParams(param);
2032 binding.fields.addView(row.getRoot());
2033 for (Element el : item.el.getChildren()) {
2034 if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data") && el.getAttribute("var") != null && el.getAttribute("var").equals(field.getVar())) {
2035 for (String label : field.getLabel().asSet()) {
2036 el.setAttribute("label", label);
2037 }
2038 for (String desc : field.getDesc().asSet()) {
2039 el.setAttribute("desc", desc);
2040 }
2041 for (String type : field.getType().asSet()) {
2042 el.setAttribute("type", type);
2043 }
2044 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2045 if (validate != null) el.addChild(validate);
2046 new ResultFieldViewHolder(row).bind(new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), -1));
2047 }
2048 }
2049 }
2050 }
2051 }
2052
2053 class CheckboxFieldViewHolder extends ViewHolder<CommandCheckboxFieldBinding> implements CompoundButton.OnCheckedChangeListener {
2054 public CheckboxFieldViewHolder(CommandCheckboxFieldBinding binding) {
2055 super(binding);
2056 binding.row.setOnClickListener((v) -> {
2057 binding.checkbox.toggle();
2058 });
2059 binding.checkbox.setOnCheckedChangeListener(this);
2060 }
2061 protected Element mValue = null;
2062
2063 @Override
2064 public void bind(Item item) {
2065 Field field = (Field) item;
2066 binding.label.setText(field.getLabel().or(""));
2067 setTextOrHide(binding.desc, field.getDesc());
2068 mValue = field.getValue();
2069 binding.checkbox.setChecked(mValue.getContent() != null && (mValue.getContent().equals("true") || mValue.getContent().equals("1")));
2070 }
2071
2072 @Override
2073 public void onCheckedChanged(CompoundButton checkbox, boolean isChecked) {
2074 if (mValue == null) return;
2075
2076 mValue.setContent(isChecked ? "true" : "false");
2077 }
2078 }
2079
2080 class SearchListFieldViewHolder extends ViewHolder<CommandSearchListFieldBinding> implements TextWatcher {
2081 public SearchListFieldViewHolder(CommandSearchListFieldBinding binding) {
2082 super(binding);
2083 binding.search.addTextChangedListener(this);
2084 }
2085 protected Field field = null;
2086 Set<String> filteredValues;
2087 List<Option> options = new ArrayList<>();
2088 protected ArrayAdapter<Option> adapter;
2089 protected boolean open;
2090 protected boolean multi;
2091 protected int textColor = -1;
2092
2093 @Override
2094 public void bind(Item item) {
2095 ViewGroup.LayoutParams layout = binding.list.getLayoutParams();
2096 final float density = xmppConnectionService.getResources().getDisplayMetrics().density;
2097 if (fillableFieldCount > 1) {
2098 layout.height = (int) (density * 200);
2099 } else {
2100 layout.height = (int) Math.max(density * 200, xmppConnectionService.getResources().getDisplayMetrics().heightPixels / 2);
2101 }
2102 binding.list.setLayoutParams(layout);
2103
2104 field = (Field) item;
2105 setTextOrHide(binding.label, field.getLabel());
2106 setTextOrHide(binding.desc, field.getDesc());
2107
2108 if (textColor == -1) textColor = binding.desc.getCurrentTextColor();
2109 if (field.error != null) {
2110 binding.desc.setVisibility(View.VISIBLE);
2111 binding.desc.setText(field.error);
2112 binding.desc.setTextColor(com.google.android.material.R.attr.colorError);
2113 } else {
2114 binding.desc.setTextColor(textColor);
2115 }
2116
2117 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2118 open = validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null;
2119 setupInputType(field.el, binding.search, null);
2120
2121 multi = field.getType().equals(Optional.of("list-multi"));
2122 if (multi) {
2123 binding.list.setChoiceMode(AbsListView.CHOICE_MODE_MULTIPLE);
2124 } else {
2125 binding.list.setChoiceMode(AbsListView.CHOICE_MODE_SINGLE);
2126 }
2127
2128 options = field.getOptions();
2129 binding.list.setOnItemClickListener((parent, view, position, id) -> {
2130 Set<String> values = new HashSet<>();
2131 if (multi) {
2132 values.addAll(field.getValues());
2133 for (final String value : field.getValues()) {
2134 if (filteredValues.contains(value)) {
2135 values.remove(value);
2136 }
2137 }
2138 }
2139
2140 SparseBooleanArray positions = binding.list.getCheckedItemPositions();
2141 for (int i = 0; i < positions.size(); i++) {
2142 if (positions.valueAt(i)) values.add(adapter.getItem(positions.keyAt(i)).getValue());
2143 }
2144 field.setValues(values);
2145
2146 if (!multi && open) binding.search.setText(String.join("\n", field.getValues()));
2147 });
2148 search("");
2149 }
2150
2151 @Override
2152 public void afterTextChanged(Editable s) {
2153 if (!multi && open) field.setValues(List.of(s.toString()));
2154 search(s.toString());
2155 }
2156
2157 @Override
2158 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2159
2160 @Override
2161 public void onTextChanged(CharSequence s, int start, int count, int after) { }
2162
2163 protected void search(String s) {
2164 List<Option> filteredOptions;
2165 final String q = s.replaceAll("\\W", "").toLowerCase();
2166 if (q == null || q.equals("")) {
2167 filteredOptions = options;
2168 } else {
2169 filteredOptions = options.stream().filter(o -> o.toString().replaceAll("\\W", "").toLowerCase().contains(q)).collect(Collectors.toList());
2170 }
2171 filteredValues = filteredOptions.stream().map(o -> o.getValue()).collect(Collectors.toSet());
2172 adapter = new ArrayAdapter(binding.getRoot().getContext(), R.layout.simple_list_item, filteredOptions);
2173 binding.list.setAdapter(adapter);
2174
2175 for (final String value : field.getValues()) {
2176 int checkedPos = filteredOptions.indexOf(new Option(value, ""));
2177 if (checkedPos >= 0) binding.list.setItemChecked(checkedPos, true);
2178 }
2179 }
2180 }
2181
2182 class RadioEditFieldViewHolder extends ViewHolder<CommandRadioEditFieldBinding> implements CompoundButton.OnCheckedChangeListener, TextWatcher {
2183 public RadioEditFieldViewHolder(CommandRadioEditFieldBinding binding) {
2184 super(binding);
2185 binding.open.addTextChangedListener(this);
2186 options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.radio_grid_item) {
2187 @Override
2188 public View getView(int position, View convertView, ViewGroup parent) {
2189 CompoundButton v = (CompoundButton) super.getView(position, convertView, parent);
2190 v.setId(position);
2191 v.setChecked(getItem(position).getValue().equals(mValue.getContent()));
2192 v.setOnCheckedChangeListener(RadioEditFieldViewHolder.this);
2193 return v;
2194 }
2195 };
2196 }
2197 protected Element mValue = null;
2198 protected ArrayAdapter<Option> options;
2199 protected int textColor = -1;
2200
2201 @Override
2202 public void bind(Item item) {
2203 Field field = (Field) item;
2204 setTextOrHide(binding.label, field.getLabel());
2205 setTextOrHide(binding.desc, field.getDesc());
2206
2207 if (textColor == -1) textColor = binding.desc.getCurrentTextColor();
2208 if (field.error != null) {
2209 binding.desc.setVisibility(View.VISIBLE);
2210 binding.desc.setText(field.error);
2211 binding.desc.setTextColor(com.google.android.material.R.attr.colorError);
2212 } else {
2213 binding.desc.setTextColor(textColor);
2214 }
2215
2216 mValue = field.getValue();
2217
2218 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2219 binding.open.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
2220 binding.open.setText(mValue.getContent());
2221 setupInputType(field.el, binding.open, null);
2222
2223 options.clear();
2224 List<Option> theOptions = field.getOptions();
2225 options.addAll(theOptions);
2226
2227 float screenWidth = binding.getRoot().getContext().getResources().getDisplayMetrics().widthPixels;
2228 TextPaint paint = ((TextView) LayoutInflater.from(binding.getRoot().getContext()).inflate(R.layout.radio_grid_item, null)).getPaint();
2229 float maxColumnWidth = theOptions.stream().map((x) ->
2230 StaticLayout.getDesiredWidth(x.toString(), paint)
2231 ).max(Float::compare).orElse(new Float(0.0));
2232 if (maxColumnWidth * theOptions.size() < 0.90 * screenWidth) {
2233 binding.radios.setNumColumns(theOptions.size());
2234 } else if (maxColumnWidth * (theOptions.size() / 2) < 0.90 * screenWidth) {
2235 binding.radios.setNumColumns(theOptions.size() / 2);
2236 } else {
2237 binding.radios.setNumColumns(1);
2238 }
2239 binding.radios.setAdapter(options);
2240 }
2241
2242 @Override
2243 public void onCheckedChanged(CompoundButton radio, boolean isChecked) {
2244 if (mValue == null) return;
2245
2246 if (isChecked) {
2247 mValue.setContent(options.getItem(radio.getId()).getValue());
2248 binding.open.setText(mValue.getContent());
2249 }
2250 options.notifyDataSetChanged();
2251 }
2252
2253 @Override
2254 public void afterTextChanged(Editable s) {
2255 if (mValue == null) return;
2256
2257 mValue.setContent(s.toString());
2258 options.notifyDataSetChanged();
2259 }
2260
2261 @Override
2262 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2263
2264 @Override
2265 public void onTextChanged(CharSequence s, int start, int count, int after) { }
2266 }
2267
2268 class SpinnerFieldViewHolder extends ViewHolder<CommandSpinnerFieldBinding> implements AdapterView.OnItemSelectedListener {
2269 public SpinnerFieldViewHolder(CommandSpinnerFieldBinding binding) {
2270 super(binding);
2271 binding.spinner.setOnItemSelectedListener(this);
2272 }
2273 protected Element mValue = null;
2274
2275 @Override
2276 public void bind(Item item) {
2277 Field field = (Field) item;
2278 setTextOrHide(binding.label, field.getLabel());
2279 binding.spinner.setPrompt(field.getLabel().or(""));
2280 setTextOrHide(binding.desc, field.getDesc());
2281
2282 mValue = field.getValue();
2283
2284 ArrayAdapter<Option> options = new ArrayAdapter<Option>(binding.getRoot().getContext(), android.R.layout.simple_spinner_item);
2285 options.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
2286 options.addAll(field.getOptions());
2287
2288 binding.spinner.setAdapter(options);
2289 binding.spinner.setSelection(options.getPosition(new Option(mValue.getContent(), null)));
2290 }
2291
2292 @Override
2293 public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
2294 Option o = (Option) parent.getItemAtPosition(pos);
2295 if (mValue == null) return;
2296
2297 mValue.setContent(o == null ? "" : o.getValue());
2298 }
2299
2300 @Override
2301 public void onNothingSelected(AdapterView<?> parent) {
2302 mValue.setContent("");
2303 }
2304 }
2305
2306 class ButtonGridFieldViewHolder extends ViewHolder<CommandButtonGridFieldBinding> {
2307 public ButtonGridFieldViewHolder(CommandButtonGridFieldBinding binding) {
2308 super(binding);
2309 options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.button_grid_item) {
2310 protected int height = 0;
2311
2312 @Override
2313 public View getView(int position, View convertView, ViewGroup parent) {
2314 Button v = (Button) super.getView(position, convertView, parent);
2315 v.setOnClickListener((view) -> {
2316 mValue.setContent(getItem(position).getValue());
2317 execute();
2318 loading = true;
2319 });
2320
2321 final SVG icon = getItem(position).getIcon();
2322 if (icon != null) {
2323 final Element iconEl = getItem(position).getIconEl();
2324 if (height < 1) {
2325 v.measure(0, 0);
2326 height = v.getMeasuredHeight();
2327 }
2328 if (height < 1) return v;
2329 if (mediaSelector) {
2330 final Drawable d = getDrawableForSVG(icon, iconEl, height * 4);
2331 if (d != null) {
2332 final int boundsHeight = 35 + (int)((height * 4) / xmppConnectionService.getResources().getDisplayMetrics().density);
2333 d.setBounds(0, 0, d.getIntrinsicWidth(), boundsHeight);
2334 }
2335 v.setCompoundDrawables(null, d, null, null);
2336 } else {
2337 v.setCompoundDrawablesRelativeWithIntrinsicBounds(getDrawableForSVG(icon, iconEl, height), null, null, null);
2338 }
2339 }
2340
2341 return v;
2342 }
2343 };
2344 }
2345 protected Element mValue = null;
2346 protected ArrayAdapter<Option> options;
2347 protected Option defaultOption = null;
2348 protected boolean mediaSelector = false;
2349 protected int textColor = -1;
2350
2351 @Override
2352 public void bind(Item item) {
2353 Field field = (Field) item;
2354 setTextOrHide(binding.label, field.getLabel());
2355 setTextOrHide(binding.desc, field.getDesc());
2356
2357 if (textColor == -1) textColor = binding.desc.getCurrentTextColor();
2358 if (field.error != null) {
2359 binding.desc.setVisibility(View.VISIBLE);
2360 binding.desc.setText(field.error);
2361 binding.desc.setTextColor(com.google.android.material.R.attr.colorError);
2362 } else {
2363 binding.desc.setTextColor(textColor);
2364 }
2365
2366 mValue = field.getValue();
2367 mediaSelector = field.el.findChild("media-selector", "https://ns.cheogram.com/") != null;
2368
2369 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2370 binding.openButton.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
2371 binding.openButton.setOnClickListener((view) -> {
2372 AlertDialog.Builder builder = new AlertDialog.Builder(binding.getRoot().getContext());
2373 DialogQuickeditBinding dialogBinding = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.dialog_quickedit, null, false);
2374 builder.setPositiveButton(R.string.action_execute, null);
2375 if (field.getDesc().isPresent()) {
2376 dialogBinding.inputLayout.setHint(field.getDesc().get());
2377 }
2378 dialogBinding.inputEditText.requestFocus();
2379 dialogBinding.inputEditText.getText().append(mValue.getContent());
2380 builder.setView(dialogBinding.getRoot());
2381 builder.setNegativeButton(R.string.cancel, null);
2382 final AlertDialog dialog = builder.create();
2383 dialog.setOnShowListener(d -> SoftKeyboardUtils.showKeyboard(dialogBinding.inputEditText));
2384 dialog.show();
2385 View.OnClickListener clickListener = v -> {
2386 String value = dialogBinding.inputEditText.getText().toString();
2387 mValue.setContent(value);
2388 SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2389 dialog.dismiss();
2390 execute();
2391 loading = true;
2392 };
2393 dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener(clickListener);
2394 dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setOnClickListener((v -> {
2395 SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2396 dialog.dismiss();
2397 }));
2398 dialog.setCanceledOnTouchOutside(false);
2399 dialog.setOnDismissListener(dialog1 -> {
2400 SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2401 });
2402 });
2403
2404 options.clear();
2405 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();
2406
2407 defaultOption = null;
2408 for (Option option : theOptions) {
2409 if (option.getValue().equals(mValue.getContent())) {
2410 defaultOption = option;
2411 break;
2412 }
2413 }
2414 if (defaultOption == null && !mValue.getContent().equals("")) {
2415 // Synthesize default option for custom value
2416 defaultOption = new Option(mValue.getContent(), mValue.getContent());
2417 }
2418 if (defaultOption == null) {
2419 binding.defaultButton.setVisibility(View.GONE);
2420 } else {
2421 theOptions.remove(defaultOption);
2422 binding.defaultButton.setVisibility(View.VISIBLE);
2423
2424 final SVG defaultIcon = defaultOption.getIcon();
2425 if (defaultIcon != null) {
2426 DisplayMetrics display = mPager.getContext().getResources().getDisplayMetrics();
2427 int height = (int)(display.heightPixels*display.density/4);
2428 binding.defaultButton.setCompoundDrawablesRelativeWithIntrinsicBounds(null, getDrawableForSVG(defaultIcon, defaultOption.getIconEl(), height), null, null);
2429 }
2430
2431 binding.defaultButton.setText(defaultOption.toString());
2432 binding.defaultButton.setOnClickListener((view) -> {
2433 mValue.setContent(defaultOption.getValue());
2434 execute();
2435 loading = true;
2436 });
2437 }
2438
2439 options.addAll(theOptions);
2440 binding.buttons.setAdapter(options);
2441 }
2442 }
2443
2444 class TextFieldViewHolder extends ViewHolder<CommandTextFieldBinding> implements TextWatcher {
2445 public TextFieldViewHolder(CommandTextFieldBinding binding) {
2446 super(binding);
2447 binding.textinput.addTextChangedListener(this);
2448 }
2449 protected Field field = null;
2450
2451 @Override
2452 public void bind(Item item) {
2453 field = (Field) item;
2454 binding.textinputLayout.setHint(field.getLabel().or(""));
2455
2456 binding.textinputLayout.setHelperTextEnabled(field.getDesc().isPresent());
2457 for (String desc : field.getDesc().asSet()) {
2458 binding.textinputLayout.setHelperText(desc);
2459 }
2460
2461 binding.textinputLayout.setErrorEnabled(field.error != null);
2462 if (field.error != null) binding.textinputLayout.setError(field.error);
2463
2464 binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_GRAVITY);
2465 String suffixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/suffix-label");
2466 if (suffixLabel == null) {
2467 binding.textinputLayout.setSuffixText("");
2468 } else {
2469 binding.textinputLayout.setSuffixText(suffixLabel);
2470 binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_TEXT_END);
2471 }
2472
2473 String prefixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/prefix-label");
2474 binding.textinputLayout.setPrefixText(prefixLabel == null ? "" : prefixLabel);
2475
2476 binding.textinput.setText(String.join("\n", field.getValues()));
2477 setupInputType(field.el, binding.textinput, binding.textinputLayout);
2478 }
2479
2480 @Override
2481 public void afterTextChanged(Editable s) {
2482 if (field == null) return;
2483
2484 field.setValues(List.of(s.toString().split("\n")));
2485 }
2486
2487 @Override
2488 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2489
2490 @Override
2491 public void onTextChanged(CharSequence s, int start, int count, int after) { }
2492 }
2493
2494 class SliderFieldViewHolder extends ViewHolder<CommandSliderFieldBinding> {
2495 public SliderFieldViewHolder(CommandSliderFieldBinding binding) { super(binding); }
2496 protected Field field = null;
2497
2498 @Override
2499 public void bind(Item item) {
2500 field = (Field) item;
2501 setTextOrHide(binding.label, field.getLabel());
2502 setTextOrHide(binding.desc, field.getDesc());
2503 final Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2504 final String datatype = validate == null ? null : validate.getAttribute("datatype");
2505 final Element range = validate == null ? null : validate.findChild("range", "http://jabber.org/protocol/xdata-validate");
2506 // NOTE: range also implies open, so we don't have to be bound by the options strictly
2507 // Also, we don't have anywhere to put labels so we show only values, which might sometimes be bad...
2508 Float min = null;
2509 try { min = range.getAttribute("min") == null ? null : Float.valueOf(range.getAttribute("min")); } catch (NumberFormatException e) { }
2510 Float max = null;
2511 try { max = range.getAttribute("max") == null ? null : Float.valueOf(range.getAttribute("max")); } catch (NumberFormatException e) { }
2512
2513 List<Float> options = field.getOptions().stream().map(o -> Float.valueOf(o.getValue())).collect(Collectors.toList());
2514 Collections.sort(options);
2515 if (options.size() > 0) {
2516 // min/max should be on the range, but if you have options and didn't put them on the range we can imply
2517 if (min == null) min = options.get(0);
2518 if (max == null) max = options.get(options.size()-1);
2519 }
2520
2521 if (field.getValues().size() > 0) {
2522 binding.slider.setValue(Float.valueOf(field.getValue().getContent()));
2523 } else {
2524 binding.slider.setValue(min == null ? Float.MIN_VALUE : min);
2525 }
2526 binding.slider.setValueFrom(min == null ? Float.MIN_VALUE : min);
2527 binding.slider.setValueTo(max == null ? Float.MAX_VALUE : max);
2528 if ("xs:integer".equals(datatype) || "xs:int".equals(datatype) || "xs:long".equals(datatype) || "xs:short".equals(datatype) || "xs:byte".equals(datatype)) {
2529 binding.slider.setStepSize(1);
2530 } else {
2531 binding.slider.setStepSize(0);
2532 }
2533
2534 if (options.size() > 0) {
2535 float step = -1;
2536 Float prev = null;
2537 for (final Float option : options) {
2538 if (prev != null) {
2539 float nextStep = option - prev;
2540 if (step > 0 && step != nextStep) {
2541 step = -1;
2542 break;
2543 }
2544 step = nextStep;
2545 }
2546 prev = option;
2547 }
2548 if (step > 0) binding.slider.setStepSize(step);
2549 }
2550
2551 binding.slider.addOnChangeListener((slider, value, fromUser) -> {
2552 field.setValues(List.of(new DecimalFormat().format(value)));
2553 });
2554 }
2555 }
2556
2557 class WebViewHolder extends ViewHolder<CommandWebviewBinding> {
2558 public WebViewHolder(CommandWebviewBinding binding) { super(binding); }
2559 protected String boundUrl = "";
2560
2561 @Override
2562 public void bind(Item oob) {
2563 setTextOrHide(binding.desc, Optional.fromNullable(oob.el.findChildContent("desc", "jabber:x:oob")));
2564 binding.webview.getSettings().setJavaScriptEnabled(true);
2565 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");
2566 binding.webview.getSettings().setDatabaseEnabled(true);
2567 binding.webview.getSettings().setDomStorageEnabled(true);
2568 binding.webview.setWebChromeClient(new WebChromeClient() {
2569 @Override
2570 public void onProgressChanged(WebView view, int newProgress) {
2571 binding.progressbar.setVisibility(newProgress < 100 ? View.VISIBLE : View.GONE);
2572 binding.progressbar.setProgress(newProgress);
2573 }
2574 });
2575 binding.webview.setWebViewClient(new WebViewClient() {
2576 @Override
2577 public void onPageFinished(WebView view, String url) {
2578 super.onPageFinished(view, url);
2579 mTitle = view.getTitle();
2580 ConversationPagerAdapter.this.notifyDataSetChanged();
2581 }
2582 });
2583 final String url = oob.el.findChildContent("url", "jabber:x:oob");
2584 if (!boundUrl.equals(url)) {
2585 binding.webview.addJavascriptInterface(new JsObject(), "xmpp_xep0050");
2586 binding.webview.loadUrl(url);
2587 boundUrl = url;
2588 }
2589 }
2590
2591 class JsObject {
2592 @JavascriptInterface
2593 public void execute() { execute("execute"); }
2594
2595 @JavascriptInterface
2596 public void execute(String action) {
2597 getView().post(() -> {
2598 actionToWebview = null;
2599 if(CommandSession.this.execute(action)) {
2600 removeSession(CommandSession.this);
2601 }
2602 });
2603 }
2604
2605 @JavascriptInterface
2606 public void preventDefault() {
2607 actionToWebview = binding.webview;
2608 }
2609 }
2610 }
2611
2612 class ProgressBarViewHolder extends ViewHolder<CommandProgressBarBinding> {
2613 public ProgressBarViewHolder(CommandProgressBarBinding binding) { super(binding); }
2614
2615 @Override
2616 public void bind(Item item) {
2617 binding.text.setVisibility(loadingHasBeenLong ? View.VISIBLE : View.GONE);
2618 }
2619 }
2620
2621 class Item {
2622 protected Element el;
2623 protected int viewType;
2624 protected String error = null;
2625
2626 Item(Element el, int viewType) {
2627 this.el = el;
2628 this.viewType = viewType;
2629 }
2630
2631 public boolean validate() {
2632 error = null;
2633 return true;
2634 }
2635 }
2636
2637 class Field extends Item {
2638 Field(eu.siacs.conversations.xmpp.forms.Field el, int viewType) { super(el, viewType); }
2639
2640 @Override
2641 public boolean validate() {
2642 if (!super.validate()) return false;
2643 if (el.findChild("required", "jabber:x:data") == null) return true;
2644 if (getValue().getContent() != null && !getValue().getContent().equals("")) return true;
2645
2646 error = "this value is required";
2647 return false;
2648 }
2649
2650 public String getVar() {
2651 return el.getAttribute("var");
2652 }
2653
2654 public Optional<String> getType() {
2655 return Optional.fromNullable(el.getAttribute("type"));
2656 }
2657
2658 public Optional<String> getLabel() {
2659 String label = el.getAttribute("label");
2660 if (label == null) label = getVar();
2661 return Optional.fromNullable(label);
2662 }
2663
2664 public Optional<String> getDesc() {
2665 return Optional.fromNullable(el.findChildContent("desc", "jabber:x:data"));
2666 }
2667
2668 public Element getValue() {
2669 Element value = el.findChild("value", "jabber:x:data");
2670 if (value == null) {
2671 value = el.addChild("value", "jabber:x:data");
2672 }
2673 return value;
2674 }
2675
2676 public void setValues(Collection<String> values) {
2677 for(Element child : el.getChildren()) {
2678 if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
2679 el.removeChild(child);
2680 }
2681 }
2682
2683 for (String value : values) {
2684 el.addChild("value", "jabber:x:data").setContent(value);
2685 }
2686 }
2687
2688 public List<String> getValues() {
2689 List<String> values = new ArrayList<>();
2690 for(Element child : el.getChildren()) {
2691 if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
2692 values.add(child.getContent());
2693 }
2694 }
2695 return values;
2696 }
2697
2698 public List<Option> getOptions() {
2699 return Option.forField(el);
2700 }
2701 }
2702
2703 class Cell extends Item {
2704 protected Field reported;
2705
2706 Cell(Field reported, Element item) {
2707 super(item, TYPE_RESULT_CELL);
2708 this.reported = reported;
2709 }
2710 }
2711
2712 protected Field mkField(Element el) {
2713 int viewType = -1;
2714
2715 String formType = responseElement.getAttribute("type");
2716 if (formType != null) {
2717 String fieldType = el.getAttribute("type");
2718 if (fieldType == null) fieldType = "text-single";
2719
2720 if (formType.equals("result") || fieldType.equals("fixed")) {
2721 viewType = TYPE_RESULT_FIELD;
2722 } else if (formType.equals("form")) {
2723 final Element validate = el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2724 final String datatype = validate == null ? null : validate.getAttribute("datatype");
2725 final Element range = validate == null ? null : validate.findChild("range", "http://jabber.org/protocol/xdata-validate");
2726 if (fieldType.equals("boolean")) {
2727 if (fillableFieldCount == 1 && actionsAdapter.countProceed() < 1) {
2728 viewType = TYPE_BUTTON_GRID_FIELD;
2729 } else {
2730 viewType = TYPE_CHECKBOX_FIELD;
2731 }
2732 } else if (
2733 range != null && range.getAttribute("min") != null && range.getAttribute("max") != null && (
2734 "xs:integer".equals(datatype) || "xs:int".equals(datatype) || "xs:long".equals(datatype) || "xs:short".equals(datatype) || "xs:byte".equals(datatype) ||
2735 "xs:decimal".equals(datatype) || "xs:double".equals(datatype)
2736 )
2737 ) {
2738 // has a range and is numeric, use a slider
2739 viewType = TYPE_SLIDER_FIELD;
2740 } else if (fieldType.equals("list-single")) {
2741 if (fillableFieldCount == 1 && actionsAdapter.countProceed() < 1 && Option.forField(el).size() < 50) {
2742 viewType = TYPE_BUTTON_GRID_FIELD;
2743 } else if (Option.forField(el).size() > 9) {
2744 viewType = TYPE_SEARCH_LIST_FIELD;
2745 } else if (el.findChild("value", "jabber:x:data") == null || (validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null)) {
2746 viewType = TYPE_RADIO_EDIT_FIELD;
2747 } else {
2748 viewType = TYPE_SPINNER_FIELD;
2749 }
2750 } else if (fieldType.equals("list-multi")) {
2751 viewType = TYPE_SEARCH_LIST_FIELD;
2752 } else {
2753 viewType = TYPE_TEXT_FIELD;
2754 }
2755 }
2756
2757 return new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), viewType);
2758 }
2759
2760 return null;
2761 }
2762
2763 protected Item mkItem(Element el, int pos) {
2764 int viewType = TYPE_ERROR;
2765
2766 if (response != null && response.getType() == IqPacket.TYPE.RESULT) {
2767 if (el.getName().equals("note")) {
2768 viewType = TYPE_NOTE;
2769 } else if (el.getNamespace().equals("jabber:x:oob")) {
2770 viewType = TYPE_WEB;
2771 } else if (el.getName().equals("instructions") && el.getNamespace().equals("jabber:x:data")) {
2772 viewType = TYPE_NOTE;
2773 } else if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data")) {
2774 Field field = mkField(el);
2775 if (field != null) {
2776 items.put(pos, field);
2777 return field;
2778 }
2779 }
2780 }
2781
2782 Item item = new Item(el, viewType);
2783 items.put(pos, item);
2784 return item;
2785 }
2786
2787 class ActionsAdapter extends ArrayAdapter<Pair<String, String>> {
2788 protected Context ctx;
2789
2790 public ActionsAdapter(Context ctx) {
2791 super(ctx, R.layout.simple_list_item);
2792 this.ctx = ctx;
2793 }
2794
2795 @Override
2796 public View getView(int position, View convertView, ViewGroup parent) {
2797 View v = super.getView(position, convertView, parent);
2798 TextView tv = (TextView) v.findViewById(android.R.id.text1);
2799 tv.setGravity(Gravity.CENTER);
2800 tv.setText(getItem(position).second);
2801 int resId = ctx.getResources().getIdentifier("action_" + getItem(position).first, "string" , ctx.getPackageName());
2802 if (resId != 0 && getItem(position).second.equals(getItem(position).first)) tv.setText(ctx.getResources().getString(resId));
2803 final var colors = MaterialColors.getColorRoles(ctx, UIHelper.getColorForName(getItem(position).first));
2804 tv.setTextColor(colors.getOnAccent());
2805 tv.setBackgroundColor(MaterialColors.harmonizeWithPrimary(ctx, colors.getAccent()));
2806 return v;
2807 }
2808
2809 public int getPosition(String s) {
2810 for(int i = 0; i < getCount(); i++) {
2811 if (getItem(i).first.equals(s)) return i;
2812 }
2813 return -1;
2814 }
2815
2816 public int countProceed() {
2817 int count = 0;
2818 for(int i = 0; i < getCount(); i++) {
2819 if (!"cancel".equals(getItem(i).first) && !"prev".equals(getItem(i).first)) count++;
2820 }
2821 return count;
2822 }
2823
2824 public int countExceptCancel() {
2825 int count = 0;
2826 for(int i = 0; i < getCount(); i++) {
2827 if (!getItem(i).first.equals("cancel")) count++;
2828 }
2829 return count;
2830 }
2831
2832 public void clearProceed() {
2833 Pair<String,String> cancelItem = null;
2834 Pair<String,String> prevItem = null;
2835 for(int i = 0; i < getCount(); i++) {
2836 if (getItem(i).first.equals("cancel")) cancelItem = getItem(i);
2837 if (getItem(i).first.equals("prev")) prevItem = getItem(i);
2838 }
2839 clear();
2840 if (cancelItem != null) add(cancelItem);
2841 if (prevItem != null) add(prevItem);
2842 }
2843 }
2844
2845 final int TYPE_ERROR = 1;
2846 final int TYPE_NOTE = 2;
2847 final int TYPE_WEB = 3;
2848 final int TYPE_RESULT_FIELD = 4;
2849 final int TYPE_TEXT_FIELD = 5;
2850 final int TYPE_CHECKBOX_FIELD = 6;
2851 final int TYPE_SPINNER_FIELD = 7;
2852 final int TYPE_RADIO_EDIT_FIELD = 8;
2853 final int TYPE_RESULT_CELL = 9;
2854 final int TYPE_PROGRESSBAR = 10;
2855 final int TYPE_SEARCH_LIST_FIELD = 11;
2856 final int TYPE_ITEM_CARD = 12;
2857 final int TYPE_BUTTON_GRID_FIELD = 13;
2858 final int TYPE_SLIDER_FIELD = 14;
2859
2860 protected boolean executing = false;
2861 protected boolean loading = false;
2862 protected boolean loadingHasBeenLong = false;
2863 protected Timer loadingTimer = new Timer();
2864 protected String mTitle;
2865 protected String mNode;
2866 protected CommandPageBinding mBinding = null;
2867 protected IqPacket response = null;
2868 protected Element responseElement = null;
2869 protected boolean expectingRemoval = false;
2870 protected List<Field> reported = null;
2871 protected SparseArray<Item> items = new SparseArray<>();
2872 protected XmppConnectionService xmppConnectionService;
2873 protected ActionsAdapter actionsAdapter = null;
2874 protected GridLayoutManager layoutManager;
2875 protected WebView actionToWebview = null;
2876 protected int fillableFieldCount = 0;
2877 protected IqPacket pendingResponsePacket = null;
2878 protected boolean waitingForRefresh = false;
2879
2880 CommandSession(String title, String node, XmppConnectionService xmppConnectionService) {
2881 loading();
2882 mTitle = title;
2883 mNode = node;
2884 this.xmppConnectionService = xmppConnectionService;
2885 if (mPager != null) setupLayoutManager(mPager.getContext());
2886 }
2887
2888 public String getTitle() {
2889 return mTitle;
2890 }
2891
2892 public String getNode() {
2893 return mNode;
2894 }
2895
2896 public void updateWithResponse(final IqPacket iq) {
2897 if (getView() != null && getView().isAttachedToWindow()) {
2898 getView().post(() -> updateWithResponseUiThread(iq));
2899 } else {
2900 pendingResponsePacket = iq;
2901 }
2902 }
2903
2904 protected void updateWithResponseUiThread(final IqPacket iq) {
2905 Timer oldTimer = this.loadingTimer;
2906 this.loadingTimer = new Timer();
2907 oldTimer.cancel();
2908 this.executing = false;
2909 this.loading = false;
2910 this.loadingHasBeenLong = false;
2911 this.responseElement = null;
2912 this.fillableFieldCount = 0;
2913 this.reported = null;
2914 this.response = iq;
2915 this.items.clear();
2916 this.actionsAdapter.clear();
2917 layoutManager.setSpanCount(1);
2918
2919 boolean actionsCleared = false;
2920 Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
2921 if (iq.getType() == IqPacket.TYPE.RESULT && command != null) {
2922 if (mNode.equals("jabber:iq:register") && command.getAttribute("status") != null && command.getAttribute("status").equals("completed")) {
2923 xmppConnectionService.createContact(getAccount().getRoster().getContact(iq.getFrom()), true);
2924 }
2925
2926 if (xmppConnectionService.isOnboarding() && mNode.equals("jabber:iq:register") && !"canceled".equals(command.getAttribute("status")) && xmppConnectionService.getPreferences().contains("onboarding_action")) {
2927 xmppConnectionService.getPreferences().edit().putBoolean("onboarding_continued", true).commit();
2928 }
2929
2930 Element actions = command.findChild("actions", "http://jabber.org/protocol/commands");
2931 if (actions != null) {
2932 for (Element action : actions.getChildren()) {
2933 if (!"http://jabber.org/protocol/commands".equals(action.getNamespace())) continue;
2934 if ("execute".equals(action.getName())) continue;
2935
2936 actionsAdapter.add(Pair.create(action.getName(), action.getName()));
2937 }
2938 }
2939
2940 for (Element el : command.getChildren()) {
2941 if ("x".equals(el.getName()) && "jabber:x:data".equals(el.getNamespace())) {
2942 Data form = Data.parse(el);
2943 String title = form.getTitle();
2944 if (title != null) {
2945 mTitle = title;
2946 ConversationPagerAdapter.this.notifyDataSetChanged();
2947 }
2948
2949 if ("result".equals(el.getAttribute("type")) || "form".equals(el.getAttribute("type"))) {
2950 this.responseElement = el;
2951 setupReported(el.findChild("reported", "jabber:x:data"));
2952 if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager(mBinding.getRoot().getContext()));
2953 }
2954
2955 eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
2956 if (actionList != null) {
2957 actionsAdapter.clear();
2958
2959 for (Option action : actionList.getOptions()) {
2960 actionsAdapter.add(Pair.create(action.getValue(), action.toString()));
2961 }
2962 }
2963
2964 eu.siacs.conversations.xmpp.forms.Field fillableField = null;
2965 for (eu.siacs.conversations.xmpp.forms.Field field : form.getFields()) {
2966 if ((field.getType() == null || (!field.getType().equals("hidden") && !field.getType().equals("fixed"))) && field.getFieldName() != null && !field.getFieldName().equals("http://jabber.org/protocol/commands#actions")) {
2967 final var validate = field.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2968 final var range = validate == null ? null : validate.findChild("range", "http://jabber.org/protocol/xdata-validate");
2969 fillableField = range == null ? field : null;
2970 fillableFieldCount++;
2971 }
2972 }
2973
2974 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))) {
2975 actionsCleared = true;
2976 actionsAdapter.clearProceed();
2977 }
2978 break;
2979 }
2980 if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
2981 String url = el.findChildContent("url", "jabber:x:oob");
2982 if (url != null) {
2983 String scheme = Uri.parse(url).getScheme();
2984 if (scheme.equals("http") || scheme.equals("https")) {
2985 this.responseElement = el;
2986 break;
2987 }
2988 if (scheme.equals("xmpp")) {
2989 expectingRemoval = true;
2990 final Intent intent = new Intent(getView().getContext(), UriHandlerActivity.class);
2991 intent.setAction(Intent.ACTION_VIEW);
2992 intent.setData(Uri.parse(url));
2993 getView().getContext().startActivity(intent);
2994 break;
2995 }
2996 }
2997 }
2998 if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2999 this.responseElement = el;
3000 break;
3001 }
3002 }
3003
3004 if (responseElement == null && command.getAttribute("status") != null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
3005 if ("jabber:iq:register".equals(mNode) && "canceled".equals(command.getAttribute("status"))) {
3006 if (xmppConnectionService.isOnboarding()) {
3007 if (xmppConnectionService.getPreferences().contains("onboarding_action")) {
3008 xmppConnectionService.deleteAccount(getAccount());
3009 } else {
3010 if (xmppConnectionService.getPreferences().getBoolean("onboarding_continued", false)) {
3011 removeSession(this);
3012 return;
3013 } else {
3014 xmppConnectionService.getPreferences().edit().putString("onboarding_action", "cancel").commit();
3015 xmppConnectionService.deleteAccount(getAccount());
3016 }
3017 }
3018 }
3019 xmppConnectionService.archiveConversation(Conversation.this);
3020 }
3021
3022 expectingRemoval = true;
3023 removeSession(this);
3024 return;
3025 }
3026
3027 if ("executing".equals(command.getAttribute("status")) && actionsAdapter.countExceptCancel() < 1 && !actionsCleared) {
3028 // No actions have been given, but we are not done?
3029 // This is probably a spec violation, but we should do *something*
3030 actionsAdapter.add(Pair.create("execute", "execute"));
3031 }
3032
3033 if (!actionsAdapter.isEmpty() || fillableFieldCount > 0) {
3034 if ("completed".equals(command.getAttribute("status")) || "canceled".equals(command.getAttribute("status"))) {
3035 actionsAdapter.add(Pair.create("close", "close"));
3036 } else if (actionsAdapter.getPosition("cancel") < 0 && !xmppConnectionService.isOnboarding()) {
3037 actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
3038 }
3039 }
3040 }
3041
3042 if (actionsAdapter.isEmpty()) {
3043 actionsAdapter.add(Pair.create("close", "close"));
3044 }
3045
3046 actionsAdapter.sort((x, y) -> {
3047 if (x.first.equals("cancel")) return -1;
3048 if (y.first.equals("cancel")) return 1;
3049 if (x.first.equals("prev") && xmppConnectionService.isOnboarding()) return -1;
3050 if (y.first.equals("prev") && xmppConnectionService.isOnboarding()) return 1;
3051 return 0;
3052 });
3053
3054 Data dataForm = null;
3055 if (responseElement != null && responseElement.getName().equals("x") && responseElement.getNamespace().equals("jabber:x:data")) dataForm = Data.parse(responseElement);
3056 if (mNode.equals("jabber:iq:register") &&
3057 xmppConnectionService.getPreferences().contains("onboarding_action") &&
3058 dataForm != null && dataForm.getFieldByName("gateway-jid") != null) {
3059
3060
3061 dataForm.put("gateway-jid", xmppConnectionService.getPreferences().getString("onboarding_action", ""));
3062 execute();
3063 }
3064 xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
3065 notifyDataSetChanged();
3066 }
3067
3068 protected void setupReported(Element el) {
3069 if (el == null) {
3070 reported = null;
3071 return;
3072 }
3073
3074 reported = new ArrayList<>();
3075 for (Element fieldEl : el.getChildren()) {
3076 if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
3077 reported.add(mkField(fieldEl));
3078 }
3079 }
3080
3081 @Override
3082 public int getItemCount() {
3083 if (loading) return 1;
3084 if (response == null) return 0;
3085 if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
3086 int i = 0;
3087 for (Element el : responseElement.getChildren()) {
3088 if (!el.getNamespace().equals("jabber:x:data")) continue;
3089 if (el.getName().equals("title")) continue;
3090 if (el.getName().equals("field")) {
3091 String type = el.getAttribute("type");
3092 if (type != null && type.equals("hidden")) continue;
3093 if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
3094 }
3095
3096 if (el.getName().equals("reported") || el.getName().equals("item")) {
3097 if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
3098 if (el.getName().equals("reported")) continue;
3099 i += 1;
3100 } else {
3101 if (reported != null) i += reported.size();
3102 }
3103 continue;
3104 }
3105
3106 i++;
3107 }
3108 return i;
3109 }
3110 return 1;
3111 }
3112
3113 public Item getItem(int position) {
3114 if (loading) return new Item(null, TYPE_PROGRESSBAR);
3115 if (items.get(position) != null) return items.get(position);
3116 if (response == null) return null;
3117
3118 if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null) {
3119 if (responseElement.getNamespace().equals("jabber:x:data")) {
3120 int i = 0;
3121 for (Element el : responseElement.getChildren()) {
3122 if (!el.getNamespace().equals("jabber:x:data")) continue;
3123 if (el.getName().equals("title")) continue;
3124 if (el.getName().equals("field")) {
3125 String type = el.getAttribute("type");
3126 if (type != null && type.equals("hidden")) continue;
3127 if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
3128 }
3129
3130 if (el.getName().equals("reported") || el.getName().equals("item")) {
3131 Cell cell = null;
3132
3133 if (reported != null) {
3134 if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
3135 if (el.getName().equals("reported")) continue;
3136 if (i == position) {
3137 items.put(position, new Item(el, TYPE_ITEM_CARD));
3138 return items.get(position);
3139 }
3140 } else {
3141 if (reported.size() > position - i) {
3142 Field reportedField = reported.get(position - i);
3143 Element itemField = null;
3144 if (el.getName().equals("item")) {
3145 for (Element subel : el.getChildren()) {
3146 if (subel.getAttribute("var").equals(reportedField.getVar())) {
3147 itemField = subel;
3148 break;
3149 }
3150 }
3151 }
3152 cell = new Cell(reportedField, itemField);
3153 } else {
3154 i += reported.size();
3155 continue;
3156 }
3157 }
3158 }
3159
3160 if (cell != null) {
3161 items.put(position, cell);
3162 return cell;
3163 }
3164 }
3165
3166 if (i < position) {
3167 i++;
3168 continue;
3169 }
3170
3171 return mkItem(el, position);
3172 }
3173 }
3174 }
3175
3176 return mkItem(responseElement == null ? response : responseElement, position);
3177 }
3178
3179 @Override
3180 public int getItemViewType(int position) {
3181 return getItem(position).viewType;
3182 }
3183
3184 @Override
3185 public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
3186 switch(viewType) {
3187 case TYPE_ERROR: {
3188 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3189 return new ErrorViewHolder(binding);
3190 }
3191 case TYPE_NOTE: {
3192 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3193 return new NoteViewHolder(binding);
3194 }
3195 case TYPE_WEB: {
3196 CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
3197 return new WebViewHolder(binding);
3198 }
3199 case TYPE_RESULT_FIELD: {
3200 CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
3201 return new ResultFieldViewHolder(binding);
3202 }
3203 case TYPE_RESULT_CELL: {
3204 CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
3205 return new ResultCellViewHolder(binding);
3206 }
3207 case TYPE_ITEM_CARD: {
3208 CommandItemCardBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_item_card, container, false);
3209 return new ItemCardViewHolder(binding);
3210 }
3211 case TYPE_CHECKBOX_FIELD: {
3212 CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
3213 return new CheckboxFieldViewHolder(binding);
3214 }
3215 case TYPE_SEARCH_LIST_FIELD: {
3216 CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
3217 return new SearchListFieldViewHolder(binding);
3218 }
3219 case TYPE_RADIO_EDIT_FIELD: {
3220 CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
3221 return new RadioEditFieldViewHolder(binding);
3222 }
3223 case TYPE_SPINNER_FIELD: {
3224 CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
3225 return new SpinnerFieldViewHolder(binding);
3226 }
3227 case TYPE_BUTTON_GRID_FIELD: {
3228 CommandButtonGridFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_button_grid_field, container, false);
3229 return new ButtonGridFieldViewHolder(binding);
3230 }
3231 case TYPE_TEXT_FIELD: {
3232 CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
3233 return new TextFieldViewHolder(binding);
3234 }
3235 case TYPE_SLIDER_FIELD: {
3236 CommandSliderFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_slider_field, container, false);
3237 return new SliderFieldViewHolder(binding);
3238 }
3239 case TYPE_PROGRESSBAR: {
3240 CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
3241 return new ProgressBarViewHolder(binding);
3242 }
3243 default:
3244 if (expectingRemoval) {
3245 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3246 return new NoteViewHolder(binding);
3247 }
3248
3249 throw new IllegalArgumentException("Unknown viewType: " + viewType + " based on: " + response + ", " + responseElement + ", " + expectingRemoval);
3250 }
3251 }
3252
3253 @Override
3254 public void onBindViewHolder(ViewHolder viewHolder, int position) {
3255 viewHolder.bind(getItem(position));
3256 }
3257
3258 public View getView() {
3259 if (mBinding == null) return null;
3260 return mBinding.getRoot();
3261 }
3262
3263 public boolean validate() {
3264 int count = getItemCount();
3265 boolean isValid = true;
3266 for (int i = 0; i < count; i++) {
3267 boolean oneIsValid = getItem(i).validate();
3268 isValid = isValid && oneIsValid;
3269 }
3270 notifyDataSetChanged();
3271 return isValid;
3272 }
3273
3274 public boolean execute() {
3275 return execute("execute");
3276 }
3277
3278 public boolean execute(int actionPosition) {
3279 return execute(actionsAdapter.getItem(actionPosition).first);
3280 }
3281
3282 public synchronized boolean execute(String action) {
3283 if (!"cancel".equals(action) && executing) {
3284 loadingHasBeenLong = true;
3285 notifyDataSetChanged();
3286 return false;
3287 }
3288 if (!action.equals("cancel") && !action.equals("prev") && !validate()) return false;
3289
3290 if (response == null) return true;
3291 Element command = response.findChild("command", "http://jabber.org/protocol/commands");
3292 if (command == null) return true;
3293 String status = command.getAttribute("status");
3294 if (status == null || (!status.equals("executing") && !action.equals("prev"))) return true;
3295
3296 if (actionToWebview != null && !action.equals("cancel") && Build.VERSION.SDK_INT >= 23) {
3297 actionToWebview.postWebMessage(new WebMessage("xmpp_xep0050/" + action), Uri.parse("*"));
3298 return false;
3299 }
3300
3301 final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
3302 packet.setTo(response.getFrom());
3303 final Element c = packet.addChild("command", Namespace.COMMANDS);
3304 c.setAttribute("node", mNode);
3305 c.setAttribute("sessionid", command.getAttribute("sessionid"));
3306
3307 String formType = responseElement == null ? null : responseElement.getAttribute("type");
3308 if (!action.equals("cancel") &&
3309 !action.equals("prev") &&
3310 responseElement != null &&
3311 responseElement.getName().equals("x") &&
3312 responseElement.getNamespace().equals("jabber:x:data") &&
3313 formType != null && formType.equals("form")) {
3314
3315 Data form = Data.parse(responseElement);
3316 eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
3317 if (actionList != null) {
3318 actionList.setValue(action);
3319 c.setAttribute("action", "execute");
3320 }
3321
3322 if (mNode.equals("jabber:iq:register") && xmppConnectionService.isOnboarding() && form.getFieldByName("gateway-jid") != null) {
3323 if (form.getValue("gateway-jid") == null) {
3324 xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
3325 } else {
3326 xmppConnectionService.getPreferences().edit().putString("onboarding_action", form.getValue("gateway-jid")).commit();
3327 }
3328 }
3329
3330 responseElement.setAttribute("type", "submit");
3331 Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
3332 if (rsm != null) {
3333 Element max = new Element("max", "http://jabber.org/protocol/rsm");
3334 max.setContent("1000");
3335 rsm.addChild(max);
3336 }
3337
3338 c.addChild(responseElement);
3339 }
3340
3341 if (c.getAttribute("action") == null) c.setAttribute("action", action);
3342
3343 executing = true;
3344 xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
3345 updateWithResponse(iq);
3346 }, 120L);
3347
3348 loading();
3349 return false;
3350 }
3351
3352 public void refresh() {
3353 synchronized(this) {
3354 if (waitingForRefresh) notifyDataSetChanged();
3355 }
3356 }
3357
3358 protected void loading() {
3359 View v = getView();
3360 try {
3361 loadingTimer.schedule(new TimerTask() {
3362 @Override
3363 public void run() {
3364 View v2 = getView();
3365 loading = true;
3366
3367 try {
3368 loadingTimer.schedule(new TimerTask() {
3369 @Override
3370 public void run() {
3371 loadingHasBeenLong = true;
3372 if (v == null && v2 == null) return;
3373 (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3374 }
3375 }, 3000);
3376 } catch (final IllegalStateException e) { }
3377
3378 if (v == null && v2 == null) return;
3379 (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3380 }
3381 }, 500);
3382 } catch (final IllegalStateException e) { }
3383 }
3384
3385 protected GridLayoutManager setupLayoutManager(final Context ctx) {
3386 int spanCount = 1;
3387
3388 if (reported != null) {
3389 float screenWidth = ctx.getResources().getDisplayMetrics().widthPixels;
3390 TextPaint paint = ((TextView) LayoutInflater.from(mPager.getContext()).inflate(R.layout.command_result_cell, null)).getPaint();
3391 float tableHeaderWidth = reported.stream().reduce(
3392 0f,
3393 (total, field) -> total + StaticLayout.getDesiredWidth(field.getLabel().or("--------") + "\t", paint),
3394 (a, b) -> a + b
3395 );
3396
3397 spanCount = tableHeaderWidth > 0.59 * screenWidth ? 1 : this.reported.size();
3398 }
3399
3400 if (layoutManager != null && layoutManager.getSpanCount() != spanCount) {
3401 items.clear();
3402 notifyDataSetChanged();
3403 }
3404
3405 layoutManager = new GridLayoutManager(ctx, spanCount);
3406 layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
3407 @Override
3408 public int getSpanSize(int position) {
3409 if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
3410 return 1;
3411 }
3412 });
3413 return layoutManager;
3414 }
3415
3416 protected void setBinding(CommandPageBinding b) {
3417 mBinding = b;
3418 // https://stackoverflow.com/a/32350474/8611
3419 mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
3420 @Override
3421 public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
3422 if(rv.getChildCount() > 0) {
3423 int[] location = new int[2];
3424 rv.getLocationOnScreen(location);
3425 View childView = rv.findChildViewUnder(e.getX(), e.getY());
3426 if (childView instanceof ViewGroup) {
3427 childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
3428 }
3429 int action = e.getAction();
3430 switch (action) {
3431 case MotionEvent.ACTION_DOWN:
3432 if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(1)) || childView instanceof WebView) {
3433 rv.requestDisallowInterceptTouchEvent(true);
3434 }
3435 case MotionEvent.ACTION_UP:
3436 if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(-11)) || childView instanceof WebView) {
3437 rv.requestDisallowInterceptTouchEvent(true);
3438 }
3439 }
3440 }
3441
3442 return false;
3443 }
3444
3445 @Override
3446 public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }
3447
3448 @Override
3449 public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
3450 });
3451 mBinding.form.setLayoutManager(setupLayoutManager(mBinding.getRoot().getContext()));
3452 mBinding.form.setAdapter(this);
3453
3454 if (actionsAdapter == null) {
3455 actionsAdapter = new ActionsAdapter(mBinding.getRoot().getContext());
3456 actionsAdapter.registerDataSetObserver(new DataSetObserver() {
3457 @Override
3458 public void onChanged() {
3459 if (mBinding == null) return;
3460
3461 mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
3462 }
3463
3464 @Override
3465 public void onInvalidated() {}
3466 });
3467 }
3468
3469 mBinding.actions.setAdapter(actionsAdapter);
3470 mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
3471 if (execute(pos)) {
3472 removeSession(CommandSession.this);
3473 }
3474 });
3475
3476 actionsAdapter.notifyDataSetChanged();
3477
3478 if (pendingResponsePacket != null) {
3479 final IqPacket pending = pendingResponsePacket;
3480 pendingResponsePacket = null;
3481 updateWithResponseUiThread(pending);
3482 }
3483 }
3484
3485 private Drawable getDrawableForSVG(SVG svg, Element svgElement, int size) {
3486 if (svgElement != null && svgElement.getChildren().size() == 1 && svgElement.getChildren().get(0).getName().equals("image")) {
3487 return getDrawableForUrl(svgElement.getChildren().get(0).getAttribute("href"));
3488 } else {
3489 return xmppConnectionService.getFileBackend().drawSVG(svg, size);
3490 }
3491 }
3492
3493 private Drawable getDrawableForUrl(final String url) {
3494 final LruCache<String, Drawable> cache = xmppConnectionService.getDrawableCache();
3495 final HttpConnectionManager httpManager = xmppConnectionService.getHttpConnectionManager();
3496 final Drawable d = cache.get(url);
3497 if (Build.VERSION.SDK_INT >= 28 && d instanceof AnimatedImageDrawable) ((AnimatedImageDrawable) d).start();
3498 if (d == null) {
3499 synchronized (CommandSession.this) {
3500 waitingForRefresh = true;
3501 }
3502 int size = (int)(xmppConnectionService.getResources().getDisplayMetrics().density * 288);
3503 Message dummy = new Message(Conversation.this, url, Message.ENCRYPTION_NONE);
3504 dummy.setStatus(Message.STATUS_DUMMY);
3505 dummy.setFileParams(new Message.FileParams(url));
3506 httpManager.createNewDownloadConnection(dummy, true, (file) -> {
3507 if (file == null) {
3508 dummy.getTransferable().start();
3509 } else {
3510 try {
3511 xmppConnectionService.getFileBackend().getThumbnail(file, xmppConnectionService.getResources(), size, false, url);
3512 } catch (final Exception e) { }
3513 }
3514 });
3515 }
3516 return d;
3517 }
3518
3519 public View inflateUi(Context context, Consumer<ConversationPage> remover) {
3520 CommandPageBinding binding = DataBindingUtil.inflate(LayoutInflater.from(context), R.layout.command_page, null, false);
3521 setBinding(binding);
3522 return binding.getRoot();
3523 }
3524
3525 // https://stackoverflow.com/a/36037991/8611
3526 private View findViewAt(ViewGroup viewGroup, float x, float y) {
3527 for(int i = 0; i < viewGroup.getChildCount(); i++) {
3528 View child = viewGroup.getChildAt(i);
3529 if (child instanceof ViewGroup && !(child instanceof AbsListView) && !(child instanceof WebView)) {
3530 View foundView = findViewAt((ViewGroup) child, x, y);
3531 if (foundView != null && foundView.isShown()) {
3532 return foundView;
3533 }
3534 } else {
3535 int[] location = new int[2];
3536 child.getLocationOnScreen(location);
3537 Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
3538 if (rect.contains((int)x, (int)y)) {
3539 return child;
3540 }
3541 }
3542 }
3543
3544 return null;
3545 }
3546 }
3547
3548 class MucConfigSession extends CommandSession {
3549 MucConfigSession(XmppConnectionService xmppConnectionService) {
3550 super("Configure Channel", null, xmppConnectionService);
3551 }
3552
3553 @Override
3554 protected void updateWithResponseUiThread(final IqPacket iq) {
3555 Timer oldTimer = this.loadingTimer;
3556 this.loadingTimer = new Timer();
3557 oldTimer.cancel();
3558 this.executing = false;
3559 this.loading = false;
3560 this.loadingHasBeenLong = false;
3561 this.responseElement = null;
3562 this.fillableFieldCount = 0;
3563 this.reported = null;
3564 this.response = iq;
3565 this.items.clear();
3566 this.actionsAdapter.clear();
3567 layoutManager.setSpanCount(1);
3568
3569 final Element query = iq.findChild("query", "http://jabber.org/protocol/muc#owner");
3570 if (iq.getType() == IqPacket.TYPE.RESULT && query != null) {
3571 final Data form = Data.parse(query.findChild("x", "jabber:x:data"));
3572 final String title = form.getTitle();
3573 if (title != null) {
3574 mTitle = title;
3575 ConversationPagerAdapter.this.notifyDataSetChanged();
3576 }
3577
3578 this.responseElement = form;
3579 setupReported(form.findChild("reported", "jabber:x:data"));
3580 if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager(mBinding.getRoot().getContext()));
3581
3582 if (actionsAdapter.countExceptCancel() < 1) {
3583 actionsAdapter.add(Pair.create("save", "Save"));
3584 }
3585
3586 if (actionsAdapter.getPosition("cancel") < 0) {
3587 actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
3588 }
3589 } else if (iq.getType() == IqPacket.TYPE.RESULT) {
3590 expectingRemoval = true;
3591 removeSession(this);
3592 return;
3593 } else {
3594 actionsAdapter.add(Pair.create("close", "close"));
3595 }
3596
3597 notifyDataSetChanged();
3598 }
3599
3600 @Override
3601 public synchronized boolean execute(String action) {
3602 if ("cancel".equals(action)) {
3603 final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
3604 packet.setTo(response.getFrom());
3605 final Element form = packet
3606 .addChild("query", "http://jabber.org/protocol/muc#owner")
3607 .addChild("x", "jabber:x:data");
3608 form.setAttribute("type", "cancel");
3609 xmppConnectionService.sendIqPacket(getAccount(), packet, null);
3610 return true;
3611 }
3612
3613 if (!"save".equals(action)) return true;
3614
3615 final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
3616 packet.setTo(response.getFrom());
3617
3618 String formType = responseElement == null ? null : responseElement.getAttribute("type");
3619 if (responseElement != null &&
3620 responseElement.getName().equals("x") &&
3621 responseElement.getNamespace().equals("jabber:x:data") &&
3622 formType != null && formType.equals("form")) {
3623
3624 responseElement.setAttribute("type", "submit");
3625 packet
3626 .addChild("query", "http://jabber.org/protocol/muc#owner")
3627 .addChild(responseElement);
3628 }
3629
3630 executing = true;
3631 xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
3632 updateWithResponse(iq);
3633 }, 120L);
3634
3635 loading();
3636
3637 return false;
3638 }
3639 }
3640 }
3641
3642 public static class Thread {
3643 protected Message subject = null;
3644 protected Message first = null;
3645 protected Message last = null;
3646 protected final String threadId;
3647
3648 protected Thread(final String threadId) {
3649 this.threadId = threadId;
3650 }
3651
3652 public String getThreadId() {
3653 return threadId;
3654 }
3655
3656 public String getSubject() {
3657 if (subject == null) return null;
3658
3659 return subject.getSubject();
3660 }
3661
3662 public String getDisplay() {
3663 final String s = getSubject();
3664 if (s != null) return s;
3665
3666 if (first != null) {
3667 return first.getBody();
3668 }
3669
3670 return "";
3671 }
3672
3673 public long getLastTime() {
3674 if (last == null) return 0;
3675
3676 return last.getTimeSent();
3677 }
3678 }
3679}