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