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