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