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.isOOb() && (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.isOOb() && (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, XmppConnectionService xmppConnectionService) {
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.isOOb() && (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(xmppConnectionService) || (m.getSubject() != null && !m.isOOb() && (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.isOOb() && (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.isOOb() && (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<>();
2034 if (multi) {
2035 values.addAll(field.getValues());
2036 for (final String value : field.getValues()) {
2037 if (filteredValues.contains(value)) {
2038 values.remove(value);
2039 }
2040 }
2041 }
2042
2043 SparseBooleanArray positions = binding.list.getCheckedItemPositions();
2044 for (int i = 0; i < positions.size(); i++) {
2045 values.add(adapter.getItem(positions.keyAt(i)).getValue());
2046 }
2047 field.setValues(values);
2048
2049 if (!multi && open) binding.search.setText(String.join("\n", field.getValues()));
2050 });
2051 search("");
2052 }
2053
2054 @Override
2055 public void afterTextChanged(Editable s) {
2056 if (!multi && open) field.setValues(List.of(s.toString()));
2057 search(s.toString());
2058 }
2059
2060 @Override
2061 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2062
2063 @Override
2064 public void onTextChanged(CharSequence s, int start, int count, int after) { }
2065
2066 protected void search(String s) {
2067 List<Option> filteredOptions;
2068 final String q = s.replaceAll("\\W", "").toLowerCase();
2069 if (q == null || q.equals("")) {
2070 filteredOptions = options;
2071 } else {
2072 filteredOptions = options.stream().filter(o -> o.toString().replaceAll("\\W", "").toLowerCase().contains(q)).collect(Collectors.toList());
2073 }
2074 filteredValues = filteredOptions.stream().map(o -> o.getValue()).collect(Collectors.toSet());
2075 adapter = new ArrayAdapter(binding.getRoot().getContext(), R.layout.simple_list_item, filteredOptions);
2076 binding.list.setAdapter(adapter);
2077
2078 for (final String value : field.getValues()) {
2079 int checkedPos = filteredOptions.indexOf(new Option(value, ""));
2080 if (checkedPos >= 0) binding.list.setItemChecked(checkedPos, true);
2081 }
2082 }
2083 }
2084
2085 class RadioEditFieldViewHolder extends ViewHolder<CommandRadioEditFieldBinding> implements CompoundButton.OnCheckedChangeListener, TextWatcher {
2086 public RadioEditFieldViewHolder(CommandRadioEditFieldBinding binding) {
2087 super(binding);
2088 binding.open.addTextChangedListener(this);
2089 options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.radio_grid_item) {
2090 @Override
2091 public View getView(int position, View convertView, ViewGroup parent) {
2092 CompoundButton v = (CompoundButton) super.getView(position, convertView, parent);
2093 v.setId(position);
2094 v.setChecked(getItem(position).getValue().equals(mValue.getContent()));
2095 v.setOnCheckedChangeListener(RadioEditFieldViewHolder.this);
2096 return v;
2097 }
2098 };
2099 }
2100 protected Element mValue = null;
2101 protected ArrayAdapter<Option> options;
2102
2103 @Override
2104 public void bind(Item item) {
2105 Field field = (Field) item;
2106 setTextOrHide(binding.label, field.getLabel());
2107 setTextOrHide(binding.desc, field.getDesc());
2108
2109 if (field.error != null) {
2110 binding.desc.setVisibility(View.VISIBLE);
2111 binding.desc.setText(field.error);
2112 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
2113 } else {
2114 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
2115 }
2116
2117 mValue = field.getValue();
2118
2119 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2120 binding.open.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
2121 binding.open.setText(mValue.getContent());
2122 setupInputType(field.el, binding.open, null);
2123
2124 options.clear();
2125 List<Option> theOptions = field.getOptions();
2126 options.addAll(theOptions);
2127
2128 float screenWidth = binding.getRoot().getContext().getResources().getDisplayMetrics().widthPixels;
2129 TextPaint paint = ((TextView) LayoutInflater.from(binding.getRoot().getContext()).inflate(R.layout.radio_grid_item, null)).getPaint();
2130 float maxColumnWidth = theOptions.stream().map((x) ->
2131 StaticLayout.getDesiredWidth(x.toString(), paint)
2132 ).max(Float::compare).orElse(new Float(0.0));
2133 if (maxColumnWidth * theOptions.size() < 0.90 * screenWidth) {
2134 binding.radios.setNumColumns(theOptions.size());
2135 } else if (maxColumnWidth * (theOptions.size() / 2) < 0.90 * screenWidth) {
2136 binding.radios.setNumColumns(theOptions.size() / 2);
2137 } else {
2138 binding.radios.setNumColumns(1);
2139 }
2140 binding.radios.setAdapter(options);
2141 }
2142
2143 @Override
2144 public void onCheckedChanged(CompoundButton radio, boolean isChecked) {
2145 if (mValue == null) return;
2146
2147 if (isChecked) {
2148 mValue.setContent(options.getItem(radio.getId()).getValue());
2149 binding.open.setText(mValue.getContent());
2150 }
2151 options.notifyDataSetChanged();
2152 }
2153
2154 @Override
2155 public void afterTextChanged(Editable s) {
2156 if (mValue == null) return;
2157
2158 mValue.setContent(s.toString());
2159 options.notifyDataSetChanged();
2160 }
2161
2162 @Override
2163 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2164
2165 @Override
2166 public void onTextChanged(CharSequence s, int start, int count, int after) { }
2167 }
2168
2169 class SpinnerFieldViewHolder extends ViewHolder<CommandSpinnerFieldBinding> implements AdapterView.OnItemSelectedListener {
2170 public SpinnerFieldViewHolder(CommandSpinnerFieldBinding binding) {
2171 super(binding);
2172 binding.spinner.setOnItemSelectedListener(this);
2173 }
2174 protected Element mValue = null;
2175
2176 @Override
2177 public void bind(Item item) {
2178 Field field = (Field) item;
2179 setTextOrHide(binding.label, field.getLabel());
2180 binding.spinner.setPrompt(field.getLabel().or(""));
2181 setTextOrHide(binding.desc, field.getDesc());
2182
2183 mValue = field.getValue();
2184
2185 ArrayAdapter<Option> options = new ArrayAdapter<Option>(binding.getRoot().getContext(), android.R.layout.simple_spinner_item);
2186 options.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
2187 options.addAll(field.getOptions());
2188
2189 binding.spinner.setAdapter(options);
2190 binding.spinner.setSelection(options.getPosition(new Option(mValue.getContent(), null)));
2191 }
2192
2193 @Override
2194 public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
2195 Option o = (Option) parent.getItemAtPosition(pos);
2196 if (mValue == null) return;
2197
2198 mValue.setContent(o == null ? "" : o.getValue());
2199 }
2200
2201 @Override
2202 public void onNothingSelected(AdapterView<?> parent) {
2203 mValue.setContent("");
2204 }
2205 }
2206
2207 class ButtonGridFieldViewHolder extends ViewHolder<CommandButtonGridFieldBinding> {
2208 public ButtonGridFieldViewHolder(CommandButtonGridFieldBinding binding) {
2209 super(binding);
2210 options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.button_grid_item) {
2211 protected int height = 0;
2212
2213 @Override
2214 public View getView(int position, View convertView, ViewGroup parent) {
2215 Button v = (Button) super.getView(position, convertView, parent);
2216 v.setOnClickListener((view) -> {
2217 mValue.setContent(getItem(position).getValue());
2218 execute();
2219 loading = true;
2220 });
2221
2222 final SVG icon = getItem(position).getIcon();
2223 if (icon != null) {
2224 final Element iconEl = getItem(position).getIconEl();
2225 if (height < 1) {
2226 v.measure(0, 0);
2227 height = v.getMeasuredHeight();
2228 }
2229 if (height < 1) return v;
2230 if (mediaSelector) {
2231 final Drawable d = getDrawableForSVG(icon, iconEl, height * 4);
2232 if (d != null) {
2233 final int boundsHeight = 35 + (int)((height * 4) / xmppConnectionService.getResources().getDisplayMetrics().density);
2234 d.setBounds(0, 0, d.getIntrinsicWidth(), boundsHeight);
2235 }
2236 v.setCompoundDrawables(null, d, null, null);
2237 } else {
2238 v.setCompoundDrawablesRelativeWithIntrinsicBounds(getDrawableForSVG(icon, iconEl, height), null, null, null);
2239 }
2240 }
2241
2242 return v;
2243 }
2244 };
2245 }
2246 protected Element mValue = null;
2247 protected ArrayAdapter<Option> options;
2248 protected Option defaultOption = null;
2249 protected boolean mediaSelector = false;
2250
2251 @Override
2252 public void bind(Item item) {
2253 Field field = (Field) item;
2254 setTextOrHide(binding.label, field.getLabel());
2255 setTextOrHide(binding.desc, field.getDesc());
2256
2257 if (field.error != null) {
2258 binding.desc.setVisibility(View.VISIBLE);
2259 binding.desc.setText(field.error);
2260 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
2261 } else {
2262 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
2263 }
2264
2265 mValue = field.getValue();
2266 mediaSelector = field.el.findChild("media-selector", "https://ns.cheogram.com/") != null;
2267
2268 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2269 binding.openButton.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
2270 binding.openButton.setOnClickListener((view) -> {
2271 AlertDialog.Builder builder = new AlertDialog.Builder(binding.getRoot().getContext());
2272 DialogQuickeditBinding dialogBinding = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.dialog_quickedit, null, false);
2273 builder.setPositiveButton(R.string.action_execute, null);
2274 if (field.getDesc().isPresent()) {
2275 dialogBinding.inputLayout.setHint(field.getDesc().get());
2276 }
2277 dialogBinding.inputEditText.requestFocus();
2278 dialogBinding.inputEditText.getText().append(mValue.getContent());
2279 builder.setView(dialogBinding.getRoot());
2280 builder.setNegativeButton(R.string.cancel, null);
2281 final AlertDialog dialog = builder.create();
2282 dialog.setOnShowListener(d -> SoftKeyboardUtils.showKeyboard(dialogBinding.inputEditText));
2283 dialog.show();
2284 View.OnClickListener clickListener = v -> {
2285 String value = dialogBinding.inputEditText.getText().toString();
2286 mValue.setContent(value);
2287 SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2288 dialog.dismiss();
2289 execute();
2290 loading = true;
2291 };
2292 dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener(clickListener);
2293 dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setOnClickListener((v -> {
2294 SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2295 dialog.dismiss();
2296 }));
2297 dialog.setCanceledOnTouchOutside(false);
2298 dialog.setOnDismissListener(dialog1 -> {
2299 SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2300 });
2301 });
2302
2303 options.clear();
2304 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();
2305
2306 defaultOption = null;
2307 for (Option option : theOptions) {
2308 if (option.getValue().equals(mValue.getContent())) {
2309 defaultOption = option;
2310 break;
2311 }
2312 }
2313 if (defaultOption == null && !mValue.getContent().equals("")) {
2314 // Synthesize default option for custom value
2315 defaultOption = new Option(mValue.getContent(), mValue.getContent());
2316 }
2317 if (defaultOption == null) {
2318 binding.defaultButton.setVisibility(View.GONE);
2319 } else {
2320 theOptions.remove(defaultOption);
2321 binding.defaultButton.setVisibility(View.VISIBLE);
2322
2323 final SVG defaultIcon = defaultOption.getIcon();
2324 if (defaultIcon != null) {
2325 DisplayMetrics display = mPager.getContext().getResources().getDisplayMetrics();
2326 int height = (int)(display.heightPixels*display.density/4);
2327 binding.defaultButton.setCompoundDrawablesRelativeWithIntrinsicBounds(null, getDrawableForSVG(defaultIcon, defaultOption.getIconEl(), height), null, null);
2328 }
2329
2330 binding.defaultButton.setText(defaultOption.toString());
2331 binding.defaultButton.setOnClickListener((view) -> {
2332 mValue.setContent(defaultOption.getValue());
2333 execute();
2334 loading = true;
2335 });
2336 }
2337
2338 options.addAll(theOptions);
2339 binding.buttons.setAdapter(options);
2340 }
2341 }
2342
2343 class TextFieldViewHolder extends ViewHolder<CommandTextFieldBinding> implements TextWatcher {
2344 public TextFieldViewHolder(CommandTextFieldBinding binding) {
2345 super(binding);
2346 binding.textinput.addTextChangedListener(this);
2347 }
2348 protected Field field = null;
2349
2350 @Override
2351 public void bind(Item item) {
2352 field = (Field) item;
2353 binding.textinputLayout.setHint(field.getLabel().or(""));
2354
2355 binding.textinputLayout.setHelperTextEnabled(field.getDesc().isPresent());
2356 for (String desc : field.getDesc().asSet()) {
2357 binding.textinputLayout.setHelperText(desc);
2358 }
2359
2360 binding.textinputLayout.setErrorEnabled(field.error != null);
2361 if (field.error != null) binding.textinputLayout.setError(field.error);
2362
2363 binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_GRAVITY);
2364 String suffixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/suffix-label");
2365 if (suffixLabel == null) {
2366 binding.textinputLayout.setSuffixText("");
2367 } else {
2368 binding.textinputLayout.setSuffixText(suffixLabel);
2369 binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_TEXT_END);
2370 }
2371
2372 String prefixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/prefix-label");
2373 binding.textinputLayout.setPrefixText(prefixLabel == null ? "" : prefixLabel);
2374
2375 binding.textinput.setText(String.join("\n", field.getValues()));
2376 setupInputType(field.el, binding.textinput, binding.textinputLayout);
2377 }
2378
2379 @Override
2380 public void afterTextChanged(Editable s) {
2381 if (field == null) return;
2382
2383 field.setValues(List.of(s.toString().split("\n")));
2384 }
2385
2386 @Override
2387 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2388
2389 @Override
2390 public void onTextChanged(CharSequence s, int start, int count, int after) { }
2391 }
2392
2393 class SliderFieldViewHolder extends ViewHolder<CommandSliderFieldBinding> {
2394 public SliderFieldViewHolder(CommandSliderFieldBinding binding) { super(binding); }
2395 protected Field field = null;
2396
2397 @Override
2398 public void bind(Item item) {
2399 field = (Field) item;
2400 setTextOrHide(binding.label, field.getLabel());
2401 setTextOrHide(binding.desc, field.getDesc());
2402 final Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2403 final String datatype = validate == null ? null : validate.getAttribute("datatype");
2404 final Element range = validate == null ? null : validate.findChild("range", "http://jabber.org/protocol/xdata-validate");
2405 // NOTE: range also implies open, so we don't have to be bound by the options strictly
2406 // Also, we don't have anywhere to put labels so we show only values, which might sometimes be bad...
2407 Float min = null;
2408 try { min = range.getAttribute("min") == null ? null : Float.valueOf(range.getAttribute("min")); } catch (NumberFormatException e) { }
2409 Float max = null;
2410 try { max = range.getAttribute("max") == null ? null : Float.valueOf(range.getAttribute("max")); } catch (NumberFormatException e) { }
2411
2412 List<Float> options = field.getOptions().stream().map(o -> Float.valueOf(o.getValue())).collect(Collectors.toList());
2413 Collections.sort(options);
2414 if (options.size() > 0) {
2415 // min/max should be on the range, but if you have options and didn't put them on the range we can imply
2416 if (min == null) min = options.get(0);
2417 if (max == null) max = options.get(options.size()-1);
2418 }
2419
2420 if (field.getValues().size() > 0) binding.slider.setValue(Float.valueOf(field.getValue().getContent()));
2421 binding.slider.setValueFrom(min == null ? Float.MIN_VALUE : min);
2422 binding.slider.setValueTo(max == null ? Float.MAX_VALUE : max);
2423 if ("xs:integer".equals(datatype) || "xs:int".equals(datatype) || "xs:long".equals(datatype) || "xs:short".equals(datatype) || "xs:byte".equals(datatype)) {
2424 binding.slider.setStepSize(1);
2425 } else {
2426 binding.slider.setStepSize(0);
2427 }
2428
2429 if (options.size() > 0) {
2430 float step = -1;
2431 Float prev = null;
2432 for (final Float option : options) {
2433 if (prev != null) {
2434 float nextStep = option - prev;
2435 if (step > 0 && step != nextStep) {
2436 step = -1;
2437 break;
2438 }
2439 step = nextStep;
2440 }
2441 prev = option;
2442 }
2443 if (step > 0) binding.slider.setStepSize(step);
2444 }
2445
2446 binding.slider.addOnChangeListener((slider, value, fromUser) -> {
2447 field.setValues(List.of(new DecimalFormat().format(value)));
2448 });
2449 }
2450 }
2451
2452 class WebViewHolder extends ViewHolder<CommandWebviewBinding> {
2453 public WebViewHolder(CommandWebviewBinding binding) { super(binding); }
2454 protected String boundUrl = "";
2455
2456 @Override
2457 public void bind(Item oob) {
2458 setTextOrHide(binding.desc, Optional.fromNullable(oob.el.findChildContent("desc", "jabber:x:oob")));
2459 binding.webview.getSettings().setJavaScriptEnabled(true);
2460 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");
2461 binding.webview.getSettings().setDatabaseEnabled(true);
2462 binding.webview.getSettings().setDomStorageEnabled(true);
2463 binding.webview.setWebChromeClient(new WebChromeClient() {
2464 @Override
2465 public void onProgressChanged(WebView view, int newProgress) {
2466 binding.progressbar.setVisibility(newProgress < 100 ? View.VISIBLE : View.GONE);
2467 binding.progressbar.setProgress(newProgress);
2468 }
2469 });
2470 binding.webview.setWebViewClient(new WebViewClient() {
2471 @Override
2472 public void onPageFinished(WebView view, String url) {
2473 super.onPageFinished(view, url);
2474 mTitle = view.getTitle();
2475 ConversationPagerAdapter.this.notifyDataSetChanged();
2476 }
2477 });
2478 final String url = oob.el.findChildContent("url", "jabber:x:oob");
2479 if (!boundUrl.equals(url)) {
2480 binding.webview.addJavascriptInterface(new JsObject(), "xmpp_xep0050");
2481 binding.webview.loadUrl(url);
2482 boundUrl = url;
2483 }
2484 }
2485
2486 class JsObject {
2487 @JavascriptInterface
2488 public void execute() { execute("execute"); }
2489
2490 @JavascriptInterface
2491 public void execute(String action) {
2492 getView().post(() -> {
2493 actionToWebview = null;
2494 if(CommandSession.this.execute(action)) {
2495 removeSession(CommandSession.this);
2496 }
2497 });
2498 }
2499
2500 @JavascriptInterface
2501 public void preventDefault() {
2502 actionToWebview = binding.webview;
2503 }
2504 }
2505 }
2506
2507 class ProgressBarViewHolder extends ViewHolder<CommandProgressBarBinding> {
2508 public ProgressBarViewHolder(CommandProgressBarBinding binding) { super(binding); }
2509
2510 @Override
2511 public void bind(Item item) {
2512 binding.text.setVisibility(loadingHasBeenLong ? View.VISIBLE : View.GONE);
2513 }
2514 }
2515
2516 class Item {
2517 protected Element el;
2518 protected int viewType;
2519 protected String error = null;
2520
2521 Item(Element el, int viewType) {
2522 this.el = el;
2523 this.viewType = viewType;
2524 }
2525
2526 public boolean validate() {
2527 error = null;
2528 return true;
2529 }
2530 }
2531
2532 class Field extends Item {
2533 Field(eu.siacs.conversations.xmpp.forms.Field el, int viewType) { super(el, viewType); }
2534
2535 @Override
2536 public boolean validate() {
2537 if (!super.validate()) return false;
2538 if (el.findChild("required", "jabber:x:data") == null) return true;
2539 if (getValue().getContent() != null && !getValue().getContent().equals("")) return true;
2540
2541 error = "this value is required";
2542 return false;
2543 }
2544
2545 public String getVar() {
2546 return el.getAttribute("var");
2547 }
2548
2549 public Optional<String> getType() {
2550 return Optional.fromNullable(el.getAttribute("type"));
2551 }
2552
2553 public Optional<String> getLabel() {
2554 String label = el.getAttribute("label");
2555 if (label == null) label = getVar();
2556 return Optional.fromNullable(label);
2557 }
2558
2559 public Optional<String> getDesc() {
2560 return Optional.fromNullable(el.findChildContent("desc", "jabber:x:data"));
2561 }
2562
2563 public Element getValue() {
2564 Element value = el.findChild("value", "jabber:x:data");
2565 if (value == null) {
2566 value = el.addChild("value", "jabber:x:data");
2567 }
2568 return value;
2569 }
2570
2571 public void setValues(Collection<String> values) {
2572 for(Element child : el.getChildren()) {
2573 if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
2574 el.removeChild(child);
2575 }
2576 }
2577
2578 for (String value : values) {
2579 el.addChild("value", "jabber:x:data").setContent(value);
2580 }
2581 }
2582
2583 public List<String> getValues() {
2584 List<String> values = new ArrayList<>();
2585 for(Element child : el.getChildren()) {
2586 if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
2587 values.add(child.getContent());
2588 }
2589 }
2590 return values;
2591 }
2592
2593 public List<Option> getOptions() {
2594 return Option.forField(el);
2595 }
2596 }
2597
2598 class Cell extends Item {
2599 protected Field reported;
2600
2601 Cell(Field reported, Element item) {
2602 super(item, TYPE_RESULT_CELL);
2603 this.reported = reported;
2604 }
2605 }
2606
2607 protected Field mkField(Element el) {
2608 int viewType = -1;
2609
2610 String formType = responseElement.getAttribute("type");
2611 if (formType != null) {
2612 String fieldType = el.getAttribute("type");
2613 if (fieldType == null) fieldType = "text-single";
2614
2615 if (formType.equals("result") || fieldType.equals("fixed")) {
2616 viewType = TYPE_RESULT_FIELD;
2617 } else if (formType.equals("form")) {
2618 final Element validate = el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2619 final String datatype = validate == null ? null : validate.getAttribute("datatype");
2620 final Element range = validate == null ? null : validate.findChild("range", "http://jabber.org/protocol/xdata-validate");
2621 if (fieldType.equals("boolean")) {
2622 if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 1) {
2623 viewType = TYPE_BUTTON_GRID_FIELD;
2624 } else {
2625 viewType = TYPE_CHECKBOX_FIELD;
2626 }
2627 } else if (
2628 range != null && (
2629 "xs:integer".equals(datatype) || "xs:int".equals(datatype) || "xs:long".equals(datatype) || "xs:short".equals(datatype) || "xs:byte".equals(datatype) ||
2630 "xs:decimal".equals(datatype) || "xs:double".equals(datatype)
2631 )
2632 ) {
2633 // has a range and is numeric, use a slider
2634 viewType = TYPE_SLIDER_FIELD;
2635 } else if (fieldType.equals("list-single")) {
2636 if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 1) {
2637 viewType = TYPE_BUTTON_GRID_FIELD;
2638 } else if (Option.forField(el).size() > 9) {
2639 viewType = TYPE_SEARCH_LIST_FIELD;
2640 } else if (el.findChild("value", "jabber:x:data") == null || (validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null)) {
2641 viewType = TYPE_RADIO_EDIT_FIELD;
2642 } else {
2643 viewType = TYPE_SPINNER_FIELD;
2644 }
2645 } else if (fieldType.equals("list-multi")) {
2646 viewType = TYPE_SEARCH_LIST_FIELD;
2647 } else {
2648 viewType = TYPE_TEXT_FIELD;
2649 }
2650 }
2651
2652 return new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), viewType);
2653 }
2654
2655 return null;
2656 }
2657
2658 protected Item mkItem(Element el, int pos) {
2659 int viewType = -1;
2660
2661 if (response != null && response.getType() == IqPacket.TYPE.RESULT) {
2662 if (el.getName().equals("note")) {
2663 viewType = TYPE_NOTE;
2664 } else if (el.getNamespace().equals("jabber:x:oob")) {
2665 viewType = TYPE_WEB;
2666 } else if (el.getName().equals("instructions") && el.getNamespace().equals("jabber:x:data")) {
2667 viewType = TYPE_NOTE;
2668 } else if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data")) {
2669 Field field = mkField(el);
2670 if (field != null) {
2671 items.put(pos, field);
2672 return field;
2673 }
2674 }
2675 } else if (response != null) {
2676 viewType = TYPE_ERROR;
2677 }
2678
2679 Item item = new Item(el, viewType);
2680 items.put(pos, item);
2681 return item;
2682 }
2683
2684 class ActionsAdapter extends ArrayAdapter<Pair<String, String>> {
2685 protected Context ctx;
2686
2687 public ActionsAdapter(Context ctx) {
2688 super(ctx, R.layout.simple_list_item);
2689 this.ctx = ctx;
2690 }
2691
2692 @Override
2693 public View getView(int position, View convertView, ViewGroup parent) {
2694 View v = super.getView(position, convertView, parent);
2695 TextView tv = (TextView) v.findViewById(android.R.id.text1);
2696 tv.setGravity(Gravity.CENTER);
2697 tv.setText(getItem(position).second);
2698 int resId = ctx.getResources().getIdentifier("action_" + getItem(position).first, "string" , ctx.getPackageName());
2699 if (resId != 0 && getItem(position).second.equals(getItem(position).first)) tv.setText(ctx.getResources().getString(resId));
2700 tv.setTextColor(ContextCompat.getColor(ctx, R.color.white));
2701 tv.setBackgroundColor(UIHelper.getColorForName(getItem(position).first));
2702 return v;
2703 }
2704
2705 public int getPosition(String s) {
2706 for(int i = 0; i < getCount(); i++) {
2707 if (getItem(i).first.equals(s)) return i;
2708 }
2709 return -1;
2710 }
2711
2712 public int countExceptCancel() {
2713 int count = 0;
2714 for(int i = 0; i < getCount(); i++) {
2715 if (!getItem(i).first.equals("cancel")) count++;
2716 }
2717 return count;
2718 }
2719
2720 public void clearExceptCancel() {
2721 Pair<String,String> cancelItem = null;
2722 for(int i = 0; i < getCount(); i++) {
2723 if (getItem(i).first.equals("cancel")) cancelItem = getItem(i);
2724 }
2725 clear();
2726 if (cancelItem != null) add(cancelItem);
2727 }
2728 }
2729
2730 final int TYPE_ERROR = 1;
2731 final int TYPE_NOTE = 2;
2732 final int TYPE_WEB = 3;
2733 final int TYPE_RESULT_FIELD = 4;
2734 final int TYPE_TEXT_FIELD = 5;
2735 final int TYPE_CHECKBOX_FIELD = 6;
2736 final int TYPE_SPINNER_FIELD = 7;
2737 final int TYPE_RADIO_EDIT_FIELD = 8;
2738 final int TYPE_RESULT_CELL = 9;
2739 final int TYPE_PROGRESSBAR = 10;
2740 final int TYPE_SEARCH_LIST_FIELD = 11;
2741 final int TYPE_ITEM_CARD = 12;
2742 final int TYPE_BUTTON_GRID_FIELD = 13;
2743 final int TYPE_SLIDER_FIELD = 14;
2744
2745 protected boolean executing = false;
2746 protected boolean loading = false;
2747 protected boolean loadingHasBeenLong = false;
2748 protected Timer loadingTimer = new Timer();
2749 protected String mTitle;
2750 protected String mNode;
2751 protected CommandPageBinding mBinding = null;
2752 protected IqPacket response = null;
2753 protected Element responseElement = null;
2754 protected boolean expectingRemoval = false;
2755 protected List<Field> reported = null;
2756 protected SparseArray<Item> items = new SparseArray<>();
2757 protected XmppConnectionService xmppConnectionService;
2758 protected ActionsAdapter actionsAdapter;
2759 protected GridLayoutManager layoutManager;
2760 protected WebView actionToWebview = null;
2761 protected int fillableFieldCount = 0;
2762 protected IqPacket pendingResponsePacket = null;
2763 protected boolean waitingForRefresh = false;
2764
2765 CommandSession(String title, String node, XmppConnectionService xmppConnectionService) {
2766 loading();
2767 mTitle = title;
2768 mNode = node;
2769 this.xmppConnectionService = xmppConnectionService;
2770 if (mPager != null) setupLayoutManager();
2771 actionsAdapter = new ActionsAdapter(xmppConnectionService);
2772 actionsAdapter.registerDataSetObserver(new DataSetObserver() {
2773 @Override
2774 public void onChanged() {
2775 if (mBinding == null) return;
2776
2777 mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
2778 }
2779
2780 @Override
2781 public void onInvalidated() {}
2782 });
2783 }
2784
2785 public String getTitle() {
2786 return mTitle;
2787 }
2788
2789 public String getNode() {
2790 return mNode;
2791 }
2792
2793 public void updateWithResponse(final IqPacket iq) {
2794 if (getView() != null && getView().isAttachedToWindow()) {
2795 getView().post(() -> updateWithResponseUiThread(iq));
2796 } else {
2797 pendingResponsePacket = iq;
2798 }
2799 }
2800
2801 protected void updateWithResponseUiThread(final IqPacket iq) {
2802 Timer oldTimer = this.loadingTimer;
2803 this.loadingTimer = new Timer();
2804 oldTimer.cancel();
2805 this.executing = false;
2806 this.loading = false;
2807 this.loadingHasBeenLong = false;
2808 this.responseElement = null;
2809 this.fillableFieldCount = 0;
2810 this.reported = null;
2811 this.response = iq;
2812 this.items.clear();
2813 this.actionsAdapter.clear();
2814 layoutManager.setSpanCount(1);
2815
2816 boolean actionsCleared = false;
2817 Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
2818 if (iq.getType() == IqPacket.TYPE.RESULT && command != null) {
2819 if (mNode.equals("jabber:iq:register") && command.getAttribute("status") != null && command.getAttribute("status").equals("completed")) {
2820 xmppConnectionService.createContact(getAccount().getRoster().getContact(iq.getFrom()), true);
2821 }
2822
2823 if (xmppConnectionService.isOnboarding() && mNode.equals("jabber:iq:register") && !"canceled".equals(command.getAttribute("status")) && xmppConnectionService.getPreferences().contains("onboarding_action")) {
2824 xmppConnectionService.getPreferences().edit().putBoolean("onboarding_continued", true).commit();
2825 }
2826
2827 Element actions = command.findChild("actions", "http://jabber.org/protocol/commands");
2828 if (actions != null) {
2829 for (Element action : actions.getChildren()) {
2830 if (!"http://jabber.org/protocol/commands".equals(action.getNamespace())) continue;
2831 if ("execute".equals(action.getName())) continue;
2832
2833 actionsAdapter.add(Pair.create(action.getName(), action.getName()));
2834 }
2835 }
2836
2837 for (Element el : command.getChildren()) {
2838 if ("x".equals(el.getName()) && "jabber:x:data".equals(el.getNamespace())) {
2839 Data form = Data.parse(el);
2840 String title = form.getTitle();
2841 if (title != null) {
2842 mTitle = title;
2843 ConversationPagerAdapter.this.notifyDataSetChanged();
2844 }
2845
2846 if ("result".equals(el.getAttribute("type")) || "form".equals(el.getAttribute("type"))) {
2847 this.responseElement = el;
2848 setupReported(el.findChild("reported", "jabber:x:data"));
2849 if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager());
2850 }
2851
2852 eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
2853 if (actionList != null) {
2854 actionsAdapter.clear();
2855
2856 for (Option action : actionList.getOptions()) {
2857 actionsAdapter.add(Pair.create(action.getValue(), action.toString()));
2858 }
2859 }
2860
2861 String fillableFieldType = null;
2862 String fillableFieldValue = null;
2863 for (eu.siacs.conversations.xmpp.forms.Field field : form.getFields()) {
2864 if ((field.getType() == null || (!field.getType().equals("hidden") && !field.getType().equals("fixed"))) && field.getFieldName() != null && !field.getFieldName().equals("http://jabber.org/protocol/commands#actions")) {
2865 fillableFieldType = field.getType();
2866 fillableFieldValue = field.getValue();
2867 fillableFieldCount++;
2868 }
2869 }
2870
2871 if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 2 && fillableFieldType != null && (fillableFieldType.equals("list-single") || (fillableFieldType.equals("boolean") && fillableFieldValue == null))) {
2872 actionsCleared = true;
2873 actionsAdapter.clearExceptCancel();
2874 }
2875 break;
2876 }
2877 if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
2878 String url = el.findChildContent("url", "jabber:x:oob");
2879 if (url != null) {
2880 String scheme = Uri.parse(url).getScheme();
2881 if (scheme.equals("http") || scheme.equals("https")) {
2882 this.responseElement = el;
2883 break;
2884 }
2885 if (scheme.equals("xmpp")) {
2886 expectingRemoval = true;
2887 final Intent intent = new Intent(getView().getContext(), UriHandlerActivity.class);
2888 intent.setAction(Intent.ACTION_VIEW);
2889 intent.setData(Uri.parse(url));
2890 getView().getContext().startActivity(intent);
2891 break;
2892 }
2893 }
2894 }
2895 if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2896 this.responseElement = el;
2897 break;
2898 }
2899 }
2900
2901 if (responseElement == null && command.getAttribute("status") != null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
2902 if ("jabber:iq:register".equals(mNode) && "canceled".equals(command.getAttribute("status"))) {
2903 if (xmppConnectionService.isOnboarding()) {
2904 if (xmppConnectionService.getPreferences().contains("onboarding_action")) {
2905 xmppConnectionService.deleteAccount(getAccount());
2906 } else {
2907 if (xmppConnectionService.getPreferences().getBoolean("onboarding_continued", false)) {
2908 removeSession(this);
2909 return;
2910 } else {
2911 xmppConnectionService.getPreferences().edit().putString("onboarding_action", "cancel").commit();
2912 xmppConnectionService.deleteAccount(getAccount());
2913 }
2914 }
2915 }
2916 xmppConnectionService.archiveConversation(Conversation.this);
2917 }
2918
2919 expectingRemoval = true;
2920 removeSession(this);
2921 return;
2922 }
2923
2924 if ("executing".equals(command.getAttribute("status")) && actionsAdapter.countExceptCancel() < 1 && !actionsCleared) {
2925 // No actions have been given, but we are not done?
2926 // This is probably a spec violation, but we should do *something*
2927 actionsAdapter.add(Pair.create("execute", "execute"));
2928 }
2929
2930 if (!actionsAdapter.isEmpty() || fillableFieldCount > 0) {
2931 if ("completed".equals(command.getAttribute("status")) || "canceled".equals(command.getAttribute("status"))) {
2932 actionsAdapter.add(Pair.create("close", "close"));
2933 } else if (actionsAdapter.getPosition("cancel") < 0 && !xmppConnectionService.isOnboarding()) {
2934 actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
2935 }
2936 }
2937 }
2938
2939 if (actionsAdapter.isEmpty()) {
2940 actionsAdapter.add(Pair.create("close", "close"));
2941 }
2942
2943 actionsAdapter.sort((x, y) -> {
2944 if (x.first.equals("cancel")) return -1;
2945 if (y.first.equals("cancel")) return 1;
2946 if (x.first.equals("prev") && xmppConnectionService.isOnboarding()) return -1;
2947 if (y.first.equals("prev") && xmppConnectionService.isOnboarding()) return 1;
2948 return 0;
2949 });
2950
2951 Data dataForm = null;
2952 if (responseElement != null && responseElement.getName().equals("x") && responseElement.getNamespace().equals("jabber:x:data")) dataForm = Data.parse(responseElement);
2953 if (mNode.equals("jabber:iq:register") &&
2954 xmppConnectionService.getPreferences().contains("onboarding_action") &&
2955 dataForm != null && dataForm.getFieldByName("gateway-jid") != null) {
2956
2957
2958 dataForm.put("gateway-jid", xmppConnectionService.getPreferences().getString("onboarding_action", ""));
2959 execute();
2960 }
2961 xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
2962 notifyDataSetChanged();
2963 }
2964
2965 protected void setupReported(Element el) {
2966 if (el == null) {
2967 reported = null;
2968 return;
2969 }
2970
2971 reported = new ArrayList<>();
2972 for (Element fieldEl : el.getChildren()) {
2973 if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
2974 reported.add(mkField(fieldEl));
2975 }
2976 }
2977
2978 @Override
2979 public int getItemCount() {
2980 if (loading) return 1;
2981 if (response == null) return 0;
2982 if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
2983 int i = 0;
2984 for (Element el : responseElement.getChildren()) {
2985 if (!el.getNamespace().equals("jabber:x:data")) continue;
2986 if (el.getName().equals("title")) continue;
2987 if (el.getName().equals("field")) {
2988 String type = el.getAttribute("type");
2989 if (type != null && type.equals("hidden")) continue;
2990 if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
2991 }
2992
2993 if (el.getName().equals("reported") || el.getName().equals("item")) {
2994 if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
2995 if (el.getName().equals("reported")) continue;
2996 i += 1;
2997 } else {
2998 if (reported != null) i += reported.size();
2999 }
3000 continue;
3001 }
3002
3003 i++;
3004 }
3005 return i;
3006 }
3007 return 1;
3008 }
3009
3010 public Item getItem(int position) {
3011 if (loading) return new Item(null, TYPE_PROGRESSBAR);
3012 if (items.get(position) != null) return items.get(position);
3013 if (response == null) return null;
3014
3015 if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null) {
3016 if (responseElement.getNamespace().equals("jabber:x:data")) {
3017 int i = 0;
3018 for (Element el : responseElement.getChildren()) {
3019 if (!el.getNamespace().equals("jabber:x:data")) continue;
3020 if (el.getName().equals("title")) continue;
3021 if (el.getName().equals("field")) {
3022 String type = el.getAttribute("type");
3023 if (type != null && type.equals("hidden")) continue;
3024 if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
3025 }
3026
3027 if (el.getName().equals("reported") || el.getName().equals("item")) {
3028 Cell cell = null;
3029
3030 if (reported != null) {
3031 if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
3032 if (el.getName().equals("reported")) continue;
3033 if (i == position) {
3034 items.put(position, new Item(el, TYPE_ITEM_CARD));
3035 return items.get(position);
3036 }
3037 } else {
3038 if (reported.size() > position - i) {
3039 Field reportedField = reported.get(position - i);
3040 Element itemField = null;
3041 if (el.getName().equals("item")) {
3042 for (Element subel : el.getChildren()) {
3043 if (subel.getAttribute("var").equals(reportedField.getVar())) {
3044 itemField = subel;
3045 break;
3046 }
3047 }
3048 }
3049 cell = new Cell(reportedField, itemField);
3050 } else {
3051 i += reported.size();
3052 continue;
3053 }
3054 }
3055 }
3056
3057 if (cell != null) {
3058 items.put(position, cell);
3059 return cell;
3060 }
3061 }
3062
3063 if (i < position) {
3064 i++;
3065 continue;
3066 }
3067
3068 return mkItem(el, position);
3069 }
3070 }
3071 }
3072
3073 return mkItem(responseElement == null ? response : responseElement, position);
3074 }
3075
3076 @Override
3077 public int getItemViewType(int position) {
3078 return getItem(position).viewType;
3079 }
3080
3081 @Override
3082 public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
3083 switch(viewType) {
3084 case TYPE_ERROR: {
3085 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3086 return new ErrorViewHolder(binding);
3087 }
3088 case TYPE_NOTE: {
3089 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3090 return new NoteViewHolder(binding);
3091 }
3092 case TYPE_WEB: {
3093 CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
3094 return new WebViewHolder(binding);
3095 }
3096 case TYPE_RESULT_FIELD: {
3097 CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
3098 return new ResultFieldViewHolder(binding);
3099 }
3100 case TYPE_RESULT_CELL: {
3101 CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
3102 return new ResultCellViewHolder(binding);
3103 }
3104 case TYPE_ITEM_CARD: {
3105 CommandItemCardBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_item_card, container, false);
3106 return new ItemCardViewHolder(binding);
3107 }
3108 case TYPE_CHECKBOX_FIELD: {
3109 CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
3110 return new CheckboxFieldViewHolder(binding);
3111 }
3112 case TYPE_SEARCH_LIST_FIELD: {
3113 CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
3114 return new SearchListFieldViewHolder(binding);
3115 }
3116 case TYPE_RADIO_EDIT_FIELD: {
3117 CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
3118 return new RadioEditFieldViewHolder(binding);
3119 }
3120 case TYPE_SPINNER_FIELD: {
3121 CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
3122 return new SpinnerFieldViewHolder(binding);
3123 }
3124 case TYPE_BUTTON_GRID_FIELD: {
3125 CommandButtonGridFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_button_grid_field, container, false);
3126 return new ButtonGridFieldViewHolder(binding);
3127 }
3128 case TYPE_TEXT_FIELD: {
3129 CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
3130 return new TextFieldViewHolder(binding);
3131 }
3132 case TYPE_SLIDER_FIELD: {
3133 CommandSliderFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_slider_field, container, false);
3134 return new SliderFieldViewHolder(binding);
3135 }
3136 case TYPE_PROGRESSBAR: {
3137 CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
3138 return new ProgressBarViewHolder(binding);
3139 }
3140 default:
3141 if (expectingRemoval) {
3142 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3143 return new NoteViewHolder(binding);
3144 }
3145
3146 throw new IllegalArgumentException("Unknown viewType: " + viewType + " based on: " + response + ", " + responseElement + ", " + expectingRemoval);
3147 }
3148 }
3149
3150 @Override
3151 public void onBindViewHolder(ViewHolder viewHolder, int position) {
3152 viewHolder.bind(getItem(position));
3153 }
3154
3155 public View getView() {
3156 if (mBinding == null) return null;
3157 return mBinding.getRoot();
3158 }
3159
3160 public boolean validate() {
3161 int count = getItemCount();
3162 boolean isValid = true;
3163 for (int i = 0; i < count; i++) {
3164 boolean oneIsValid = getItem(i).validate();
3165 isValid = isValid && oneIsValid;
3166 }
3167 notifyDataSetChanged();
3168 return isValid;
3169 }
3170
3171 public boolean execute() {
3172 return execute("execute");
3173 }
3174
3175 public boolean execute(int actionPosition) {
3176 return execute(actionsAdapter.getItem(actionPosition).first);
3177 }
3178
3179 public synchronized boolean execute(String action) {
3180 if (!"cancel".equals(action) && executing) {
3181 loadingHasBeenLong = true;
3182 notifyDataSetChanged();
3183 return false;
3184 }
3185 if (!action.equals("cancel") && !action.equals("prev") && !validate()) return false;
3186
3187 if (response == null) return true;
3188 Element command = response.findChild("command", "http://jabber.org/protocol/commands");
3189 if (command == null) return true;
3190 String status = command.getAttribute("status");
3191 if (status == null || (!status.equals("executing") && !action.equals("prev"))) return true;
3192
3193 if (actionToWebview != null && !action.equals("cancel") && Build.VERSION.SDK_INT >= 23) {
3194 actionToWebview.postWebMessage(new WebMessage("xmpp_xep0050/" + action), Uri.parse("*"));
3195 return false;
3196 }
3197
3198 final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
3199 packet.setTo(response.getFrom());
3200 final Element c = packet.addChild("command", Namespace.COMMANDS);
3201 c.setAttribute("node", mNode);
3202 c.setAttribute("sessionid", command.getAttribute("sessionid"));
3203
3204 String formType = responseElement == null ? null : responseElement.getAttribute("type");
3205 if (!action.equals("cancel") &&
3206 !action.equals("prev") &&
3207 responseElement != null &&
3208 responseElement.getName().equals("x") &&
3209 responseElement.getNamespace().equals("jabber:x:data") &&
3210 formType != null && formType.equals("form")) {
3211
3212 Data form = Data.parse(responseElement);
3213 eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
3214 if (actionList != null) {
3215 actionList.setValue(action);
3216 c.setAttribute("action", "execute");
3217 }
3218
3219 if (mNode.equals("jabber:iq:register") && xmppConnectionService.isOnboarding() && form.getFieldByName("gateway-jid") != null) {
3220 if (form.getValue("gateway-jid") == null) {
3221 xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
3222 } else {
3223 xmppConnectionService.getPreferences().edit().putString("onboarding_action", form.getValue("gateway-jid")).commit();
3224 }
3225 }
3226
3227 responseElement.setAttribute("type", "submit");
3228 Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
3229 if (rsm != null) {
3230 Element max = new Element("max", "http://jabber.org/protocol/rsm");
3231 max.setContent("1000");
3232 rsm.addChild(max);
3233 }
3234
3235 c.addChild(responseElement);
3236 }
3237
3238 if (c.getAttribute("action") == null) c.setAttribute("action", action);
3239
3240 executing = true;
3241 xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
3242 updateWithResponse(iq);
3243 }, 120L);
3244
3245 loading();
3246 return false;
3247 }
3248
3249 public void refresh() {
3250 synchronized(this) {
3251 if (waitingForRefresh) notifyDataSetChanged();
3252 }
3253 }
3254
3255 protected void loading() {
3256 View v = getView();
3257 try {
3258 loadingTimer.schedule(new TimerTask() {
3259 @Override
3260 public void run() {
3261 View v2 = getView();
3262 loading = true;
3263
3264 try {
3265 loadingTimer.schedule(new TimerTask() {
3266 @Override
3267 public void run() {
3268 loadingHasBeenLong = true;
3269 if (v == null && v2 == null) return;
3270 (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3271 }
3272 }, 3000);
3273 } catch (final IllegalStateException e) { }
3274
3275 if (v == null && v2 == null) return;
3276 (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3277 }
3278 }, 500);
3279 } catch (final IllegalStateException e) { }
3280 }
3281
3282 protected GridLayoutManager setupLayoutManager() {
3283 int spanCount = 1;
3284
3285 Context ctx = mPager == null ? getView().getContext() : mPager.getContext();
3286 if (reported != null) {
3287 float screenWidth = ctx.getResources().getDisplayMetrics().widthPixels;
3288 TextPaint paint = ((TextView) LayoutInflater.from(mPager.getContext()).inflate(R.layout.command_result_cell, null)).getPaint();
3289 float tableHeaderWidth = reported.stream().reduce(
3290 0f,
3291 (total, field) -> total + StaticLayout.getDesiredWidth(field.getLabel().or("--------") + "\t", paint),
3292 (a, b) -> a + b
3293 );
3294
3295 spanCount = tableHeaderWidth > 0.59 * screenWidth ? 1 : this.reported.size();
3296 }
3297
3298 if (layoutManager != null && layoutManager.getSpanCount() != spanCount) {
3299 items.clear();
3300 notifyDataSetChanged();
3301 }
3302
3303 layoutManager = new GridLayoutManager(ctx, spanCount);
3304 layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
3305 @Override
3306 public int getSpanSize(int position) {
3307 if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
3308 return 1;
3309 }
3310 });
3311 return layoutManager;
3312 }
3313
3314 protected void setBinding(CommandPageBinding b) {
3315 mBinding = b;
3316 // https://stackoverflow.com/a/32350474/8611
3317 mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
3318 @Override
3319 public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
3320 if(rv.getChildCount() > 0) {
3321 int[] location = new int[2];
3322 rv.getLocationOnScreen(location);
3323 View childView = rv.findChildViewUnder(e.getX(), e.getY());
3324 if (childView instanceof ViewGroup) {
3325 childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
3326 }
3327 int action = e.getAction();
3328 switch (action) {
3329 case MotionEvent.ACTION_DOWN:
3330 if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(1)) || childView instanceof WebView) {
3331 rv.requestDisallowInterceptTouchEvent(true);
3332 }
3333 case MotionEvent.ACTION_UP:
3334 if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(-11)) || childView instanceof WebView) {
3335 rv.requestDisallowInterceptTouchEvent(true);
3336 }
3337 }
3338 }
3339
3340 return false;
3341 }
3342
3343 @Override
3344 public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }
3345
3346 @Override
3347 public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
3348 });
3349 mBinding.form.setLayoutManager(setupLayoutManager());
3350 mBinding.form.setAdapter(this);
3351 mBinding.actions.setAdapter(actionsAdapter);
3352 mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
3353 if (execute(pos)) {
3354 removeSession(CommandSession.this);
3355 }
3356 });
3357
3358 actionsAdapter.notifyDataSetChanged();
3359
3360 if (pendingResponsePacket != null) {
3361 final IqPacket pending = pendingResponsePacket;
3362 pendingResponsePacket = null;
3363 updateWithResponseUiThread(pending);
3364 }
3365 }
3366
3367 private Drawable getDrawableForSVG(SVG svg, Element svgElement, int size) {
3368 if (svgElement != null && svgElement.getChildren().size() == 1 && svgElement.getChildren().get(0).getName().equals("image")) {
3369 return getDrawableForUrl(svgElement.getChildren().get(0).getAttribute("href"));
3370 } else {
3371 return xmppConnectionService.getFileBackend().drawSVG(svg, size);
3372 }
3373 }
3374
3375 private Drawable getDrawableForUrl(final String url) {
3376 final LruCache<String, Drawable> cache = xmppConnectionService.getDrawableCache();
3377 final HttpConnectionManager httpManager = xmppConnectionService.getHttpConnectionManager();
3378 final Drawable d = cache.get(url);
3379 if (Build.VERSION.SDK_INT >= 28 && d instanceof AnimatedImageDrawable) ((AnimatedImageDrawable) d).start();
3380 if (d == null) {
3381 synchronized (CommandSession.this) {
3382 waitingForRefresh = true;
3383 }
3384 int size = (int)(xmppConnectionService.getResources().getDisplayMetrics().density * 288);
3385 Message dummy = new Message(Conversation.this, url, Message.ENCRYPTION_NONE);
3386 dummy.setFileParams(new Message.FileParams(url));
3387 httpManager.createNewDownloadConnection(dummy, true, (file) -> {
3388 if (file == null) {
3389 dummy.getTransferable().start();
3390 } else {
3391 try {
3392 xmppConnectionService.getFileBackend().getThumbnail(file, xmppConnectionService.getResources(), size, false, url);
3393 } catch (final Exception e) { }
3394 }
3395 });
3396 }
3397 return d;
3398 }
3399
3400 public View inflateUi(Context context, Consumer<ConversationPage> remover) {
3401 CommandPageBinding binding = DataBindingUtil.inflate(LayoutInflater.from(context), R.layout.command_page, null, false);
3402 setBinding(binding);
3403 return binding.getRoot();
3404 }
3405
3406 // https://stackoverflow.com/a/36037991/8611
3407 private View findViewAt(ViewGroup viewGroup, float x, float y) {
3408 for(int i = 0; i < viewGroup.getChildCount(); i++) {
3409 View child = viewGroup.getChildAt(i);
3410 if (child instanceof ViewGroup && !(child instanceof AbsListView) && !(child instanceof WebView)) {
3411 View foundView = findViewAt((ViewGroup) child, x, y);
3412 if (foundView != null && foundView.isShown()) {
3413 return foundView;
3414 }
3415 } else {
3416 int[] location = new int[2];
3417 child.getLocationOnScreen(location);
3418 Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
3419 if (rect.contains((int)x, (int)y)) {
3420 return child;
3421 }
3422 }
3423 }
3424
3425 return null;
3426 }
3427 }
3428 }
3429
3430 public static class Thread {
3431 protected Message subject = null;
3432 protected Message first = null;
3433 protected Message last = null;
3434 protected final String threadId;
3435
3436 protected Thread(final String threadId) {
3437 this.threadId = threadId;
3438 }
3439
3440 public String getThreadId() {
3441 return threadId;
3442 }
3443
3444 public String getSubject() {
3445 if (subject == null) return null;
3446
3447 return subject.getSubject();
3448 }
3449
3450 public String getDisplay() {
3451 final String s = getSubject();
3452 if (s != null) return s;
3453
3454 if (first != null) {
3455 return first.getBody();
3456 }
3457
3458 return "";
3459 }
3460
3461 public long getLastTime() {
3462 if (last == null) return 0;
3463
3464 return last.getTimeSent();
3465 }
3466 }
3467}