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 tv.setTextColor(ContextCompat.getColor(ctx, R.color.white));
2779 tv.setBackgroundColor(MaterialColors.harmonizeWithPrimary(ctx,UIHelper.getColorForName(getItem(position).first)));
2780 return v;
2781 }
2782
2783 public int getPosition(String s) {
2784 for(int i = 0; i < getCount(); i++) {
2785 if (getItem(i).first.equals(s)) return i;
2786 }
2787 return -1;
2788 }
2789
2790 public int countProceed() {
2791 int count = 0;
2792 for(int i = 0; i < getCount(); i++) {
2793 if (!"cancel".equals(getItem(i).first) && !"prev".equals(getItem(i).first)) count++;
2794 }
2795 return count;
2796 }
2797
2798 public int countExceptCancel() {
2799 int count = 0;
2800 for(int i = 0; i < getCount(); i++) {
2801 if (!getItem(i).first.equals("cancel")) count++;
2802 }
2803 return count;
2804 }
2805
2806 public void clearProceed() {
2807 Pair<String,String> cancelItem = null;
2808 Pair<String,String> prevItem = null;
2809 for(int i = 0; i < getCount(); i++) {
2810 if (getItem(i).first.equals("cancel")) cancelItem = getItem(i);
2811 if (getItem(i).first.equals("prev")) prevItem = getItem(i);
2812 }
2813 clear();
2814 if (cancelItem != null) add(cancelItem);
2815 if (prevItem != null) add(prevItem);
2816 }
2817 }
2818
2819 final int TYPE_ERROR = 1;
2820 final int TYPE_NOTE = 2;
2821 final int TYPE_WEB = 3;
2822 final int TYPE_RESULT_FIELD = 4;
2823 final int TYPE_TEXT_FIELD = 5;
2824 final int TYPE_CHECKBOX_FIELD = 6;
2825 final int TYPE_SPINNER_FIELD = 7;
2826 final int TYPE_RADIO_EDIT_FIELD = 8;
2827 final int TYPE_RESULT_CELL = 9;
2828 final int TYPE_PROGRESSBAR = 10;
2829 final int TYPE_SEARCH_LIST_FIELD = 11;
2830 final int TYPE_ITEM_CARD = 12;
2831 final int TYPE_BUTTON_GRID_FIELD = 13;
2832 final int TYPE_SLIDER_FIELD = 14;
2833
2834 protected boolean executing = false;
2835 protected boolean loading = false;
2836 protected boolean loadingHasBeenLong = false;
2837 protected Timer loadingTimer = new Timer();
2838 protected String mTitle;
2839 protected String mNode;
2840 protected CommandPageBinding mBinding = null;
2841 protected IqPacket response = null;
2842 protected Element responseElement = null;
2843 protected boolean expectingRemoval = false;
2844 protected List<Field> reported = null;
2845 protected SparseArray<Item> items = new SparseArray<>();
2846 protected XmppConnectionService xmppConnectionService;
2847 protected ActionsAdapter actionsAdapter;
2848 protected GridLayoutManager layoutManager;
2849 protected WebView actionToWebview = null;
2850 protected int fillableFieldCount = 0;
2851 protected IqPacket pendingResponsePacket = null;
2852 protected boolean waitingForRefresh = false;
2853
2854 CommandSession(String title, String node, XmppConnectionService xmppConnectionService) {
2855 loading();
2856 mTitle = title;
2857 mNode = node;
2858 this.xmppConnectionService = xmppConnectionService;
2859 if (mPager != null) setupLayoutManager();
2860 }
2861
2862 public String getTitle() {
2863 return mTitle;
2864 }
2865
2866 public String getNode() {
2867 return mNode;
2868 }
2869
2870 public void updateWithResponse(final IqPacket iq) {
2871 if (getView() != null && getView().isAttachedToWindow()) {
2872 getView().post(() -> updateWithResponseUiThread(iq));
2873 } else {
2874 pendingResponsePacket = iq;
2875 }
2876 }
2877
2878 protected void updateWithResponseUiThread(final IqPacket iq) {
2879 Timer oldTimer = this.loadingTimer;
2880 this.loadingTimer = new Timer();
2881 oldTimer.cancel();
2882 this.executing = false;
2883 this.loading = false;
2884 this.loadingHasBeenLong = false;
2885 this.responseElement = null;
2886 this.fillableFieldCount = 0;
2887 this.reported = null;
2888 this.response = iq;
2889 this.items.clear();
2890 this.actionsAdapter.clear();
2891 layoutManager.setSpanCount(1);
2892
2893 boolean actionsCleared = false;
2894 Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
2895 if (iq.getType() == IqPacket.TYPE.RESULT && command != null) {
2896 if (mNode.equals("jabber:iq:register") && command.getAttribute("status") != null && command.getAttribute("status").equals("completed")) {
2897 xmppConnectionService.createContact(getAccount().getRoster().getContact(iq.getFrom()), true);
2898 }
2899
2900 if (xmppConnectionService.isOnboarding() && mNode.equals("jabber:iq:register") && !"canceled".equals(command.getAttribute("status")) && xmppConnectionService.getPreferences().contains("onboarding_action")) {
2901 xmppConnectionService.getPreferences().edit().putBoolean("onboarding_continued", true).commit();
2902 }
2903
2904 Element actions = command.findChild("actions", "http://jabber.org/protocol/commands");
2905 if (actions != null) {
2906 for (Element action : actions.getChildren()) {
2907 if (!"http://jabber.org/protocol/commands".equals(action.getNamespace())) continue;
2908 if ("execute".equals(action.getName())) continue;
2909
2910 actionsAdapter.add(Pair.create(action.getName(), action.getName()));
2911 }
2912 }
2913
2914 for (Element el : command.getChildren()) {
2915 if ("x".equals(el.getName()) && "jabber:x:data".equals(el.getNamespace())) {
2916 Data form = Data.parse(el);
2917 String title = form.getTitle();
2918 if (title != null) {
2919 mTitle = title;
2920 ConversationPagerAdapter.this.notifyDataSetChanged();
2921 }
2922
2923 if ("result".equals(el.getAttribute("type")) || "form".equals(el.getAttribute("type"))) {
2924 this.responseElement = el;
2925 setupReported(el.findChild("reported", "jabber:x:data"));
2926 if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager());
2927 }
2928
2929 eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
2930 if (actionList != null) {
2931 actionsAdapter.clear();
2932
2933 for (Option action : actionList.getOptions()) {
2934 actionsAdapter.add(Pair.create(action.getValue(), action.toString()));
2935 }
2936 }
2937
2938 eu.siacs.conversations.xmpp.forms.Field fillableField = null;
2939 for (eu.siacs.conversations.xmpp.forms.Field field : form.getFields()) {
2940 if ((field.getType() == null || (!field.getType().equals("hidden") && !field.getType().equals("fixed"))) && field.getFieldName() != null && !field.getFieldName().equals("http://jabber.org/protocol/commands#actions")) {
2941 fillableField = field;
2942 fillableFieldCount++;
2943 }
2944 }
2945
2946 if (fillableFieldCount == 1 && actionsAdapter.countProceed() < 2 && (("list-single".equals(fillableField.getType()) && Option.forField(fillableField).size() < 50) || ("boolean".equals(fillableField.getType()) && fillableField.getValue() == null))) {
2947 actionsCleared = true;
2948 actionsAdapter.clearProceed();
2949 }
2950 break;
2951 }
2952 if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
2953 String url = el.findChildContent("url", "jabber:x:oob");
2954 if (url != null) {
2955 String scheme = Uri.parse(url).getScheme();
2956 if (scheme.equals("http") || scheme.equals("https")) {
2957 this.responseElement = el;
2958 break;
2959 }
2960 if (scheme.equals("xmpp")) {
2961 expectingRemoval = true;
2962 final Intent intent = new Intent(getView().getContext(), UriHandlerActivity.class);
2963 intent.setAction(Intent.ACTION_VIEW);
2964 intent.setData(Uri.parse(url));
2965 getView().getContext().startActivity(intent);
2966 break;
2967 }
2968 }
2969 }
2970 if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2971 this.responseElement = el;
2972 break;
2973 }
2974 }
2975
2976 if (responseElement == null && command.getAttribute("status") != null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
2977 if ("jabber:iq:register".equals(mNode) && "canceled".equals(command.getAttribute("status"))) {
2978 if (xmppConnectionService.isOnboarding()) {
2979 if (xmppConnectionService.getPreferences().contains("onboarding_action")) {
2980 xmppConnectionService.deleteAccount(getAccount());
2981 } else {
2982 if (xmppConnectionService.getPreferences().getBoolean("onboarding_continued", false)) {
2983 removeSession(this);
2984 return;
2985 } else {
2986 xmppConnectionService.getPreferences().edit().putString("onboarding_action", "cancel").commit();
2987 xmppConnectionService.deleteAccount(getAccount());
2988 }
2989 }
2990 }
2991 xmppConnectionService.archiveConversation(Conversation.this);
2992 }
2993
2994 expectingRemoval = true;
2995 removeSession(this);
2996 return;
2997 }
2998
2999 if ("executing".equals(command.getAttribute("status")) && actionsAdapter.countExceptCancel() < 1 && !actionsCleared) {
3000 // No actions have been given, but we are not done?
3001 // This is probably a spec violation, but we should do *something*
3002 actionsAdapter.add(Pair.create("execute", "execute"));
3003 }
3004
3005 if (!actionsAdapter.isEmpty() || fillableFieldCount > 0) {
3006 if ("completed".equals(command.getAttribute("status")) || "canceled".equals(command.getAttribute("status"))) {
3007 actionsAdapter.add(Pair.create("close", "close"));
3008 } else if (actionsAdapter.getPosition("cancel") < 0 && !xmppConnectionService.isOnboarding()) {
3009 actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
3010 }
3011 }
3012 }
3013
3014 if (actionsAdapter.isEmpty()) {
3015 actionsAdapter.add(Pair.create("close", "close"));
3016 }
3017
3018 actionsAdapter.sort((x, y) -> {
3019 if (x.first.equals("cancel")) return -1;
3020 if (y.first.equals("cancel")) return 1;
3021 if (x.first.equals("prev") && xmppConnectionService.isOnboarding()) return -1;
3022 if (y.first.equals("prev") && xmppConnectionService.isOnboarding()) return 1;
3023 return 0;
3024 });
3025
3026 Data dataForm = null;
3027 if (responseElement != null && responseElement.getName().equals("x") && responseElement.getNamespace().equals("jabber:x:data")) dataForm = Data.parse(responseElement);
3028 if (mNode.equals("jabber:iq:register") &&
3029 xmppConnectionService.getPreferences().contains("onboarding_action") &&
3030 dataForm != null && dataForm.getFieldByName("gateway-jid") != null) {
3031
3032
3033 dataForm.put("gateway-jid", xmppConnectionService.getPreferences().getString("onboarding_action", ""));
3034 execute();
3035 }
3036 xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
3037 notifyDataSetChanged();
3038 }
3039
3040 protected void setupReported(Element el) {
3041 if (el == null) {
3042 reported = null;
3043 return;
3044 }
3045
3046 reported = new ArrayList<>();
3047 for (Element fieldEl : el.getChildren()) {
3048 if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
3049 reported.add(mkField(fieldEl));
3050 }
3051 }
3052
3053 @Override
3054 public int getItemCount() {
3055 if (loading) return 1;
3056 if (response == null) return 0;
3057 if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
3058 int i = 0;
3059 for (Element el : responseElement.getChildren()) {
3060 if (!el.getNamespace().equals("jabber:x:data")) continue;
3061 if (el.getName().equals("title")) continue;
3062 if (el.getName().equals("field")) {
3063 String type = el.getAttribute("type");
3064 if (type != null && type.equals("hidden")) continue;
3065 if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
3066 }
3067
3068 if (el.getName().equals("reported") || el.getName().equals("item")) {
3069 if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
3070 if (el.getName().equals("reported")) continue;
3071 i += 1;
3072 } else {
3073 if (reported != null) i += reported.size();
3074 }
3075 continue;
3076 }
3077
3078 i++;
3079 }
3080 return i;
3081 }
3082 return 1;
3083 }
3084
3085 public Item getItem(int position) {
3086 if (loading) return new Item(null, TYPE_PROGRESSBAR);
3087 if (items.get(position) != null) return items.get(position);
3088 if (response == null) return null;
3089
3090 if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null) {
3091 if (responseElement.getNamespace().equals("jabber:x:data")) {
3092 int i = 0;
3093 for (Element el : responseElement.getChildren()) {
3094 if (!el.getNamespace().equals("jabber:x:data")) continue;
3095 if (el.getName().equals("title")) continue;
3096 if (el.getName().equals("field")) {
3097 String type = el.getAttribute("type");
3098 if (type != null && type.equals("hidden")) continue;
3099 if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
3100 }
3101
3102 if (el.getName().equals("reported") || el.getName().equals("item")) {
3103 Cell cell = null;
3104
3105 if (reported != null) {
3106 if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
3107 if (el.getName().equals("reported")) continue;
3108 if (i == position) {
3109 items.put(position, new Item(el, TYPE_ITEM_CARD));
3110 return items.get(position);
3111 }
3112 } else {
3113 if (reported.size() > position - i) {
3114 Field reportedField = reported.get(position - i);
3115 Element itemField = null;
3116 if (el.getName().equals("item")) {
3117 for (Element subel : el.getChildren()) {
3118 if (subel.getAttribute("var").equals(reportedField.getVar())) {
3119 itemField = subel;
3120 break;
3121 }
3122 }
3123 }
3124 cell = new Cell(reportedField, itemField);
3125 } else {
3126 i += reported.size();
3127 continue;
3128 }
3129 }
3130 }
3131
3132 if (cell != null) {
3133 items.put(position, cell);
3134 return cell;
3135 }
3136 }
3137
3138 if (i < position) {
3139 i++;
3140 continue;
3141 }
3142
3143 return mkItem(el, position);
3144 }
3145 }
3146 }
3147
3148 return mkItem(responseElement == null ? response : responseElement, position);
3149 }
3150
3151 @Override
3152 public int getItemViewType(int position) {
3153 return getItem(position).viewType;
3154 }
3155
3156 @Override
3157 public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
3158 switch(viewType) {
3159 case TYPE_ERROR: {
3160 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3161 return new ErrorViewHolder(binding);
3162 }
3163 case TYPE_NOTE: {
3164 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3165 return new NoteViewHolder(binding);
3166 }
3167 case TYPE_WEB: {
3168 CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
3169 return new WebViewHolder(binding);
3170 }
3171 case TYPE_RESULT_FIELD: {
3172 CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
3173 return new ResultFieldViewHolder(binding);
3174 }
3175 case TYPE_RESULT_CELL: {
3176 CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
3177 return new ResultCellViewHolder(binding);
3178 }
3179 case TYPE_ITEM_CARD: {
3180 CommandItemCardBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_item_card, container, false);
3181 return new ItemCardViewHolder(binding);
3182 }
3183 case TYPE_CHECKBOX_FIELD: {
3184 CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
3185 return new CheckboxFieldViewHolder(binding);
3186 }
3187 case TYPE_SEARCH_LIST_FIELD: {
3188 CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
3189 return new SearchListFieldViewHolder(binding);
3190 }
3191 case TYPE_RADIO_EDIT_FIELD: {
3192 CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
3193 return new RadioEditFieldViewHolder(binding);
3194 }
3195 case TYPE_SPINNER_FIELD: {
3196 CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
3197 return new SpinnerFieldViewHolder(binding);
3198 }
3199 case TYPE_BUTTON_GRID_FIELD: {
3200 CommandButtonGridFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_button_grid_field, container, false);
3201 return new ButtonGridFieldViewHolder(binding);
3202 }
3203 case TYPE_TEXT_FIELD: {
3204 CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
3205 return new TextFieldViewHolder(binding);
3206 }
3207 case TYPE_SLIDER_FIELD: {
3208 CommandSliderFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_slider_field, container, false);
3209 return new SliderFieldViewHolder(binding);
3210 }
3211 case TYPE_PROGRESSBAR: {
3212 CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
3213 return new ProgressBarViewHolder(binding);
3214 }
3215 default:
3216 if (expectingRemoval) {
3217 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3218 return new NoteViewHolder(binding);
3219 }
3220
3221 throw new IllegalArgumentException("Unknown viewType: " + viewType + " based on: " + response + ", " + responseElement + ", " + expectingRemoval);
3222 }
3223 }
3224
3225 @Override
3226 public void onBindViewHolder(ViewHolder viewHolder, int position) {
3227 viewHolder.bind(getItem(position));
3228 }
3229
3230 public View getView() {
3231 if (mBinding == null) return null;
3232 return mBinding.getRoot();
3233 }
3234
3235 public boolean validate() {
3236 int count = getItemCount();
3237 boolean isValid = true;
3238 for (int i = 0; i < count; i++) {
3239 boolean oneIsValid = getItem(i).validate();
3240 isValid = isValid && oneIsValid;
3241 }
3242 notifyDataSetChanged();
3243 return isValid;
3244 }
3245
3246 public boolean execute() {
3247 return execute("execute");
3248 }
3249
3250 public boolean execute(int actionPosition) {
3251 return execute(actionsAdapter.getItem(actionPosition).first);
3252 }
3253
3254 public synchronized boolean execute(String action) {
3255 if (!"cancel".equals(action) && executing) {
3256 loadingHasBeenLong = true;
3257 notifyDataSetChanged();
3258 return false;
3259 }
3260 if (!action.equals("cancel") && !action.equals("prev") && !validate()) return false;
3261
3262 if (response == null) return true;
3263 Element command = response.findChild("command", "http://jabber.org/protocol/commands");
3264 if (command == null) return true;
3265 String status = command.getAttribute("status");
3266 if (status == null || (!status.equals("executing") && !action.equals("prev"))) return true;
3267
3268 if (actionToWebview != null && !action.equals("cancel") && Build.VERSION.SDK_INT >= 23) {
3269 actionToWebview.postWebMessage(new WebMessage("xmpp_xep0050/" + action), Uri.parse("*"));
3270 return false;
3271 }
3272
3273 final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
3274 packet.setTo(response.getFrom());
3275 final Element c = packet.addChild("command", Namespace.COMMANDS);
3276 c.setAttribute("node", mNode);
3277 c.setAttribute("sessionid", command.getAttribute("sessionid"));
3278
3279 String formType = responseElement == null ? null : responseElement.getAttribute("type");
3280 if (!action.equals("cancel") &&
3281 !action.equals("prev") &&
3282 responseElement != null &&
3283 responseElement.getName().equals("x") &&
3284 responseElement.getNamespace().equals("jabber:x:data") &&
3285 formType != null && formType.equals("form")) {
3286
3287 Data form = Data.parse(responseElement);
3288 eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
3289 if (actionList != null) {
3290 actionList.setValue(action);
3291 c.setAttribute("action", "execute");
3292 }
3293
3294 if (mNode.equals("jabber:iq:register") && xmppConnectionService.isOnboarding() && form.getFieldByName("gateway-jid") != null) {
3295 if (form.getValue("gateway-jid") == null) {
3296 xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
3297 } else {
3298 xmppConnectionService.getPreferences().edit().putString("onboarding_action", form.getValue("gateway-jid")).commit();
3299 }
3300 }
3301
3302 responseElement.setAttribute("type", "submit");
3303 Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
3304 if (rsm != null) {
3305 Element max = new Element("max", "http://jabber.org/protocol/rsm");
3306 max.setContent("1000");
3307 rsm.addChild(max);
3308 }
3309
3310 c.addChild(responseElement);
3311 }
3312
3313 if (c.getAttribute("action") == null) c.setAttribute("action", action);
3314
3315 executing = true;
3316 xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
3317 updateWithResponse(iq);
3318 }, 120L);
3319
3320 loading();
3321 return false;
3322 }
3323
3324 public void refresh() {
3325 synchronized(this) {
3326 if (waitingForRefresh) notifyDataSetChanged();
3327 }
3328 }
3329
3330 protected void loading() {
3331 View v = getView();
3332 try {
3333 loadingTimer.schedule(new TimerTask() {
3334 @Override
3335 public void run() {
3336 View v2 = getView();
3337 loading = true;
3338
3339 try {
3340 loadingTimer.schedule(new TimerTask() {
3341 @Override
3342 public void run() {
3343 loadingHasBeenLong = true;
3344 if (v == null && v2 == null) return;
3345 (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3346 }
3347 }, 3000);
3348 } catch (final IllegalStateException e) { }
3349
3350 if (v == null && v2 == null) return;
3351 (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3352 }
3353 }, 500);
3354 } catch (final IllegalStateException e) { }
3355 }
3356
3357 protected GridLayoutManager setupLayoutManager() {
3358 int spanCount = 1;
3359
3360 Context ctx = mPager == null ? getView().getContext() : mPager.getContext();
3361 if (reported != null) {
3362 float screenWidth = ctx.getResources().getDisplayMetrics().widthPixels;
3363 TextPaint paint = ((TextView) LayoutInflater.from(mPager.getContext()).inflate(R.layout.command_result_cell, null)).getPaint();
3364 float tableHeaderWidth = reported.stream().reduce(
3365 0f,
3366 (total, field) -> total + StaticLayout.getDesiredWidth(field.getLabel().or("--------") + "\t", paint),
3367 (a, b) -> a + b
3368 );
3369
3370 spanCount = tableHeaderWidth > 0.59 * screenWidth ? 1 : this.reported.size();
3371 }
3372
3373 if (layoutManager != null && layoutManager.getSpanCount() != spanCount) {
3374 items.clear();
3375 notifyDataSetChanged();
3376 }
3377
3378 layoutManager = new GridLayoutManager(ctx, spanCount);
3379 layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
3380 @Override
3381 public int getSpanSize(int position) {
3382 if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
3383 return 1;
3384 }
3385 });
3386 return layoutManager;
3387 }
3388
3389 protected void setBinding(CommandPageBinding b) {
3390 mBinding = b;
3391 // https://stackoverflow.com/a/32350474/8611
3392 mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
3393 @Override
3394 public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
3395 if(rv.getChildCount() > 0) {
3396 int[] location = new int[2];
3397 rv.getLocationOnScreen(location);
3398 View childView = rv.findChildViewUnder(e.getX(), e.getY());
3399 if (childView instanceof ViewGroup) {
3400 childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
3401 }
3402 int action = e.getAction();
3403 switch (action) {
3404 case MotionEvent.ACTION_DOWN:
3405 if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(1)) || childView instanceof WebView) {
3406 rv.requestDisallowInterceptTouchEvent(true);
3407 }
3408 case MotionEvent.ACTION_UP:
3409 if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(-11)) || childView instanceof WebView) {
3410 rv.requestDisallowInterceptTouchEvent(true);
3411 }
3412 }
3413 }
3414
3415 return false;
3416 }
3417
3418 @Override
3419 public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }
3420
3421 @Override
3422 public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
3423 });
3424 mBinding.form.setLayoutManager(setupLayoutManager());
3425 mBinding.form.setAdapter(this);
3426
3427 actionsAdapter = new ActionsAdapter(mBinding.getRoot().getContext());
3428 actionsAdapter.registerDataSetObserver(new DataSetObserver() {
3429 @Override
3430 public void onChanged() {
3431 if (mBinding == null) return;
3432
3433 mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
3434 }
3435
3436 @Override
3437 public void onInvalidated() {}
3438 });
3439 mBinding.actions.setAdapter(actionsAdapter);
3440 mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
3441 if (execute(pos)) {
3442 removeSession(CommandSession.this);
3443 }
3444 });
3445
3446 actionsAdapter.notifyDataSetChanged();
3447
3448 if (pendingResponsePacket != null) {
3449 final IqPacket pending = pendingResponsePacket;
3450 pendingResponsePacket = null;
3451 updateWithResponseUiThread(pending);
3452 }
3453 }
3454
3455 private Drawable getDrawableForSVG(SVG svg, Element svgElement, int size) {
3456 if (svgElement != null && svgElement.getChildren().size() == 1 && svgElement.getChildren().get(0).getName().equals("image")) {
3457 return getDrawableForUrl(svgElement.getChildren().get(0).getAttribute("href"));
3458 } else {
3459 return xmppConnectionService.getFileBackend().drawSVG(svg, size);
3460 }
3461 }
3462
3463 private Drawable getDrawableForUrl(final String url) {
3464 final LruCache<String, Drawable> cache = xmppConnectionService.getDrawableCache();
3465 final HttpConnectionManager httpManager = xmppConnectionService.getHttpConnectionManager();
3466 final Drawable d = cache.get(url);
3467 if (Build.VERSION.SDK_INT >= 28 && d instanceof AnimatedImageDrawable) ((AnimatedImageDrawable) d).start();
3468 if (d == null) {
3469 synchronized (CommandSession.this) {
3470 waitingForRefresh = true;
3471 }
3472 int size = (int)(xmppConnectionService.getResources().getDisplayMetrics().density * 288);
3473 Message dummy = new Message(Conversation.this, url, Message.ENCRYPTION_NONE);
3474 dummy.setFileParams(new Message.FileParams(url));
3475 httpManager.createNewDownloadConnection(dummy, true, (file) -> {
3476 if (file == null) {
3477 dummy.getTransferable().start();
3478 } else {
3479 try {
3480 xmppConnectionService.getFileBackend().getThumbnail(file, xmppConnectionService.getResources(), size, false, url);
3481 } catch (final Exception e) { }
3482 }
3483 });
3484 }
3485 return d;
3486 }
3487
3488 public View inflateUi(Context context, Consumer<ConversationPage> remover) {
3489 CommandPageBinding binding = DataBindingUtil.inflate(LayoutInflater.from(context), R.layout.command_page, null, false);
3490 setBinding(binding);
3491 return binding.getRoot();
3492 }
3493
3494 // https://stackoverflow.com/a/36037991/8611
3495 private View findViewAt(ViewGroup viewGroup, float x, float y) {
3496 for(int i = 0; i < viewGroup.getChildCount(); i++) {
3497 View child = viewGroup.getChildAt(i);
3498 if (child instanceof ViewGroup && !(child instanceof AbsListView) && !(child instanceof WebView)) {
3499 View foundView = findViewAt((ViewGroup) child, x, y);
3500 if (foundView != null && foundView.isShown()) {
3501 return foundView;
3502 }
3503 } else {
3504 int[] location = new int[2];
3505 child.getLocationOnScreen(location);
3506 Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
3507 if (rect.contains((int)x, (int)y)) {
3508 return child;
3509 }
3510 }
3511 }
3512
3513 return null;
3514 }
3515 }
3516
3517 class MucConfigSession extends CommandSession {
3518 MucConfigSession(XmppConnectionService xmppConnectionService) {
3519 super("Configure Channel", null, xmppConnectionService);
3520 }
3521
3522 @Override
3523 protected void updateWithResponseUiThread(final IqPacket iq) {
3524 Timer oldTimer = this.loadingTimer;
3525 this.loadingTimer = new Timer();
3526 oldTimer.cancel();
3527 this.executing = false;
3528 this.loading = false;
3529 this.loadingHasBeenLong = false;
3530 this.responseElement = null;
3531 this.fillableFieldCount = 0;
3532 this.reported = null;
3533 this.response = iq;
3534 this.items.clear();
3535 this.actionsAdapter.clear();
3536 layoutManager.setSpanCount(1);
3537
3538 final Element query = iq.findChild("query", "http://jabber.org/protocol/muc#owner");
3539 if (iq.getType() == IqPacket.TYPE.RESULT && query != null) {
3540 final Data form = Data.parse(query.findChild("x", "jabber:x:data"));
3541 final String title = form.getTitle();
3542 if (title != null) {
3543 mTitle = title;
3544 ConversationPagerAdapter.this.notifyDataSetChanged();
3545 }
3546
3547 this.responseElement = form;
3548 setupReported(form.findChild("reported", "jabber:x:data"));
3549 if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager());
3550
3551 if (actionsAdapter.countExceptCancel() < 1) {
3552 actionsAdapter.add(Pair.create("save", "Save"));
3553 }
3554
3555 if (actionsAdapter.getPosition("cancel") < 0) {
3556 actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
3557 }
3558 } else if (iq.getType() == IqPacket.TYPE.RESULT) {
3559 expectingRemoval = true;
3560 removeSession(this);
3561 return;
3562 } else {
3563 actionsAdapter.add(Pair.create("close", "close"));
3564 }
3565
3566 notifyDataSetChanged();
3567 }
3568
3569 @Override
3570 public synchronized boolean execute(String action) {
3571 if ("cancel".equals(action)) {
3572 final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
3573 packet.setTo(response.getFrom());
3574 final Element form = packet
3575 .addChild("query", "http://jabber.org/protocol/muc#owner")
3576 .addChild("x", "jabber:x:data");
3577 form.setAttribute("type", "cancel");
3578 xmppConnectionService.sendIqPacket(getAccount(), packet, null);
3579 return true;
3580 }
3581
3582 if (!"save".equals(action)) return true;
3583
3584 final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
3585 packet.setTo(response.getFrom());
3586
3587 String formType = responseElement == null ? null : responseElement.getAttribute("type");
3588 if (responseElement != null &&
3589 responseElement.getName().equals("x") &&
3590 responseElement.getNamespace().equals("jabber:x:data") &&
3591 formType != null && formType.equals("form")) {
3592
3593 responseElement.setAttribute("type", "submit");
3594 packet
3595 .addChild("query", "http://jabber.org/protocol/muc#owner")
3596 .addChild(responseElement);
3597 }
3598
3599 executing = true;
3600 xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
3601 updateWithResponse(iq);
3602 }, 120L);
3603
3604 loading();
3605
3606 return false;
3607 }
3608 }
3609 }
3610
3611 public static class Thread {
3612 protected Message subject = null;
3613 protected Message first = null;
3614 protected Message last = null;
3615 protected final String threadId;
3616
3617 protected Thread(final String threadId) {
3618 this.threadId = threadId;
3619 }
3620
3621 public String getThreadId() {
3622 return threadId;
3623 }
3624
3625 public String getSubject() {
3626 if (subject == null) return null;
3627
3628 return subject.getSubject();
3629 }
3630
3631 public String getDisplay() {
3632 final String s = getSubject();
3633 if (s != null) return s;
3634
3635 if (first != null) {
3636 return first.getBody();
3637 }
3638
3639 return "";
3640 }
3641
3642 public long getLastTime() {
3643 if (last == null) return 0;
3644
3645 return last.getTimeSent();
3646 }
3647 }
3648}