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