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