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