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