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