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