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