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