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