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