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