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