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;
50
51import androidx.annotation.NonNull;
52import androidx.annotation.Nullable;
53import androidx.appcompat.app.AlertDialog;
54import androidx.appcompat.app.AlertDialog.Builder;
55import androidx.core.content.ContextCompat;
56import androidx.core.util.Consumer;
57import androidx.databinding.DataBindingUtil;
58import androidx.databinding.ViewDataBinding;
59import androidx.viewpager.widget.PagerAdapter;
60import androidx.recyclerview.widget.RecyclerView;
61import androidx.recyclerview.widget.GridLayoutManager;
62import androidx.viewpager.widget.ViewPager;
63
64import com.caverock.androidsvg.SVG;
65
66import com.cheogram.android.ConversationPage;
67import com.cheogram.android.Util;
68import com.cheogram.android.WebxdcPage;
69
70import com.google.android.material.tabs.TabLayout;
71import com.google.android.material.textfield.TextInputLayout;
72import com.google.common.base.Optional;
73import com.google.common.collect.ComparisonChain;
74import com.google.common.collect.Lists;
75
76import io.ipfs.cid.Cid;
77
78import io.michaelrocks.libphonenumber.android.NumberParseException;
79
80import org.json.JSONArray;
81import org.json.JSONException;
82import org.json.JSONObject;
83
84import java.time.LocalDateTime;
85import java.time.ZoneId;
86import java.time.ZonedDateTime;
87import java.time.format.DateTimeParseException;
88import java.time.format.DateTimeFormatter;
89import java.time.format.FormatStyle;
90import java.util.ArrayList;
91import java.util.Collections;
92import java.util.Iterator;
93import java.util.HashMap;
94import java.util.HashSet;
95import java.util.List;
96import java.util.ListIterator;
97import java.util.concurrent.atomic.AtomicBoolean;
98import java.util.stream.Collectors;
99import java.util.Set;
100import java.util.Timer;
101import java.util.TimerTask;
102
103import me.saket.bettermovementmethod.BetterLinkMovementMethod;
104
105import eu.siacs.conversations.Config;
106import eu.siacs.conversations.R;
107import eu.siacs.conversations.crypto.OmemoSetting;
108import eu.siacs.conversations.crypto.PgpDecryptionService;
109import eu.siacs.conversations.databinding.CommandButtonGridFieldBinding;
110import eu.siacs.conversations.databinding.CommandCheckboxFieldBinding;
111import eu.siacs.conversations.databinding.CommandItemCardBinding;
112import eu.siacs.conversations.databinding.CommandNoteBinding;
113import eu.siacs.conversations.databinding.CommandPageBinding;
114import eu.siacs.conversations.databinding.CommandProgressBarBinding;
115import eu.siacs.conversations.databinding.CommandRadioEditFieldBinding;
116import eu.siacs.conversations.databinding.CommandResultCellBinding;
117import eu.siacs.conversations.databinding.CommandResultFieldBinding;
118import eu.siacs.conversations.databinding.CommandSearchListFieldBinding;
119import eu.siacs.conversations.databinding.CommandSpinnerFieldBinding;
120import eu.siacs.conversations.databinding.CommandTextFieldBinding;
121import eu.siacs.conversations.databinding.CommandWebviewBinding;
122import eu.siacs.conversations.databinding.DialogQuickeditBinding;
123import eu.siacs.conversations.http.HttpConnectionManager;
124import eu.siacs.conversations.persistance.DatabaseBackend;
125import eu.siacs.conversations.services.AvatarService;
126import eu.siacs.conversations.services.QuickConversationsService;
127import eu.siacs.conversations.services.XmppConnectionService;
128import eu.siacs.conversations.ui.UriHandlerActivity;
129import eu.siacs.conversations.ui.text.FixedURLSpan;
130import eu.siacs.conversations.ui.util.ShareUtil;
131import eu.siacs.conversations.ui.util.SoftKeyboardUtils;
132import eu.siacs.conversations.utils.JidHelper;
133import eu.siacs.conversations.utils.MessageUtils;
134import eu.siacs.conversations.utils.PhoneNumberUtilWrapper;
135import eu.siacs.conversations.utils.UIHelper;
136import eu.siacs.conversations.xml.Element;
137import eu.siacs.conversations.xml.Namespace;
138import eu.siacs.conversations.xmpp.Jid;
139import eu.siacs.conversations.xmpp.chatstate.ChatState;
140import eu.siacs.conversations.xmpp.forms.Data;
141import eu.siacs.conversations.xmpp.forms.Option;
142import eu.siacs.conversations.xmpp.mam.MamReference;
143import eu.siacs.conversations.xmpp.stanzas.IqPacket;
144
145import static eu.siacs.conversations.entities.Bookmark.printableValue;
146
147
148public class Conversation extends AbstractEntity implements Blockable, Comparable<Conversation>, Conversational, AvatarService.Avatarable {
149 public static final String TABLENAME = "conversations";
150
151 public static final int STATUS_AVAILABLE = 0;
152 public static final int STATUS_ARCHIVED = 1;
153
154 public static final String NAME = "name";
155 public static final String ACCOUNT = "accountUuid";
156 public static final String CONTACT = "contactUuid";
157 public static final String CONTACTJID = "contactJid";
158 public static final String STATUS = "status";
159 public static final String CREATED = "created";
160 public static final String MODE = "mode";
161 public static final String ATTRIBUTES = "attributes";
162
163 public static final String ATTRIBUTE_MUTED_TILL = "muted_till";
164 public static final String ATTRIBUTE_ALWAYS_NOTIFY = "always_notify";
165 public static final String ATTRIBUTE_NOTIFY_REPLIES = "notify_replies";
166 public static final String ATTRIBUTE_LAST_CLEAR_HISTORY = "last_clear_history";
167 public static final String ATTRIBUTE_FORMERLY_PRIVATE_NON_ANONYMOUS = "formerly_private_non_anonymous";
168 public static final String ATTRIBUTE_PINNED_ON_TOP = "pinned_on_top";
169 static final String ATTRIBUTE_MUC_PASSWORD = "muc_password";
170 static final String ATTRIBUTE_MEMBERS_ONLY = "members_only";
171 static final String ATTRIBUTE_MODERATED = "moderated";
172 static final String ATTRIBUTE_NON_ANONYMOUS = "non_anonymous";
173 private static final String ATTRIBUTE_NEXT_MESSAGE = "next_message";
174 private static final String ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP = "next_message_timestamp";
175 private static final String ATTRIBUTE_CRYPTO_TARGETS = "crypto_targets";
176 private static final String ATTRIBUTE_NEXT_ENCRYPTION = "next_encryption";
177 private static final String ATTRIBUTE_CORRECTING_MESSAGE = "correcting_message";
178 protected final ArrayList<Message> messages = new ArrayList<>();
179 public AtomicBoolean messagesLoaded = new AtomicBoolean(true);
180 protected Account account = null;
181 private String draftMessage;
182 private final String name;
183 private final String contactUuid;
184 private final String accountUuid;
185 private Jid contactJid;
186 private int status;
187 private final long created;
188 private int mode;
189 private JSONObject attributes;
190 private Jid nextCounterpart;
191 private transient MucOptions mucOptions = null;
192 private boolean messagesLeftOnServer = true;
193 private ChatState mOutgoingChatState = Config.DEFAULT_CHAT_STATE;
194 private ChatState mIncomingChatState = Config.DEFAULT_CHAT_STATE;
195 private String mFirstMamReference = null;
196 protected int mCurrentTab = -1;
197 protected ConversationPagerAdapter pagerAdapter = new ConversationPagerAdapter();
198 protected Element thread = null;
199 protected boolean lockThread = false;
200 protected boolean userSelectedThread = false;
201 protected Message replyTo = null;
202 protected HashMap<String, Thread> threads = new HashMap<>();
203
204 public Conversation(final String name, final Account account, final Jid contactJid,
205 final int mode) {
206 this(java.util.UUID.randomUUID().toString(), name, null, account
207 .getUuid(), contactJid, System.currentTimeMillis(),
208 STATUS_AVAILABLE, mode, "");
209 this.account = account;
210 }
211
212 public Conversation(final String uuid, final String name, final String contactUuid,
213 final String accountUuid, final Jid contactJid, final long created, final int status,
214 final int mode, final String attributes) {
215 this.uuid = uuid;
216 this.name = name;
217 this.contactUuid = contactUuid;
218 this.accountUuid = accountUuid;
219 this.contactJid = contactJid;
220 this.created = created;
221 this.status = status;
222 this.mode = mode;
223 try {
224 this.attributes = new JSONObject(attributes == null ? "" : attributes);
225 } catch (JSONException e) {
226 this.attributes = new JSONObject();
227 }
228 }
229
230 public static Conversation fromCursor(Cursor cursor) {
231 return new Conversation(cursor.getString(cursor.getColumnIndex(UUID)),
232 cursor.getString(cursor.getColumnIndex(NAME)),
233 cursor.getString(cursor.getColumnIndex(CONTACT)),
234 cursor.getString(cursor.getColumnIndex(ACCOUNT)),
235 JidHelper.parseOrFallbackToInvalid(cursor.getString(cursor.getColumnIndex(CONTACTJID))),
236 cursor.getLong(cursor.getColumnIndex(CREATED)),
237 cursor.getInt(cursor.getColumnIndex(STATUS)),
238 cursor.getInt(cursor.getColumnIndex(MODE)),
239 cursor.getString(cursor.getColumnIndex(ATTRIBUTES)));
240 }
241
242 public static Message getLatestMarkableMessage(final List<Message> messages, boolean isPrivateAndNonAnonymousMuc) {
243 for (int i = messages.size() - 1; i >= 0; --i) {
244 final Message message = messages.get(i);
245 if (message.getStatus() <= Message.STATUS_RECEIVED
246 && (message.markable || isPrivateAndNonAnonymousMuc)
247 && !message.isPrivateMessage()) {
248 return message;
249 }
250 }
251 return null;
252 }
253
254 private static boolean suitableForOmemoByDefault(final Conversation conversation) {
255 if (conversation.getJid().asBareJid().equals(Config.BUG_REPORTS)) {
256 return false;
257 }
258 if (conversation.getContact().isOwnServer()) {
259 return false;
260 }
261 final String contact = conversation.getJid().getDomain().toEscapedString();
262 final String account = conversation.getAccount().getServer();
263 if (Config.OMEMO_EXCEPTIONS.matchesContactDomain(contact) || Config.OMEMO_EXCEPTIONS.ACCOUNT_DOMAINS.contains(account)) {
264 return false;
265 }
266 return conversation.isSingleOrPrivateAndNonAnonymous() || conversation.getBooleanAttribute(ATTRIBUTE_FORMERLY_PRIVATE_NON_ANONYMOUS, false);
267 }
268
269 public boolean hasMessagesLeftOnServer() {
270 return messagesLeftOnServer;
271 }
272
273 public void setHasMessagesLeftOnServer(boolean value) {
274 this.messagesLeftOnServer = value;
275 }
276
277 public Message getFirstUnreadMessage() {
278 Message first = null;
279 synchronized (this.messages) {
280 for (int i = messages.size() - 1; i >= 0; --i) {
281 if (messages.get(i).isRead()) {
282 return first;
283 } else {
284 first = messages.get(i);
285 }
286 }
287 }
288 return first;
289 }
290
291 public String findMostRecentRemoteDisplayableId() {
292 final boolean multi = mode == Conversation.MODE_MULTI;
293 synchronized (this.messages) {
294 for (final Message message : Lists.reverse(this.messages)) {
295 if (message.getStatus() == Message.STATUS_RECEIVED) {
296 final String serverMsgId = message.getServerMsgId();
297 if (serverMsgId != null && multi) {
298 return serverMsgId;
299 }
300 return message.getRemoteMsgId();
301 }
302 }
303 }
304 return null;
305 }
306
307 public int countFailedDeliveries() {
308 int count = 0;
309 synchronized (this.messages) {
310 for(final Message message : this.messages) {
311 if (message.getStatus() == Message.STATUS_SEND_FAILED) {
312 ++count;
313 }
314 }
315 }
316 return count;
317 }
318
319 public Message getLastEditableMessage() {
320 synchronized (this.messages) {
321 for (final Message message : Lists.reverse(this.messages)) {
322 if (message.isEditable()) {
323 if (message.isGeoUri() || message.getType() != Message.TYPE_TEXT) {
324 return null;
325 }
326 return message;
327 }
328 }
329 }
330 return null;
331 }
332
333
334 public Message findUnsentMessageWithUuid(String uuid) {
335 synchronized (this.messages) {
336 for (final Message message : this.messages) {
337 final int s = message.getStatus();
338 if ((s == Message.STATUS_UNSEND || s == Message.STATUS_WAITING) && message.getUuid().equals(uuid)) {
339 return message;
340 }
341 }
342 }
343 return null;
344 }
345
346 public void findWaitingMessages(OnMessageFound onMessageFound) {
347 final ArrayList<Message> results = new ArrayList<>();
348 synchronized (this.messages) {
349 for (Message message : this.messages) {
350 if (message.getStatus() == Message.STATUS_WAITING) {
351 results.add(message);
352 }
353 }
354 }
355 for (Message result : results) {
356 onMessageFound.onMessageFound(result);
357 }
358 }
359
360 public void findUnreadMessagesAndCalls(OnMessageFound onMessageFound) {
361 final ArrayList<Message> results = new ArrayList<>();
362 synchronized (this.messages) {
363 for (final Message message : this.messages) {
364 if (message.isRead()) {
365 continue;
366 }
367 results.add(message);
368 }
369 }
370 for (final Message result : results) {
371 onMessageFound.onMessageFound(result);
372 }
373 }
374
375 public Message findMessageWithFileAndUuid(final String uuid) {
376 synchronized (this.messages) {
377 for (final Message message : this.messages) {
378 final Transferable transferable = message.getTransferable();
379 final boolean unInitiatedButKnownSize = MessageUtils.unInitiatedButKnownSize(message);
380 if (message.getUuid().equals(uuid)
381 && message.getEncryption() != Message.ENCRYPTION_PGP
382 && (message.isFileOrImage() || message.treatAsDownloadable() || unInitiatedButKnownSize || (transferable != null && transferable.getStatus() != Transferable.STATUS_UPLOADING))) {
383 return message;
384 }
385 }
386 }
387 return null;
388 }
389
390 public Message findMessageWithUuid(final String uuid) {
391 synchronized (this.messages) {
392 for (final Message message : this.messages) {
393 if (message.getUuid().equals(uuid)) {
394 return message;
395 }
396 }
397 }
398 return null;
399 }
400
401 public boolean markAsDeleted(final List<String> uuids) {
402 boolean deleted = false;
403 final PgpDecryptionService pgpDecryptionService = account.getPgpDecryptionService();
404 synchronized (this.messages) {
405 for (Message message : this.messages) {
406 if (uuids.contains(message.getUuid())) {
407 message.setDeleted(true);
408 deleted = true;
409 if (message.getEncryption() == Message.ENCRYPTION_PGP && pgpDecryptionService != null) {
410 pgpDecryptionService.discard(message);
411 }
412 }
413 }
414 }
415 return deleted;
416 }
417
418 public boolean markAsChanged(final List<DatabaseBackend.FilePathInfo> files) {
419 boolean changed = false;
420 final PgpDecryptionService pgpDecryptionService = account.getPgpDecryptionService();
421 synchronized (this.messages) {
422 for (Message message : this.messages) {
423 for (final DatabaseBackend.FilePathInfo file : files)
424 if (file.uuid.toString().equals(message.getUuid())) {
425 message.setDeleted(file.deleted);
426 changed = true;
427 if (file.deleted && message.getEncryption() == Message.ENCRYPTION_PGP && pgpDecryptionService != null) {
428 pgpDecryptionService.discard(message);
429 }
430 }
431 }
432 }
433 return changed;
434 }
435
436 public void clearMessages() {
437 synchronized (this.messages) {
438 this.messages.clear();
439 }
440 }
441
442 public boolean setIncomingChatState(ChatState state) {
443 if (this.mIncomingChatState == state) {
444 return false;
445 }
446 this.mIncomingChatState = state;
447 return true;
448 }
449
450 public ChatState getIncomingChatState() {
451 return this.mIncomingChatState;
452 }
453
454 public boolean setOutgoingChatState(ChatState state) {
455 if (mode == MODE_SINGLE && !getContact().isSelf() || (isPrivateAndNonAnonymous() && getNextCounterpart() == null)) {
456 if (this.mOutgoingChatState != state) {
457 this.mOutgoingChatState = state;
458 return true;
459 }
460 }
461 return false;
462 }
463
464 public ChatState getOutgoingChatState() {
465 return this.mOutgoingChatState;
466 }
467
468 public void trim() {
469 synchronized (this.messages) {
470 final int size = messages.size();
471 final int maxsize = Config.PAGE_SIZE * Config.MAX_NUM_PAGES;
472 if (size > maxsize) {
473 List<Message> discards = this.messages.subList(0, size - maxsize);
474 final PgpDecryptionService pgpDecryptionService = account.getPgpDecryptionService();
475 if (pgpDecryptionService != null) {
476 pgpDecryptionService.discard(discards);
477 }
478 discards.clear();
479 untieMessages();
480 }
481 }
482 }
483
484 public void findUnsentTextMessages(OnMessageFound onMessageFound) {
485 final ArrayList<Message> results = new ArrayList<>();
486 synchronized (this.messages) {
487 for (Message message : this.messages) {
488 if ((message.getType() == Message.TYPE_TEXT || message.hasFileOnRemoteHost()) && message.getStatus() == Message.STATUS_UNSEND) {
489 results.add(message);
490 }
491 }
492 }
493 for (Message result : results) {
494 onMessageFound.onMessageFound(result);
495 }
496 }
497
498 public Message findSentMessageWithUuidOrRemoteId(String id) {
499 synchronized (this.messages) {
500 for (Message message : this.messages) {
501 if (id.equals(message.getUuid())
502 || (message.getStatus() >= Message.STATUS_SEND
503 && id.equals(message.getRemoteMsgId()))) {
504 return message;
505 }
506 }
507 }
508 return null;
509 }
510
511 public Message findMessageWithRemoteIdAndCounterpart(String id, Jid counterpart) {
512 synchronized (this.messages) {
513 for (int i = this.messages.size() - 1; i >= 0; --i) {
514 final Message message = messages.get(i);
515 final Jid mcp = message.getCounterpart();
516 if (mcp == null && counterpart != null) {
517 continue;
518 }
519 if (counterpart == null || mcp.equals(counterpart) || mcp.asBareJid().equals(counterpart)) {
520 final boolean idMatch = id.equals(message.getUuid()) || id.equals(message.getRemoteMsgId()) || message.remoteMsgIdMatchInEdit(id) || (getMode() == MODE_MULTI && id.equals(message.getServerMsgId()));
521 if (idMatch) return message;
522 }
523 }
524 }
525 return null;
526 }
527
528 public Message findSentMessageWithUuid(String id) {
529 synchronized (this.messages) {
530 for (Message message : this.messages) {
531 if (id.equals(message.getUuid())) {
532 return message;
533 }
534 }
535 }
536 return null;
537 }
538
539 public Message findMessageWithRemoteId(String id, Jid counterpart) {
540 synchronized (this.messages) {
541 for (Message message : this.messages) {
542 if (counterpart.equals(message.getCounterpart())
543 && (id.equals(message.getRemoteMsgId()) || id.equals(message.getUuid()))) {
544 return message;
545 }
546 }
547 }
548 return null;
549 }
550
551 public Message findMessageWithServerMsgId(String id) {
552 synchronized (this.messages) {
553 for (Message message : this.messages) {
554 if (id != null && id.equals(message.getServerMsgId())) {
555 return message;
556 }
557 }
558 }
559 return null;
560 }
561
562 public boolean hasMessageWithCounterpart(Jid counterpart) {
563 synchronized (this.messages) {
564 for (Message message : this.messages) {
565 if (counterpart.equals(message.getCounterpart())) {
566 return true;
567 }
568 }
569 }
570 return false;
571 }
572
573 public Message findMessageReactingTo(String id, Jid reactor) {
574 if (id == null) return null;
575
576 synchronized (this.messages) {
577 for (int i = this.messages.size() - 1; i >= 0; --i) {
578 final Message message = messages.get(i);
579 if (reactor == null && message.getStatus() < Message.STATUS_SEND) continue;
580 if (reactor != null && message.getCounterpart() == null) continue;
581 if (reactor != null && !(message.getCounterpart().equals(reactor) || message.getCounterpart().asBareJid().equals(reactor))) continue;
582
583 final Element r = message.getReactions();
584 if (r != null && r.getAttribute("id") != null && id.equals(r.getAttribute("id"))) {
585 return message;
586 }
587 }
588 }
589 return null;
590 }
591
592 public List<Message> findMessagesBy(MucOptions.User user) {
593 List<Message> result = new ArrayList<>();
594 synchronized (this.messages) {
595 for (Message m : this.messages) {
596 // occupant id?
597 final Jid trueCp = m.getTrueCounterpart();
598 if (m.getCounterpart().equals(user.getFullJid()) || (trueCp != null && trueCp.equals(user.getRealJid()))) {
599 result.add(m);
600 }
601 }
602 }
603 return result;
604 }
605
606 public Set<String> findReactionsTo(String id, Jid reactor) {
607 Set<String> reactionEmoji = new HashSet<>();
608 Message reactM = findMessageReactingTo(id, reactor);
609 Element reactions = reactM == null ? null : reactM.getReactions();
610 if (reactions != null) {
611 for (Element el : reactions.getChildren()) {
612 if (el.getName().equals("reaction") && el.getNamespace().equals("urn:xmpp:reactions:0")) {
613 reactionEmoji.add(el.getContent());
614 }
615 }
616 }
617 return reactionEmoji;
618 }
619
620 public Set<Message> findReplies(String id) {
621 Set<Message> replies = new HashSet<>();
622 if (id == null) return replies;
623
624 synchronized (this.messages) {
625 for (int i = this.messages.size() - 1; i >= 0; --i) {
626 final Message message = messages.get(i);
627 if (id.equals(message.getServerMsgId())) break;
628 if (id.equals(message.getUuid())) break;
629 final Element r = message.getReply();
630 if (r != null && r.getAttribute("id") != null && id.equals(r.getAttribute("id"))) {
631 replies.add(message);
632 }
633 }
634 }
635 return replies;
636 }
637
638 public long loadMoreTimestamp() {
639 if (messages.size() < 1) return 0;
640 if (getLockThread() && messages.size() > 5000) return 0;
641
642 if (messages.get(0).getType() == Message.TYPE_STATUS && messages.size() >= 2) {
643 return messages.get(1).getTimeSent();
644 } else {
645 return messages.get(0).getTimeSent();
646 }
647 }
648
649 public void populateWithMessages(final List<Message> messages) {
650 synchronized (this.messages) {
651 messages.clear();
652 messages.addAll(this.messages);
653 threads.clear();
654 }
655 Set<String> extraIds = new HashSet<>();
656 for (ListIterator<Message> iterator = messages.listIterator(messages.size()); iterator.hasPrevious(); ) {
657 Message m = iterator.previous();
658 final Element mthread = m.getThread();
659 if (mthread != null) {
660 Thread thread = threads.get(mthread.getContent());
661 if (thread == null) {
662 thread = new Thread();
663 threads.put(mthread.getContent(), thread);
664 }
665 if (thread.subject == null && (m.getSubject() != null && (m.getRawBody() == null || m.getRawBody().length() == 0))) {
666 thread.subject = m;
667 } else {
668 if (thread.last == null) thread.last = m;
669 thread.first = m;
670 }
671 }
672 if (m.wasMergedIntoPrevious() || (m.getSubject() != null && (m.getRawBody() == null || m.getRawBody().length() == 0)) || (getLockThread() && !extraIds.contains(m.replyId()) && (mthread == null || !mthread.getContent().equals(getThread() == null ? "" : getThread().getContent())))) {
673 iterator.remove();
674 } else if (getLockThread() && mthread != null) {
675 Element reply = m.getReply();
676 if (reply != null && reply.getAttribute("id") != null) extraIds.add(reply.getAttribute("id"));
677 Element reactions = m.getReactions();
678 if (reactions != null && reactions.getAttribute("id") != null) extraIds.add(reactions.getAttribute("id"));
679 }
680 }
681 }
682
683 public Thread getThread(String id) {
684 return threads.get(id);
685 }
686
687 @Override
688 public boolean isBlocked() {
689 return getContact().isBlocked();
690 }
691
692 @Override
693 public boolean isDomainBlocked() {
694 return getContact().isDomainBlocked();
695 }
696
697 @Override
698 public Jid getBlockedJid() {
699 return getContact().getBlockedJid();
700 }
701
702 public int countMessages() {
703 synchronized (this.messages) {
704 return this.messages.size();
705 }
706 }
707
708 public String getFirstMamReference() {
709 return this.mFirstMamReference;
710 }
711
712 public void setFirstMamReference(String reference) {
713 this.mFirstMamReference = reference;
714 }
715
716 public void setLastClearHistory(long time, String reference) {
717 if (reference != null) {
718 setAttribute(ATTRIBUTE_LAST_CLEAR_HISTORY, time + ":" + reference);
719 } else {
720 setAttribute(ATTRIBUTE_LAST_CLEAR_HISTORY, time);
721 }
722 }
723
724 public MamReference getLastClearHistory() {
725 return MamReference.fromAttribute(getAttribute(ATTRIBUTE_LAST_CLEAR_HISTORY));
726 }
727
728 public List<Jid> getAcceptedCryptoTargets() {
729 if (mode == MODE_SINGLE) {
730 return Collections.singletonList(getJid().asBareJid());
731 } else {
732 return getJidListAttribute(ATTRIBUTE_CRYPTO_TARGETS);
733 }
734 }
735
736 public void setAcceptedCryptoTargets(List<Jid> acceptedTargets) {
737 setAttribute(ATTRIBUTE_CRYPTO_TARGETS, acceptedTargets);
738 }
739
740 public boolean setCorrectingMessage(Message correctingMessage) {
741 setAttribute(ATTRIBUTE_CORRECTING_MESSAGE, correctingMessage == null ? null : correctingMessage.getUuid());
742 return correctingMessage == null && draftMessage != null;
743 }
744
745 public Message getCorrectingMessage() {
746 final String uuid = getAttribute(ATTRIBUTE_CORRECTING_MESSAGE);
747 return uuid == null ? null : findSentMessageWithUuid(uuid);
748 }
749
750 public boolean withSelf() {
751 return getContact().isSelf();
752 }
753
754 @Override
755 public int compareTo(@NonNull Conversation another) {
756 return ComparisonChain.start()
757 .compareFalseFirst(another.getBooleanAttribute(ATTRIBUTE_PINNED_ON_TOP, false), getBooleanAttribute(ATTRIBUTE_PINNED_ON_TOP, false))
758 .compare(another.getSortableTime(), getSortableTime())
759 .result();
760 }
761
762 public long getSortableTime() {
763 Draft draft = getDraft();
764 long messageTime = getLatestMessage().getTimeReceived();
765 if (draft == null) {
766 return messageTime;
767 } else {
768 return Math.max(messageTime, draft.getTimestamp());
769 }
770 }
771
772 public String getDraftMessage() {
773 return draftMessage;
774 }
775
776 public void setDraftMessage(String draftMessage) {
777 this.draftMessage = draftMessage;
778 }
779
780 public Element getThread() {
781 return this.thread;
782 }
783
784 public void setThread(Element thread) {
785 this.thread = thread;
786 }
787
788 public void setLockThread(boolean flag) {
789 this.lockThread = flag;
790 if (flag) setUserSelectedThread(true);
791 }
792
793 public boolean getLockThread() {
794 return this.lockThread;
795 }
796
797 public void setUserSelectedThread(boolean flag) {
798 this.userSelectedThread = flag;
799 }
800
801 public boolean getUserSelectedThread() {
802 return this.userSelectedThread;
803 }
804
805 public void setReplyTo(Message m) {
806 this.replyTo = m;
807 }
808
809 public Message getReplyTo() {
810 return this.replyTo;
811 }
812
813 public boolean isRead() {
814 synchronized (this.messages) {
815 for(final Message message : Lists.reverse(this.messages)) {
816 if (message.isRead() && message.getType() == Message.TYPE_RTP_SESSION) {
817 continue;
818 }
819 return message.isRead();
820 }
821 return true;
822 }
823 }
824
825 public List<Message> markRead(String upToUuid) {
826 final List<Message> unread = new ArrayList<>();
827 synchronized (this.messages) {
828 for (Message message : this.messages) {
829 if (!message.isRead()) {
830 message.markRead();
831 unread.add(message);
832 }
833 if (message.getUuid().equals(upToUuid)) {
834 return unread;
835 }
836 }
837 }
838 return unread;
839 }
840
841 public Message getLatestMessage() {
842 synchronized (this.messages) {
843 if (this.messages.size() == 0) {
844 Message message = new Message(this, "", Message.ENCRYPTION_NONE);
845 message.setType(Message.TYPE_STATUS);
846 message.setTime(Math.max(getCreated(), getLastClearHistory().getTimestamp()));
847 message.setTimeReceived(Math.max(getCreated(), getLastClearHistory().getTimestamp()));
848 return message;
849 } else {
850 return this.messages.get(this.messages.size() - 1);
851 }
852 }
853 }
854
855 public @NonNull
856 CharSequence getName() {
857 if (getMode() == MODE_MULTI) {
858 final String roomName = getMucOptions().getName();
859 final String subject = getMucOptions().getSubject();
860 final Bookmark bookmark = getBookmark();
861 final String bookmarkName = bookmark != null ? bookmark.getBookmarkName() : null;
862 if (printableValue(roomName)) {
863 return roomName;
864 } else if (printableValue(subject)) {
865 return subject;
866 } else if (printableValue(bookmarkName, false)) {
867 return bookmarkName;
868 } else {
869 final String generatedName = getMucOptions().createNameFromParticipants();
870 if (printableValue(generatedName)) {
871 return generatedName;
872 } else {
873 return contactJid.getLocal() != null ? contactJid.getLocal() : contactJid;
874 }
875 }
876 } else {
877 return this.getContact().getDisplayName();
878 }
879 }
880
881 public String getAccountUuid() {
882 return this.accountUuid;
883 }
884
885 public Account getAccount() {
886 return this.account;
887 }
888
889 public void setAccount(final Account account) {
890 this.account = account;
891 }
892
893 public Contact getContact() {
894 return this.account.getRoster().getContact(this.contactJid);
895 }
896
897 @Override
898 public Jid getJid() {
899 return this.contactJid;
900 }
901
902 public int getStatus() {
903 return this.status;
904 }
905
906 public void setStatus(int status) {
907 this.status = status;
908 }
909
910 public long getCreated() {
911 return this.created;
912 }
913
914 public ContentValues getContentValues() {
915 ContentValues values = new ContentValues();
916 values.put(UUID, uuid);
917 values.put(NAME, name);
918 values.put(CONTACT, contactUuid);
919 values.put(ACCOUNT, accountUuid);
920 values.put(CONTACTJID, contactJid.toString());
921 values.put(CREATED, created);
922 values.put(STATUS, status);
923 values.put(MODE, mode);
924 synchronized (this.attributes) {
925 values.put(ATTRIBUTES, attributes.toString());
926 }
927 return values;
928 }
929
930 public int getMode() {
931 return this.mode;
932 }
933
934 public void setMode(int mode) {
935 this.mode = mode;
936 }
937
938 /**
939 * short for is Private and Non-anonymous
940 */
941 public boolean isSingleOrPrivateAndNonAnonymous() {
942 return mode == MODE_SINGLE || isPrivateAndNonAnonymous();
943 }
944
945 public boolean isPrivateAndNonAnonymous() {
946 return getMucOptions().isPrivateAndNonAnonymous();
947 }
948
949 public synchronized MucOptions getMucOptions() {
950 if (this.mucOptions == null) {
951 this.mucOptions = new MucOptions(this);
952 }
953 return this.mucOptions;
954 }
955
956 public void resetMucOptions() {
957 this.mucOptions = null;
958 }
959
960 public void setContactJid(final Jid jid) {
961 this.contactJid = jid;
962 }
963
964 public Jid getNextCounterpart() {
965 return this.nextCounterpart;
966 }
967
968 public void setNextCounterpart(Jid jid) {
969 this.nextCounterpart = jid;
970 }
971
972 public int getNextEncryption() {
973 if (!Config.supportOmemo() && !Config.supportOpenPgp()) {
974 return Message.ENCRYPTION_NONE;
975 }
976 if (OmemoSetting.isAlways()) {
977 return suitableForOmemoByDefault(this) ? Message.ENCRYPTION_AXOLOTL : Message.ENCRYPTION_NONE;
978 }
979 final int defaultEncryption;
980 if (suitableForOmemoByDefault(this)) {
981 defaultEncryption = OmemoSetting.getEncryption();
982 } else {
983 defaultEncryption = Message.ENCRYPTION_NONE;
984 }
985 int encryption = this.getIntAttribute(ATTRIBUTE_NEXT_ENCRYPTION, defaultEncryption);
986 if (encryption == Message.ENCRYPTION_OTR || encryption < 0) {
987 return defaultEncryption;
988 } else {
989 return encryption;
990 }
991 }
992
993 public boolean setNextEncryption(int encryption) {
994 return this.setAttribute(ATTRIBUTE_NEXT_ENCRYPTION, encryption);
995 }
996
997 public String getNextMessage() {
998 final String nextMessage = getAttribute(ATTRIBUTE_NEXT_MESSAGE);
999 return nextMessage == null ? "" : nextMessage;
1000 }
1001
1002 public @Nullable
1003 Draft getDraft() {
1004 long timestamp = getLongAttribute(ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP, 0);
1005 if (timestamp > getLatestMessage().getTimeSent()) {
1006 String message = getAttribute(ATTRIBUTE_NEXT_MESSAGE);
1007 if (!TextUtils.isEmpty(message) && timestamp != 0) {
1008 return new Draft(message, timestamp);
1009 }
1010 }
1011 return null;
1012 }
1013
1014 public boolean setNextMessage(final String input) {
1015 final String message = input == null || input.trim().isEmpty() ? null : input;
1016 boolean changed = !getNextMessage().equals(message);
1017 this.setAttribute(ATTRIBUTE_NEXT_MESSAGE, message);
1018 if (changed) {
1019 this.setAttribute(ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP, message == null ? 0 : System.currentTimeMillis());
1020 }
1021 return changed;
1022 }
1023
1024 public Bookmark getBookmark() {
1025 return this.account.getBookmark(this.contactJid);
1026 }
1027
1028 public Message findDuplicateMessage(Message message) {
1029 synchronized (this.messages) {
1030 for (int i = this.messages.size() - 1; i >= 0; --i) {
1031 if (this.messages.get(i).similar(message)) {
1032 return this.messages.get(i);
1033 }
1034 }
1035 }
1036 return null;
1037 }
1038
1039 public boolean hasDuplicateMessage(Message message) {
1040 return findDuplicateMessage(message) != null;
1041 }
1042
1043 public Message findSentMessageWithBody(String body) {
1044 synchronized (this.messages) {
1045 for (int i = this.messages.size() - 1; i >= 0; --i) {
1046 Message message = this.messages.get(i);
1047 if (message.getStatus() == Message.STATUS_UNSEND || message.getStatus() == Message.STATUS_SEND) {
1048 String otherBody;
1049 if (message.hasFileOnRemoteHost()) {
1050 otherBody = message.getFileParams().url;
1051 } else {
1052 otherBody = message.body;
1053 }
1054 if (otherBody != null && otherBody.equals(body)) {
1055 return message;
1056 }
1057 }
1058 }
1059 return null;
1060 }
1061 }
1062
1063 public Message findRtpSession(final String sessionId, final int s) {
1064 synchronized (this.messages) {
1065 for (int i = this.messages.size() - 1; i >= 0; --i) {
1066 final Message message = this.messages.get(i);
1067 if ((message.getStatus() == s) && (message.getType() == Message.TYPE_RTP_SESSION) && sessionId.equals(message.getRemoteMsgId())) {
1068 return message;
1069 }
1070 }
1071 }
1072 return null;
1073 }
1074
1075 public boolean possibleDuplicate(final String serverMsgId, final String remoteMsgId) {
1076 if (serverMsgId == null || remoteMsgId == null) {
1077 return false;
1078 }
1079 synchronized (this.messages) {
1080 for (Message message : this.messages) {
1081 if (serverMsgId.equals(message.getServerMsgId()) || remoteMsgId.equals(message.getRemoteMsgId())) {
1082 return true;
1083 }
1084 }
1085 }
1086 return false;
1087 }
1088
1089 public MamReference getLastMessageTransmitted() {
1090 final MamReference lastClear = getLastClearHistory();
1091 MamReference lastReceived = new MamReference(0);
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.isPrivateMessage()) {
1096 continue; //it's unsafe to use private messages as anchor. They could be coming from user archive
1097 }
1098 if (message.getStatus() == Message.STATUS_RECEIVED || message.isCarbon() || message.getServerMsgId() != null) {
1099 lastReceived = new MamReference(message.getTimeSent(), message.getServerMsgId());
1100 break;
1101 }
1102 }
1103 }
1104 return MamReference.max(lastClear, lastReceived);
1105 }
1106
1107 public void setMutedTill(long value) {
1108 this.setAttribute(ATTRIBUTE_MUTED_TILL, String.valueOf(value));
1109 }
1110
1111 public boolean isMuted() {
1112 return System.currentTimeMillis() < this.getLongAttribute(ATTRIBUTE_MUTED_TILL, 0);
1113 }
1114
1115 public boolean alwaysNotify() {
1116 return mode == MODE_SINGLE || getBooleanAttribute(ATTRIBUTE_ALWAYS_NOTIFY, Config.ALWAYS_NOTIFY_BY_DEFAULT || isPrivateAndNonAnonymous());
1117 }
1118
1119 public boolean notifyReplies() {
1120 return alwaysNotify() || getBooleanAttribute(ATTRIBUTE_NOTIFY_REPLIES, false);
1121 }
1122
1123 public boolean setAttribute(String key, boolean value) {
1124 return setAttribute(key, String.valueOf(value));
1125 }
1126
1127 private boolean setAttribute(String key, long value) {
1128 return setAttribute(key, Long.toString(value));
1129 }
1130
1131 private boolean setAttribute(String key, int value) {
1132 return setAttribute(key, String.valueOf(value));
1133 }
1134
1135 public boolean setAttribute(String key, String value) {
1136 synchronized (this.attributes) {
1137 try {
1138 if (value == null) {
1139 if (this.attributes.has(key)) {
1140 this.attributes.remove(key);
1141 return true;
1142 } else {
1143 return false;
1144 }
1145 } else {
1146 final String prev = this.attributes.optString(key, null);
1147 this.attributes.put(key, value);
1148 return !value.equals(prev);
1149 }
1150 } catch (JSONException e) {
1151 throw new AssertionError(e);
1152 }
1153 }
1154 }
1155
1156 public boolean setAttribute(String key, List<Jid> jids) {
1157 JSONArray array = new JSONArray();
1158 for (Jid jid : jids) {
1159 array.put(jid.asBareJid().toString());
1160 }
1161 synchronized (this.attributes) {
1162 try {
1163 this.attributes.put(key, array);
1164 return true;
1165 } catch (JSONException e) {
1166 return false;
1167 }
1168 }
1169 }
1170
1171 public String getAttribute(String key) {
1172 synchronized (this.attributes) {
1173 return this.attributes.optString(key, null);
1174 }
1175 }
1176
1177 private List<Jid> getJidListAttribute(String key) {
1178 ArrayList<Jid> list = new ArrayList<>();
1179 synchronized (this.attributes) {
1180 try {
1181 JSONArray array = this.attributes.getJSONArray(key);
1182 for (int i = 0; i < array.length(); ++i) {
1183 try {
1184 list.add(Jid.of(array.getString(i)));
1185 } catch (IllegalArgumentException e) {
1186 //ignored
1187 }
1188 }
1189 } catch (JSONException e) {
1190 //ignored
1191 }
1192 }
1193 return list;
1194 }
1195
1196 private int getIntAttribute(String key, int defaultValue) {
1197 String value = this.getAttribute(key);
1198 if (value == null) {
1199 return defaultValue;
1200 } else {
1201 try {
1202 return Integer.parseInt(value);
1203 } catch (NumberFormatException e) {
1204 return defaultValue;
1205 }
1206 }
1207 }
1208
1209 public long getLongAttribute(String key, long defaultValue) {
1210 String value = this.getAttribute(key);
1211 if (value == null) {
1212 return defaultValue;
1213 } else {
1214 try {
1215 return Long.parseLong(value);
1216 } catch (NumberFormatException e) {
1217 return defaultValue;
1218 }
1219 }
1220 }
1221
1222 public boolean getBooleanAttribute(String key, boolean defaultValue) {
1223 String value = this.getAttribute(key);
1224 if (value == null) {
1225 return defaultValue;
1226 } else {
1227 return Boolean.parseBoolean(value);
1228 }
1229 }
1230
1231 public void add(Message message) {
1232 synchronized (this.messages) {
1233 this.messages.add(message);
1234 }
1235 }
1236
1237 public void prepend(int offset, Message message) {
1238 synchronized (this.messages) {
1239 this.messages.add(Math.min(offset, this.messages.size()), message);
1240 }
1241 }
1242
1243 public void addAll(int index, List<Message> messages) {
1244 synchronized (this.messages) {
1245 this.messages.addAll(index, messages);
1246 }
1247 account.getPgpDecryptionService().decrypt(messages);
1248 }
1249
1250 public void expireOldMessages(long timestamp) {
1251 synchronized (this.messages) {
1252 for (ListIterator<Message> iterator = this.messages.listIterator(); iterator.hasNext(); ) {
1253 if (iterator.next().getTimeSent() < timestamp) {
1254 iterator.remove();
1255 }
1256 }
1257 untieMessages();
1258 }
1259 }
1260
1261 public void sort() {
1262 synchronized (this.messages) {
1263 Collections.sort(this.messages, (left, right) -> {
1264 if (left.getTimeSent() < right.getTimeSent()) {
1265 return -1;
1266 } else if (left.getTimeSent() > right.getTimeSent()) {
1267 return 1;
1268 } else {
1269 return 0;
1270 }
1271 });
1272 untieMessages();
1273 }
1274 }
1275
1276 private void untieMessages() {
1277 for (Message message : this.messages) {
1278 message.untie();
1279 }
1280 }
1281
1282 public int unreadCount() {
1283 synchronized (this.messages) {
1284 int count = 0;
1285 for(final Message message : Lists.reverse(this.messages)) {
1286 if (message.isRead()) {
1287 if (message.getType() == Message.TYPE_RTP_SESSION) {
1288 continue;
1289 }
1290 return count;
1291 }
1292 ++count;
1293 }
1294 return count;
1295 }
1296 }
1297
1298 public int receivedMessagesCount() {
1299 int count = 0;
1300 synchronized (this.messages) {
1301 for (Message message : messages) {
1302 if (message.getStatus() == Message.STATUS_RECEIVED) {
1303 ++count;
1304 }
1305 }
1306 }
1307 return count;
1308 }
1309
1310 public int sentMessagesCount() {
1311 int count = 0;
1312 synchronized (this.messages) {
1313 for (Message message : messages) {
1314 if (message.getStatus() != Message.STATUS_RECEIVED) {
1315 ++count;
1316 }
1317 }
1318 }
1319 return count;
1320 }
1321
1322 public boolean canInferPresence() {
1323 final Contact contact = getContact();
1324 if (contact != null && contact.canInferPresence()) return true;
1325 return sentMessagesCount() > 0;
1326 }
1327
1328 public boolean isWithStranger() {
1329 final Contact contact = getContact();
1330 return mode == MODE_SINGLE
1331 && !contact.isOwnServer()
1332 && !contact.showInContactList()
1333 && !contact.isSelf()
1334 && !(contact.getJid().isDomainJid() && JidHelper.isQuicksyDomain(contact.getJid()))
1335 && sentMessagesCount() == 0;
1336 }
1337
1338 public int getReceivedMessagesCountSinceUuid(String uuid) {
1339 if (uuid == null) {
1340 return 0;
1341 }
1342 int count = 0;
1343 synchronized (this.messages) {
1344 for (int i = messages.size() - 1; i >= 0; i--) {
1345 final Message message = messages.get(i);
1346 if (uuid.equals(message.getUuid())) {
1347 return count;
1348 }
1349 if (message.getStatus() <= Message.STATUS_RECEIVED) {
1350 ++count;
1351 }
1352 }
1353 }
1354 return 0;
1355 }
1356
1357 @Override
1358 public int getAvatarBackgroundColor() {
1359 return UIHelper.getColorForName(getName().toString());
1360 }
1361
1362 @Override
1363 public String getAvatarName() {
1364 return getName().toString();
1365 }
1366
1367 public void setCurrentTab(int tab) {
1368 mCurrentTab = tab;
1369 }
1370
1371 public int getCurrentTab() {
1372 if (mCurrentTab >= 0) return mCurrentTab;
1373
1374 if (!isRead() || getContact().resourceWhichSupport(Namespace.COMMANDS) == null) {
1375 return 0;
1376 }
1377
1378 return 1;
1379 }
1380
1381 public void refreshSessions() {
1382 pagerAdapter.refreshSessions();
1383 }
1384
1385 public void startWebxdc(WebxdcPage page) {
1386 pagerAdapter.startWebxdc(page);
1387 }
1388
1389 public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
1390 pagerAdapter.startCommand(command, xmppConnectionService);
1391 }
1392
1393 public boolean switchToSession(final String node) {
1394 return pagerAdapter.switchToSession(node);
1395 }
1396
1397 public void setupViewPager(ViewPager pager, TabLayout tabs, boolean onboarding, Conversation oldConversation) {
1398 pagerAdapter.setupViewPager(pager, tabs, onboarding, oldConversation);
1399 }
1400
1401 public void showViewPager() {
1402 pagerAdapter.show();
1403 }
1404
1405 public void hideViewPager() {
1406 pagerAdapter.hide();
1407 }
1408
1409 public interface OnMessageFound {
1410 void onMessageFound(final Message message);
1411 }
1412
1413 public static class Draft {
1414 private final String message;
1415 private final long timestamp;
1416
1417 private Draft(String message, long timestamp) {
1418 this.message = message;
1419 this.timestamp = timestamp;
1420 }
1421
1422 public long getTimestamp() {
1423 return timestamp;
1424 }
1425
1426 public String getMessage() {
1427 return message;
1428 }
1429 }
1430
1431 public class ConversationPagerAdapter extends PagerAdapter {
1432 protected ViewPager mPager = null;
1433 protected TabLayout mTabs = null;
1434 ArrayList<ConversationPage> sessions = null;
1435 protected View page1 = null;
1436 protected View page2 = null;
1437 protected boolean mOnboarding = false;
1438
1439 public void setupViewPager(ViewPager pager, TabLayout tabs, boolean onboarding, Conversation oldConversation) {
1440 mPager = pager;
1441 mTabs = tabs;
1442 mOnboarding = onboarding;
1443
1444 if (oldConversation != null) {
1445 oldConversation.pagerAdapter.mPager = null;
1446 oldConversation.pagerAdapter.mTabs = null;
1447 }
1448
1449 if (mPager == null) {
1450 page1 = null;
1451 page2 = null;
1452 return;
1453 }
1454 if (sessions != null) show();
1455
1456 if (pager.getChildAt(0) != null) page1 = pager.getChildAt(0);
1457 if (pager.getChildAt(1) != null) page2 = pager.getChildAt(1);
1458 if (page2 != null && page2.findViewById(R.id.commands_view) == null) {
1459 page1 = null;
1460 page2 = null;
1461 }
1462 if (page1 == null) page1 = oldConversation.pagerAdapter.page1;
1463 if (page2 == null) page2 = oldConversation.pagerAdapter.page2;
1464 if (page1 == null || page2 == null) {
1465 throw new IllegalStateException("page1 or page2 were not present as child or in model?");
1466 }
1467 pager.removeView(page1);
1468 pager.removeView(page2);
1469 pager.setAdapter(this);
1470 tabs.setupWithViewPager(mPager);
1471 pager.post(() -> pager.setCurrentItem(getCurrentTab()));
1472
1473 mPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
1474 public void onPageScrollStateChanged(int state) { }
1475 public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { }
1476
1477 public void onPageSelected(int position) {
1478 setCurrentTab(position);
1479 }
1480 });
1481 }
1482
1483 public void show() {
1484 if (sessions == null) {
1485 sessions = new ArrayList<>();
1486 notifyDataSetChanged();
1487 }
1488 if (!mOnboarding && mTabs != null) mTabs.setVisibility(View.VISIBLE);
1489 }
1490
1491 public void hide() {
1492 if (sessions != null && !sessions.isEmpty()) return; // Do not hide during active session
1493 if (mPager != null) mPager.setCurrentItem(0);
1494 if (mTabs != null) mTabs.setVisibility(View.GONE);
1495 sessions = null;
1496 notifyDataSetChanged();
1497 }
1498
1499 public void refreshSessions() {
1500 if (sessions == null) return;
1501
1502 for (ConversationPage session : sessions) {
1503 session.refresh();
1504 }
1505 }
1506
1507 public void startWebxdc(WebxdcPage page) {
1508 show();
1509 sessions.add(page);
1510 notifyDataSetChanged();
1511 if (mPager != null) mPager.setCurrentItem(getCount() - 1);
1512 }
1513
1514 public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
1515 show();
1516 CommandSession session = new CommandSession(command.getAttribute("name"), command.getAttribute("node"), xmppConnectionService);
1517
1518 final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
1519 packet.setTo(command.getAttributeAsJid("jid"));
1520 final Element c = packet.addChild("command", Namespace.COMMANDS);
1521 c.setAttribute("node", command.getAttribute("node"));
1522 c.setAttribute("action", "execute");
1523
1524 final TimerTask task = new TimerTask() {
1525 @Override
1526 public void run() {
1527 if (getAccount().getStatus() != Account.State.ONLINE) {
1528 final TimerTask self = this;
1529 new Timer().schedule(new TimerTask() {
1530 @Override
1531 public void run() {
1532 self.run();
1533 }
1534 }, 1000);
1535 } else {
1536 xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
1537 session.updateWithResponse(iq);
1538 }, 120L);
1539 }
1540 }
1541 };
1542
1543 if (command.getAttribute("node").equals("jabber:iq:register") && packet.getTo().asBareJid().equals(Jid.of("cheogram.com"))) {
1544 new com.cheogram.android.CheogramLicenseChecker(mPager.getContext(), (signedData, signature) -> {
1545 if (signedData != null && signature != null) {
1546 c.addChild("license", "https://ns.cheogram.com/google-play").setContent(signedData);
1547 c.addChild("licenseSignature", "https://ns.cheogram.com/google-play").setContent(signature);
1548 }
1549
1550 task.run();
1551 }).checkLicense();
1552 } else {
1553 task.run();
1554 }
1555
1556 sessions.add(session);
1557 notifyDataSetChanged();
1558 if (mPager != null) mPager.setCurrentItem(getCount() - 1);
1559 }
1560
1561 public void removeSession(ConversationPage session) {
1562 sessions.remove(session);
1563 notifyDataSetChanged();
1564 if (session instanceof WebxdcPage) mPager.setCurrentItem(0);
1565 }
1566
1567 public boolean switchToSession(final String node) {
1568 if (sessions == null) return false;
1569
1570 int i = 0;
1571 for (ConversationPage session : sessions) {
1572 if (session.getNode().equals(node)) {
1573 if (mPager != null) mPager.setCurrentItem(i + 2);
1574 return true;
1575 }
1576 i++;
1577 }
1578
1579 return false;
1580 }
1581
1582 @NonNull
1583 @Override
1584 public Object instantiateItem(@NonNull ViewGroup container, int position) {
1585 if (position == 0) {
1586 if (page1 != null && page1.getParent() != null) {
1587 ((ViewGroup) page1.getParent()).removeView(page1);
1588 }
1589 container.addView(page1);
1590 return page1;
1591 }
1592 if (position == 1) {
1593 if (page2 != null && page2.getParent() != null) {
1594 ((ViewGroup) page2.getParent()).removeView(page2);
1595 }
1596 container.addView(page2);
1597 return page2;
1598 }
1599
1600 ConversationPage session = sessions.get(position-2);
1601 View v = session.inflateUi(container.getContext(), (s) -> removeSession(s));
1602 if (v != null && v.getParent() != null) {
1603 ((ViewGroup) v.getParent()).removeView(v);
1604 }
1605 container.addView(v);
1606 return session;
1607 }
1608
1609 @Override
1610 public void destroyItem(@NonNull ViewGroup container, int position, Object o) {
1611 if (position < 2) {
1612 container.removeView((View) o);
1613 return;
1614 }
1615
1616 container.removeView(((ConversationPage) o).getView());
1617 }
1618
1619 @Override
1620 public int getItemPosition(Object o) {
1621 if (mPager != null) {
1622 if (o == page1) return PagerAdapter.POSITION_UNCHANGED;
1623 if (o == page2) return PagerAdapter.POSITION_UNCHANGED;
1624 }
1625
1626 int pos = sessions == null ? -1 : sessions.indexOf(o);
1627 if (pos < 0) return PagerAdapter.POSITION_NONE;
1628 return pos + 2;
1629 }
1630
1631 @Override
1632 public int getCount() {
1633 if (sessions == null) return 1;
1634
1635 int count = 2 + sessions.size();
1636 if (mTabs == null) return count;
1637
1638 if (count > 2) {
1639 mTabs.setTabMode(TabLayout.MODE_SCROLLABLE);
1640 } else {
1641 mTabs.setTabMode(TabLayout.MODE_FIXED);
1642 }
1643 return count;
1644 }
1645
1646 @Override
1647 public boolean isViewFromObject(@NonNull View view, @NonNull Object o) {
1648 if (view == o) return true;
1649
1650 if (o instanceof ConversationPage) {
1651 return ((ConversationPage) o).getView() == view;
1652 }
1653
1654 return false;
1655 }
1656
1657 @Nullable
1658 @Override
1659 public CharSequence getPageTitle(int position) {
1660 switch (position) {
1661 case 0:
1662 return "Conversation";
1663 case 1:
1664 return "Commands";
1665 default:
1666 ConversationPage session = sessions.get(position-2);
1667 if (session == null) return super.getPageTitle(position);
1668 return session.getTitle();
1669 }
1670 }
1671
1672 class CommandSession extends RecyclerView.Adapter<CommandSession.ViewHolder> implements ConversationPage {
1673 abstract class ViewHolder<T extends ViewDataBinding> extends RecyclerView.ViewHolder {
1674 protected T binding;
1675
1676 public ViewHolder(T binding) {
1677 super(binding.getRoot());
1678 this.binding = binding;
1679 }
1680
1681 abstract public void bind(Item el);
1682
1683 protected void setTextOrHide(TextView v, Optional<String> s) {
1684 if (s == null || !s.isPresent()) {
1685 v.setVisibility(View.GONE);
1686 } else {
1687 v.setVisibility(View.VISIBLE);
1688 v.setText(s.get());
1689 }
1690 }
1691
1692 protected void setupInputType(Element field, TextView textinput, TextInputLayout layout) {
1693 int flags = 0;
1694 if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_NONE);
1695 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1696
1697 String type = field.getAttribute("type");
1698 if (type != null) {
1699 if (type.equals("text-multi") || type.equals("jid-multi")) {
1700 flags |= InputType.TYPE_TEXT_FLAG_MULTI_LINE;
1701 }
1702
1703 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1704
1705 if (type.equals("jid-single") || type.equals("jid-multi")) {
1706 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
1707 }
1708
1709 if (type.equals("text-private")) {
1710 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
1711 if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_PASSWORD_TOGGLE);
1712 }
1713 }
1714
1715 Element validate = field.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1716 if (validate == null) return;
1717 String datatype = validate.getAttribute("datatype");
1718 if (datatype == null) return;
1719
1720 if (datatype.equals("xs:integer") || datatype.equals("xs:int") || datatype.equals("xs:long") || datatype.equals("xs:short") || datatype.equals("xs:byte")) {
1721 textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED);
1722 }
1723
1724 if (datatype.equals("xs:decimal") || datatype.equals("xs:double")) {
1725 textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED | InputType.TYPE_NUMBER_FLAG_DECIMAL);
1726 }
1727
1728 if (datatype.equals("xs:date")) {
1729 textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_DATE);
1730 }
1731
1732 if (datatype.equals("xs:dateTime")) {
1733 textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_NORMAL);
1734 }
1735
1736 if (datatype.equals("xs:time")) {
1737 textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_TIME);
1738 }
1739
1740 if (datatype.equals("xs:anyURI")) {
1741 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
1742 }
1743
1744 if (datatype.equals("html:tel")) {
1745 textinput.setInputType(flags | InputType.TYPE_CLASS_PHONE);
1746 }
1747
1748 if (datatype.equals("html:email")) {
1749 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
1750 }
1751 }
1752
1753 protected String formatValue(String datatype, String value, boolean compact) {
1754 if ("xs:dateTime".equals(datatype)) {
1755 ZonedDateTime zonedDateTime = null;
1756 try {
1757 zonedDateTime = ZonedDateTime.parse(value, DateTimeFormatter.ISO_DATE_TIME);
1758 } catch (final DateTimeParseException e) {
1759 try {
1760 DateTimeFormatter almostIso = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm[:ss] X");
1761 zonedDateTime = ZonedDateTime.parse(value, almostIso);
1762 } catch (final DateTimeParseException e2) { }
1763 }
1764 if (zonedDateTime == null) return value;
1765 ZonedDateTime localZonedDateTime = zonedDateTime.withZoneSameInstant(ZoneId.systemDefault());
1766 DateTimeFormatter outputFormat = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT);
1767 return localZonedDateTime.toLocalDateTime().format(outputFormat);
1768 }
1769
1770 if ("html:tel".equals(datatype) && !compact) {
1771 return PhoneNumberUtils.formatNumber(value, value, null);
1772 }
1773
1774 return value;
1775 }
1776 }
1777
1778 class ErrorViewHolder extends ViewHolder<CommandNoteBinding> {
1779 public ErrorViewHolder(CommandNoteBinding binding) { super(binding); }
1780
1781 @Override
1782 public void bind(Item iq) {
1783 binding.errorIcon.setVisibility(View.VISIBLE);
1784
1785 Element error = iq.el.findChild("error");
1786 if (error == null) return;
1787 String text = error.findChildContent("text", "urn:ietf:params:xml:ns:xmpp-stanzas");
1788 if (text == null || text.equals("")) {
1789 text = error.getChildren().get(0).getName();
1790 }
1791 binding.message.setText(text);
1792 }
1793 }
1794
1795 class NoteViewHolder extends ViewHolder<CommandNoteBinding> {
1796 public NoteViewHolder(CommandNoteBinding binding) { super(binding); }
1797
1798 @Override
1799 public void bind(Item note) {
1800 binding.message.setText(note != null && note.el != null ? note.el.getContent() : "");
1801
1802 String type = note.el.getAttribute("type");
1803 if (type != null && type.equals("error")) {
1804 binding.errorIcon.setVisibility(View.VISIBLE);
1805 }
1806 }
1807 }
1808
1809 class ResultFieldViewHolder extends ViewHolder<CommandResultFieldBinding> {
1810 public ResultFieldViewHolder(CommandResultFieldBinding binding) { super(binding); }
1811
1812 @Override
1813 public void bind(Item item) {
1814 Field field = (Field) item;
1815 setTextOrHide(binding.label, field.getLabel());
1816 setTextOrHide(binding.desc, field.getDesc());
1817
1818 Element media = field.el.findChild("media", "urn:xmpp:media-element");
1819 if (media == null) {
1820 binding.mediaImage.setVisibility(View.GONE);
1821 } else {
1822 final LruCache<String, Drawable> cache = xmppConnectionService.getDrawableCache();
1823 final HttpConnectionManager httpManager = xmppConnectionService.getHttpConnectionManager();
1824 for (Element uriEl : media.getChildren()) {
1825 if (!"uri".equals(uriEl.getName())) continue;
1826 if (!"urn:xmpp:media-element".equals(uriEl.getNamespace())) continue;
1827 String mimeType = uriEl.getAttribute("type");
1828 String uriS = uriEl.getContent();
1829 if (mimeType == null || uriS == null) continue;
1830 Uri uri = Uri.parse(uriS);
1831 if (mimeType.startsWith("image/") && "https".equals(uri.getScheme())) {
1832 final Drawable d = getDrawableForUrl(uri.toString());
1833 if (d != null) {
1834 binding.mediaImage.setImageDrawable(d);
1835 binding.mediaImage.setVisibility(View.VISIBLE);
1836 }
1837 }
1838 }
1839 }
1840
1841 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1842 String datatype = validate == null ? null : validate.getAttribute("datatype");
1843
1844 ArrayAdapter<Option> values = new ArrayAdapter<>(binding.getRoot().getContext(), R.layout.simple_list_item);
1845 for (Element el : field.el.getChildren()) {
1846 if (el.getName().equals("value") && el.getNamespace().equals("jabber:x:data")) {
1847 values.add(new Option(el.getContent(), formatValue(datatype, el.getContent(), false)));
1848 }
1849 }
1850 binding.values.setAdapter(values);
1851 Util.justifyListViewHeightBasedOnChildren(binding.values);
1852
1853 if (field.getType().equals(Optional.of("jid-single")) || field.getType().equals(Optional.of("jid-multi"))) {
1854 binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
1855 new FixedURLSpan("xmpp:" + Uri.encode(Jid.ofEscaped(values.getItem(pos).getValue()).toEscapedString(), "@/+"), account).onClick(binding.values);
1856 });
1857 } else if ("xs:anyURI".equals(datatype)) {
1858 binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
1859 new FixedURLSpan(values.getItem(pos).getValue(), account).onClick(binding.values);
1860 });
1861 } else if ("html:tel".equals(datatype)) {
1862 binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
1863 try {
1864 new FixedURLSpan("tel:" + PhoneNumberUtilWrapper.normalize(binding.getRoot().getContext(), values.getItem(pos).getValue()), account).onClick(binding.values);
1865 } catch (final IllegalArgumentException | NumberParseException | NullPointerException e) { }
1866 });
1867 }
1868
1869 binding.values.setOnItemLongClickListener((arg0, arg1, pos, id) -> {
1870 if (ShareUtil.copyTextToClipboard(binding.getRoot().getContext(), values.getItem(pos).getValue(), R.string.message)) {
1871 Toast.makeText(binding.getRoot().getContext(), R.string.message_copied_to_clipboard, Toast.LENGTH_SHORT).show();
1872 }
1873 return true;
1874 });
1875 }
1876 }
1877
1878 class ResultCellViewHolder extends ViewHolder<CommandResultCellBinding> {
1879 public ResultCellViewHolder(CommandResultCellBinding binding) { super(binding); }
1880
1881 @Override
1882 public void bind(Item item) {
1883 Cell cell = (Cell) item;
1884
1885 if (cell.el == null) {
1886 binding.text.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Subhead);
1887 setTextOrHide(binding.text, cell.reported.getLabel());
1888 } else {
1889 Element validate = cell.reported.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1890 String datatype = validate == null ? null : validate.getAttribute("datatype");
1891 String value = formatValue(datatype, cell.el.findChildContent("value", "jabber:x:data"), true);
1892 SpannableStringBuilder text = new SpannableStringBuilder(value == null ? "" : value);
1893 if (cell.reported.getType().equals(Optional.of("jid-single"))) {
1894 text.setSpan(new FixedURLSpan("xmpp:" + Jid.ofEscaped(text.toString()).toEscapedString(), account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1895 } else if ("xs:anyURI".equals(datatype)) {
1896 text.setSpan(new FixedURLSpan(text.toString(), account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1897 } else if ("html:tel".equals(datatype)) {
1898 try {
1899 text.setSpan(new FixedURLSpan("tel:" + PhoneNumberUtilWrapper.normalize(binding.getRoot().getContext(), text.toString()), account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1900 } catch (final IllegalArgumentException | NumberParseException | NullPointerException e) { }
1901 }
1902
1903 binding.text.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Body1);
1904 binding.text.setText(text);
1905
1906 BetterLinkMovementMethod method = BetterLinkMovementMethod.newInstance();
1907 method.setOnLinkLongClickListener((tv, url) -> {
1908 tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
1909 ShareUtil.copyLinkToClipboard(binding.getRoot().getContext(), url);
1910 return true;
1911 });
1912 binding.text.setMovementMethod(method);
1913 }
1914 }
1915 }
1916
1917 class ItemCardViewHolder extends ViewHolder<CommandItemCardBinding> {
1918 public ItemCardViewHolder(CommandItemCardBinding binding) { super(binding); }
1919
1920 @Override
1921 public void bind(Item item) {
1922 binding.fields.removeAllViews();
1923
1924 for (Field field : reported) {
1925 CommandResultFieldBinding row = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.command_result_field, binding.fields, false);
1926 GridLayout.LayoutParams param = new GridLayout.LayoutParams();
1927 param.columnSpec = GridLayout.spec(GridLayout.UNDEFINED, GridLayout.FILL, 1f);
1928 param.width = 0;
1929 row.getRoot().setLayoutParams(param);
1930 binding.fields.addView(row.getRoot());
1931 for (Element el : item.el.getChildren()) {
1932 if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data") && el.getAttribute("var") != null && el.getAttribute("var").equals(field.getVar())) {
1933 for (String label : field.getLabel().asSet()) {
1934 el.setAttribute("label", label);
1935 }
1936 for (String desc : field.getDesc().asSet()) {
1937 el.setAttribute("desc", desc);
1938 }
1939 for (String type : field.getType().asSet()) {
1940 el.setAttribute("type", type);
1941 }
1942 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1943 if (validate != null) el.addChild(validate);
1944 new ResultFieldViewHolder(row).bind(new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), -1));
1945 }
1946 }
1947 }
1948 }
1949 }
1950
1951 class CheckboxFieldViewHolder extends ViewHolder<CommandCheckboxFieldBinding> implements CompoundButton.OnCheckedChangeListener {
1952 public CheckboxFieldViewHolder(CommandCheckboxFieldBinding binding) {
1953 super(binding);
1954 binding.row.setOnClickListener((v) -> {
1955 binding.checkbox.toggle();
1956 });
1957 binding.checkbox.setOnCheckedChangeListener(this);
1958 }
1959 protected Element mValue = null;
1960
1961 @Override
1962 public void bind(Item item) {
1963 Field field = (Field) item;
1964 binding.label.setText(field.getLabel().or(""));
1965 setTextOrHide(binding.desc, field.getDesc());
1966 mValue = field.getValue();
1967 binding.checkbox.setChecked(mValue.getContent() != null && (mValue.getContent().equals("true") || mValue.getContent().equals("1")));
1968 }
1969
1970 @Override
1971 public void onCheckedChanged(CompoundButton checkbox, boolean isChecked) {
1972 if (mValue == null) return;
1973
1974 mValue.setContent(isChecked ? "true" : "false");
1975 }
1976 }
1977
1978 class SearchListFieldViewHolder extends ViewHolder<CommandSearchListFieldBinding> implements TextWatcher {
1979 public SearchListFieldViewHolder(CommandSearchListFieldBinding binding) {
1980 super(binding);
1981 binding.search.addTextChangedListener(this);
1982 }
1983 protected Element mValue = null;
1984 List<Option> options = new ArrayList<>();
1985 protected ArrayAdapter<Option> adapter;
1986 protected boolean open;
1987
1988 @Override
1989 public void bind(Item item) {
1990 Field field = (Field) item;
1991 setTextOrHide(binding.label, field.getLabel());
1992 setTextOrHide(binding.desc, field.getDesc());
1993
1994 if (field.error != null) {
1995 binding.desc.setVisibility(View.VISIBLE);
1996 binding.desc.setText(field.error);
1997 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
1998 } else {
1999 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
2000 }
2001
2002 mValue = field.getValue();
2003
2004 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2005 open = validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null;
2006 setupInputType(field.el, binding.search, null);
2007
2008 options = field.getOptions();
2009 binding.list.setOnItemClickListener((parent, view, position, id) -> {
2010 mValue.setContent(adapter.getItem(binding.list.getCheckedItemPosition()).getValue());
2011 if (open) binding.search.setText(mValue.getContent());
2012 });
2013 search("");
2014 }
2015
2016 @Override
2017 public void afterTextChanged(Editable s) {
2018 if (open) mValue.setContent(s.toString());
2019 search(s.toString());
2020 }
2021
2022 @Override
2023 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2024
2025 @Override
2026 public void onTextChanged(CharSequence s, int start, int count, int after) { }
2027
2028 protected void search(String s) {
2029 List<Option> filteredOptions;
2030 final String q = s.replaceAll("\\W", "").toLowerCase();
2031 if (q == null || q.equals("")) {
2032 filteredOptions = options;
2033 } else {
2034 filteredOptions = options.stream().filter(o -> o.toString().replaceAll("\\W", "").toLowerCase().contains(q)).collect(Collectors.toList());
2035 }
2036 adapter = new ArrayAdapter(binding.getRoot().getContext(), R.layout.simple_list_item, filteredOptions);
2037 binding.list.setAdapter(adapter);
2038
2039 int checkedPos = filteredOptions.indexOf(new Option(mValue.getContent(), ""));
2040 if (checkedPos >= 0) binding.list.setItemChecked(checkedPos, true);
2041 }
2042 }
2043
2044 class RadioEditFieldViewHolder extends ViewHolder<CommandRadioEditFieldBinding> implements CompoundButton.OnCheckedChangeListener, TextWatcher {
2045 public RadioEditFieldViewHolder(CommandRadioEditFieldBinding binding) {
2046 super(binding);
2047 binding.open.addTextChangedListener(this);
2048 options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.radio_grid_item) {
2049 @Override
2050 public View getView(int position, View convertView, ViewGroup parent) {
2051 CompoundButton v = (CompoundButton) super.getView(position, convertView, parent);
2052 v.setId(position);
2053 v.setChecked(getItem(position).getValue().equals(mValue.getContent()));
2054 v.setOnCheckedChangeListener(RadioEditFieldViewHolder.this);
2055 return v;
2056 }
2057 };
2058 }
2059 protected Element mValue = null;
2060 protected ArrayAdapter<Option> options;
2061
2062 @Override
2063 public void bind(Item item) {
2064 Field field = (Field) item;
2065 setTextOrHide(binding.label, field.getLabel());
2066 setTextOrHide(binding.desc, field.getDesc());
2067
2068 if (field.error != null) {
2069 binding.desc.setVisibility(View.VISIBLE);
2070 binding.desc.setText(field.error);
2071 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
2072 } else {
2073 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
2074 }
2075
2076 mValue = field.getValue();
2077
2078 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2079 binding.open.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
2080 binding.open.setText(mValue.getContent());
2081 setupInputType(field.el, binding.open, null);
2082
2083 options.clear();
2084 List<Option> theOptions = field.getOptions();
2085 options.addAll(theOptions);
2086
2087 float screenWidth = binding.getRoot().getContext().getResources().getDisplayMetrics().widthPixels;
2088 TextPaint paint = ((TextView) LayoutInflater.from(binding.getRoot().getContext()).inflate(R.layout.radio_grid_item, null)).getPaint();
2089 float maxColumnWidth = theOptions.stream().map((x) ->
2090 StaticLayout.getDesiredWidth(x.toString(), paint)
2091 ).max(Float::compare).orElse(new Float(0.0));
2092 if (maxColumnWidth * theOptions.size() < 0.90 * screenWidth) {
2093 binding.radios.setNumColumns(theOptions.size());
2094 } else if (maxColumnWidth * (theOptions.size() / 2) < 0.90 * screenWidth) {
2095 binding.radios.setNumColumns(theOptions.size() / 2);
2096 } else {
2097 binding.radios.setNumColumns(1);
2098 }
2099 binding.radios.setAdapter(options);
2100 }
2101
2102 @Override
2103 public void onCheckedChanged(CompoundButton radio, boolean isChecked) {
2104 if (mValue == null) return;
2105
2106 if (isChecked) {
2107 mValue.setContent(options.getItem(radio.getId()).getValue());
2108 binding.open.setText(mValue.getContent());
2109 }
2110 options.notifyDataSetChanged();
2111 }
2112
2113 @Override
2114 public void afterTextChanged(Editable s) {
2115 if (mValue == null) return;
2116
2117 mValue.setContent(s.toString());
2118 options.notifyDataSetChanged();
2119 }
2120
2121 @Override
2122 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2123
2124 @Override
2125 public void onTextChanged(CharSequence s, int start, int count, int after) { }
2126 }
2127
2128 class SpinnerFieldViewHolder extends ViewHolder<CommandSpinnerFieldBinding> implements AdapterView.OnItemSelectedListener {
2129 public SpinnerFieldViewHolder(CommandSpinnerFieldBinding binding) {
2130 super(binding);
2131 binding.spinner.setOnItemSelectedListener(this);
2132 }
2133 protected Element mValue = null;
2134
2135 @Override
2136 public void bind(Item item) {
2137 Field field = (Field) item;
2138 setTextOrHide(binding.label, field.getLabel());
2139 binding.spinner.setPrompt(field.getLabel().or(""));
2140 setTextOrHide(binding.desc, field.getDesc());
2141
2142 mValue = field.getValue();
2143
2144 ArrayAdapter<Option> options = new ArrayAdapter<Option>(binding.getRoot().getContext(), android.R.layout.simple_spinner_item);
2145 options.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
2146 options.addAll(field.getOptions());
2147
2148 binding.spinner.setAdapter(options);
2149 binding.spinner.setSelection(options.getPosition(new Option(mValue.getContent(), null)));
2150 }
2151
2152 @Override
2153 public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
2154 Option o = (Option) parent.getItemAtPosition(pos);
2155 if (mValue == null) return;
2156
2157 mValue.setContent(o == null ? "" : o.getValue());
2158 }
2159
2160 @Override
2161 public void onNothingSelected(AdapterView<?> parent) {
2162 mValue.setContent("");
2163 }
2164 }
2165
2166 class ButtonGridFieldViewHolder extends ViewHolder<CommandButtonGridFieldBinding> {
2167 public ButtonGridFieldViewHolder(CommandButtonGridFieldBinding binding) {
2168 super(binding);
2169 options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.button_grid_item) {
2170 protected int height = 0;
2171
2172 @Override
2173 public View getView(int position, View convertView, ViewGroup parent) {
2174 Button v = (Button) super.getView(position, convertView, parent);
2175 v.setOnClickListener((view) -> {
2176 mValue.setContent(getItem(position).getValue());
2177 execute();
2178 loading = true;
2179 });
2180
2181 final SVG icon = getItem(position).getIcon();
2182 if (icon != null) {
2183 final Element iconEl = getItem(position).getIconEl();
2184 if (height < 1) {
2185 v.measure(0, 0);
2186 height = v.getMeasuredHeight();
2187 }
2188 if (height < 1) return v;
2189 if (mediaSelector) {
2190 final Drawable d = getDrawableForSVG(icon, iconEl, height * 4);
2191 if (d != null) {
2192 final int boundsHeight = 35 + (int)((height * 4) / xmppConnectionService.getResources().getDisplayMetrics().density);
2193 d.setBounds(0, 0, d.getIntrinsicWidth(), boundsHeight);
2194 }
2195 v.setCompoundDrawables(null, d, null, null);
2196 } else {
2197 v.setCompoundDrawablesRelativeWithIntrinsicBounds(getDrawableForSVG(icon, iconEl, height), null, null, null);
2198 }
2199 }
2200
2201 return v;
2202 }
2203 };
2204 }
2205 protected Element mValue = null;
2206 protected ArrayAdapter<Option> options;
2207 protected Option defaultOption = null;
2208 protected boolean mediaSelector = false;
2209
2210 @Override
2211 public void bind(Item item) {
2212 Field field = (Field) item;
2213 setTextOrHide(binding.label, field.getLabel());
2214 setTextOrHide(binding.desc, field.getDesc());
2215
2216 if (field.error != null) {
2217 binding.desc.setVisibility(View.VISIBLE);
2218 binding.desc.setText(field.error);
2219 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
2220 } else {
2221 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
2222 }
2223
2224 mValue = field.getValue();
2225 mediaSelector = field.el.findChild("media-selector", "https://ns.cheogram.com/") != null;
2226
2227 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2228 binding.openButton.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
2229 binding.openButton.setOnClickListener((view) -> {
2230 AlertDialog.Builder builder = new AlertDialog.Builder(binding.getRoot().getContext());
2231 DialogQuickeditBinding dialogBinding = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.dialog_quickedit, null, false);
2232 builder.setPositiveButton(R.string.action_execute, null);
2233 if (field.getDesc().isPresent()) {
2234 dialogBinding.inputLayout.setHint(field.getDesc().get());
2235 }
2236 dialogBinding.inputEditText.requestFocus();
2237 dialogBinding.inputEditText.getText().append(mValue.getContent());
2238 builder.setView(dialogBinding.getRoot());
2239 builder.setNegativeButton(R.string.cancel, null);
2240 final AlertDialog dialog = builder.create();
2241 dialog.setOnShowListener(d -> SoftKeyboardUtils.showKeyboard(dialogBinding.inputEditText));
2242 dialog.show();
2243 View.OnClickListener clickListener = v -> {
2244 String value = dialogBinding.inputEditText.getText().toString();
2245 mValue.setContent(value);
2246 SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2247 dialog.dismiss();
2248 execute();
2249 loading = true;
2250 };
2251 dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener(clickListener);
2252 dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setOnClickListener((v -> {
2253 SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2254 dialog.dismiss();
2255 }));
2256 dialog.setCanceledOnTouchOutside(false);
2257 dialog.setOnDismissListener(dialog1 -> {
2258 SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2259 });
2260 });
2261
2262 options.clear();
2263 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();
2264
2265 defaultOption = null;
2266 for (Option option : theOptions) {
2267 if (option.getValue().equals(mValue.getContent())) {
2268 defaultOption = option;
2269 break;
2270 }
2271 }
2272 if (defaultOption == null && !mValue.getContent().equals("")) {
2273 // Synthesize default option for custom value
2274 defaultOption = new Option(mValue.getContent(), mValue.getContent());
2275 }
2276 if (defaultOption == null) {
2277 binding.defaultButton.setVisibility(View.GONE);
2278 } else {
2279 theOptions.remove(defaultOption);
2280 binding.defaultButton.setVisibility(View.VISIBLE);
2281
2282 final SVG defaultIcon = defaultOption.getIcon();
2283 if (defaultIcon != null) {
2284 DisplayMetrics display = mPager.getContext().getResources().getDisplayMetrics();
2285 int height = (int)(display.heightPixels*display.density/4);
2286 binding.defaultButton.setCompoundDrawablesRelativeWithIntrinsicBounds(null, getDrawableForSVG(defaultIcon, defaultOption.getIconEl(), height), null, null);
2287 }
2288
2289 binding.defaultButton.setText(defaultOption.toString());
2290 binding.defaultButton.setOnClickListener((view) -> {
2291 mValue.setContent(defaultOption.getValue());
2292 execute();
2293 loading = true;
2294 });
2295 }
2296
2297 options.addAll(theOptions);
2298 binding.buttons.setAdapter(options);
2299 }
2300 }
2301
2302 class TextFieldViewHolder extends ViewHolder<CommandTextFieldBinding> implements TextWatcher {
2303 public TextFieldViewHolder(CommandTextFieldBinding binding) {
2304 super(binding);
2305 binding.textinput.addTextChangedListener(this);
2306 }
2307 protected Field field = null;
2308
2309 @Override
2310 public void bind(Item item) {
2311 field = (Field) item;
2312 binding.textinputLayout.setHint(field.getLabel().or(""));
2313
2314 binding.textinputLayout.setHelperTextEnabled(field.getDesc().isPresent());
2315 for (String desc : field.getDesc().asSet()) {
2316 binding.textinputLayout.setHelperText(desc);
2317 }
2318
2319 binding.textinputLayout.setErrorEnabled(field.error != null);
2320 if (field.error != null) binding.textinputLayout.setError(field.error);
2321
2322 binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_GRAVITY);
2323 String suffixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/suffix-label");
2324 if (suffixLabel == null) {
2325 binding.textinputLayout.setSuffixText("");
2326 } else {
2327 binding.textinputLayout.setSuffixText(suffixLabel);
2328 binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_TEXT_END);
2329 }
2330
2331 String prefixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/prefix-label");
2332 binding.textinputLayout.setPrefixText(prefixLabel == null ? "" : prefixLabel);
2333
2334 binding.textinput.setText(String.join("\n", field.getValues()));
2335 setupInputType(field.el, binding.textinput, binding.textinputLayout);
2336 }
2337
2338 @Override
2339 public void afterTextChanged(Editable s) {
2340 if (field == null) return;
2341
2342 field.setValues(List.of(s.toString().split("\n")));
2343 }
2344
2345 @Override
2346 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2347
2348 @Override
2349 public void onTextChanged(CharSequence s, int start, int count, int after) { }
2350 }
2351
2352 class WebViewHolder extends ViewHolder<CommandWebviewBinding> {
2353 public WebViewHolder(CommandWebviewBinding binding) { super(binding); }
2354 protected String boundUrl = "";
2355
2356 @Override
2357 public void bind(Item oob) {
2358 setTextOrHide(binding.desc, Optional.fromNullable(oob.el.findChildContent("desc", "jabber:x:oob")));
2359 binding.webview.getSettings().setJavaScriptEnabled(true);
2360 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");
2361 binding.webview.getSettings().setDatabaseEnabled(true);
2362 binding.webview.getSettings().setDomStorageEnabled(true);
2363 binding.webview.setWebChromeClient(new WebChromeClient() {
2364 @Override
2365 public void onProgressChanged(WebView view, int newProgress) {
2366 binding.progressbar.setVisibility(newProgress < 100 ? View.VISIBLE : View.GONE);
2367 binding.progressbar.setProgress(newProgress);
2368 }
2369 });
2370 binding.webview.setWebViewClient(new WebViewClient() {
2371 @Override
2372 public void onPageFinished(WebView view, String url) {
2373 super.onPageFinished(view, url);
2374 mTitle = view.getTitle();
2375 ConversationPagerAdapter.this.notifyDataSetChanged();
2376 }
2377 });
2378 final String url = oob.el.findChildContent("url", "jabber:x:oob");
2379 if (!boundUrl.equals(url)) {
2380 binding.webview.addJavascriptInterface(new JsObject(), "xmpp_xep0050");
2381 binding.webview.loadUrl(url);
2382 boundUrl = url;
2383 }
2384 }
2385
2386 class JsObject {
2387 @JavascriptInterface
2388 public void execute() { execute("execute"); }
2389
2390 @JavascriptInterface
2391 public void execute(String action) {
2392 getView().post(() -> {
2393 actionToWebview = null;
2394 if(CommandSession.this.execute(action)) {
2395 removeSession(CommandSession.this);
2396 }
2397 });
2398 }
2399
2400 @JavascriptInterface
2401 public void preventDefault() {
2402 actionToWebview = binding.webview;
2403 }
2404 }
2405 }
2406
2407 class ProgressBarViewHolder extends ViewHolder<CommandProgressBarBinding> {
2408 public ProgressBarViewHolder(CommandProgressBarBinding binding) { super(binding); }
2409
2410 @Override
2411 public void bind(Item item) {
2412 binding.text.setVisibility(loadingHasBeenLong ? View.VISIBLE : View.GONE);
2413 }
2414 }
2415
2416 class Item {
2417 protected Element el;
2418 protected int viewType;
2419 protected String error = null;
2420
2421 Item(Element el, int viewType) {
2422 this.el = el;
2423 this.viewType = viewType;
2424 }
2425
2426 public boolean validate() {
2427 error = null;
2428 return true;
2429 }
2430 }
2431
2432 class Field extends Item {
2433 Field(eu.siacs.conversations.xmpp.forms.Field el, int viewType) { super(el, viewType); }
2434
2435 @Override
2436 public boolean validate() {
2437 if (!super.validate()) return false;
2438 if (el.findChild("required", "jabber:x:data") == null) return true;
2439 if (getValue().getContent() != null && !getValue().getContent().equals("")) return true;
2440
2441 error = "this value is required";
2442 return false;
2443 }
2444
2445 public String getVar() {
2446 return el.getAttribute("var");
2447 }
2448
2449 public Optional<String> getType() {
2450 return Optional.fromNullable(el.getAttribute("type"));
2451 }
2452
2453 public Optional<String> getLabel() {
2454 String label = el.getAttribute("label");
2455 if (label == null) label = getVar();
2456 return Optional.fromNullable(label);
2457 }
2458
2459 public Optional<String> getDesc() {
2460 return Optional.fromNullable(el.findChildContent("desc", "jabber:x:data"));
2461 }
2462
2463 public Element getValue() {
2464 Element value = el.findChild("value", "jabber:x:data");
2465 if (value == null) {
2466 value = el.addChild("value", "jabber:x:data");
2467 }
2468 return value;
2469 }
2470
2471 public void setValues(List<String> values) {
2472 for(Element child : el.getChildren()) {
2473 if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
2474 el.removeChild(child);
2475 }
2476 }
2477
2478 for (String value : values) {
2479 el.addChild("value", "jabber:x:data").setContent(value);
2480 }
2481 }
2482
2483 public List<String> getValues() {
2484 List<String> values = new ArrayList<>();
2485 for(Element child : el.getChildren()) {
2486 if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
2487 values.add(child.getContent());
2488 }
2489 }
2490 return values;
2491 }
2492
2493 public List<Option> getOptions() {
2494 return Option.forField(el);
2495 }
2496 }
2497
2498 class Cell extends Item {
2499 protected Field reported;
2500
2501 Cell(Field reported, Element item) {
2502 super(item, TYPE_RESULT_CELL);
2503 this.reported = reported;
2504 }
2505 }
2506
2507 protected Field mkField(Element el) {
2508 int viewType = -1;
2509
2510 String formType = responseElement.getAttribute("type");
2511 if (formType != null) {
2512 String fieldType = el.getAttribute("type");
2513 if (fieldType == null) fieldType = "text-single";
2514
2515 if (formType.equals("result") || fieldType.equals("fixed")) {
2516 viewType = TYPE_RESULT_FIELD;
2517 } else if (formType.equals("form")) {
2518 if (fieldType.equals("boolean")) {
2519 if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 1) {
2520 viewType = TYPE_BUTTON_GRID_FIELD;
2521 } else {
2522 viewType = TYPE_CHECKBOX_FIELD;
2523 }
2524 } else if (fieldType.equals("list-single")) {
2525 Element validate = el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2526 if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 1) {
2527 viewType = TYPE_BUTTON_GRID_FIELD;
2528 } else if (Option.forField(el).size() > 9) {
2529 viewType = TYPE_SEARCH_LIST_FIELD;
2530 } else if (el.findChild("value", "jabber:x:data") == null || (validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null)) {
2531 viewType = TYPE_RADIO_EDIT_FIELD;
2532 } else {
2533 viewType = TYPE_SPINNER_FIELD;
2534 }
2535 } else {
2536 viewType = TYPE_TEXT_FIELD;
2537 }
2538 }
2539
2540 return new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), viewType);
2541 }
2542
2543 return null;
2544 }
2545
2546 protected Item mkItem(Element el, int pos) {
2547 int viewType = -1;
2548
2549 if (response != null && response.getType() == IqPacket.TYPE.RESULT) {
2550 if (el.getName().equals("note")) {
2551 viewType = TYPE_NOTE;
2552 } else if (el.getNamespace().equals("jabber:x:oob")) {
2553 viewType = TYPE_WEB;
2554 } else if (el.getName().equals("instructions") && el.getNamespace().equals("jabber:x:data")) {
2555 viewType = TYPE_NOTE;
2556 } else if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data")) {
2557 Field field = mkField(el);
2558 if (field != null) {
2559 items.put(pos, field);
2560 return field;
2561 }
2562 }
2563 } else if (response != null) {
2564 viewType = TYPE_ERROR;
2565 }
2566
2567 Item item = new Item(el, viewType);
2568 items.put(pos, item);
2569 return item;
2570 }
2571
2572 class ActionsAdapter extends ArrayAdapter<Pair<String, String>> {
2573 protected Context ctx;
2574
2575 public ActionsAdapter(Context ctx) {
2576 super(ctx, R.layout.simple_list_item);
2577 this.ctx = ctx;
2578 }
2579
2580 @Override
2581 public View getView(int position, View convertView, ViewGroup parent) {
2582 View v = super.getView(position, convertView, parent);
2583 TextView tv = (TextView) v.findViewById(android.R.id.text1);
2584 tv.setGravity(Gravity.CENTER);
2585 tv.setText(getItem(position).second);
2586 int resId = ctx.getResources().getIdentifier("action_" + getItem(position).first, "string" , ctx.getPackageName());
2587 if (resId != 0 && getItem(position).second.equals(getItem(position).first)) tv.setText(ctx.getResources().getString(resId));
2588 tv.setTextColor(ContextCompat.getColor(ctx, R.color.white));
2589 tv.setBackgroundColor(UIHelper.getColorForName(getItem(position).first));
2590 return v;
2591 }
2592
2593 public int getPosition(String s) {
2594 for(int i = 0; i < getCount(); i++) {
2595 if (getItem(i).first.equals(s)) return i;
2596 }
2597 return -1;
2598 }
2599
2600 public int countExceptCancel() {
2601 int count = 0;
2602 for(int i = 0; i < getCount(); i++) {
2603 if (!getItem(i).first.equals("cancel")) count++;
2604 }
2605 return count;
2606 }
2607
2608 public void clearExceptCancel() {
2609 Pair<String,String> cancelItem = null;
2610 for(int i = 0; i < getCount(); i++) {
2611 if (getItem(i).first.equals("cancel")) cancelItem = getItem(i);
2612 }
2613 clear();
2614 if (cancelItem != null) add(cancelItem);
2615 }
2616 }
2617
2618 final int TYPE_ERROR = 1;
2619 final int TYPE_NOTE = 2;
2620 final int TYPE_WEB = 3;
2621 final int TYPE_RESULT_FIELD = 4;
2622 final int TYPE_TEXT_FIELD = 5;
2623 final int TYPE_CHECKBOX_FIELD = 6;
2624 final int TYPE_SPINNER_FIELD = 7;
2625 final int TYPE_RADIO_EDIT_FIELD = 8;
2626 final int TYPE_RESULT_CELL = 9;
2627 final int TYPE_PROGRESSBAR = 10;
2628 final int TYPE_SEARCH_LIST_FIELD = 11;
2629 final int TYPE_ITEM_CARD = 12;
2630 final int TYPE_BUTTON_GRID_FIELD = 13;
2631
2632 protected boolean executing = false;
2633 protected boolean loading = false;
2634 protected boolean loadingHasBeenLong = false;
2635 protected Timer loadingTimer = new Timer();
2636 protected String mTitle;
2637 protected String mNode;
2638 protected CommandPageBinding mBinding = null;
2639 protected IqPacket response = null;
2640 protected Element responseElement = null;
2641 protected boolean expectingRemoval = false;
2642 protected List<Field> reported = null;
2643 protected SparseArray<Item> items = new SparseArray<>();
2644 protected XmppConnectionService xmppConnectionService;
2645 protected ActionsAdapter actionsAdapter;
2646 protected GridLayoutManager layoutManager;
2647 protected WebView actionToWebview = null;
2648 protected int fillableFieldCount = 0;
2649 protected IqPacket pendingResponsePacket = null;
2650 protected boolean waitingForRefresh = false;
2651
2652 CommandSession(String title, String node, XmppConnectionService xmppConnectionService) {
2653 loading();
2654 mTitle = title;
2655 mNode = node;
2656 this.xmppConnectionService = xmppConnectionService;
2657 if (mPager != null) setupLayoutManager();
2658 actionsAdapter = new ActionsAdapter(xmppConnectionService);
2659 actionsAdapter.registerDataSetObserver(new DataSetObserver() {
2660 @Override
2661 public void onChanged() {
2662 if (mBinding == null) return;
2663
2664 mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
2665 }
2666
2667 @Override
2668 public void onInvalidated() {}
2669 });
2670 }
2671
2672 public String getTitle() {
2673 return mTitle;
2674 }
2675
2676 public String getNode() {
2677 return mNode;
2678 }
2679
2680 public void updateWithResponse(final IqPacket iq) {
2681 if (getView() != null && getView().isAttachedToWindow()) {
2682 getView().post(() -> updateWithResponseUiThread(iq));
2683 } else {
2684 pendingResponsePacket = iq;
2685 }
2686 }
2687
2688 protected void updateWithResponseUiThread(final IqPacket iq) {
2689 Timer oldTimer = this.loadingTimer;
2690 this.loadingTimer = new Timer();
2691 oldTimer.cancel();
2692 this.executing = false;
2693 this.loading = false;
2694 this.loadingHasBeenLong = false;
2695 this.responseElement = null;
2696 this.fillableFieldCount = 0;
2697 this.reported = null;
2698 this.response = iq;
2699 this.items.clear();
2700 this.actionsAdapter.clear();
2701 layoutManager.setSpanCount(1);
2702
2703 boolean actionsCleared = false;
2704 Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
2705 if (iq.getType() == IqPacket.TYPE.RESULT && command != null) {
2706 if (mNode.equals("jabber:iq:register") && command.getAttribute("status") != null && command.getAttribute("status").equals("completed")) {
2707 xmppConnectionService.createContact(getAccount().getRoster().getContact(iq.getFrom()), true);
2708 }
2709
2710 if (xmppConnectionService.isOnboarding() && mNode.equals("jabber:iq:register") && !"canceled".equals(command.getAttribute("status")) && xmppConnectionService.getPreferences().contains("onboarding_action")) {
2711 xmppConnectionService.getPreferences().edit().putBoolean("onboarding_continued", true).commit();
2712 }
2713
2714 Element actions = command.findChild("actions", "http://jabber.org/protocol/commands");
2715 if (actions != null) {
2716 for (Element action : actions.getChildren()) {
2717 if (!"http://jabber.org/protocol/commands".equals(action.getNamespace())) continue;
2718 if ("execute".equals(action.getName())) continue;
2719
2720 actionsAdapter.add(Pair.create(action.getName(), action.getName()));
2721 }
2722 }
2723
2724 for (Element el : command.getChildren()) {
2725 if ("x".equals(el.getName()) && "jabber:x:data".equals(el.getNamespace())) {
2726 Data form = Data.parse(el);
2727 String title = form.getTitle();
2728 if (title != null) {
2729 mTitle = title;
2730 ConversationPagerAdapter.this.notifyDataSetChanged();
2731 }
2732
2733 if ("result".equals(el.getAttribute("type")) || "form".equals(el.getAttribute("type"))) {
2734 this.responseElement = el;
2735 setupReported(el.findChild("reported", "jabber:x:data"));
2736 if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager());
2737 }
2738
2739 eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
2740 if (actionList != null) {
2741 actionsAdapter.clear();
2742
2743 for (Option action : actionList.getOptions()) {
2744 actionsAdapter.add(Pair.create(action.getValue(), action.toString()));
2745 }
2746 }
2747
2748 String fillableFieldType = null;
2749 String fillableFieldValue = null;
2750 for (eu.siacs.conversations.xmpp.forms.Field field : form.getFields()) {
2751 if ((field.getType() == null || (!field.getType().equals("hidden") && !field.getType().equals("fixed"))) && field.getFieldName() != null && !field.getFieldName().equals("http://jabber.org/protocol/commands#actions")) {
2752 fillableFieldType = field.getType();
2753 fillableFieldValue = field.getValue();
2754 fillableFieldCount++;
2755 }
2756 }
2757
2758 if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 2 && fillableFieldType != null && (fillableFieldType.equals("list-single") || (fillableFieldType.equals("boolean") && fillableFieldValue == null))) {
2759 actionsCleared = true;
2760 actionsAdapter.clearExceptCancel();
2761 }
2762 break;
2763 }
2764 if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
2765 String url = el.findChildContent("url", "jabber:x:oob");
2766 if (url != null) {
2767 String scheme = Uri.parse(url).getScheme();
2768 if (scheme.equals("http") || scheme.equals("https")) {
2769 this.responseElement = el;
2770 break;
2771 }
2772 if (scheme.equals("xmpp")) {
2773 expectingRemoval = true;
2774 final Intent intent = new Intent(getView().getContext(), UriHandlerActivity.class);
2775 intent.setAction(Intent.ACTION_VIEW);
2776 intent.setData(Uri.parse(url));
2777 getView().getContext().startActivity(intent);
2778 break;
2779 }
2780 }
2781 }
2782 if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2783 this.responseElement = el;
2784 break;
2785 }
2786 }
2787
2788 if (responseElement == null && command.getAttribute("status") != null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
2789 if ("jabber:iq:register".equals(mNode) && "canceled".equals(command.getAttribute("status"))) {
2790 if (xmppConnectionService.isOnboarding()) {
2791 if (xmppConnectionService.getPreferences().contains("onboarding_action")) {
2792 xmppConnectionService.deleteAccount(getAccount());
2793 } else {
2794 if (xmppConnectionService.getPreferences().getBoolean("onboarding_continued", false)) {
2795 removeSession(this);
2796 return;
2797 } else {
2798 xmppConnectionService.getPreferences().edit().putString("onboarding_action", "cancel").commit();
2799 xmppConnectionService.deleteAccount(getAccount());
2800 }
2801 }
2802 }
2803 xmppConnectionService.archiveConversation(Conversation.this);
2804 }
2805
2806 expectingRemoval = true;
2807 removeSession(this);
2808 return;
2809 }
2810
2811 if ("executing".equals(command.getAttribute("status")) && actionsAdapter.countExceptCancel() < 1 && !actionsCleared) {
2812 // No actions have been given, but we are not done?
2813 // This is probably a spec violation, but we should do *something*
2814 actionsAdapter.add(Pair.create("execute", "execute"));
2815 }
2816
2817 if (!actionsAdapter.isEmpty() || fillableFieldCount > 0) {
2818 if ("completed".equals(command.getAttribute("status")) || "canceled".equals(command.getAttribute("status"))) {
2819 actionsAdapter.add(Pair.create("close", "close"));
2820 } else if (actionsAdapter.getPosition("cancel") < 0 && !xmppConnectionService.isOnboarding()) {
2821 actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
2822 }
2823 }
2824 }
2825
2826 if (actionsAdapter.isEmpty()) {
2827 actionsAdapter.add(Pair.create("close", "close"));
2828 }
2829
2830 actionsAdapter.sort((x, y) -> {
2831 if (x.first.equals("cancel")) return -1;
2832 if (y.first.equals("cancel")) return 1;
2833 if (x.first.equals("prev") && xmppConnectionService.isOnboarding()) return -1;
2834 if (y.first.equals("prev") && xmppConnectionService.isOnboarding()) return 1;
2835 return 0;
2836 });
2837
2838 Data dataForm = null;
2839 if (responseElement != null && responseElement.getName().equals("x") && responseElement.getNamespace().equals("jabber:x:data")) dataForm = Data.parse(responseElement);
2840 if (mNode.equals("jabber:iq:register") &&
2841 xmppConnectionService.getPreferences().contains("onboarding_action") &&
2842 dataForm != null && dataForm.getFieldByName("gateway-jid") != null) {
2843
2844
2845 dataForm.put("gateway-jid", xmppConnectionService.getPreferences().getString("onboarding_action", ""));
2846 execute();
2847 }
2848 xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
2849 notifyDataSetChanged();
2850 }
2851
2852 protected void setupReported(Element el) {
2853 if (el == null) {
2854 reported = null;
2855 return;
2856 }
2857
2858 reported = new ArrayList<>();
2859 for (Element fieldEl : el.getChildren()) {
2860 if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
2861 reported.add(mkField(fieldEl));
2862 }
2863 }
2864
2865 @Override
2866 public int getItemCount() {
2867 if (loading) return 1;
2868 if (response == null) return 0;
2869 if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
2870 int i = 0;
2871 for (Element el : responseElement.getChildren()) {
2872 if (!el.getNamespace().equals("jabber:x:data")) continue;
2873 if (el.getName().equals("title")) continue;
2874 if (el.getName().equals("field")) {
2875 String type = el.getAttribute("type");
2876 if (type != null && type.equals("hidden")) continue;
2877 if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
2878 }
2879
2880 if (el.getName().equals("reported") || el.getName().equals("item")) {
2881 if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
2882 if (el.getName().equals("reported")) continue;
2883 i += 1;
2884 } else {
2885 if (reported != null) i += reported.size();
2886 }
2887 continue;
2888 }
2889
2890 i++;
2891 }
2892 return i;
2893 }
2894 return 1;
2895 }
2896
2897 public Item getItem(int position) {
2898 if (loading) return new Item(null, TYPE_PROGRESSBAR);
2899 if (items.get(position) != null) return items.get(position);
2900 if (response == null) return null;
2901
2902 if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null) {
2903 if (responseElement.getNamespace().equals("jabber:x:data")) {
2904 int i = 0;
2905 for (Element el : responseElement.getChildren()) {
2906 if (!el.getNamespace().equals("jabber:x:data")) continue;
2907 if (el.getName().equals("title")) continue;
2908 if (el.getName().equals("field")) {
2909 String type = el.getAttribute("type");
2910 if (type != null && type.equals("hidden")) continue;
2911 if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
2912 }
2913
2914 if (el.getName().equals("reported") || el.getName().equals("item")) {
2915 Cell cell = null;
2916
2917 if (reported != null) {
2918 if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
2919 if (el.getName().equals("reported")) continue;
2920 if (i == position) {
2921 items.put(position, new Item(el, TYPE_ITEM_CARD));
2922 return items.get(position);
2923 }
2924 } else {
2925 if (reported.size() > position - i) {
2926 Field reportedField = reported.get(position - i);
2927 Element itemField = null;
2928 if (el.getName().equals("item")) {
2929 for (Element subel : el.getChildren()) {
2930 if (subel.getAttribute("var").equals(reportedField.getVar())) {
2931 itemField = subel;
2932 break;
2933 }
2934 }
2935 }
2936 cell = new Cell(reportedField, itemField);
2937 } else {
2938 i += reported.size();
2939 continue;
2940 }
2941 }
2942 }
2943
2944 if (cell != null) {
2945 items.put(position, cell);
2946 return cell;
2947 }
2948 }
2949
2950 if (i < position) {
2951 i++;
2952 continue;
2953 }
2954
2955 return mkItem(el, position);
2956 }
2957 }
2958 }
2959
2960 return mkItem(responseElement == null ? response : responseElement, position);
2961 }
2962
2963 @Override
2964 public int getItemViewType(int position) {
2965 return getItem(position).viewType;
2966 }
2967
2968 @Override
2969 public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
2970 switch(viewType) {
2971 case TYPE_ERROR: {
2972 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2973 return new ErrorViewHolder(binding);
2974 }
2975 case TYPE_NOTE: {
2976 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2977 return new NoteViewHolder(binding);
2978 }
2979 case TYPE_WEB: {
2980 CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
2981 return new WebViewHolder(binding);
2982 }
2983 case TYPE_RESULT_FIELD: {
2984 CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
2985 return new ResultFieldViewHolder(binding);
2986 }
2987 case TYPE_RESULT_CELL: {
2988 CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
2989 return new ResultCellViewHolder(binding);
2990 }
2991 case TYPE_ITEM_CARD: {
2992 CommandItemCardBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_item_card, container, false);
2993 return new ItemCardViewHolder(binding);
2994 }
2995 case TYPE_CHECKBOX_FIELD: {
2996 CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
2997 return new CheckboxFieldViewHolder(binding);
2998 }
2999 case TYPE_SEARCH_LIST_FIELD: {
3000 CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
3001 return new SearchListFieldViewHolder(binding);
3002 }
3003 case TYPE_RADIO_EDIT_FIELD: {
3004 CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
3005 return new RadioEditFieldViewHolder(binding);
3006 }
3007 case TYPE_SPINNER_FIELD: {
3008 CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
3009 return new SpinnerFieldViewHolder(binding);
3010 }
3011 case TYPE_BUTTON_GRID_FIELD: {
3012 CommandButtonGridFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_button_grid_field, container, false);
3013 return new ButtonGridFieldViewHolder(binding);
3014 }
3015 case TYPE_TEXT_FIELD: {
3016 CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
3017 return new TextFieldViewHolder(binding);
3018 }
3019 case TYPE_PROGRESSBAR: {
3020 CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
3021 return new ProgressBarViewHolder(binding);
3022 }
3023 default:
3024 if (expectingRemoval) {
3025 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3026 return new NoteViewHolder(binding);
3027 }
3028
3029 throw new IllegalArgumentException("Unknown viewType: " + viewType + " based on: " + response + ", " + responseElement + ", " + expectingRemoval);
3030 }
3031 }
3032
3033 @Override
3034 public void onBindViewHolder(ViewHolder viewHolder, int position) {
3035 viewHolder.bind(getItem(position));
3036 }
3037
3038 public View getView() {
3039 if (mBinding == null) return null;
3040 return mBinding.getRoot();
3041 }
3042
3043 public boolean validate() {
3044 int count = getItemCount();
3045 boolean isValid = true;
3046 for (int i = 0; i < count; i++) {
3047 boolean oneIsValid = getItem(i).validate();
3048 isValid = isValid && oneIsValid;
3049 }
3050 notifyDataSetChanged();
3051 return isValid;
3052 }
3053
3054 public boolean execute() {
3055 return execute("execute");
3056 }
3057
3058 public boolean execute(int actionPosition) {
3059 return execute(actionsAdapter.getItem(actionPosition).first);
3060 }
3061
3062 public synchronized boolean execute(String action) {
3063 if (!"cancel".equals(action) && executing) {
3064 loadingHasBeenLong = true;
3065 notifyDataSetChanged();
3066 return false;
3067 }
3068 if (!action.equals("cancel") && !action.equals("prev") && !validate()) return false;
3069
3070 if (response == null) return true;
3071 Element command = response.findChild("command", "http://jabber.org/protocol/commands");
3072 if (command == null) return true;
3073 String status = command.getAttribute("status");
3074 if (status == null || (!status.equals("executing") && !action.equals("prev"))) return true;
3075
3076 if (actionToWebview != null && !action.equals("cancel") && Build.VERSION.SDK_INT >= 23) {
3077 actionToWebview.postWebMessage(new WebMessage("xmpp_xep0050/" + action), Uri.parse("*"));
3078 return false;
3079 }
3080
3081 final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
3082 packet.setTo(response.getFrom());
3083 final Element c = packet.addChild("command", Namespace.COMMANDS);
3084 c.setAttribute("node", mNode);
3085 c.setAttribute("sessionid", command.getAttribute("sessionid"));
3086
3087 String formType = responseElement == null ? null : responseElement.getAttribute("type");
3088 if (!action.equals("cancel") &&
3089 !action.equals("prev") &&
3090 responseElement != null &&
3091 responseElement.getName().equals("x") &&
3092 responseElement.getNamespace().equals("jabber:x:data") &&
3093 formType != null && formType.equals("form")) {
3094
3095 Data form = Data.parse(responseElement);
3096 eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
3097 if (actionList != null) {
3098 actionList.setValue(action);
3099 c.setAttribute("action", "execute");
3100 }
3101
3102 if (mNode.equals("jabber:iq:register") && xmppConnectionService.isOnboarding() && form.getFieldByName("gateway-jid") != null) {
3103 if (form.getValue("gateway-jid") == null) {
3104 xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
3105 } else {
3106 xmppConnectionService.getPreferences().edit().putString("onboarding_action", form.getValue("gateway-jid")).commit();
3107 }
3108 }
3109
3110 responseElement.setAttribute("type", "submit");
3111 Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
3112 if (rsm != null) {
3113 Element max = new Element("max", "http://jabber.org/protocol/rsm");
3114 max.setContent("1000");
3115 rsm.addChild(max);
3116 }
3117
3118 c.addChild(responseElement);
3119 }
3120
3121 if (c.getAttribute("action") == null) c.setAttribute("action", action);
3122
3123 executing = true;
3124 xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
3125 updateWithResponse(iq);
3126 }, 120L);
3127
3128 loading();
3129 return false;
3130 }
3131
3132 public void refresh() {
3133 synchronized(this) {
3134 if (waitingForRefresh) notifyDataSetChanged();
3135 }
3136 }
3137
3138 protected void loading() {
3139 View v = getView();
3140 try {
3141 loadingTimer.schedule(new TimerTask() {
3142 @Override
3143 public void run() {
3144 View v2 = getView();
3145 loading = true;
3146
3147 loadingTimer.schedule(new TimerTask() {
3148 @Override
3149 public void run() {
3150 loadingHasBeenLong = true;
3151 if (v == null && v2 == null) return;
3152 (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3153 }
3154 }, 3000);
3155
3156 if (v == null && v2 == null) return;
3157 (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3158 }
3159 }, 500);
3160 } catch (final IllegalStateException e) { }
3161 }
3162
3163 protected GridLayoutManager setupLayoutManager() {
3164 int spanCount = 1;
3165
3166 Context ctx = mPager == null ? getView().getContext() : mPager.getContext();
3167 if (reported != null) {
3168 float screenWidth = ctx.getResources().getDisplayMetrics().widthPixels;
3169 TextPaint paint = ((TextView) LayoutInflater.from(mPager.getContext()).inflate(R.layout.command_result_cell, null)).getPaint();
3170 float tableHeaderWidth = reported.stream().reduce(
3171 0f,
3172 (total, field) -> total + StaticLayout.getDesiredWidth(field.getLabel().or("--------") + "\t", paint),
3173 (a, b) -> a + b
3174 );
3175
3176 spanCount = tableHeaderWidth > 0.59 * screenWidth ? 1 : this.reported.size();
3177 }
3178
3179 if (layoutManager != null && layoutManager.getSpanCount() != spanCount) {
3180 items.clear();
3181 notifyDataSetChanged();
3182 }
3183
3184 layoutManager = new GridLayoutManager(ctx, spanCount);
3185 layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
3186 @Override
3187 public int getSpanSize(int position) {
3188 if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
3189 return 1;
3190 }
3191 });
3192 return layoutManager;
3193 }
3194
3195 protected void setBinding(CommandPageBinding b) {
3196 mBinding = b;
3197 // https://stackoverflow.com/a/32350474/8611
3198 mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
3199 @Override
3200 public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
3201 if(rv.getChildCount() > 0) {
3202 int[] location = new int[2];
3203 rv.getLocationOnScreen(location);
3204 View childView = rv.findChildViewUnder(e.getX(), e.getY());
3205 if (childView instanceof ViewGroup) {
3206 childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
3207 }
3208 int action = e.getAction();
3209 switch (action) {
3210 case MotionEvent.ACTION_DOWN:
3211 if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(1)) || childView instanceof WebView) {
3212 rv.requestDisallowInterceptTouchEvent(true);
3213 }
3214 case MotionEvent.ACTION_UP:
3215 if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(-11)) || childView instanceof WebView) {
3216 rv.requestDisallowInterceptTouchEvent(true);
3217 }
3218 }
3219 }
3220
3221 return false;
3222 }
3223
3224 @Override
3225 public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }
3226
3227 @Override
3228 public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
3229 });
3230 mBinding.form.setLayoutManager(setupLayoutManager());
3231 mBinding.form.setAdapter(this);
3232 mBinding.actions.setAdapter(actionsAdapter);
3233 mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
3234 if (execute(pos)) {
3235 removeSession(CommandSession.this);
3236 }
3237 });
3238
3239 actionsAdapter.notifyDataSetChanged();
3240
3241 if (pendingResponsePacket != null) {
3242 final IqPacket pending = pendingResponsePacket;
3243 pendingResponsePacket = null;
3244 updateWithResponseUiThread(pending);
3245 }
3246 }
3247
3248 private Drawable getDrawableForSVG(SVG svg, Element svgElement, int size) {
3249 if (svgElement != null && svgElement.getChildren().size() == 1 && svgElement.getChildren().get(0).getName().equals("image")) {
3250 return getDrawableForUrl(svgElement.getChildren().get(0).getAttribute("href"));
3251 } else {
3252 return xmppConnectionService.getFileBackend().drawSVG(svg, size);
3253 }
3254 }
3255
3256 private Drawable getDrawableForUrl(final String url) {
3257 final LruCache<String, Drawable> cache = xmppConnectionService.getDrawableCache();
3258 final HttpConnectionManager httpManager = xmppConnectionService.getHttpConnectionManager();
3259 final Drawable d = cache.get(url);
3260 if (Build.VERSION.SDK_INT >= 28 && d instanceof AnimatedImageDrawable) ((AnimatedImageDrawable) d).start();
3261 if (d == null) {
3262 synchronized (CommandSession.this) {
3263 waitingForRefresh = true;
3264 }
3265 int size = (int)(xmppConnectionService.getResources().getDisplayMetrics().density * 288);
3266 Message dummy = new Message(Conversation.this, url, Message.ENCRYPTION_NONE);
3267 dummy.setFileParams(new Message.FileParams(url));
3268 httpManager.createNewDownloadConnection(dummy, true, (file) -> {
3269 if (file == null) {
3270 dummy.getTransferable().start();
3271 } else {
3272 try {
3273 xmppConnectionService.getFileBackend().getThumbnail(file, xmppConnectionService.getResources(), size, false, url);
3274 } catch (final Exception e) { }
3275 }
3276 });
3277 }
3278 return d;
3279 }
3280
3281 public View inflateUi(Context context, Consumer<ConversationPage> remover) {
3282 CommandPageBinding binding = DataBindingUtil.inflate(LayoutInflater.from(context), R.layout.command_page, null, false);
3283 setBinding(binding);
3284 return binding.getRoot();
3285 }
3286
3287 // https://stackoverflow.com/a/36037991/8611
3288 private View findViewAt(ViewGroup viewGroup, float x, float y) {
3289 for(int i = 0; i < viewGroup.getChildCount(); i++) {
3290 View child = viewGroup.getChildAt(i);
3291 if (child instanceof ViewGroup && !(child instanceof AbsListView) && !(child instanceof WebView)) {
3292 View foundView = findViewAt((ViewGroup) child, x, y);
3293 if (foundView != null && foundView.isShown()) {
3294 return foundView;
3295 }
3296 } else {
3297 int[] location = new int[2];
3298 child.getLocationOnScreen(location);
3299 Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
3300 if (rect.contains((int)x, (int)y)) {
3301 return child;
3302 }
3303 }
3304 }
3305
3306 return null;
3307 }
3308 }
3309 }
3310
3311 public static class Thread {
3312 protected Message subject = null;
3313 protected Message first = null;
3314 protected Message last = null;
3315
3316 protected Thread() {}
3317
3318 public String getSubject() {
3319 if (subject == null) return null;
3320
3321 return subject.getSubject();
3322 }
3323 }
3324}