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