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