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