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