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