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