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