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