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