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