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 binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_GRAVITY);
2063 String suffixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/suffix-label");
2064 if (suffixLabel != null) {
2065 binding.textinputLayout.setSuffixText(suffixLabel);
2066 binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_TEXT_END);
2067 }
2068
2069 String prefixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/prefix-label");
2070 if (prefixLabel != null) {
2071 binding.textinputLayout.setPrefixText(prefixLabel);
2072 }
2073
2074 mValue = field.getValue();
2075 binding.textinput.setText(mValue.getContent());
2076 setupInputType(field.el, binding.textinput, binding.textinputLayout);
2077 }
2078
2079 @Override
2080 public void afterTextChanged(Editable s) {
2081 if (mValue == null) return;
2082
2083 mValue.setContent(s.toString());
2084 }
2085
2086 @Override
2087 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2088
2089 @Override
2090 public void onTextChanged(CharSequence s, int start, int count, int after) { }
2091 }
2092
2093 class WebViewHolder extends ViewHolder<CommandWebviewBinding> {
2094 public WebViewHolder(CommandWebviewBinding binding) { super(binding); }
2095 protected String boundUrl = "";
2096
2097 @Override
2098 public void bind(Item oob) {
2099 setTextOrHide(binding.desc, Optional.fromNullable(oob.el.findChildContent("desc", "jabber:x:oob")));
2100 binding.webview.getSettings().setJavaScriptEnabled(true);
2101 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");
2102 binding.webview.getSettings().setDatabaseEnabled(true);
2103 binding.webview.getSettings().setDomStorageEnabled(true);
2104 binding.webview.setWebChromeClient(new WebChromeClient() {
2105 @Override
2106 public void onProgressChanged(WebView view, int newProgress) {
2107 binding.progressbar.setVisibility(newProgress < 100 ? View.VISIBLE : View.GONE);
2108 binding.progressbar.setProgress(newProgress);
2109 }
2110 });
2111 binding.webview.setWebViewClient(new WebViewClient() {
2112 @Override
2113 public void onPageFinished(WebView view, String url) {
2114 super.onPageFinished(view, url);
2115 mTitle = view.getTitle();
2116 ConversationPagerAdapter.this.notifyDataSetChanged();
2117 }
2118 });
2119 final String url = oob.el.findChildContent("url", "jabber:x:oob");
2120 if (!boundUrl.equals(url)) {
2121 binding.webview.addJavascriptInterface(new JsObject(), "xmpp_xep0050");
2122 binding.webview.loadUrl(url);
2123 boundUrl = url;
2124 }
2125 }
2126
2127 class JsObject {
2128 @JavascriptInterface
2129 public void execute() { execute("execute"); }
2130
2131 @JavascriptInterface
2132 public void execute(String action) {
2133 getView().post(() -> {
2134 actionToWebview = null;
2135 if(CommandSession.this.execute(action)) {
2136 removeSession(CommandSession.this);
2137 }
2138 });
2139 }
2140
2141 @JavascriptInterface
2142 public void preventDefault() {
2143 actionToWebview = binding.webview;
2144 }
2145 }
2146 }
2147
2148 class ProgressBarViewHolder extends ViewHolder<CommandProgressBarBinding> {
2149 public ProgressBarViewHolder(CommandProgressBarBinding binding) { super(binding); }
2150
2151 @Override
2152 public void bind(Item item) { }
2153 }
2154
2155 class Item {
2156 protected Element el;
2157 protected int viewType;
2158 protected String error = null;
2159
2160 Item(Element el, int viewType) {
2161 this.el = el;
2162 this.viewType = viewType;
2163 }
2164
2165 public boolean validate() {
2166 error = null;
2167 return true;
2168 }
2169 }
2170
2171 class Field extends Item {
2172 Field(eu.siacs.conversations.xmpp.forms.Field el, int viewType) { super(el, viewType); }
2173
2174 @Override
2175 public boolean validate() {
2176 if (!super.validate()) return false;
2177 if (el.findChild("required", "jabber:x:data") == null) return true;
2178 if (getValue().getContent() != null && !getValue().getContent().equals("")) return true;
2179
2180 error = "this value is required";
2181 return false;
2182 }
2183
2184 public String getVar() {
2185 return el.getAttribute("var");
2186 }
2187
2188 public Optional<String> getType() {
2189 return Optional.fromNullable(el.getAttribute("type"));
2190 }
2191
2192 public Optional<String> getLabel() {
2193 String label = el.getAttribute("label");
2194 if (label == null) label = getVar();
2195 return Optional.fromNullable(label);
2196 }
2197
2198 public Optional<String> getDesc() {
2199 return Optional.fromNullable(el.findChildContent("desc", "jabber:x:data"));
2200 }
2201
2202 public Element getValue() {
2203 Element value = el.findChild("value", "jabber:x:data");
2204 if (value == null) {
2205 value = el.addChild("value", "jabber:x:data");
2206 }
2207 return value;
2208 }
2209
2210 public List<Option> getOptions() {
2211 return Option.forField(el);
2212 }
2213 }
2214
2215 class Cell extends Item {
2216 protected Field reported;
2217
2218 Cell(Field reported, Element item) {
2219 super(item, TYPE_RESULT_CELL);
2220 this.reported = reported;
2221 }
2222 }
2223
2224 protected Field mkField(Element el) {
2225 int viewType = -1;
2226
2227 String formType = responseElement.getAttribute("type");
2228 if (formType != null) {
2229 String fieldType = el.getAttribute("type");
2230 if (fieldType == null) fieldType = "text-single";
2231
2232 if (formType.equals("result") || fieldType.equals("fixed")) {
2233 viewType = TYPE_RESULT_FIELD;
2234 } else if (formType.equals("form")) {
2235 if (fieldType.equals("boolean")) {
2236 if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 1) {
2237 viewType = TYPE_BUTTON_GRID_FIELD;
2238 } else {
2239 viewType = TYPE_CHECKBOX_FIELD;
2240 }
2241 } else if (fieldType.equals("list-single")) {
2242 Element validate = el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2243 if (Option.forField(el).size() > 9) {
2244 viewType = TYPE_SEARCH_LIST_FIELD;
2245 } else if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 1) {
2246 viewType = TYPE_BUTTON_GRID_FIELD;
2247 } else if (el.findChild("value", "jabber:x:data") == null || (validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null)) {
2248 viewType = TYPE_RADIO_EDIT_FIELD;
2249 } else {
2250 viewType = TYPE_SPINNER_FIELD;
2251 }
2252 } else {
2253 viewType = TYPE_TEXT_FIELD;
2254 }
2255 }
2256
2257 return new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), viewType);
2258 }
2259
2260 return null;
2261 }
2262
2263 protected Item mkItem(Element el, int pos) {
2264 int viewType = -1;
2265
2266 if (response != null && response.getType() == IqPacket.TYPE.RESULT) {
2267 if (el.getName().equals("note")) {
2268 viewType = TYPE_NOTE;
2269 } else if (el.getNamespace().equals("jabber:x:oob")) {
2270 viewType = TYPE_WEB;
2271 } else if (el.getName().equals("instructions") && el.getNamespace().equals("jabber:x:data")) {
2272 viewType = TYPE_NOTE;
2273 } else if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data")) {
2274 Field field = mkField(el);
2275 if (field != null) {
2276 items.put(pos, field);
2277 return field;
2278 }
2279 }
2280 } else if (response != null) {
2281 viewType = TYPE_ERROR;
2282 }
2283
2284 Item item = new Item(el, viewType);
2285 items.put(pos, item);
2286 return item;
2287 }
2288
2289 class ActionsAdapter extends ArrayAdapter<Pair<String, String>> {
2290 protected Context ctx;
2291
2292 public ActionsAdapter(Context ctx) {
2293 super(ctx, R.layout.simple_list_item);
2294 this.ctx = ctx;
2295 }
2296
2297 @Override
2298 public View getView(int position, View convertView, ViewGroup parent) {
2299 View v = super.getView(position, convertView, parent);
2300 TextView tv = (TextView) v.findViewById(android.R.id.text1);
2301 tv.setGravity(Gravity.CENTER);
2302 tv.setText(getItem(position).second);
2303 int resId = ctx.getResources().getIdentifier("action_" + getItem(position).first, "string" , ctx.getPackageName());
2304 if (resId != 0 && getItem(position).second.equals(getItem(position).first)) tv.setText(ctx.getResources().getString(resId));
2305 tv.setTextColor(ContextCompat.getColor(ctx, R.color.white));
2306 tv.setBackgroundColor(UIHelper.getColorForName(getItem(position).first));
2307 return v;
2308 }
2309
2310 public int getPosition(String s) {
2311 for(int i = 0; i < getCount(); i++) {
2312 if (getItem(i).first.equals(s)) return i;
2313 }
2314 return -1;
2315 }
2316
2317 public int countExceptCancel() {
2318 int count = 0;
2319 for(int i = 0; i < getCount(); i++) {
2320 if (!getItem(i).first.equals("cancel")) count++;
2321 }
2322 return count;
2323 }
2324
2325 public void clearExceptCancel() {
2326 Pair<String,String> cancelItem = null;
2327 for(int i = 0; i < getCount(); i++) {
2328 if (getItem(i).first.equals("cancel")) cancelItem = getItem(i);
2329 }
2330 clear();
2331 if (cancelItem != null) add(cancelItem);
2332 }
2333 }
2334
2335 final int TYPE_ERROR = 1;
2336 final int TYPE_NOTE = 2;
2337 final int TYPE_WEB = 3;
2338 final int TYPE_RESULT_FIELD = 4;
2339 final int TYPE_TEXT_FIELD = 5;
2340 final int TYPE_CHECKBOX_FIELD = 6;
2341 final int TYPE_SPINNER_FIELD = 7;
2342 final int TYPE_RADIO_EDIT_FIELD = 8;
2343 final int TYPE_RESULT_CELL = 9;
2344 final int TYPE_PROGRESSBAR = 10;
2345 final int TYPE_SEARCH_LIST_FIELD = 11;
2346 final int TYPE_ITEM_CARD = 12;
2347 final int TYPE_BUTTON_GRID_FIELD = 13;
2348
2349 protected boolean loading = false;
2350 protected Timer loadingTimer = new Timer();
2351 protected String mTitle;
2352 protected String mNode;
2353 protected CommandPageBinding mBinding = null;
2354 protected IqPacket response = null;
2355 protected Element responseElement = null;
2356 protected List<Field> reported = null;
2357 protected SparseArray<Item> items = new SparseArray<>();
2358 protected XmppConnectionService xmppConnectionService;
2359 protected ActionsAdapter actionsAdapter;
2360 protected GridLayoutManager layoutManager;
2361 protected WebView actionToWebview = null;
2362 protected int fillableFieldCount = 0;
2363
2364 CommandSession(String title, String node, XmppConnectionService xmppConnectionService) {
2365 loading();
2366 mTitle = title;
2367 mNode = node;
2368 this.xmppConnectionService = xmppConnectionService;
2369 if (mPager != null) setupLayoutManager();
2370 actionsAdapter = new ActionsAdapter(xmppConnectionService);
2371 actionsAdapter.registerDataSetObserver(new DataSetObserver() {
2372 @Override
2373 public void onChanged() {
2374 if (mBinding == null) return;
2375
2376 mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
2377 }
2378
2379 @Override
2380 public void onInvalidated() {}
2381 });
2382 }
2383
2384 public String getTitle() {
2385 return mTitle;
2386 }
2387
2388 public void updateWithResponse(IqPacket iq) {
2389 this.loadingTimer.cancel();
2390 this.loadingTimer = new Timer();
2391 this.loading = false;
2392 this.responseElement = null;
2393 this.fillableFieldCount = 0;
2394 this.reported = null;
2395 this.response = iq;
2396 this.items.clear();
2397 this.actionsAdapter.clear();
2398 layoutManager.setSpanCount(1);
2399
2400 boolean actionsCleared = false;
2401 Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
2402 if (iq.getType() == IqPacket.TYPE.RESULT && command != null) {
2403 if (mNode.equals("jabber:iq:register") && command.getAttribute("status") != null && command.getAttribute("status").equals("completed")) {
2404 xmppConnectionService.createContact(getAccount().getRoster().getContact(iq.getFrom()), true);
2405 }
2406
2407 for (Element el : command.getChildren()) {
2408 if (el.getName().equals("actions") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2409 for (Element action : el.getChildren()) {
2410 if (!el.getNamespace().equals("http://jabber.org/protocol/commands")) continue;
2411 if (action.getName().equals("execute")) continue;
2412
2413 actionsAdapter.add(Pair.create(action.getName(), action.getName()));
2414 }
2415 }
2416 if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:data")) {
2417 Data form = Data.parse(el);
2418 String title = form.getTitle();
2419 if (title != null) {
2420 mTitle = title;
2421 ConversationPagerAdapter.this.notifyDataSetChanged();
2422 }
2423
2424 if (el.getAttribute("type").equals("result") || el.getAttribute("type").equals("form")) {
2425 this.responseElement = el;
2426 setupReported(el.findChild("reported", "jabber:x:data"));
2427 if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager());
2428 }
2429
2430 eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
2431 if (actionList != null) {
2432 actionsAdapter.clear();
2433
2434 for (Option action : actionList.getOptions()) {
2435 actionsAdapter.add(Pair.create(action.getValue(), action.toString()));
2436 }
2437 }
2438
2439 String fillableFieldType = null;
2440 String fillableFieldValue = null;
2441 for (eu.siacs.conversations.xmpp.forms.Field field : form.getFields()) {
2442 if ((field.getType() == null || (!field.getType().equals("hidden") && !field.getType().equals("fixed"))) && !field.getFieldName().equals("http://jabber.org/protocol/commands#actions")) {
2443 fillableFieldType = field.getType();
2444 fillableFieldValue = field.getValue();
2445 fillableFieldCount++;
2446 }
2447 }
2448
2449 if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 2 && fillableFieldType != null && (fillableFieldType.equals("list-single") || (fillableFieldType.equals("boolean") && fillableFieldValue == null))) {
2450 actionsCleared = true;
2451 actionsAdapter.clearExceptCancel();
2452 }
2453 break;
2454 }
2455 if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
2456 String url = el.findChildContent("url", "jabber:x:oob");
2457 if (url != null) {
2458 String scheme = Uri.parse(url).getScheme();
2459 if (scheme.equals("http") || scheme.equals("https")) {
2460 this.responseElement = el;
2461 break;
2462 }
2463 }
2464 }
2465 if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2466 this.responseElement = el;
2467 break;
2468 }
2469 }
2470
2471 if (responseElement == null && command.getAttribute("status") != null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
2472 removeSession(this);
2473 return;
2474 }
2475
2476 if (command.getAttribute("status").equals("executing") && actionsAdapter.countExceptCancel() < 1 && !actionsCleared) {
2477 // No actions have been given, but we are not done?
2478 // This is probably a spec violation, but we should do *something*
2479 actionsAdapter.add(Pair.create("execute", "execute"));
2480 }
2481
2482 if (!actionsAdapter.isEmpty() || fillableFieldCount > 0) {
2483 if (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled")) {
2484 actionsAdapter.add(Pair.create("close", "close"));
2485 } else if (actionsAdapter.getPosition("cancel") < 0) {
2486 actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
2487 }
2488 }
2489 }
2490
2491 if (actionsAdapter.isEmpty()) {
2492 actionsAdapter.add(Pair.create("close", "close"));
2493 }
2494
2495 notifyDataSetChanged();
2496 }
2497
2498 protected void setupReported(Element el) {
2499 if (el == null) {
2500 reported = null;
2501 return;
2502 }
2503
2504 reported = new ArrayList<>();
2505 for (Element fieldEl : el.getChildren()) {
2506 if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
2507 reported.add(mkField(fieldEl));
2508 }
2509 }
2510
2511 @Override
2512 public int getItemCount() {
2513 if (loading) return 1;
2514 if (response == null) return 0;
2515 if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
2516 int i = 0;
2517 for (Element el : responseElement.getChildren()) {
2518 if (!el.getNamespace().equals("jabber:x:data")) continue;
2519 if (el.getName().equals("title")) continue;
2520 if (el.getName().equals("field")) {
2521 String type = el.getAttribute("type");
2522 if (type != null && type.equals("hidden")) continue;
2523 if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
2524 }
2525
2526 if (el.getName().equals("reported") || el.getName().equals("item")) {
2527 if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
2528 if (el.getName().equals("reported")) continue;
2529 i += 1;
2530 } else {
2531 if (reported != null) i += reported.size();
2532 }
2533 continue;
2534 }
2535
2536 i++;
2537 }
2538 return i;
2539 }
2540 return 1;
2541 }
2542
2543 public Item getItem(int position) {
2544 if (loading) return new Item(null, TYPE_PROGRESSBAR);
2545 if (items.get(position) != null) return items.get(position);
2546 if (response == null) return null;
2547
2548 if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null) {
2549 if (responseElement.getNamespace().equals("jabber:x:data")) {
2550 int i = 0;
2551 for (Element el : responseElement.getChildren()) {
2552 if (!el.getNamespace().equals("jabber:x:data")) continue;
2553 if (el.getName().equals("title")) continue;
2554 if (el.getName().equals("field")) {
2555 String type = el.getAttribute("type");
2556 if (type != null && type.equals("hidden")) continue;
2557 if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
2558 }
2559
2560 if (el.getName().equals("reported") || el.getName().equals("item")) {
2561 Cell cell = null;
2562
2563 if (reported != null) {
2564 if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
2565 if (el.getName().equals("reported")) continue;
2566 if (i == position) {
2567 items.put(position, new Item(el, TYPE_ITEM_CARD));
2568 return items.get(position);
2569 }
2570 } else {
2571 if (reported.size() > position - i) {
2572 Field reportedField = reported.get(position - i);
2573 Element itemField = null;
2574 if (el.getName().equals("item")) {
2575 for (Element subel : el.getChildren()) {
2576 if (subel.getAttribute("var").equals(reportedField.getVar())) {
2577 itemField = subel;
2578 break;
2579 }
2580 }
2581 }
2582 cell = new Cell(reportedField, itemField);
2583 } else {
2584 i += reported.size();
2585 continue;
2586 }
2587 }
2588 }
2589
2590 if (cell != null) {
2591 items.put(position, cell);
2592 return cell;
2593 }
2594 }
2595
2596 if (i < position) {
2597 i++;
2598 continue;
2599 }
2600
2601 return mkItem(el, position);
2602 }
2603 }
2604 }
2605
2606 return mkItem(responseElement == null ? response : responseElement, position);
2607 }
2608
2609 @Override
2610 public int getItemViewType(int position) {
2611 return getItem(position).viewType;
2612 }
2613
2614 @Override
2615 public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
2616 switch(viewType) {
2617 case TYPE_ERROR: {
2618 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2619 return new ErrorViewHolder(binding);
2620 }
2621 case TYPE_NOTE: {
2622 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2623 return new NoteViewHolder(binding);
2624 }
2625 case TYPE_WEB: {
2626 CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
2627 return new WebViewHolder(binding);
2628 }
2629 case TYPE_RESULT_FIELD: {
2630 CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
2631 return new ResultFieldViewHolder(binding);
2632 }
2633 case TYPE_RESULT_CELL: {
2634 CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
2635 return new ResultCellViewHolder(binding);
2636 }
2637 case TYPE_ITEM_CARD: {
2638 CommandItemCardBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_item_card, container, false);
2639 return new ItemCardViewHolder(binding);
2640 }
2641 case TYPE_CHECKBOX_FIELD: {
2642 CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
2643 return new CheckboxFieldViewHolder(binding);
2644 }
2645 case TYPE_SEARCH_LIST_FIELD: {
2646 CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
2647 return new SearchListFieldViewHolder(binding);
2648 }
2649 case TYPE_RADIO_EDIT_FIELD: {
2650 CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
2651 return new RadioEditFieldViewHolder(binding);
2652 }
2653 case TYPE_SPINNER_FIELD: {
2654 CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
2655 return new SpinnerFieldViewHolder(binding);
2656 }
2657 case TYPE_BUTTON_GRID_FIELD: {
2658 CommandButtonGridFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_button_grid_field, container, false);
2659 return new ButtonGridFieldViewHolder(binding);
2660 }
2661 case TYPE_TEXT_FIELD: {
2662 CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
2663 return new TextFieldViewHolder(binding);
2664 }
2665 case TYPE_PROGRESSBAR: {
2666 CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
2667 return new ProgressBarViewHolder(binding);
2668 }
2669 default:
2670 throw new IllegalArgumentException("Unknown viewType: " + viewType);
2671 }
2672 }
2673
2674 @Override
2675 public void onBindViewHolder(ViewHolder viewHolder, int position) {
2676 viewHolder.bind(getItem(position));
2677 }
2678
2679 public View getView() {
2680 return mBinding.getRoot();
2681 }
2682
2683 public boolean validate() {
2684 int count = getItemCount();
2685 boolean isValid = true;
2686 for (int i = 0; i < count; i++) {
2687 boolean oneIsValid = getItem(i).validate();
2688 isValid = isValid && oneIsValid;
2689 }
2690 notifyDataSetChanged();
2691 return isValid;
2692 }
2693
2694 public boolean execute() {
2695 return execute("execute");
2696 }
2697
2698 public boolean execute(int actionPosition) {
2699 return execute(actionsAdapter.getItem(actionPosition).first);
2700 }
2701
2702 public boolean execute(String action) {
2703 if (!action.equals("cancel") && !action.equals("prev") && !validate()) return false;
2704
2705 if (response == null) return true;
2706 Element command = response.findChild("command", "http://jabber.org/protocol/commands");
2707 if (command == null) return true;
2708 String status = command.getAttribute("status");
2709 if (status == null || (!status.equals("executing") && !action.equals("prev"))) return true;
2710
2711 if (actionToWebview != null && !action.equals("cancel")) {
2712 actionToWebview.postWebMessage(new WebMessage("xmpp_xep0050/" + action), Uri.parse("*"));
2713 return false;
2714 }
2715
2716 final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
2717 packet.setTo(response.getFrom());
2718 final Element c = packet.addChild("command", Namespace.COMMANDS);
2719 c.setAttribute("node", mNode);
2720 c.setAttribute("sessionid", command.getAttribute("sessionid"));
2721
2722 String formType = responseElement == null ? null : responseElement.getAttribute("type");
2723 if (!action.equals("cancel") &&
2724 !action.equals("prev") &&
2725 responseElement != null &&
2726 responseElement.getName().equals("x") &&
2727 responseElement.getNamespace().equals("jabber:x:data") &&
2728 formType != null && formType.equals("form")) {
2729
2730 Data form = Data.parse(responseElement);
2731 eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
2732 if (actionList != null) {
2733 actionList.setValue(action);
2734 c.setAttribute("action", "execute");
2735 }
2736
2737 responseElement.setAttribute("type", "submit");
2738 Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
2739 if (rsm != null) {
2740 Element max = new Element("max", "http://jabber.org/protocol/rsm");
2741 max.setContent("1000");
2742 rsm.addChild(max);
2743 }
2744
2745 c.addChild(responseElement);
2746 }
2747
2748 if (c.getAttribute("action") == null) c.setAttribute("action", action);
2749
2750 xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
2751 getView().post(() -> {
2752 updateWithResponse(iq);
2753 });
2754 });
2755
2756 loading();
2757 return false;
2758 }
2759
2760 protected void loading() {
2761 loadingTimer.schedule(new TimerTask() {
2762 @Override
2763 public void run() {
2764 getView().post(() -> {
2765 loading = true;
2766 notifyDataSetChanged();
2767 });
2768 }
2769 }, 500);
2770 }
2771
2772 protected GridLayoutManager setupLayoutManager() {
2773 int spanCount = 1;
2774
2775 if (reported != null && mPager != null) {
2776 float screenWidth = mPager.getContext().getResources().getDisplayMetrics().widthPixels;
2777 TextPaint paint = ((TextView) LayoutInflater.from(mPager.getContext()).inflate(R.layout.command_result_cell, null)).getPaint();
2778 float tableHeaderWidth = reported.stream().reduce(
2779 0f,
2780 (total, field) -> total + StaticLayout.getDesiredWidth(field.getLabel().or("--------"), paint),
2781 (a, b) -> a + b
2782 );
2783
2784 spanCount = tableHeaderWidth > 0.65 * screenWidth ? 1 : this.reported.size();
2785 }
2786
2787 if (layoutManager != null && layoutManager.getSpanCount() != spanCount) {
2788 items.clear();
2789 notifyDataSetChanged();
2790 }
2791
2792 layoutManager = new GridLayoutManager(mPager.getContext(), spanCount);
2793 layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
2794 @Override
2795 public int getSpanSize(int position) {
2796 if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
2797 return 1;
2798 }
2799 });
2800 return layoutManager;
2801 }
2802
2803 public void setBinding(CommandPageBinding b) {
2804 mBinding = b;
2805 // https://stackoverflow.com/a/32350474/8611
2806 mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
2807 @Override
2808 public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
2809 if(rv.getChildCount() > 0) {
2810 int[] location = new int[2];
2811 rv.getLocationOnScreen(location);
2812 View childView = rv.findChildViewUnder(e.getX(), e.getY());
2813 if (childView instanceof ViewGroup) {
2814 childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
2815 }
2816 int action = e.getAction();
2817 switch (action) {
2818 case MotionEvent.ACTION_DOWN:
2819 if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(1)) || childView instanceof WebView) {
2820 rv.requestDisallowInterceptTouchEvent(true);
2821 }
2822 case MotionEvent.ACTION_UP:
2823 if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(-11)) || childView instanceof WebView) {
2824 rv.requestDisallowInterceptTouchEvent(true);
2825 }
2826 }
2827 }
2828
2829 return false;
2830 }
2831
2832 @Override
2833 public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }
2834
2835 @Override
2836 public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
2837 });
2838 mBinding.form.setLayoutManager(setupLayoutManager());
2839 mBinding.form.setAdapter(this);
2840 mBinding.actions.setAdapter(actionsAdapter);
2841 mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
2842 if (execute(pos)) {
2843 removeSession(CommandSession.this);
2844 }
2845 });
2846
2847 actionsAdapter.notifyDataSetChanged();
2848 }
2849
2850 // https://stackoverflow.com/a/36037991/8611
2851 private View findViewAt(ViewGroup viewGroup, float x, float y) {
2852 for(int i = 0; i < viewGroup.getChildCount(); i++) {
2853 View child = viewGroup.getChildAt(i);
2854 if (child instanceof ViewGroup && !(child instanceof AbsListView) && !(child instanceof WebView)) {
2855 View foundView = findViewAt((ViewGroup) child, x, y);
2856 if (foundView != null && foundView.isShown()) {
2857 return foundView;
2858 }
2859 } else {
2860 int[] location = new int[2];
2861 child.getLocationOnScreen(location);
2862 Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
2863 if (rect.contains((int)x, (int)y)) {
2864 return child;
2865 }
2866 }
2867 }
2868
2869 return null;
2870 }
2871 }
2872 }
2873}