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