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