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