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
1394 if (command.getAttribute("node").equals("jabber:iq:register") && packet.getTo().asBareJid().equals(Jid.of("cheogram.com"))) {
1395 new com.cheogram.android.CheogramLicenseChecker(mPager.getContext(), (signedData, signature) -> {
1396 if (signedData != null && signature != null) {
1397 c.addChild("license", "https://ns.cheogram.com/google-play").setContent(signedData);
1398 c.addChild("licenseSignature", "https://ns.cheogram.com/google-play").setContent(signature);
1399 }
1400
1401 xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
1402 session.updateWithResponse(iq);
1403 });
1404 }).checkLicense();
1405 } else {
1406 xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
1407 session.updateWithResponse(iq);
1408 });
1409 }
1410
1411 sessions.add(session);
1412 notifyDataSetChanged();
1413 if (mPager != null) mPager.setCurrentItem(getCount() - 1);
1414 }
1415
1416 public void removeSession(CommandSession session) {
1417 sessions.remove(session);
1418 notifyDataSetChanged();
1419 }
1420
1421 @NonNull
1422 @Override
1423 public Object instantiateItem(@NonNull ViewGroup container, int position) {
1424 if (position == 0) {
1425 if (page1.getParent() == null) container.addView(page1);
1426 return page1;
1427 }
1428 if (position == 1) {
1429 if (page2.getParent() == null) container.addView(page2);
1430 return page2;
1431 }
1432
1433 CommandSession session = sessions.get(position-2);
1434 CommandPageBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_page, container, false);
1435 container.addView(binding.getRoot());
1436 session.setBinding(binding);
1437 return session;
1438 }
1439
1440 @Override
1441 public void destroyItem(@NonNull ViewGroup container, int position, Object o) {
1442 if (position < 2) return;
1443
1444 container.removeView(((CommandSession) o).getView());
1445 }
1446
1447 @Override
1448 public int getItemPosition(Object o) {
1449 if (mPager != null) {
1450 if (o == page1) return PagerAdapter.POSITION_UNCHANGED;
1451 if (o == page2) return PagerAdapter.POSITION_UNCHANGED;
1452 }
1453
1454 int pos = sessions == null ? -1 : sessions.indexOf(o);
1455 if (pos < 0) return PagerAdapter.POSITION_NONE;
1456 return pos + 2;
1457 }
1458
1459 @Override
1460 public int getCount() {
1461 if (sessions == null) return 1;
1462
1463 int count = 2 + sessions.size();
1464 if (mTabs == null) return count;
1465
1466 if (count > 2) {
1467 mTabs.setTabMode(TabLayout.MODE_SCROLLABLE);
1468 } else {
1469 mTabs.setTabMode(TabLayout.MODE_FIXED);
1470 }
1471 return count;
1472 }
1473
1474 @Override
1475 public boolean isViewFromObject(@NonNull View view, @NonNull Object o) {
1476 if (view == o) return true;
1477
1478 if (o instanceof CommandSession) {
1479 return ((CommandSession) o).getView() == view;
1480 }
1481
1482 return false;
1483 }
1484
1485 @Nullable
1486 @Override
1487 public CharSequence getPageTitle(int position) {
1488 switch (position) {
1489 case 0:
1490 return "Conversation";
1491 case 1:
1492 return "Commands";
1493 default:
1494 CommandSession session = sessions.get(position-2);
1495 if (session == null) return super.getPageTitle(position);
1496 return session.getTitle();
1497 }
1498 }
1499
1500 class CommandSession extends RecyclerView.Adapter<CommandSession.ViewHolder> {
1501 abstract class ViewHolder<T extends ViewDataBinding> extends RecyclerView.ViewHolder {
1502 protected T binding;
1503
1504 public ViewHolder(T binding) {
1505 super(binding.getRoot());
1506 this.binding = binding;
1507 }
1508
1509 abstract public void bind(Item el);
1510
1511 protected void setTextOrHide(TextView v, Optional<String> s) {
1512 if (s == null || !s.isPresent()) {
1513 v.setVisibility(View.GONE);
1514 } else {
1515 v.setVisibility(View.VISIBLE);
1516 v.setText(s.get());
1517 }
1518 }
1519
1520 protected void setupInputType(Element field, TextView textinput, TextInputLayout layout) {
1521 int flags = 0;
1522 if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_NONE);
1523 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1524
1525 String type = field.getAttribute("type");
1526 if (type != null) {
1527 if (type.equals("text-multi") || type.equals("jid-multi")) {
1528 flags |= InputType.TYPE_TEXT_FLAG_MULTI_LINE;
1529 }
1530
1531 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1532
1533 if (type.equals("jid-single") || type.equals("jid-multi")) {
1534 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
1535 }
1536
1537 if (type.equals("text-private")) {
1538 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
1539 if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_PASSWORD_TOGGLE);
1540 }
1541 }
1542
1543 Element validate = field.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1544 if (validate == null) return;
1545 String datatype = validate.getAttribute("datatype");
1546 if (datatype == null) return;
1547
1548 if (datatype.equals("xs:integer") || datatype.equals("xs:int") || datatype.equals("xs:long") || datatype.equals("xs:short") || datatype.equals("xs:byte")) {
1549 textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED);
1550 }
1551
1552 if (datatype.equals("xs:decimal") || datatype.equals("xs:double")) {
1553 textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED | InputType.TYPE_NUMBER_FLAG_DECIMAL);
1554 }
1555
1556 if (datatype.equals("xs:date")) {
1557 textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_DATE);
1558 }
1559
1560 if (datatype.equals("xs:dateTime")) {
1561 textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_NORMAL);
1562 }
1563
1564 if (datatype.equals("xs:time")) {
1565 textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_TIME);
1566 }
1567
1568 if (datatype.equals("xs:anyURI")) {
1569 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
1570 }
1571
1572 if (datatype.equals("html:tel")) {
1573 textinput.setInputType(flags | InputType.TYPE_CLASS_PHONE);
1574 }
1575
1576 if (datatype.equals("html:email")) {
1577 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
1578 }
1579 }
1580 }
1581
1582 class ErrorViewHolder extends ViewHolder<CommandNoteBinding> {
1583 public ErrorViewHolder(CommandNoteBinding binding) { super(binding); }
1584
1585 @Override
1586 public void bind(Item iq) {
1587 binding.errorIcon.setVisibility(View.VISIBLE);
1588
1589 Element error = iq.el.findChild("error");
1590 if (error == null) return;
1591 String text = error.findChildContent("text", "urn:ietf:params:xml:ns:xmpp-stanzas");
1592 if (text == null || text.equals("")) {
1593 text = error.getChildren().get(0).getName();
1594 }
1595 binding.message.setText(text);
1596 }
1597 }
1598
1599 class NoteViewHolder extends ViewHolder<CommandNoteBinding> {
1600 public NoteViewHolder(CommandNoteBinding binding) { super(binding); }
1601
1602 @Override
1603 public void bind(Item note) {
1604 binding.message.setText(note.el.getContent());
1605
1606 String type = note.el.getAttribute("type");
1607 if (type != null && type.equals("error")) {
1608 binding.errorIcon.setVisibility(View.VISIBLE);
1609 }
1610 }
1611 }
1612
1613 class ResultFieldViewHolder extends ViewHolder<CommandResultFieldBinding> {
1614 public ResultFieldViewHolder(CommandResultFieldBinding binding) { super(binding); }
1615
1616 @Override
1617 public void bind(Item item) {
1618 Field field = (Field) item;
1619 setTextOrHide(binding.label, field.getLabel());
1620 setTextOrHide(binding.desc, field.getDesc());
1621
1622 ArrayAdapter<String> values = new ArrayAdapter<String>(binding.getRoot().getContext(), R.layout.simple_list_item);
1623 for (Element el : field.el.getChildren()) {
1624 if (el.getName().equals("value") && el.getNamespace().equals("jabber:x:data")) {
1625 values.add(el.getContent());
1626 }
1627 }
1628 binding.values.setAdapter(values);
1629
1630 if (field.getType().equals(Optional.of("jid-single")) || field.getType().equals(Optional.of("jid-multi"))) {
1631 binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
1632 new FixedURLSpan("xmpp:" + Jid.ofEscaped(values.getItem(pos)).toEscapedString()).onClick(binding.values);
1633 });
1634 }
1635
1636 binding.values.setOnItemLongClickListener((arg0, arg1, pos, id) -> {
1637 if (ShareUtil.copyTextToClipboard(binding.getRoot().getContext(), values.getItem(pos), R.string.message)) {
1638 Toast.makeText(binding.getRoot().getContext(), R.string.message_copied_to_clipboard, Toast.LENGTH_SHORT).show();
1639 }
1640 return true;
1641 });
1642 }
1643 }
1644
1645 class ResultCellViewHolder extends ViewHolder<CommandResultCellBinding> {
1646 public ResultCellViewHolder(CommandResultCellBinding binding) { super(binding); }
1647
1648 @Override
1649 public void bind(Item item) {
1650 Cell cell = (Cell) item;
1651
1652 if (cell.el == null) {
1653 binding.text.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Subhead);
1654 setTextOrHide(binding.text, cell.reported.getLabel());
1655 } else {
1656 String value = cell.el.findChildContent("value", "jabber:x:data");
1657 SpannableStringBuilder text = new SpannableStringBuilder(value == null ? "" : value);
1658 if (cell.reported.getType().equals(Optional.of("jid-single"))) {
1659 text.setSpan(new FixedURLSpan("xmpp:" + Jid.ofEscaped(text.toString()).toEscapedString()), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1660 }
1661
1662 binding.text.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Body1);
1663 binding.text.setText(text);
1664
1665 BetterLinkMovementMethod method = BetterLinkMovementMethod.newInstance();
1666 method.setOnLinkLongClickListener((tv, url) -> {
1667 tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
1668 ShareUtil.copyLinkToClipboard(binding.getRoot().getContext(), url);
1669 return true;
1670 });
1671 binding.text.setMovementMethod(method);
1672 }
1673 }
1674 }
1675
1676 class ItemCardViewHolder extends ViewHolder<CommandItemCardBinding> {
1677 public ItemCardViewHolder(CommandItemCardBinding binding) { super(binding); }
1678
1679 @Override
1680 public void bind(Item item) {
1681 binding.fields.removeAllViews();
1682
1683 for (Field field : reported) {
1684 CommandResultFieldBinding row = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.command_result_field, binding.fields, false);
1685 GridLayout.LayoutParams param = new GridLayout.LayoutParams();
1686 param.columnSpec = GridLayout.spec(GridLayout.UNDEFINED, GridLayout.FILL, 1f);
1687 param.width = 0;
1688 row.getRoot().setLayoutParams(param);
1689 binding.fields.addView(row.getRoot());
1690 for (Element el : item.el.getChildren()) {
1691 if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data") && el.getAttribute("var") != null && el.getAttribute("var").equals(field.getVar())) {
1692 for (String label : field.getLabel().asSet()) {
1693 el.setAttribute("label", label);
1694 }
1695 for (String desc : field.getDesc().asSet()) {
1696 el.setAttribute("desc", desc);
1697 }
1698 for (String type : field.getType().asSet()) {
1699 el.setAttribute("type", type);
1700 }
1701 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1702 if (validate != null) el.addChild(validate);
1703 new ResultFieldViewHolder(row).bind(new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), -1));
1704 }
1705 }
1706 }
1707 }
1708 }
1709
1710 class CheckboxFieldViewHolder extends ViewHolder<CommandCheckboxFieldBinding> implements CompoundButton.OnCheckedChangeListener {
1711 public CheckboxFieldViewHolder(CommandCheckboxFieldBinding binding) {
1712 super(binding);
1713 binding.row.setOnClickListener((v) -> {
1714 binding.checkbox.toggle();
1715 });
1716 binding.checkbox.setOnCheckedChangeListener(this);
1717 }
1718 protected Element mValue = null;
1719
1720 @Override
1721 public void bind(Item item) {
1722 Field field = (Field) item;
1723 binding.label.setText(field.getLabel().or(""));
1724 setTextOrHide(binding.desc, field.getDesc());
1725 mValue = field.getValue();
1726 binding.checkbox.setChecked(mValue.getContent() != null && (mValue.getContent().equals("true") || mValue.getContent().equals("1")));
1727 }
1728
1729 @Override
1730 public void onCheckedChanged(CompoundButton checkbox, boolean isChecked) {
1731 if (mValue == null) return;
1732
1733 mValue.setContent(isChecked ? "true" : "false");
1734 }
1735 }
1736
1737 class SearchListFieldViewHolder extends ViewHolder<CommandSearchListFieldBinding> implements TextWatcher {
1738 public SearchListFieldViewHolder(CommandSearchListFieldBinding binding) {
1739 super(binding);
1740 binding.search.addTextChangedListener(this);
1741 }
1742 protected Element mValue = null;
1743 List<Option> options = new ArrayList<>();
1744 protected ArrayAdapter<Option> adapter;
1745 protected boolean open;
1746
1747 @Override
1748 public void bind(Item item) {
1749 Field field = (Field) item;
1750 setTextOrHide(binding.label, field.getLabel());
1751 setTextOrHide(binding.desc, field.getDesc());
1752
1753 if (field.error != null) {
1754 binding.desc.setVisibility(View.VISIBLE);
1755 binding.desc.setText(field.error);
1756 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
1757 } else {
1758 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
1759 }
1760
1761 mValue = field.getValue();
1762
1763 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1764 open = validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null;
1765 setupInputType(field.el, binding.search, null);
1766
1767 options = field.getOptions();
1768 binding.list.setOnItemClickListener((parent, view, position, id) -> {
1769 mValue.setContent(adapter.getItem(binding.list.getCheckedItemPosition()).getValue());
1770 if (open) binding.search.setText(mValue.getContent());
1771 });
1772 search("");
1773 }
1774
1775 @Override
1776 public void afterTextChanged(Editable s) {
1777 if (open) mValue.setContent(s.toString());
1778 search(s.toString());
1779 }
1780
1781 @Override
1782 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
1783
1784 @Override
1785 public void onTextChanged(CharSequence s, int start, int count, int after) { }
1786
1787 protected void search(String s) {
1788 List<Option> filteredOptions;
1789 final String q = s.replaceAll("\\W", "").toLowerCase();
1790 if (q == null || q.equals("")) {
1791 filteredOptions = options;
1792 } else {
1793 filteredOptions = options.stream().filter(o -> o.toString().replaceAll("\\W", "").toLowerCase().contains(q)).collect(Collectors.toList());
1794 }
1795 adapter = new ArrayAdapter(binding.getRoot().getContext(), R.layout.simple_list_item, filteredOptions);
1796 binding.list.setAdapter(adapter);
1797
1798 int checkedPos = filteredOptions.indexOf(new Option(mValue.getContent(), ""));
1799 if (checkedPos >= 0) binding.list.setItemChecked(checkedPos, true);
1800 }
1801 }
1802
1803 class RadioEditFieldViewHolder extends ViewHolder<CommandRadioEditFieldBinding> implements CompoundButton.OnCheckedChangeListener, TextWatcher {
1804 public RadioEditFieldViewHolder(CommandRadioEditFieldBinding binding) {
1805 super(binding);
1806 binding.open.addTextChangedListener(this);
1807 options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.radio_grid_item) {
1808 @Override
1809 public View getView(int position, View convertView, ViewGroup parent) {
1810 CompoundButton v = (CompoundButton) super.getView(position, convertView, parent);
1811 v.setId(position);
1812 v.setChecked(getItem(position).getValue().equals(mValue.getContent()));
1813 v.setOnCheckedChangeListener(RadioEditFieldViewHolder.this);
1814 return v;
1815 }
1816 };
1817 }
1818 protected Element mValue = null;
1819 protected ArrayAdapter<Option> options;
1820
1821 @Override
1822 public void bind(Item item) {
1823 Field field = (Field) item;
1824 setTextOrHide(binding.label, field.getLabel());
1825 setTextOrHide(binding.desc, field.getDesc());
1826
1827 if (field.error != null) {
1828 binding.desc.setVisibility(View.VISIBLE);
1829 binding.desc.setText(field.error);
1830 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
1831 } else {
1832 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
1833 }
1834
1835 mValue = field.getValue();
1836
1837 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1838 binding.open.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
1839 binding.open.setText(mValue.getContent());
1840 setupInputType(field.el, binding.open, null);
1841
1842 options.clear();
1843 List<Option> theOptions = field.getOptions();
1844 options.addAll(theOptions);
1845
1846 float screenWidth = binding.getRoot().getContext().getResources().getDisplayMetrics().widthPixels;
1847 TextPaint paint = ((TextView) LayoutInflater.from(binding.getRoot().getContext()).inflate(R.layout.radio_grid_item, null)).getPaint();
1848 float maxColumnWidth = theOptions.stream().map((x) ->
1849 StaticLayout.getDesiredWidth(x.toString(), paint)
1850 ).max(Float::compare).orElse(new Float(0.0));
1851 if (maxColumnWidth * theOptions.size() < 0.90 * screenWidth) {
1852 binding.radios.setNumColumns(theOptions.size());
1853 } else if (maxColumnWidth * (theOptions.size() / 2) < 0.90 * screenWidth) {
1854 binding.radios.setNumColumns(theOptions.size() / 2);
1855 } else {
1856 binding.radios.setNumColumns(1);
1857 }
1858 binding.radios.setAdapter(options);
1859 }
1860
1861 @Override
1862 public void onCheckedChanged(CompoundButton radio, boolean isChecked) {
1863 if (mValue == null) return;
1864
1865 if (isChecked) {
1866 mValue.setContent(options.getItem(radio.getId()).getValue());
1867 binding.open.setText(mValue.getContent());
1868 }
1869 options.notifyDataSetChanged();
1870 }
1871
1872 @Override
1873 public void afterTextChanged(Editable s) {
1874 if (mValue == null) return;
1875
1876 mValue.setContent(s.toString());
1877 options.notifyDataSetChanged();
1878 }
1879
1880 @Override
1881 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
1882
1883 @Override
1884 public void onTextChanged(CharSequence s, int start, int count, int after) { }
1885 }
1886
1887 class SpinnerFieldViewHolder extends ViewHolder<CommandSpinnerFieldBinding> implements AdapterView.OnItemSelectedListener {
1888 public SpinnerFieldViewHolder(CommandSpinnerFieldBinding binding) {
1889 super(binding);
1890 binding.spinner.setOnItemSelectedListener(this);
1891 }
1892 protected Element mValue = null;
1893
1894 @Override
1895 public void bind(Item item) {
1896 Field field = (Field) item;
1897 setTextOrHide(binding.label, field.getLabel());
1898 binding.spinner.setPrompt(field.getLabel().or(""));
1899 setTextOrHide(binding.desc, field.getDesc());
1900
1901 mValue = field.getValue();
1902
1903 ArrayAdapter<Option> options = new ArrayAdapter<Option>(binding.getRoot().getContext(), android.R.layout.simple_spinner_item);
1904 options.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
1905 options.addAll(field.getOptions());
1906
1907 binding.spinner.setAdapter(options);
1908 binding.spinner.setSelection(options.getPosition(new Option(mValue.getContent(), null)));
1909 }
1910
1911 @Override
1912 public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
1913 Option o = (Option) parent.getItemAtPosition(pos);
1914 if (mValue == null) return;
1915
1916 mValue.setContent(o == null ? "" : o.getValue());
1917 }
1918
1919 @Override
1920 public void onNothingSelected(AdapterView<?> parent) {
1921 mValue.setContent("");
1922 }
1923 }
1924
1925 class ButtonGridFieldViewHolder extends ViewHolder<CommandButtonGridFieldBinding> {
1926 public ButtonGridFieldViewHolder(CommandButtonGridFieldBinding binding) {
1927 super(binding);
1928 options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.button_grid_item) {
1929 @Override
1930 public View getView(int position, View convertView, ViewGroup parent) {
1931 Button v = (Button) super.getView(position, convertView, parent);
1932 v.setOnClickListener((view) -> {
1933 loading = true;
1934 mValue.setContent(getItem(position).getValue());
1935 execute();
1936 });
1937
1938 final SVG icon = getItem(position).getIcon();
1939 if (icon != null) {
1940 v.post(() -> {
1941 if (v.getHeight() == 0) return;
1942 icon.setDocumentPreserveAspectRatio(com.caverock.androidsvg.PreserveAspectRatio.TOP);
1943 Bitmap bitmap = Bitmap.createBitmap(v.getHeight(), v.getHeight(), Bitmap.Config.ARGB_8888);
1944 Canvas bmcanvas = new Canvas(bitmap);
1945 icon.renderToCanvas(bmcanvas);
1946 v.setCompoundDrawablesRelativeWithIntrinsicBounds(new BitmapDrawable(bitmap), null, null, null);
1947 });
1948 }
1949
1950 return v;
1951 }
1952 };
1953 }
1954 protected Element mValue = null;
1955 protected ArrayAdapter<Option> options;
1956 protected Option defaultOption = null;
1957
1958 @Override
1959 public void bind(Item item) {
1960 Field field = (Field) item;
1961 setTextOrHide(binding.label, field.getLabel());
1962 setTextOrHide(binding.desc, field.getDesc());
1963
1964 if (field.error != null) {
1965 binding.desc.setVisibility(View.VISIBLE);
1966 binding.desc.setText(field.error);
1967 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
1968 } else {
1969 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
1970 }
1971
1972 mValue = field.getValue();
1973
1974 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1975 binding.openButton.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
1976 binding.openButton.setOnClickListener((view) -> {
1977 AlertDialog.Builder builder = new AlertDialog.Builder(binding.getRoot().getContext());
1978 DialogQuickeditBinding dialogBinding = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.dialog_quickedit, null, false);
1979 builder.setPositiveButton(R.string.action_execute, null);
1980 if (field.getDesc().isPresent()) {
1981 dialogBinding.inputLayout.setHint(field.getDesc().get());
1982 }
1983 dialogBinding.inputEditText.requestFocus();
1984 dialogBinding.inputEditText.getText().append(mValue.getContent());
1985 builder.setView(dialogBinding.getRoot());
1986 builder.setNegativeButton(R.string.cancel, null);
1987 final AlertDialog dialog = builder.create();
1988 dialog.setOnShowListener(d -> SoftKeyboardUtils.showKeyboard(dialogBinding.inputEditText));
1989 dialog.show();
1990 View.OnClickListener clickListener = v -> {
1991 loading = true;
1992 String value = dialogBinding.inputEditText.getText().toString();
1993 mValue.setContent(value);
1994 SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
1995 dialog.dismiss();
1996 execute();
1997 };
1998 dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener(clickListener);
1999 dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setOnClickListener((v -> {
2000 SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2001 dialog.dismiss();
2002 }));
2003 dialog.setCanceledOnTouchOutside(false);
2004 dialog.setOnDismissListener(dialog1 -> {
2005 SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2006 });
2007 });
2008
2009 options.clear();
2010 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();
2011
2012 defaultOption = null;
2013 for (Option option : theOptions) {
2014 if (option.getValue().equals(mValue.getContent())) {
2015 defaultOption = option;
2016 break;
2017 }
2018 }
2019 if (defaultOption == null && !mValue.getContent().equals("")) {
2020 // Synthesize default option for custom value
2021 defaultOption = new Option(mValue.getContent(), mValue.getContent());
2022 }
2023 if (defaultOption == null) {
2024 binding.defaultButton.setVisibility(View.GONE);
2025 } else {
2026 theOptions.remove(defaultOption);
2027 binding.defaultButton.setVisibility(View.VISIBLE);
2028
2029 final SVG defaultIcon = defaultOption.getIcon();
2030 if (defaultIcon != null) {
2031 defaultIcon.setDocumentPreserveAspectRatio(com.caverock.androidsvg.PreserveAspectRatio.TOP);
2032 DisplayMetrics display = mPager.getContext().getResources().getDisplayMetrics();
2033 Bitmap bitmap = Bitmap.createBitmap((int)(display.heightPixels*display.density/4), (int)(display.heightPixels*display.density/4), Bitmap.Config.ARGB_8888);
2034 bitmap.setDensity(display.densityDpi);
2035 Canvas bmcanvas = new Canvas(bitmap);
2036 defaultIcon.renderToCanvas(bmcanvas);
2037 binding.defaultButton.setCompoundDrawablesRelativeWithIntrinsicBounds(null, new BitmapDrawable(bitmap), null, null);
2038 }
2039
2040 binding.defaultButton.setText(defaultOption.toString());
2041 binding.defaultButton.setOnClickListener((view) -> {
2042 loading = true;
2043 mValue.setContent(defaultOption.getValue());
2044 execute();
2045 });
2046 }
2047
2048 options.addAll(theOptions);
2049 binding.buttons.setAdapter(options);
2050 }
2051 }
2052
2053 class TextFieldViewHolder extends ViewHolder<CommandTextFieldBinding> implements TextWatcher {
2054 public TextFieldViewHolder(CommandTextFieldBinding binding) {
2055 super(binding);
2056 binding.textinput.addTextChangedListener(this);
2057 }
2058 protected Element mValue = null;
2059
2060 @Override
2061 public void bind(Item item) {
2062 Field field = (Field) item;
2063 binding.textinputLayout.setHint(field.getLabel().or(""));
2064
2065 binding.textinputLayout.setHelperTextEnabled(field.getDesc().isPresent());
2066 for (String desc : field.getDesc().asSet()) {
2067 binding.textinputLayout.setHelperText(desc);
2068 }
2069
2070 binding.textinputLayout.setErrorEnabled(field.error != null);
2071 if (field.error != null) binding.textinputLayout.setError(field.error);
2072
2073 binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_GRAVITY);
2074 String suffixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/suffix-label");
2075 if (suffixLabel != null) {
2076 binding.textinputLayout.setSuffixText(suffixLabel);
2077 binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_TEXT_END);
2078 }
2079
2080 String prefixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/prefix-label");
2081 if (prefixLabel != null) {
2082 binding.textinputLayout.setPrefixText(prefixLabel);
2083 }
2084
2085 mValue = field.getValue();
2086 binding.textinput.setText(mValue.getContent());
2087 setupInputType(field.el, binding.textinput, binding.textinputLayout);
2088 }
2089
2090 @Override
2091 public void afterTextChanged(Editable s) {
2092 if (mValue == null) return;
2093
2094 mValue.setContent(s.toString());
2095 }
2096
2097 @Override
2098 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2099
2100 @Override
2101 public void onTextChanged(CharSequence s, int start, int count, int after) { }
2102 }
2103
2104 class WebViewHolder extends ViewHolder<CommandWebviewBinding> {
2105 public WebViewHolder(CommandWebviewBinding binding) { super(binding); }
2106 protected String boundUrl = "";
2107
2108 @Override
2109 public void bind(Item oob) {
2110 setTextOrHide(binding.desc, Optional.fromNullable(oob.el.findChildContent("desc", "jabber:x:oob")));
2111 binding.webview.getSettings().setJavaScriptEnabled(true);
2112 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");
2113 binding.webview.getSettings().setDatabaseEnabled(true);
2114 binding.webview.getSettings().setDomStorageEnabled(true);
2115 binding.webview.setWebChromeClient(new WebChromeClient() {
2116 @Override
2117 public void onProgressChanged(WebView view, int newProgress) {
2118 binding.progressbar.setVisibility(newProgress < 100 ? View.VISIBLE : View.GONE);
2119 binding.progressbar.setProgress(newProgress);
2120 }
2121 });
2122 binding.webview.setWebViewClient(new WebViewClient() {
2123 @Override
2124 public void onPageFinished(WebView view, String url) {
2125 super.onPageFinished(view, url);
2126 mTitle = view.getTitle();
2127 ConversationPagerAdapter.this.notifyDataSetChanged();
2128 }
2129 });
2130 final String url = oob.el.findChildContent("url", "jabber:x:oob");
2131 if (!boundUrl.equals(url)) {
2132 binding.webview.addJavascriptInterface(new JsObject(), "xmpp_xep0050");
2133 binding.webview.loadUrl(url);
2134 boundUrl = url;
2135 }
2136 }
2137
2138 class JsObject {
2139 @JavascriptInterface
2140 public void execute() { execute("execute"); }
2141
2142 @JavascriptInterface
2143 public void execute(String action) {
2144 getView().post(() -> {
2145 actionToWebview = null;
2146 if(CommandSession.this.execute(action)) {
2147 removeSession(CommandSession.this);
2148 }
2149 });
2150 }
2151
2152 @JavascriptInterface
2153 public void preventDefault() {
2154 actionToWebview = binding.webview;
2155 }
2156 }
2157 }
2158
2159 class ProgressBarViewHolder extends ViewHolder<CommandProgressBarBinding> {
2160 public ProgressBarViewHolder(CommandProgressBarBinding binding) { super(binding); }
2161
2162 @Override
2163 public void bind(Item item) { }
2164 }
2165
2166 class Item {
2167 protected Element el;
2168 protected int viewType;
2169 protected String error = null;
2170
2171 Item(Element el, int viewType) {
2172 this.el = el;
2173 this.viewType = viewType;
2174 }
2175
2176 public boolean validate() {
2177 error = null;
2178 return true;
2179 }
2180 }
2181
2182 class Field extends Item {
2183 Field(eu.siacs.conversations.xmpp.forms.Field el, int viewType) { super(el, viewType); }
2184
2185 @Override
2186 public boolean validate() {
2187 if (!super.validate()) return false;
2188 if (el.findChild("required", "jabber:x:data") == null) return true;
2189 if (getValue().getContent() != null && !getValue().getContent().equals("")) return true;
2190
2191 error = "this value is required";
2192 return false;
2193 }
2194
2195 public String getVar() {
2196 return el.getAttribute("var");
2197 }
2198
2199 public Optional<String> getType() {
2200 return Optional.fromNullable(el.getAttribute("type"));
2201 }
2202
2203 public Optional<String> getLabel() {
2204 String label = el.getAttribute("label");
2205 if (label == null) label = getVar();
2206 return Optional.fromNullable(label);
2207 }
2208
2209 public Optional<String> getDesc() {
2210 return Optional.fromNullable(el.findChildContent("desc", "jabber:x:data"));
2211 }
2212
2213 public Element getValue() {
2214 Element value = el.findChild("value", "jabber:x:data");
2215 if (value == null) {
2216 value = el.addChild("value", "jabber:x:data");
2217 }
2218 return value;
2219 }
2220
2221 public List<Option> getOptions() {
2222 return Option.forField(el);
2223 }
2224 }
2225
2226 class Cell extends Item {
2227 protected Field reported;
2228
2229 Cell(Field reported, Element item) {
2230 super(item, TYPE_RESULT_CELL);
2231 this.reported = reported;
2232 }
2233 }
2234
2235 protected Field mkField(Element el) {
2236 int viewType = -1;
2237
2238 String formType = responseElement.getAttribute("type");
2239 if (formType != null) {
2240 String fieldType = el.getAttribute("type");
2241 if (fieldType == null) fieldType = "text-single";
2242
2243 if (formType.equals("result") || fieldType.equals("fixed")) {
2244 viewType = TYPE_RESULT_FIELD;
2245 } else if (formType.equals("form")) {
2246 if (fieldType.equals("boolean")) {
2247 if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 1) {
2248 viewType = TYPE_BUTTON_GRID_FIELD;
2249 } else {
2250 viewType = TYPE_CHECKBOX_FIELD;
2251 }
2252 } else if (fieldType.equals("list-single")) {
2253 Element validate = el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2254 if (Option.forField(el).size() > 9) {
2255 viewType = TYPE_SEARCH_LIST_FIELD;
2256 } else if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 1) {
2257 viewType = TYPE_BUTTON_GRID_FIELD;
2258 } else if (el.findChild("value", "jabber:x:data") == null || (validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null)) {
2259 viewType = TYPE_RADIO_EDIT_FIELD;
2260 } else {
2261 viewType = TYPE_SPINNER_FIELD;
2262 }
2263 } else {
2264 viewType = TYPE_TEXT_FIELD;
2265 }
2266 }
2267
2268 return new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), viewType);
2269 }
2270
2271 return null;
2272 }
2273
2274 protected Item mkItem(Element el, int pos) {
2275 int viewType = -1;
2276
2277 if (response != null && response.getType() == IqPacket.TYPE.RESULT) {
2278 if (el.getName().equals("note")) {
2279 viewType = TYPE_NOTE;
2280 } else if (el.getNamespace().equals("jabber:x:oob")) {
2281 viewType = TYPE_WEB;
2282 } else if (el.getName().equals("instructions") && el.getNamespace().equals("jabber:x:data")) {
2283 viewType = TYPE_NOTE;
2284 } else if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data")) {
2285 Field field = mkField(el);
2286 if (field != null) {
2287 items.put(pos, field);
2288 return field;
2289 }
2290 }
2291 } else if (response != null) {
2292 viewType = TYPE_ERROR;
2293 }
2294
2295 Item item = new Item(el, viewType);
2296 items.put(pos, item);
2297 return item;
2298 }
2299
2300 class ActionsAdapter extends ArrayAdapter<Pair<String, String>> {
2301 protected Context ctx;
2302
2303 public ActionsAdapter(Context ctx) {
2304 super(ctx, R.layout.simple_list_item);
2305 this.ctx = ctx;
2306 }
2307
2308 @Override
2309 public View getView(int position, View convertView, ViewGroup parent) {
2310 View v = super.getView(position, convertView, parent);
2311 TextView tv = (TextView) v.findViewById(android.R.id.text1);
2312 tv.setGravity(Gravity.CENTER);
2313 tv.setText(getItem(position).second);
2314 int resId = ctx.getResources().getIdentifier("action_" + getItem(position).first, "string" , ctx.getPackageName());
2315 if (resId != 0 && getItem(position).second.equals(getItem(position).first)) tv.setText(ctx.getResources().getString(resId));
2316 tv.setTextColor(ContextCompat.getColor(ctx, R.color.white));
2317 tv.setBackgroundColor(UIHelper.getColorForName(getItem(position).first));
2318 return v;
2319 }
2320
2321 public int getPosition(String s) {
2322 for(int i = 0; i < getCount(); i++) {
2323 if (getItem(i).first.equals(s)) return i;
2324 }
2325 return -1;
2326 }
2327
2328 public int countExceptCancel() {
2329 int count = 0;
2330 for(int i = 0; i < getCount(); i++) {
2331 if (!getItem(i).first.equals("cancel")) count++;
2332 }
2333 return count;
2334 }
2335
2336 public void clearExceptCancel() {
2337 Pair<String,String> cancelItem = null;
2338 for(int i = 0; i < getCount(); i++) {
2339 if (getItem(i).first.equals("cancel")) cancelItem = getItem(i);
2340 }
2341 clear();
2342 if (cancelItem != null) add(cancelItem);
2343 }
2344 }
2345
2346 final int TYPE_ERROR = 1;
2347 final int TYPE_NOTE = 2;
2348 final int TYPE_WEB = 3;
2349 final int TYPE_RESULT_FIELD = 4;
2350 final int TYPE_TEXT_FIELD = 5;
2351 final int TYPE_CHECKBOX_FIELD = 6;
2352 final int TYPE_SPINNER_FIELD = 7;
2353 final int TYPE_RADIO_EDIT_FIELD = 8;
2354 final int TYPE_RESULT_CELL = 9;
2355 final int TYPE_PROGRESSBAR = 10;
2356 final int TYPE_SEARCH_LIST_FIELD = 11;
2357 final int TYPE_ITEM_CARD = 12;
2358 final int TYPE_BUTTON_GRID_FIELD = 13;
2359
2360 protected boolean loading = false;
2361 protected Timer loadingTimer = new Timer();
2362 protected String mTitle;
2363 protected String mNode;
2364 protected CommandPageBinding mBinding = null;
2365 protected IqPacket response = null;
2366 protected Element responseElement = null;
2367 protected List<Field> reported = null;
2368 protected SparseArray<Item> items = new SparseArray<>();
2369 protected XmppConnectionService xmppConnectionService;
2370 protected ActionsAdapter actionsAdapter;
2371 protected GridLayoutManager layoutManager;
2372 protected WebView actionToWebview = null;
2373 protected int fillableFieldCount = 0;
2374 protected IqPacket pendingResponsePacket = null;
2375
2376 CommandSession(String title, String node, XmppConnectionService xmppConnectionService) {
2377 loading();
2378 mTitle = title;
2379 mNode = node;
2380 this.xmppConnectionService = xmppConnectionService;
2381 if (mPager != null) setupLayoutManager();
2382 actionsAdapter = new ActionsAdapter(xmppConnectionService);
2383 actionsAdapter.registerDataSetObserver(new DataSetObserver() {
2384 @Override
2385 public void onChanged() {
2386 if (mBinding == null) return;
2387
2388 mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
2389 }
2390
2391 @Override
2392 public void onInvalidated() {}
2393 });
2394 }
2395
2396 public String getTitle() {
2397 return mTitle;
2398 }
2399
2400 public void updateWithResponse(final IqPacket iq) {
2401 if (getView().isAttachedToWindow()) {
2402 getView().post(() -> updateWithResponseUiThread(iq));
2403 } else {
2404 pendingResponsePacket = iq;
2405 }
2406 }
2407
2408 protected void updateWithResponseUiThread(final IqPacket iq) {
2409 this.loadingTimer.cancel();
2410 this.loadingTimer = new Timer();
2411 this.loading = false;
2412 this.responseElement = null;
2413 this.fillableFieldCount = 0;
2414 this.reported = null;
2415 this.response = iq;
2416 this.items.clear();
2417 this.actionsAdapter.clear();
2418 layoutManager.setSpanCount(1);
2419
2420 boolean actionsCleared = false;
2421 Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
2422 if (iq.getType() == IqPacket.TYPE.RESULT && command != null) {
2423 if (mNode.equals("jabber:iq:register") && command.getAttribute("status") != null && command.getAttribute("status").equals("completed")) {
2424 xmppConnectionService.createContact(getAccount().getRoster().getContact(iq.getFrom()), true);
2425 }
2426
2427 for (Element el : command.getChildren()) {
2428 if (el.getName().equals("actions") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2429 for (Element action : el.getChildren()) {
2430 if (!el.getNamespace().equals("http://jabber.org/protocol/commands")) continue;
2431 if (action.getName().equals("execute")) continue;
2432
2433 actionsAdapter.add(Pair.create(action.getName(), action.getName()));
2434 }
2435 }
2436 if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:data")) {
2437 Data form = Data.parse(el);
2438 String title = form.getTitle();
2439 if (title != null) {
2440 mTitle = title;
2441 ConversationPagerAdapter.this.notifyDataSetChanged();
2442 }
2443
2444 if (el.getAttribute("type").equals("result") || el.getAttribute("type").equals("form")) {
2445 this.responseElement = el;
2446 setupReported(el.findChild("reported", "jabber:x:data"));
2447 if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager());
2448 }
2449
2450 eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
2451 if (actionList != null) {
2452 actionsAdapter.clear();
2453
2454 for (Option action : actionList.getOptions()) {
2455 actionsAdapter.add(Pair.create(action.getValue(), action.toString()));
2456 }
2457 }
2458
2459 String fillableFieldType = null;
2460 String fillableFieldValue = null;
2461 for (eu.siacs.conversations.xmpp.forms.Field field : form.getFields()) {
2462 if ((field.getType() == null || (!field.getType().equals("hidden") && !field.getType().equals("fixed"))) && field.getFieldName() != null && !field.getFieldName().equals("http://jabber.org/protocol/commands#actions")) {
2463 fillableFieldType = field.getType();
2464 fillableFieldValue = field.getValue();
2465 fillableFieldCount++;
2466 }
2467 }
2468
2469 if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 2 && fillableFieldType != null && (fillableFieldType.equals("list-single") || (fillableFieldType.equals("boolean") && fillableFieldValue == null))) {
2470 actionsCleared = true;
2471 actionsAdapter.clearExceptCancel();
2472 }
2473 break;
2474 }
2475 if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
2476 String url = el.findChildContent("url", "jabber:x:oob");
2477 if (url != null) {
2478 String scheme = Uri.parse(url).getScheme();
2479 if (scheme.equals("http") || scheme.equals("https")) {
2480 this.responseElement = el;
2481 break;
2482 }
2483 }
2484 }
2485 if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2486 this.responseElement = el;
2487 break;
2488 }
2489 }
2490
2491 if (responseElement == null && command.getAttribute("status") != null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
2492 removeSession(this);
2493 return;
2494 }
2495
2496 if (command.getAttribute("status").equals("executing") && actionsAdapter.countExceptCancel() < 1 && !actionsCleared) {
2497 // No actions have been given, but we are not done?
2498 // This is probably a spec violation, but we should do *something*
2499 actionsAdapter.add(Pair.create("execute", "execute"));
2500 }
2501
2502 if (!actionsAdapter.isEmpty() || fillableFieldCount > 0) {
2503 if (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled")) {
2504 actionsAdapter.add(Pair.create("close", "close"));
2505 } else if (actionsAdapter.getPosition("cancel") < 0) {
2506 actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
2507 }
2508 }
2509 }
2510
2511 if (actionsAdapter.isEmpty()) {
2512 actionsAdapter.add(Pair.create("close", "close"));
2513 }
2514
2515 notifyDataSetChanged();
2516 }
2517
2518 protected void setupReported(Element el) {
2519 if (el == null) {
2520 reported = null;
2521 return;
2522 }
2523
2524 reported = new ArrayList<>();
2525 for (Element fieldEl : el.getChildren()) {
2526 if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
2527 reported.add(mkField(fieldEl));
2528 }
2529 }
2530
2531 @Override
2532 public int getItemCount() {
2533 if (loading) return 1;
2534 if (response == null) return 0;
2535 if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
2536 int i = 0;
2537 for (Element el : responseElement.getChildren()) {
2538 if (!el.getNamespace().equals("jabber:x:data")) continue;
2539 if (el.getName().equals("title")) continue;
2540 if (el.getName().equals("field")) {
2541 String type = el.getAttribute("type");
2542 if (type != null && type.equals("hidden")) continue;
2543 if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
2544 }
2545
2546 if (el.getName().equals("reported") || el.getName().equals("item")) {
2547 if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
2548 if (el.getName().equals("reported")) continue;
2549 i += 1;
2550 } else {
2551 if (reported != null) i += reported.size();
2552 }
2553 continue;
2554 }
2555
2556 i++;
2557 }
2558 return i;
2559 }
2560 return 1;
2561 }
2562
2563 public Item getItem(int position) {
2564 if (loading) return new Item(null, TYPE_PROGRESSBAR);
2565 if (items.get(position) != null) return items.get(position);
2566 if (response == null) return null;
2567
2568 if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null) {
2569 if (responseElement.getNamespace().equals("jabber:x:data")) {
2570 int i = 0;
2571 for (Element el : responseElement.getChildren()) {
2572 if (!el.getNamespace().equals("jabber:x:data")) continue;
2573 if (el.getName().equals("title")) continue;
2574 if (el.getName().equals("field")) {
2575 String type = el.getAttribute("type");
2576 if (type != null && type.equals("hidden")) continue;
2577 if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
2578 }
2579
2580 if (el.getName().equals("reported") || el.getName().equals("item")) {
2581 Cell cell = null;
2582
2583 if (reported != null) {
2584 if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
2585 if (el.getName().equals("reported")) continue;
2586 if (i == position) {
2587 items.put(position, new Item(el, TYPE_ITEM_CARD));
2588 return items.get(position);
2589 }
2590 } else {
2591 if (reported.size() > position - i) {
2592 Field reportedField = reported.get(position - i);
2593 Element itemField = null;
2594 if (el.getName().equals("item")) {
2595 for (Element subel : el.getChildren()) {
2596 if (subel.getAttribute("var").equals(reportedField.getVar())) {
2597 itemField = subel;
2598 break;
2599 }
2600 }
2601 }
2602 cell = new Cell(reportedField, itemField);
2603 } else {
2604 i += reported.size();
2605 continue;
2606 }
2607 }
2608 }
2609
2610 if (cell != null) {
2611 items.put(position, cell);
2612 return cell;
2613 }
2614 }
2615
2616 if (i < position) {
2617 i++;
2618 continue;
2619 }
2620
2621 return mkItem(el, position);
2622 }
2623 }
2624 }
2625
2626 return mkItem(responseElement == null ? response : responseElement, position);
2627 }
2628
2629 @Override
2630 public int getItemViewType(int position) {
2631 return getItem(position).viewType;
2632 }
2633
2634 @Override
2635 public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
2636 switch(viewType) {
2637 case TYPE_ERROR: {
2638 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2639 return new ErrorViewHolder(binding);
2640 }
2641 case TYPE_NOTE: {
2642 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2643 return new NoteViewHolder(binding);
2644 }
2645 case TYPE_WEB: {
2646 CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
2647 return new WebViewHolder(binding);
2648 }
2649 case TYPE_RESULT_FIELD: {
2650 CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
2651 return new ResultFieldViewHolder(binding);
2652 }
2653 case TYPE_RESULT_CELL: {
2654 CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
2655 return new ResultCellViewHolder(binding);
2656 }
2657 case TYPE_ITEM_CARD: {
2658 CommandItemCardBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_item_card, container, false);
2659 return new ItemCardViewHolder(binding);
2660 }
2661 case TYPE_CHECKBOX_FIELD: {
2662 CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
2663 return new CheckboxFieldViewHolder(binding);
2664 }
2665 case TYPE_SEARCH_LIST_FIELD: {
2666 CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
2667 return new SearchListFieldViewHolder(binding);
2668 }
2669 case TYPE_RADIO_EDIT_FIELD: {
2670 CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
2671 return new RadioEditFieldViewHolder(binding);
2672 }
2673 case TYPE_SPINNER_FIELD: {
2674 CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
2675 return new SpinnerFieldViewHolder(binding);
2676 }
2677 case TYPE_BUTTON_GRID_FIELD: {
2678 CommandButtonGridFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_button_grid_field, container, false);
2679 return new ButtonGridFieldViewHolder(binding);
2680 }
2681 case TYPE_TEXT_FIELD: {
2682 CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
2683 return new TextFieldViewHolder(binding);
2684 }
2685 case TYPE_PROGRESSBAR: {
2686 CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
2687 return new ProgressBarViewHolder(binding);
2688 }
2689 default:
2690 throw new IllegalArgumentException("Unknown viewType: " + viewType);
2691 }
2692 }
2693
2694 @Override
2695 public void onBindViewHolder(ViewHolder viewHolder, int position) {
2696 viewHolder.bind(getItem(position));
2697 }
2698
2699 public View getView() {
2700 return mBinding.getRoot();
2701 }
2702
2703 public boolean validate() {
2704 int count = getItemCount();
2705 boolean isValid = true;
2706 for (int i = 0; i < count; i++) {
2707 boolean oneIsValid = getItem(i).validate();
2708 isValid = isValid && oneIsValid;
2709 }
2710 notifyDataSetChanged();
2711 return isValid;
2712 }
2713
2714 public boolean execute() {
2715 return execute("execute");
2716 }
2717
2718 public boolean execute(int actionPosition) {
2719 return execute(actionsAdapter.getItem(actionPosition).first);
2720 }
2721
2722 public boolean execute(String action) {
2723 if (!action.equals("cancel") && !action.equals("prev") && !validate()) return false;
2724
2725 if (response == null) return true;
2726 Element command = response.findChild("command", "http://jabber.org/protocol/commands");
2727 if (command == null) return true;
2728 String status = command.getAttribute("status");
2729 if (status == null || (!status.equals("executing") && !action.equals("prev"))) return true;
2730
2731 if (actionToWebview != null && !action.equals("cancel")) {
2732 actionToWebview.postWebMessage(new WebMessage("xmpp_xep0050/" + action), Uri.parse("*"));
2733 return false;
2734 }
2735
2736 final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
2737 packet.setTo(response.getFrom());
2738 final Element c = packet.addChild("command", Namespace.COMMANDS);
2739 c.setAttribute("node", mNode);
2740 c.setAttribute("sessionid", command.getAttribute("sessionid"));
2741
2742 String formType = responseElement == null ? null : responseElement.getAttribute("type");
2743 if (!action.equals("cancel") &&
2744 !action.equals("prev") &&
2745 responseElement != null &&
2746 responseElement.getName().equals("x") &&
2747 responseElement.getNamespace().equals("jabber:x:data") &&
2748 formType != null && formType.equals("form")) {
2749
2750 Data form = Data.parse(responseElement);
2751 eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
2752 if (actionList != null) {
2753 actionList.setValue(action);
2754 c.setAttribute("action", "execute");
2755 }
2756
2757 responseElement.setAttribute("type", "submit");
2758 Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
2759 if (rsm != null) {
2760 Element max = new Element("max", "http://jabber.org/protocol/rsm");
2761 max.setContent("1000");
2762 rsm.addChild(max);
2763 }
2764
2765 c.addChild(responseElement);
2766 }
2767
2768 if (c.getAttribute("action") == null) c.setAttribute("action", action);
2769
2770 xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
2771 updateWithResponse(iq);
2772 });
2773
2774 loading();
2775 return false;
2776 }
2777
2778 protected void loading() {
2779 loadingTimer.schedule(new TimerTask() {
2780 @Override
2781 public void run() {
2782 getView().post(() -> {
2783 loading = true;
2784 notifyDataSetChanged();
2785 });
2786 }
2787 }, 500);
2788 }
2789
2790 protected GridLayoutManager setupLayoutManager() {
2791 int spanCount = 1;
2792
2793 if (reported != null && mPager != null) {
2794 float screenWidth = mPager.getContext().getResources().getDisplayMetrics().widthPixels;
2795 TextPaint paint = ((TextView) LayoutInflater.from(mPager.getContext()).inflate(R.layout.command_result_cell, null)).getPaint();
2796 float tableHeaderWidth = reported.stream().reduce(
2797 0f,
2798 (total, field) -> total + StaticLayout.getDesiredWidth(field.getLabel().or("--------"), paint),
2799 (a, b) -> a + b
2800 );
2801
2802 spanCount = tableHeaderWidth > 0.65 * screenWidth ? 1 : this.reported.size();
2803 }
2804
2805 if (layoutManager != null && layoutManager.getSpanCount() != spanCount) {
2806 items.clear();
2807 notifyDataSetChanged();
2808 }
2809
2810 layoutManager = new GridLayoutManager(mPager.getContext(), spanCount);
2811 layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
2812 @Override
2813 public int getSpanSize(int position) {
2814 if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
2815 return 1;
2816 }
2817 });
2818 return layoutManager;
2819 }
2820
2821 public void setBinding(CommandPageBinding b) {
2822 mBinding = b;
2823 // https://stackoverflow.com/a/32350474/8611
2824 mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
2825 @Override
2826 public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
2827 if(rv.getChildCount() > 0) {
2828 int[] location = new int[2];
2829 rv.getLocationOnScreen(location);
2830 View childView = rv.findChildViewUnder(e.getX(), e.getY());
2831 if (childView instanceof ViewGroup) {
2832 childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
2833 }
2834 int action = e.getAction();
2835 switch (action) {
2836 case MotionEvent.ACTION_DOWN:
2837 if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(1)) || childView instanceof WebView) {
2838 rv.requestDisallowInterceptTouchEvent(true);
2839 }
2840 case MotionEvent.ACTION_UP:
2841 if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(-11)) || childView instanceof WebView) {
2842 rv.requestDisallowInterceptTouchEvent(true);
2843 }
2844 }
2845 }
2846
2847 return false;
2848 }
2849
2850 @Override
2851 public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }
2852
2853 @Override
2854 public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
2855 });
2856 mBinding.form.setLayoutManager(setupLayoutManager());
2857 mBinding.form.setAdapter(this);
2858 mBinding.actions.setAdapter(actionsAdapter);
2859 mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
2860 if (execute(pos)) {
2861 removeSession(CommandSession.this);
2862 }
2863 });
2864
2865 actionsAdapter.notifyDataSetChanged();
2866
2867 if (pendingResponsePacket != null) {
2868 updateWithResponseUiThread(pendingResponsePacket);
2869 pendingResponsePacket = null;
2870 }
2871 }
2872
2873 // https://stackoverflow.com/a/36037991/8611
2874 private View findViewAt(ViewGroup viewGroup, float x, float y) {
2875 for(int i = 0; i < viewGroup.getChildCount(); i++) {
2876 View child = viewGroup.getChildAt(i);
2877 if (child instanceof ViewGroup && !(child instanceof AbsListView) && !(child instanceof WebView)) {
2878 View foundView = findViewAt((ViewGroup) child, x, y);
2879 if (foundView != null && foundView.isShown()) {
2880 return foundView;
2881 }
2882 } else {
2883 int[] location = new int[2];
2884 child.getLocationOnScreen(location);
2885 Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
2886 if (rect.contains((int)x, (int)y)) {
2887 return child;
2888 }
2889 }
2890 }
2891
2892 return null;
2893 }
2894 }
2895 }
2896}