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