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