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