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