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 void startMucConfig(XmppConnectionService xmppConnectionService) {
1410 pagerAdapter.startMucConfig(xmppConnectionService);
1411 }
1412
1413 public boolean switchToSession(final String node) {
1414 return pagerAdapter.switchToSession(node);
1415 }
1416
1417 public void setupViewPager(ViewPager pager, TabLayout tabs, boolean onboarding, Conversation oldConversation) {
1418 pagerAdapter.setupViewPager(pager, tabs, onboarding, oldConversation);
1419 }
1420
1421 public void showViewPager() {
1422 pagerAdapter.show();
1423 }
1424
1425 public void hideViewPager() {
1426 pagerAdapter.hide();
1427 }
1428
1429 public interface OnMessageFound {
1430 void onMessageFound(final Message message);
1431 }
1432
1433 public static class Draft {
1434 private final String message;
1435 private final long timestamp;
1436
1437 private Draft(String message, long timestamp) {
1438 this.message = message;
1439 this.timestamp = timestamp;
1440 }
1441
1442 public long getTimestamp() {
1443 return timestamp;
1444 }
1445
1446 public String getMessage() {
1447 return message;
1448 }
1449 }
1450
1451 public class ConversationPagerAdapter extends PagerAdapter {
1452 protected ViewPager mPager = null;
1453 protected TabLayout mTabs = null;
1454 ArrayList<ConversationPage> sessions = null;
1455 protected View page1 = null;
1456 protected View page2 = null;
1457 protected boolean mOnboarding = false;
1458
1459 public void setupViewPager(ViewPager pager, TabLayout tabs, boolean onboarding, Conversation oldConversation) {
1460 mPager = pager;
1461 mTabs = tabs;
1462 mOnboarding = onboarding;
1463
1464 if (oldConversation != null) {
1465 oldConversation.pagerAdapter.mPager = null;
1466 oldConversation.pagerAdapter.mTabs = null;
1467 }
1468
1469 if (mPager == null) {
1470 page1 = null;
1471 page2 = null;
1472 return;
1473 }
1474 if (sessions != null) show();
1475
1476 if (pager.getChildAt(0) != null) page1 = pager.getChildAt(0);
1477 if (pager.getChildAt(1) != null) page2 = pager.getChildAt(1);
1478 if (page2 != null && page2.findViewById(R.id.commands_view) == null) {
1479 page1 = null;
1480 page2 = null;
1481 }
1482 if (page1 == null) page1 = oldConversation.pagerAdapter.page1;
1483 if (page2 == null) page2 = oldConversation.pagerAdapter.page2;
1484 if (page1 == null || page2 == null) {
1485 throw new IllegalStateException("page1 or page2 were not present as child or in model?");
1486 }
1487 pager.removeView(page1);
1488 pager.removeView(page2);
1489 pager.setAdapter(this);
1490 tabs.setupWithViewPager(mPager);
1491 pager.post(() -> pager.setCurrentItem(getCurrentTab()));
1492
1493 mPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
1494 public void onPageScrollStateChanged(int state) { }
1495 public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { }
1496
1497 public void onPageSelected(int position) {
1498 setCurrentTab(position);
1499 }
1500 });
1501 }
1502
1503 public void show() {
1504 if (sessions == null) {
1505 sessions = new ArrayList<>();
1506 notifyDataSetChanged();
1507 }
1508 if (!mOnboarding && mTabs != null) mTabs.setVisibility(View.VISIBLE);
1509 }
1510
1511 public void hide() {
1512 if (sessions != null && !sessions.isEmpty()) return; // Do not hide during active session
1513 if (mPager != null) mPager.setCurrentItem(0);
1514 if (mTabs != null) mTabs.setVisibility(View.GONE);
1515 sessions = null;
1516 notifyDataSetChanged();
1517 }
1518
1519 public void refreshSessions() {
1520 if (sessions == null) return;
1521
1522 for (ConversationPage session : sessions) {
1523 session.refresh();
1524 }
1525 }
1526
1527 public void startWebxdc(WebxdcPage page) {
1528 show();
1529 sessions.add(page);
1530 notifyDataSetChanged();
1531 if (mPager != null) mPager.setCurrentItem(getCount() - 1);
1532 }
1533
1534 public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
1535 show();
1536 CommandSession session = new CommandSession(command.getAttribute("name"), command.getAttribute("node"), xmppConnectionService);
1537
1538 final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
1539 packet.setTo(command.getAttributeAsJid("jid"));
1540 final Element c = packet.addChild("command", Namespace.COMMANDS);
1541 c.setAttribute("node", command.getAttribute("node"));
1542 c.setAttribute("action", "execute");
1543
1544 final TimerTask task = new TimerTask() {
1545 @Override
1546 public void run() {
1547 if (getAccount().getStatus() != Account.State.ONLINE) {
1548 final TimerTask self = this;
1549 new Timer().schedule(new TimerTask() {
1550 @Override
1551 public void run() {
1552 self.run();
1553 }
1554 }, 1000);
1555 } else {
1556 xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
1557 session.updateWithResponse(iq);
1558 }, 120L);
1559 }
1560 }
1561 };
1562
1563 if (command.getAttribute("node").equals("jabber:iq:register") && packet.getTo().asBareJid().equals(Jid.of("cheogram.com"))) {
1564 new com.cheogram.android.CheogramLicenseChecker(mPager.getContext(), (signedData, signature) -> {
1565 if (signedData != null && signature != null) {
1566 c.addChild("license", "https://ns.cheogram.com/google-play").setContent(signedData);
1567 c.addChild("licenseSignature", "https://ns.cheogram.com/google-play").setContent(signature);
1568 }
1569
1570 task.run();
1571 }).checkLicense();
1572 } else {
1573 task.run();
1574 }
1575
1576 sessions.add(session);
1577 notifyDataSetChanged();
1578 if (mPager != null) mPager.setCurrentItem(getCount() - 1);
1579 }
1580
1581 public void startMucConfig(XmppConnectionService xmppConnectionService) {
1582 MucConfigSession session = new MucConfigSession(xmppConnectionService);
1583 final IqPacket packet = new IqPacket(IqPacket.TYPE.GET);
1584 packet.setTo(Conversation.this.getJid().asBareJid());
1585 packet.addChild("query", "http://jabber.org/protocol/muc#owner");
1586
1587 final TimerTask task = new TimerTask() {
1588 @Override
1589 public void run() {
1590 if (getAccount().getStatus() != Account.State.ONLINE) {
1591 final TimerTask self = this;
1592 new Timer().schedule(new TimerTask() {
1593 @Override
1594 public void run() {
1595 self.run();
1596 }
1597 }, 1000);
1598 } else {
1599 xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
1600 session.updateWithResponse(iq);
1601 }, 120L);
1602 }
1603 }
1604 };
1605 task.run();
1606
1607 sessions.add(session);
1608 notifyDataSetChanged();
1609 if (mPager != null) mPager.setCurrentItem(getCount() - 1);
1610 }
1611
1612 public void removeSession(ConversationPage session) {
1613 sessions.remove(session);
1614 notifyDataSetChanged();
1615 if (session instanceof WebxdcPage) mPager.setCurrentItem(0);
1616 }
1617
1618 public boolean switchToSession(final String node) {
1619 if (sessions == null) return false;
1620
1621 int i = 0;
1622 for (ConversationPage session : sessions) {
1623 if (session.getNode().equals(node)) {
1624 if (mPager != null) mPager.setCurrentItem(i + 2);
1625 return true;
1626 }
1627 i++;
1628 }
1629
1630 return false;
1631 }
1632
1633 @NonNull
1634 @Override
1635 public Object instantiateItem(@NonNull ViewGroup container, int position) {
1636 if (position == 0) {
1637 if (page1 != null && page1.getParent() != null) {
1638 ((ViewGroup) page1.getParent()).removeView(page1);
1639 }
1640 container.addView(page1);
1641 return page1;
1642 }
1643 if (position == 1) {
1644 if (page2 != null && page2.getParent() != null) {
1645 ((ViewGroup) page2.getParent()).removeView(page2);
1646 }
1647 container.addView(page2);
1648 return page2;
1649 }
1650
1651 ConversationPage session = sessions.get(position-2);
1652 View v = session.inflateUi(container.getContext(), (s) -> removeSession(s));
1653 if (v != null && v.getParent() != null) {
1654 ((ViewGroup) v.getParent()).removeView(v);
1655 }
1656 container.addView(v);
1657 return session;
1658 }
1659
1660 @Override
1661 public void destroyItem(@NonNull ViewGroup container, int position, Object o) {
1662 if (position < 2) {
1663 container.removeView((View) o);
1664 return;
1665 }
1666
1667 container.removeView(((ConversationPage) o).getView());
1668 }
1669
1670 @Override
1671 public int getItemPosition(Object o) {
1672 if (mPager != null) {
1673 if (o == page1) return PagerAdapter.POSITION_UNCHANGED;
1674 if (o == page2) return PagerAdapter.POSITION_UNCHANGED;
1675 }
1676
1677 int pos = sessions == null ? -1 : sessions.indexOf(o);
1678 if (pos < 0) return PagerAdapter.POSITION_NONE;
1679 return pos + 2;
1680 }
1681
1682 @Override
1683 public int getCount() {
1684 if (sessions == null) return 1;
1685
1686 int count = 2 + sessions.size();
1687 if (mTabs == null) return count;
1688
1689 if (count > 2) {
1690 mTabs.setTabMode(TabLayout.MODE_SCROLLABLE);
1691 } else {
1692 mTabs.setTabMode(TabLayout.MODE_FIXED);
1693 }
1694 return count;
1695 }
1696
1697 @Override
1698 public boolean isViewFromObject(@NonNull View view, @NonNull Object o) {
1699 if (view == o) return true;
1700
1701 if (o instanceof ConversationPage) {
1702 return ((ConversationPage) o).getView() == view;
1703 }
1704
1705 return false;
1706 }
1707
1708 @Nullable
1709 @Override
1710 public CharSequence getPageTitle(int position) {
1711 switch (position) {
1712 case 0:
1713 return "Conversation";
1714 case 1:
1715 return "Commands";
1716 default:
1717 ConversationPage session = sessions.get(position-2);
1718 if (session == null) return super.getPageTitle(position);
1719 return session.getTitle();
1720 }
1721 }
1722
1723 class CommandSession extends RecyclerView.Adapter<CommandSession.ViewHolder> implements ConversationPage {
1724 abstract class ViewHolder<T extends ViewDataBinding> extends RecyclerView.ViewHolder {
1725 protected T binding;
1726
1727 public ViewHolder(T binding) {
1728 super(binding.getRoot());
1729 this.binding = binding;
1730 }
1731
1732 abstract public void bind(Item el);
1733
1734 protected void setTextOrHide(TextView v, Optional<String> s) {
1735 if (s == null || !s.isPresent()) {
1736 v.setVisibility(View.GONE);
1737 } else {
1738 v.setVisibility(View.VISIBLE);
1739 v.setText(s.get());
1740 }
1741 }
1742
1743 protected void setupInputType(Element field, TextView textinput, TextInputLayout layout) {
1744 int flags = 0;
1745 if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_NONE);
1746 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1747
1748 String type = field.getAttribute("type");
1749 if (type != null) {
1750 if (type.equals("text-multi") || type.equals("jid-multi")) {
1751 flags |= InputType.TYPE_TEXT_FLAG_MULTI_LINE;
1752 }
1753
1754 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1755
1756 if (type.equals("jid-single") || type.equals("jid-multi")) {
1757 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
1758 }
1759
1760 if (type.equals("text-private")) {
1761 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
1762 if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_PASSWORD_TOGGLE);
1763 }
1764 }
1765
1766 Element validate = field.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1767 if (validate == null) return;
1768 String datatype = validate.getAttribute("datatype");
1769 if (datatype == null) return;
1770
1771 if (datatype.equals("xs:integer") || datatype.equals("xs:int") || datatype.equals("xs:long") || datatype.equals("xs:short") || datatype.equals("xs:byte")) {
1772 textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED);
1773 }
1774
1775 if (datatype.equals("xs:decimal") || datatype.equals("xs:double")) {
1776 textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED | InputType.TYPE_NUMBER_FLAG_DECIMAL);
1777 }
1778
1779 if (datatype.equals("xs:date")) {
1780 textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_DATE);
1781 }
1782
1783 if (datatype.equals("xs:dateTime")) {
1784 textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_NORMAL);
1785 }
1786
1787 if (datatype.equals("xs:time")) {
1788 textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_TIME);
1789 }
1790
1791 if (datatype.equals("xs:anyURI")) {
1792 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
1793 }
1794
1795 if (datatype.equals("html:tel")) {
1796 textinput.setInputType(flags | InputType.TYPE_CLASS_PHONE);
1797 }
1798
1799 if (datatype.equals("html:email")) {
1800 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
1801 }
1802 }
1803
1804 protected String formatValue(String datatype, String value, boolean compact) {
1805 if ("xs:dateTime".equals(datatype)) {
1806 ZonedDateTime zonedDateTime = null;
1807 try {
1808 zonedDateTime = ZonedDateTime.parse(value, DateTimeFormatter.ISO_DATE_TIME);
1809 } catch (final DateTimeParseException e) {
1810 try {
1811 DateTimeFormatter almostIso = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm[:ss] X");
1812 zonedDateTime = ZonedDateTime.parse(value, almostIso);
1813 } catch (final DateTimeParseException e2) { }
1814 }
1815 if (zonedDateTime == null) return value;
1816 ZonedDateTime localZonedDateTime = zonedDateTime.withZoneSameInstant(ZoneId.systemDefault());
1817 DateTimeFormatter outputFormat = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT);
1818 return localZonedDateTime.toLocalDateTime().format(outputFormat);
1819 }
1820
1821 if ("html:tel".equals(datatype) && !compact) {
1822 return PhoneNumberUtils.formatNumber(value, value, null);
1823 }
1824
1825 return value;
1826 }
1827 }
1828
1829 class ErrorViewHolder extends ViewHolder<CommandNoteBinding> {
1830 public ErrorViewHolder(CommandNoteBinding binding) { super(binding); }
1831
1832 @Override
1833 public void bind(Item iq) {
1834 binding.errorIcon.setVisibility(View.VISIBLE);
1835
1836 Element error = iq.el.findChild("error");
1837 if (error == null) return;
1838 String text = error.findChildContent("text", "urn:ietf:params:xml:ns:xmpp-stanzas");
1839 if (text == null || text.equals("")) {
1840 text = error.getChildren().get(0).getName();
1841 }
1842 binding.message.setText(text);
1843 }
1844 }
1845
1846 class NoteViewHolder extends ViewHolder<CommandNoteBinding> {
1847 public NoteViewHolder(CommandNoteBinding binding) { super(binding); }
1848
1849 @Override
1850 public void bind(Item note) {
1851 binding.message.setText(note != null && note.el != null ? note.el.getContent() : "");
1852
1853 String type = note.el.getAttribute("type");
1854 if (type != null && type.equals("error")) {
1855 binding.errorIcon.setVisibility(View.VISIBLE);
1856 }
1857 }
1858 }
1859
1860 class ResultFieldViewHolder extends ViewHolder<CommandResultFieldBinding> {
1861 public ResultFieldViewHolder(CommandResultFieldBinding binding) { super(binding); }
1862
1863 @Override
1864 public void bind(Item item) {
1865 Field field = (Field) item;
1866 setTextOrHide(binding.label, field.getLabel());
1867 setTextOrHide(binding.desc, field.getDesc());
1868
1869 Element media = field.el.findChild("media", "urn:xmpp:media-element");
1870 if (media == null) {
1871 binding.mediaImage.setVisibility(View.GONE);
1872 } else {
1873 final LruCache<String, Drawable> cache = xmppConnectionService.getDrawableCache();
1874 final HttpConnectionManager httpManager = xmppConnectionService.getHttpConnectionManager();
1875 for (Element uriEl : media.getChildren()) {
1876 if (!"uri".equals(uriEl.getName())) continue;
1877 if (!"urn:xmpp:media-element".equals(uriEl.getNamespace())) continue;
1878 String mimeType = uriEl.getAttribute("type");
1879 String uriS = uriEl.getContent();
1880 if (mimeType == null || uriS == null) continue;
1881 Uri uri = Uri.parse(uriS);
1882 if (mimeType.startsWith("image/") && "https".equals(uri.getScheme())) {
1883 final Drawable d = getDrawableForUrl(uri.toString());
1884 if (d != null) {
1885 binding.mediaImage.setImageDrawable(d);
1886 binding.mediaImage.setVisibility(View.VISIBLE);
1887 }
1888 }
1889 }
1890 }
1891
1892 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1893 String datatype = validate == null ? null : validate.getAttribute("datatype");
1894
1895 ArrayAdapter<Option> values = new ArrayAdapter<>(binding.getRoot().getContext(), R.layout.simple_list_item);
1896 for (Element el : field.el.getChildren()) {
1897 if (el.getName().equals("value") && el.getNamespace().equals("jabber:x:data")) {
1898 values.add(new Option(el.getContent(), formatValue(datatype, el.getContent(), false)));
1899 }
1900 }
1901 binding.values.setAdapter(values);
1902 Util.justifyListViewHeightBasedOnChildren(binding.values);
1903
1904 if (field.getType().equals(Optional.of("jid-single")) || field.getType().equals(Optional.of("jid-multi"))) {
1905 binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
1906 new FixedURLSpan("xmpp:" + Uri.encode(Jid.ofEscaped(values.getItem(pos).getValue()).toEscapedString(), "@/+"), account).onClick(binding.values);
1907 });
1908 } else if ("xs:anyURI".equals(datatype)) {
1909 binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
1910 new FixedURLSpan(values.getItem(pos).getValue(), account).onClick(binding.values);
1911 });
1912 } else if ("html:tel".equals(datatype)) {
1913 binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
1914 try {
1915 new FixedURLSpan("tel:" + PhoneNumberUtilWrapper.normalize(binding.getRoot().getContext(), values.getItem(pos).getValue()), account).onClick(binding.values);
1916 } catch (final IllegalArgumentException | NumberParseException | NullPointerException e) { }
1917 });
1918 }
1919
1920 binding.values.setOnItemLongClickListener((arg0, arg1, pos, id) -> {
1921 if (ShareUtil.copyTextToClipboard(binding.getRoot().getContext(), values.getItem(pos).getValue(), R.string.message)) {
1922 Toast.makeText(binding.getRoot().getContext(), R.string.message_copied_to_clipboard, Toast.LENGTH_SHORT).show();
1923 }
1924 return true;
1925 });
1926 }
1927 }
1928
1929 class ResultCellViewHolder extends ViewHolder<CommandResultCellBinding> {
1930 public ResultCellViewHolder(CommandResultCellBinding binding) { super(binding); }
1931
1932 @Override
1933 public void bind(Item item) {
1934 Cell cell = (Cell) item;
1935
1936 if (cell.el == null) {
1937 binding.text.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Subhead);
1938 setTextOrHide(binding.text, cell.reported.getLabel());
1939 } else {
1940 Element validate = cell.reported.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1941 String datatype = validate == null ? null : validate.getAttribute("datatype");
1942 String value = formatValue(datatype, cell.el.findChildContent("value", "jabber:x:data"), true);
1943 SpannableStringBuilder text = new SpannableStringBuilder(value == null ? "" : value);
1944 if (cell.reported.getType().equals(Optional.of("jid-single"))) {
1945 text.setSpan(new FixedURLSpan("xmpp:" + Jid.ofEscaped(text.toString()).toEscapedString(), account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1946 } else if ("xs:anyURI".equals(datatype)) {
1947 text.setSpan(new FixedURLSpan(text.toString(), account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1948 } else if ("html:tel".equals(datatype)) {
1949 try {
1950 text.setSpan(new FixedURLSpan("tel:" + PhoneNumberUtilWrapper.normalize(binding.getRoot().getContext(), text.toString()), account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1951 } catch (final IllegalArgumentException | NumberParseException | NullPointerException e) { }
1952 }
1953
1954 binding.text.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Body1);
1955 binding.text.setText(text);
1956
1957 BetterLinkMovementMethod method = BetterLinkMovementMethod.newInstance();
1958 method.setOnLinkLongClickListener((tv, url) -> {
1959 tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
1960 ShareUtil.copyLinkToClipboard(binding.getRoot().getContext(), url);
1961 return true;
1962 });
1963 binding.text.setMovementMethod(method);
1964 }
1965 }
1966 }
1967
1968 class ItemCardViewHolder extends ViewHolder<CommandItemCardBinding> {
1969 public ItemCardViewHolder(CommandItemCardBinding binding) { super(binding); }
1970
1971 @Override
1972 public void bind(Item item) {
1973 binding.fields.removeAllViews();
1974
1975 for (Field field : reported) {
1976 CommandResultFieldBinding row = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.command_result_field, binding.fields, false);
1977 GridLayout.LayoutParams param = new GridLayout.LayoutParams();
1978 param.columnSpec = GridLayout.spec(GridLayout.UNDEFINED, GridLayout.FILL, 1f);
1979 param.width = 0;
1980 row.getRoot().setLayoutParams(param);
1981 binding.fields.addView(row.getRoot());
1982 for (Element el : item.el.getChildren()) {
1983 if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data") && el.getAttribute("var") != null && el.getAttribute("var").equals(field.getVar())) {
1984 for (String label : field.getLabel().asSet()) {
1985 el.setAttribute("label", label);
1986 }
1987 for (String desc : field.getDesc().asSet()) {
1988 el.setAttribute("desc", desc);
1989 }
1990 for (String type : field.getType().asSet()) {
1991 el.setAttribute("type", type);
1992 }
1993 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1994 if (validate != null) el.addChild(validate);
1995 new ResultFieldViewHolder(row).bind(new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), -1));
1996 }
1997 }
1998 }
1999 }
2000 }
2001
2002 class CheckboxFieldViewHolder extends ViewHolder<CommandCheckboxFieldBinding> implements CompoundButton.OnCheckedChangeListener {
2003 public CheckboxFieldViewHolder(CommandCheckboxFieldBinding binding) {
2004 super(binding);
2005 binding.row.setOnClickListener((v) -> {
2006 binding.checkbox.toggle();
2007 });
2008 binding.checkbox.setOnCheckedChangeListener(this);
2009 }
2010 protected Element mValue = null;
2011
2012 @Override
2013 public void bind(Item item) {
2014 Field field = (Field) item;
2015 binding.label.setText(field.getLabel().or(""));
2016 setTextOrHide(binding.desc, field.getDesc());
2017 mValue = field.getValue();
2018 binding.checkbox.setChecked(mValue.getContent() != null && (mValue.getContent().equals("true") || mValue.getContent().equals("1")));
2019 }
2020
2021 @Override
2022 public void onCheckedChanged(CompoundButton checkbox, boolean isChecked) {
2023 if (mValue == null) return;
2024
2025 mValue.setContent(isChecked ? "true" : "false");
2026 }
2027 }
2028
2029 class SearchListFieldViewHolder extends ViewHolder<CommandSearchListFieldBinding> implements TextWatcher {
2030 public SearchListFieldViewHolder(CommandSearchListFieldBinding binding) {
2031 super(binding);
2032 binding.search.addTextChangedListener(this);
2033 }
2034 protected Field field = null;
2035 Set<String> filteredValues;
2036 List<Option> options = new ArrayList<>();
2037 protected ArrayAdapter<Option> adapter;
2038 protected boolean open;
2039 protected boolean multi;
2040
2041 @Override
2042 public void bind(Item item) {
2043 field = (Field) item;
2044 setTextOrHide(binding.label, field.getLabel());
2045 setTextOrHide(binding.desc, field.getDesc());
2046
2047 if (field.error != null) {
2048 binding.desc.setVisibility(View.VISIBLE);
2049 binding.desc.setText(field.error);
2050 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
2051 } else {
2052 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
2053 }
2054
2055 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2056 open = validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null;
2057 setupInputType(field.el, binding.search, null);
2058
2059 multi = field.getType().equals(Optional.of("list-multi"));
2060 if (multi) {
2061 binding.list.setChoiceMode(AbsListView.CHOICE_MODE_MULTIPLE);
2062 } else {
2063 binding.list.setChoiceMode(AbsListView.CHOICE_MODE_SINGLE);
2064 }
2065
2066 options = field.getOptions();
2067 binding.list.setOnItemClickListener((parent, view, position, id) -> {
2068 Set<String> values = new HashSet<>();
2069 if (multi) {
2070 values.addAll(field.getValues());
2071 for (final String value : field.getValues()) {
2072 if (filteredValues.contains(value)) {
2073 values.remove(value);
2074 }
2075 }
2076 }
2077
2078 SparseBooleanArray positions = binding.list.getCheckedItemPositions();
2079 for (int i = 0; i < positions.size(); i++) {
2080 if (positions.valueAt(i)) values.add(adapter.getItem(positions.keyAt(i)).getValue());
2081 }
2082 field.setValues(values);
2083
2084 if (!multi && open) binding.search.setText(String.join("\n", field.getValues()));
2085 });
2086 search("");
2087 }
2088
2089 @Override
2090 public void afterTextChanged(Editable s) {
2091 if (!multi && open) field.setValues(List.of(s.toString()));
2092 search(s.toString());
2093 }
2094
2095 @Override
2096 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2097
2098 @Override
2099 public void onTextChanged(CharSequence s, int start, int count, int after) { }
2100
2101 protected void search(String s) {
2102 List<Option> filteredOptions;
2103 final String q = s.replaceAll("\\W", "").toLowerCase();
2104 if (q == null || q.equals("")) {
2105 filteredOptions = options;
2106 } else {
2107 filteredOptions = options.stream().filter(o -> o.toString().replaceAll("\\W", "").toLowerCase().contains(q)).collect(Collectors.toList());
2108 }
2109 filteredValues = filteredOptions.stream().map(o -> o.getValue()).collect(Collectors.toSet());
2110 adapter = new ArrayAdapter(binding.getRoot().getContext(), R.layout.simple_list_item, filteredOptions);
2111 binding.list.setAdapter(adapter);
2112
2113 for (final String value : field.getValues()) {
2114 int checkedPos = filteredOptions.indexOf(new Option(value, ""));
2115 if (checkedPos >= 0) binding.list.setItemChecked(checkedPos, true);
2116 }
2117 }
2118 }
2119
2120 class RadioEditFieldViewHolder extends ViewHolder<CommandRadioEditFieldBinding> implements CompoundButton.OnCheckedChangeListener, TextWatcher {
2121 public RadioEditFieldViewHolder(CommandRadioEditFieldBinding binding) {
2122 super(binding);
2123 binding.open.addTextChangedListener(this);
2124 options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.radio_grid_item) {
2125 @Override
2126 public View getView(int position, View convertView, ViewGroup parent) {
2127 CompoundButton v = (CompoundButton) super.getView(position, convertView, parent);
2128 v.setId(position);
2129 v.setChecked(getItem(position).getValue().equals(mValue.getContent()));
2130 v.setOnCheckedChangeListener(RadioEditFieldViewHolder.this);
2131 return v;
2132 }
2133 };
2134 }
2135 protected Element mValue = null;
2136 protected ArrayAdapter<Option> options;
2137
2138 @Override
2139 public void bind(Item item) {
2140 Field field = (Field) item;
2141 setTextOrHide(binding.label, field.getLabel());
2142 setTextOrHide(binding.desc, field.getDesc());
2143
2144 if (field.error != null) {
2145 binding.desc.setVisibility(View.VISIBLE);
2146 binding.desc.setText(field.error);
2147 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
2148 } else {
2149 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
2150 }
2151
2152 mValue = field.getValue();
2153
2154 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2155 binding.open.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
2156 binding.open.setText(mValue.getContent());
2157 setupInputType(field.el, binding.open, null);
2158
2159 options.clear();
2160 List<Option> theOptions = field.getOptions();
2161 options.addAll(theOptions);
2162
2163 float screenWidth = binding.getRoot().getContext().getResources().getDisplayMetrics().widthPixels;
2164 TextPaint paint = ((TextView) LayoutInflater.from(binding.getRoot().getContext()).inflate(R.layout.radio_grid_item, null)).getPaint();
2165 float maxColumnWidth = theOptions.stream().map((x) ->
2166 StaticLayout.getDesiredWidth(x.toString(), paint)
2167 ).max(Float::compare).orElse(new Float(0.0));
2168 if (maxColumnWidth * theOptions.size() < 0.90 * screenWidth) {
2169 binding.radios.setNumColumns(theOptions.size());
2170 } else if (maxColumnWidth * (theOptions.size() / 2) < 0.90 * screenWidth) {
2171 binding.radios.setNumColumns(theOptions.size() / 2);
2172 } else {
2173 binding.radios.setNumColumns(1);
2174 }
2175 binding.radios.setAdapter(options);
2176 }
2177
2178 @Override
2179 public void onCheckedChanged(CompoundButton radio, boolean isChecked) {
2180 if (mValue == null) return;
2181
2182 if (isChecked) {
2183 mValue.setContent(options.getItem(radio.getId()).getValue());
2184 binding.open.setText(mValue.getContent());
2185 }
2186 options.notifyDataSetChanged();
2187 }
2188
2189 @Override
2190 public void afterTextChanged(Editable s) {
2191 if (mValue == null) return;
2192
2193 mValue.setContent(s.toString());
2194 options.notifyDataSetChanged();
2195 }
2196
2197 @Override
2198 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2199
2200 @Override
2201 public void onTextChanged(CharSequence s, int start, int count, int after) { }
2202 }
2203
2204 class SpinnerFieldViewHolder extends ViewHolder<CommandSpinnerFieldBinding> implements AdapterView.OnItemSelectedListener {
2205 public SpinnerFieldViewHolder(CommandSpinnerFieldBinding binding) {
2206 super(binding);
2207 binding.spinner.setOnItemSelectedListener(this);
2208 }
2209 protected Element mValue = null;
2210
2211 @Override
2212 public void bind(Item item) {
2213 Field field = (Field) item;
2214 setTextOrHide(binding.label, field.getLabel());
2215 binding.spinner.setPrompt(field.getLabel().or(""));
2216 setTextOrHide(binding.desc, field.getDesc());
2217
2218 mValue = field.getValue();
2219
2220 ArrayAdapter<Option> options = new ArrayAdapter<Option>(binding.getRoot().getContext(), android.R.layout.simple_spinner_item);
2221 options.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
2222 options.addAll(field.getOptions());
2223
2224 binding.spinner.setAdapter(options);
2225 binding.spinner.setSelection(options.getPosition(new Option(mValue.getContent(), null)));
2226 }
2227
2228 @Override
2229 public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
2230 Option o = (Option) parent.getItemAtPosition(pos);
2231 if (mValue == null) return;
2232
2233 mValue.setContent(o == null ? "" : o.getValue());
2234 }
2235
2236 @Override
2237 public void onNothingSelected(AdapterView<?> parent) {
2238 mValue.setContent("");
2239 }
2240 }
2241
2242 class ButtonGridFieldViewHolder extends ViewHolder<CommandButtonGridFieldBinding> {
2243 public ButtonGridFieldViewHolder(CommandButtonGridFieldBinding binding) {
2244 super(binding);
2245 options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.button_grid_item) {
2246 protected int height = 0;
2247
2248 @Override
2249 public View getView(int position, View convertView, ViewGroup parent) {
2250 Button v = (Button) super.getView(position, convertView, parent);
2251 v.setOnClickListener((view) -> {
2252 mValue.setContent(getItem(position).getValue());
2253 execute();
2254 loading = true;
2255 });
2256
2257 final SVG icon = getItem(position).getIcon();
2258 if (icon != null) {
2259 final Element iconEl = getItem(position).getIconEl();
2260 if (height < 1) {
2261 v.measure(0, 0);
2262 height = v.getMeasuredHeight();
2263 }
2264 if (height < 1) return v;
2265 if (mediaSelector) {
2266 final Drawable d = getDrawableForSVG(icon, iconEl, height * 4);
2267 if (d != null) {
2268 final int boundsHeight = 35 + (int)((height * 4) / xmppConnectionService.getResources().getDisplayMetrics().density);
2269 d.setBounds(0, 0, d.getIntrinsicWidth(), boundsHeight);
2270 }
2271 v.setCompoundDrawables(null, d, null, null);
2272 } else {
2273 v.setCompoundDrawablesRelativeWithIntrinsicBounds(getDrawableForSVG(icon, iconEl, height), null, null, null);
2274 }
2275 }
2276
2277 return v;
2278 }
2279 };
2280 }
2281 protected Element mValue = null;
2282 protected ArrayAdapter<Option> options;
2283 protected Option defaultOption = null;
2284 protected boolean mediaSelector = false;
2285
2286 @Override
2287 public void bind(Item item) {
2288 Field field = (Field) item;
2289 setTextOrHide(binding.label, field.getLabel());
2290 setTextOrHide(binding.desc, field.getDesc());
2291
2292 if (field.error != null) {
2293 binding.desc.setVisibility(View.VISIBLE);
2294 binding.desc.setText(field.error);
2295 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
2296 } else {
2297 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
2298 }
2299
2300 mValue = field.getValue();
2301 mediaSelector = field.el.findChild("media-selector", "https://ns.cheogram.com/") != null;
2302
2303 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2304 binding.openButton.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
2305 binding.openButton.setOnClickListener((view) -> {
2306 AlertDialog.Builder builder = new AlertDialog.Builder(binding.getRoot().getContext());
2307 DialogQuickeditBinding dialogBinding = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.dialog_quickedit, null, false);
2308 builder.setPositiveButton(R.string.action_execute, null);
2309 if (field.getDesc().isPresent()) {
2310 dialogBinding.inputLayout.setHint(field.getDesc().get());
2311 }
2312 dialogBinding.inputEditText.requestFocus();
2313 dialogBinding.inputEditText.getText().append(mValue.getContent());
2314 builder.setView(dialogBinding.getRoot());
2315 builder.setNegativeButton(R.string.cancel, null);
2316 final AlertDialog dialog = builder.create();
2317 dialog.setOnShowListener(d -> SoftKeyboardUtils.showKeyboard(dialogBinding.inputEditText));
2318 dialog.show();
2319 View.OnClickListener clickListener = v -> {
2320 String value = dialogBinding.inputEditText.getText().toString();
2321 mValue.setContent(value);
2322 SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2323 dialog.dismiss();
2324 execute();
2325 loading = true;
2326 };
2327 dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener(clickListener);
2328 dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setOnClickListener((v -> {
2329 SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2330 dialog.dismiss();
2331 }));
2332 dialog.setCanceledOnTouchOutside(false);
2333 dialog.setOnDismissListener(dialog1 -> {
2334 SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2335 });
2336 });
2337
2338 options.clear();
2339 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();
2340
2341 defaultOption = null;
2342 for (Option option : theOptions) {
2343 if (option.getValue().equals(mValue.getContent())) {
2344 defaultOption = option;
2345 break;
2346 }
2347 }
2348 if (defaultOption == null && !mValue.getContent().equals("")) {
2349 // Synthesize default option for custom value
2350 defaultOption = new Option(mValue.getContent(), mValue.getContent());
2351 }
2352 if (defaultOption == null) {
2353 binding.defaultButton.setVisibility(View.GONE);
2354 } else {
2355 theOptions.remove(defaultOption);
2356 binding.defaultButton.setVisibility(View.VISIBLE);
2357
2358 final SVG defaultIcon = defaultOption.getIcon();
2359 if (defaultIcon != null) {
2360 DisplayMetrics display = mPager.getContext().getResources().getDisplayMetrics();
2361 int height = (int)(display.heightPixels*display.density/4);
2362 binding.defaultButton.setCompoundDrawablesRelativeWithIntrinsicBounds(null, getDrawableForSVG(defaultIcon, defaultOption.getIconEl(), height), null, null);
2363 }
2364
2365 binding.defaultButton.setText(defaultOption.toString());
2366 binding.defaultButton.setOnClickListener((view) -> {
2367 mValue.setContent(defaultOption.getValue());
2368 execute();
2369 loading = true;
2370 });
2371 }
2372
2373 options.addAll(theOptions);
2374 binding.buttons.setAdapter(options);
2375 }
2376 }
2377
2378 class TextFieldViewHolder extends ViewHolder<CommandTextFieldBinding> implements TextWatcher {
2379 public TextFieldViewHolder(CommandTextFieldBinding binding) {
2380 super(binding);
2381 binding.textinput.addTextChangedListener(this);
2382 }
2383 protected Field field = null;
2384
2385 @Override
2386 public void bind(Item item) {
2387 field = (Field) item;
2388 binding.textinputLayout.setHint(field.getLabel().or(""));
2389
2390 binding.textinputLayout.setHelperTextEnabled(field.getDesc().isPresent());
2391 for (String desc : field.getDesc().asSet()) {
2392 binding.textinputLayout.setHelperText(desc);
2393 }
2394
2395 binding.textinputLayout.setErrorEnabled(field.error != null);
2396 if (field.error != null) binding.textinputLayout.setError(field.error);
2397
2398 binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_GRAVITY);
2399 String suffixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/suffix-label");
2400 if (suffixLabel == null) {
2401 binding.textinputLayout.setSuffixText("");
2402 } else {
2403 binding.textinputLayout.setSuffixText(suffixLabel);
2404 binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_TEXT_END);
2405 }
2406
2407 String prefixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/prefix-label");
2408 binding.textinputLayout.setPrefixText(prefixLabel == null ? "" : prefixLabel);
2409
2410 binding.textinput.setText(String.join("\n", field.getValues()));
2411 setupInputType(field.el, binding.textinput, binding.textinputLayout);
2412 }
2413
2414 @Override
2415 public void afterTextChanged(Editable s) {
2416 if (field == null) return;
2417
2418 field.setValues(List.of(s.toString().split("\n")));
2419 }
2420
2421 @Override
2422 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2423
2424 @Override
2425 public void onTextChanged(CharSequence s, int start, int count, int after) { }
2426 }
2427
2428 class SliderFieldViewHolder extends ViewHolder<CommandSliderFieldBinding> {
2429 public SliderFieldViewHolder(CommandSliderFieldBinding binding) { super(binding); }
2430 protected Field field = null;
2431
2432 @Override
2433 public void bind(Item item) {
2434 field = (Field) item;
2435 setTextOrHide(binding.label, field.getLabel());
2436 setTextOrHide(binding.desc, field.getDesc());
2437 final Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2438 final String datatype = validate == null ? null : validate.getAttribute("datatype");
2439 final Element range = validate == null ? null : validate.findChild("range", "http://jabber.org/protocol/xdata-validate");
2440 // NOTE: range also implies open, so we don't have to be bound by the options strictly
2441 // Also, we don't have anywhere to put labels so we show only values, which might sometimes be bad...
2442 Float min = null;
2443 try { min = range.getAttribute("min") == null ? null : Float.valueOf(range.getAttribute("min")); } catch (NumberFormatException e) { }
2444 Float max = null;
2445 try { max = range.getAttribute("max") == null ? null : Float.valueOf(range.getAttribute("max")); } catch (NumberFormatException e) { }
2446
2447 List<Float> options = field.getOptions().stream().map(o -> Float.valueOf(o.getValue())).collect(Collectors.toList());
2448 Collections.sort(options);
2449 if (options.size() > 0) {
2450 // min/max should be on the range, but if you have options and didn't put them on the range we can imply
2451 if (min == null) min = options.get(0);
2452 if (max == null) max = options.get(options.size()-1);
2453 }
2454
2455 if (field.getValues().size() > 0) binding.slider.setValue(Float.valueOf(field.getValue().getContent()));
2456 binding.slider.setValueFrom(min == null ? Float.MIN_VALUE : min);
2457 binding.slider.setValueTo(max == null ? Float.MAX_VALUE : max);
2458 if ("xs:integer".equals(datatype) || "xs:int".equals(datatype) || "xs:long".equals(datatype) || "xs:short".equals(datatype) || "xs:byte".equals(datatype)) {
2459 binding.slider.setStepSize(1);
2460 } else {
2461 binding.slider.setStepSize(0);
2462 }
2463
2464 if (options.size() > 0) {
2465 float step = -1;
2466 Float prev = null;
2467 for (final Float option : options) {
2468 if (prev != null) {
2469 float nextStep = option - prev;
2470 if (step > 0 && step != nextStep) {
2471 step = -1;
2472 break;
2473 }
2474 step = nextStep;
2475 }
2476 prev = option;
2477 }
2478 if (step > 0) binding.slider.setStepSize(step);
2479 }
2480
2481 binding.slider.addOnChangeListener((slider, value, fromUser) -> {
2482 field.setValues(List.of(new DecimalFormat().format(value)));
2483 });
2484 }
2485 }
2486
2487 class WebViewHolder extends ViewHolder<CommandWebviewBinding> {
2488 public WebViewHolder(CommandWebviewBinding binding) { super(binding); }
2489 protected String boundUrl = "";
2490
2491 @Override
2492 public void bind(Item oob) {
2493 setTextOrHide(binding.desc, Optional.fromNullable(oob.el.findChildContent("desc", "jabber:x:oob")));
2494 binding.webview.getSettings().setJavaScriptEnabled(true);
2495 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");
2496 binding.webview.getSettings().setDatabaseEnabled(true);
2497 binding.webview.getSettings().setDomStorageEnabled(true);
2498 binding.webview.setWebChromeClient(new WebChromeClient() {
2499 @Override
2500 public void onProgressChanged(WebView view, int newProgress) {
2501 binding.progressbar.setVisibility(newProgress < 100 ? View.VISIBLE : View.GONE);
2502 binding.progressbar.setProgress(newProgress);
2503 }
2504 });
2505 binding.webview.setWebViewClient(new WebViewClient() {
2506 @Override
2507 public void onPageFinished(WebView view, String url) {
2508 super.onPageFinished(view, url);
2509 mTitle = view.getTitle();
2510 ConversationPagerAdapter.this.notifyDataSetChanged();
2511 }
2512 });
2513 final String url = oob.el.findChildContent("url", "jabber:x:oob");
2514 if (!boundUrl.equals(url)) {
2515 binding.webview.addJavascriptInterface(new JsObject(), "xmpp_xep0050");
2516 binding.webview.loadUrl(url);
2517 boundUrl = url;
2518 }
2519 }
2520
2521 class JsObject {
2522 @JavascriptInterface
2523 public void execute() { execute("execute"); }
2524
2525 @JavascriptInterface
2526 public void execute(String action) {
2527 getView().post(() -> {
2528 actionToWebview = null;
2529 if(CommandSession.this.execute(action)) {
2530 removeSession(CommandSession.this);
2531 }
2532 });
2533 }
2534
2535 @JavascriptInterface
2536 public void preventDefault() {
2537 actionToWebview = binding.webview;
2538 }
2539 }
2540 }
2541
2542 class ProgressBarViewHolder extends ViewHolder<CommandProgressBarBinding> {
2543 public ProgressBarViewHolder(CommandProgressBarBinding binding) { super(binding); }
2544
2545 @Override
2546 public void bind(Item item) {
2547 binding.text.setVisibility(loadingHasBeenLong ? View.VISIBLE : View.GONE);
2548 }
2549 }
2550
2551 class Item {
2552 protected Element el;
2553 protected int viewType;
2554 protected String error = null;
2555
2556 Item(Element el, int viewType) {
2557 this.el = el;
2558 this.viewType = viewType;
2559 }
2560
2561 public boolean validate() {
2562 error = null;
2563 return true;
2564 }
2565 }
2566
2567 class Field extends Item {
2568 Field(eu.siacs.conversations.xmpp.forms.Field el, int viewType) { super(el, viewType); }
2569
2570 @Override
2571 public boolean validate() {
2572 if (!super.validate()) return false;
2573 if (el.findChild("required", "jabber:x:data") == null) return true;
2574 if (getValue().getContent() != null && !getValue().getContent().equals("")) return true;
2575
2576 error = "this value is required";
2577 return false;
2578 }
2579
2580 public String getVar() {
2581 return el.getAttribute("var");
2582 }
2583
2584 public Optional<String> getType() {
2585 return Optional.fromNullable(el.getAttribute("type"));
2586 }
2587
2588 public Optional<String> getLabel() {
2589 String label = el.getAttribute("label");
2590 if (label == null) label = getVar();
2591 return Optional.fromNullable(label);
2592 }
2593
2594 public Optional<String> getDesc() {
2595 return Optional.fromNullable(el.findChildContent("desc", "jabber:x:data"));
2596 }
2597
2598 public Element getValue() {
2599 Element value = el.findChild("value", "jabber:x:data");
2600 if (value == null) {
2601 value = el.addChild("value", "jabber:x:data");
2602 }
2603 return value;
2604 }
2605
2606 public void setValues(Collection<String> values) {
2607 for(Element child : el.getChildren()) {
2608 if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
2609 el.removeChild(child);
2610 }
2611 }
2612
2613 for (String value : values) {
2614 el.addChild("value", "jabber:x:data").setContent(value);
2615 }
2616 }
2617
2618 public List<String> getValues() {
2619 List<String> values = new ArrayList<>();
2620 for(Element child : el.getChildren()) {
2621 if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
2622 values.add(child.getContent());
2623 }
2624 }
2625 return values;
2626 }
2627
2628 public List<Option> getOptions() {
2629 return Option.forField(el);
2630 }
2631 }
2632
2633 class Cell extends Item {
2634 protected Field reported;
2635
2636 Cell(Field reported, Element item) {
2637 super(item, TYPE_RESULT_CELL);
2638 this.reported = reported;
2639 }
2640 }
2641
2642 protected Field mkField(Element el) {
2643 int viewType = -1;
2644
2645 String formType = responseElement.getAttribute("type");
2646 if (formType != null) {
2647 String fieldType = el.getAttribute("type");
2648 if (fieldType == null) fieldType = "text-single";
2649
2650 if (formType.equals("result") || fieldType.equals("fixed")) {
2651 viewType = TYPE_RESULT_FIELD;
2652 } else if (formType.equals("form")) {
2653 final Element validate = el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2654 final String datatype = validate == null ? null : validate.getAttribute("datatype");
2655 final Element range = validate == null ? null : validate.findChild("range", "http://jabber.org/protocol/xdata-validate");
2656 if (fieldType.equals("boolean")) {
2657 if (fillableFieldCount == 1 && actionsAdapter.countProceed() < 1) {
2658 viewType = TYPE_BUTTON_GRID_FIELD;
2659 } else {
2660 viewType = TYPE_CHECKBOX_FIELD;
2661 }
2662 } else if (
2663 range != null && (
2664 "xs:integer".equals(datatype) || "xs:int".equals(datatype) || "xs:long".equals(datatype) || "xs:short".equals(datatype) || "xs:byte".equals(datatype) ||
2665 "xs:decimal".equals(datatype) || "xs:double".equals(datatype)
2666 )
2667 ) {
2668 // has a range and is numeric, use a slider
2669 viewType = TYPE_SLIDER_FIELD;
2670 } else if (fieldType.equals("list-single")) {
2671 if (fillableFieldCount == 1 && actionsAdapter.countProceed() < 1 && Option.forField(el).size() < 50) {
2672 viewType = TYPE_BUTTON_GRID_FIELD;
2673 } else if (Option.forField(el).size() > 9) {
2674 viewType = TYPE_SEARCH_LIST_FIELD;
2675 } else if (el.findChild("value", "jabber:x:data") == null || (validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null)) {
2676 viewType = TYPE_RADIO_EDIT_FIELD;
2677 } else {
2678 viewType = TYPE_SPINNER_FIELD;
2679 }
2680 } else if (fieldType.equals("list-multi")) {
2681 viewType = TYPE_SEARCH_LIST_FIELD;
2682 } else {
2683 viewType = TYPE_TEXT_FIELD;
2684 }
2685 }
2686
2687 return new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), viewType);
2688 }
2689
2690 return null;
2691 }
2692
2693 protected Item mkItem(Element el, int pos) {
2694 int viewType = -1;
2695
2696 if (response != null && response.getType() == IqPacket.TYPE.RESULT) {
2697 if (el.getName().equals("note")) {
2698 viewType = TYPE_NOTE;
2699 } else if (el.getNamespace().equals("jabber:x:oob")) {
2700 viewType = TYPE_WEB;
2701 } else if (el.getName().equals("instructions") && el.getNamespace().equals("jabber:x:data")) {
2702 viewType = TYPE_NOTE;
2703 } else if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data")) {
2704 Field field = mkField(el);
2705 if (field != null) {
2706 items.put(pos, field);
2707 return field;
2708 }
2709 }
2710 } else if (response != null) {
2711 viewType = TYPE_ERROR;
2712 }
2713
2714 Item item = new Item(el, viewType);
2715 items.put(pos, item);
2716 return item;
2717 }
2718
2719 class ActionsAdapter extends ArrayAdapter<Pair<String, String>> {
2720 protected Context ctx;
2721
2722 public ActionsAdapter(Context ctx) {
2723 super(ctx, R.layout.simple_list_item);
2724 this.ctx = ctx;
2725 }
2726
2727 @Override
2728 public View getView(int position, View convertView, ViewGroup parent) {
2729 View v = super.getView(position, convertView, parent);
2730 TextView tv = (TextView) v.findViewById(android.R.id.text1);
2731 tv.setGravity(Gravity.CENTER);
2732 tv.setText(getItem(position).second);
2733 int resId = ctx.getResources().getIdentifier("action_" + getItem(position).first, "string" , ctx.getPackageName());
2734 if (resId != 0 && getItem(position).second.equals(getItem(position).first)) tv.setText(ctx.getResources().getString(resId));
2735 tv.setTextColor(ContextCompat.getColor(ctx, R.color.white));
2736 tv.setBackgroundColor(UIHelper.getColorForName(getItem(position).first));
2737 return v;
2738 }
2739
2740 public int getPosition(String s) {
2741 for(int i = 0; i < getCount(); i++) {
2742 if (getItem(i).first.equals(s)) return i;
2743 }
2744 return -1;
2745 }
2746
2747 public int countProceed() {
2748 int count = 0;
2749 for(int i = 0; i < getCount(); i++) {
2750 if (!"cancel".equals(getItem(i).first) && !"prev".equals(getItem(i).first)) count++;
2751 }
2752 return count;
2753 }
2754
2755 public int countExceptCancel() {
2756 int count = 0;
2757 for(int i = 0; i < getCount(); i++) {
2758 if (!getItem(i).first.equals("cancel")) count++;
2759 }
2760 return count;
2761 }
2762
2763 public void clearProceed() {
2764 Pair<String,String> cancelItem = null;
2765 Pair<String,String> prevItem = null;
2766 for(int i = 0; i < getCount(); i++) {
2767 if (getItem(i).first.equals("cancel")) cancelItem = getItem(i);
2768 if (getItem(i).first.equals("prev")) prevItem = getItem(i);
2769 }
2770 clear();
2771 if (cancelItem != null) add(cancelItem);
2772 if (prevItem != null) add(prevItem);
2773 }
2774 }
2775
2776 final int TYPE_ERROR = 1;
2777 final int TYPE_NOTE = 2;
2778 final int TYPE_WEB = 3;
2779 final int TYPE_RESULT_FIELD = 4;
2780 final int TYPE_TEXT_FIELD = 5;
2781 final int TYPE_CHECKBOX_FIELD = 6;
2782 final int TYPE_SPINNER_FIELD = 7;
2783 final int TYPE_RADIO_EDIT_FIELD = 8;
2784 final int TYPE_RESULT_CELL = 9;
2785 final int TYPE_PROGRESSBAR = 10;
2786 final int TYPE_SEARCH_LIST_FIELD = 11;
2787 final int TYPE_ITEM_CARD = 12;
2788 final int TYPE_BUTTON_GRID_FIELD = 13;
2789 final int TYPE_SLIDER_FIELD = 14;
2790
2791 protected boolean executing = false;
2792 protected boolean loading = false;
2793 protected boolean loadingHasBeenLong = false;
2794 protected Timer loadingTimer = new Timer();
2795 protected String mTitle;
2796 protected String mNode;
2797 protected CommandPageBinding mBinding = null;
2798 protected IqPacket response = null;
2799 protected Element responseElement = null;
2800 protected boolean expectingRemoval = false;
2801 protected List<Field> reported = null;
2802 protected SparseArray<Item> items = new SparseArray<>();
2803 protected XmppConnectionService xmppConnectionService;
2804 protected ActionsAdapter actionsAdapter;
2805 protected GridLayoutManager layoutManager;
2806 protected WebView actionToWebview = null;
2807 protected int fillableFieldCount = 0;
2808 protected IqPacket pendingResponsePacket = null;
2809 protected boolean waitingForRefresh = false;
2810
2811 CommandSession(String title, String node, XmppConnectionService xmppConnectionService) {
2812 loading();
2813 mTitle = title;
2814 mNode = node;
2815 this.xmppConnectionService = xmppConnectionService;
2816 if (mPager != null) setupLayoutManager();
2817 actionsAdapter = new ActionsAdapter(xmppConnectionService);
2818 actionsAdapter.registerDataSetObserver(new DataSetObserver() {
2819 @Override
2820 public void onChanged() {
2821 if (mBinding == null) return;
2822
2823 mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
2824 }
2825
2826 @Override
2827 public void onInvalidated() {}
2828 });
2829 }
2830
2831 public String getTitle() {
2832 return mTitle;
2833 }
2834
2835 public String getNode() {
2836 return mNode;
2837 }
2838
2839 public void updateWithResponse(final IqPacket iq) {
2840 if (getView() != null && getView().isAttachedToWindow()) {
2841 getView().post(() -> updateWithResponseUiThread(iq));
2842 } else {
2843 pendingResponsePacket = iq;
2844 }
2845 }
2846
2847 protected void updateWithResponseUiThread(final IqPacket iq) {
2848 Timer oldTimer = this.loadingTimer;
2849 this.loadingTimer = new Timer();
2850 oldTimer.cancel();
2851 this.executing = false;
2852 this.loading = false;
2853 this.loadingHasBeenLong = false;
2854 this.responseElement = null;
2855 this.fillableFieldCount = 0;
2856 this.reported = null;
2857 this.response = iq;
2858 this.items.clear();
2859 this.actionsAdapter.clear();
2860 layoutManager.setSpanCount(1);
2861
2862 boolean actionsCleared = false;
2863 Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
2864 if (iq.getType() == IqPacket.TYPE.RESULT && command != null) {
2865 if (mNode.equals("jabber:iq:register") && command.getAttribute("status") != null && command.getAttribute("status").equals("completed")) {
2866 xmppConnectionService.createContact(getAccount().getRoster().getContact(iq.getFrom()), true);
2867 }
2868
2869 if (xmppConnectionService.isOnboarding() && mNode.equals("jabber:iq:register") && !"canceled".equals(command.getAttribute("status")) && xmppConnectionService.getPreferences().contains("onboarding_action")) {
2870 xmppConnectionService.getPreferences().edit().putBoolean("onboarding_continued", true).commit();
2871 }
2872
2873 Element actions = command.findChild("actions", "http://jabber.org/protocol/commands");
2874 if (actions != null) {
2875 for (Element action : actions.getChildren()) {
2876 if (!"http://jabber.org/protocol/commands".equals(action.getNamespace())) continue;
2877 if ("execute".equals(action.getName())) continue;
2878
2879 actionsAdapter.add(Pair.create(action.getName(), action.getName()));
2880 }
2881 }
2882
2883 for (Element el : command.getChildren()) {
2884 if ("x".equals(el.getName()) && "jabber:x:data".equals(el.getNamespace())) {
2885 Data form = Data.parse(el);
2886 String title = form.getTitle();
2887 if (title != null) {
2888 mTitle = title;
2889 ConversationPagerAdapter.this.notifyDataSetChanged();
2890 }
2891
2892 if ("result".equals(el.getAttribute("type")) || "form".equals(el.getAttribute("type"))) {
2893 this.responseElement = el;
2894 setupReported(el.findChild("reported", "jabber:x:data"));
2895 if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager());
2896 }
2897
2898 eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
2899 if (actionList != null) {
2900 actionsAdapter.clear();
2901
2902 for (Option action : actionList.getOptions()) {
2903 actionsAdapter.add(Pair.create(action.getValue(), action.toString()));
2904 }
2905 }
2906
2907 eu.siacs.conversations.xmpp.forms.Field fillableField = null;
2908 for (eu.siacs.conversations.xmpp.forms.Field field : form.getFields()) {
2909 if ((field.getType() == null || (!field.getType().equals("hidden") && !field.getType().equals("fixed"))) && field.getFieldName() != null && !field.getFieldName().equals("http://jabber.org/protocol/commands#actions")) {
2910 fillableField = field;
2911 fillableFieldCount++;
2912 }
2913 }
2914
2915 if (fillableFieldCount == 1 && actionsAdapter.countProceed() < 2 && (("list-single".equals(fillableField.getType()) && Option.forField(fillableField).size() < 50) || ("boolean".equals(fillableField.getType()) && fillableField.getValue() == null))) {
2916 actionsCleared = true;
2917 actionsAdapter.clearProceed();
2918 }
2919 break;
2920 }
2921 if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
2922 String url = el.findChildContent("url", "jabber:x:oob");
2923 if (url != null) {
2924 String scheme = Uri.parse(url).getScheme();
2925 if (scheme.equals("http") || scheme.equals("https")) {
2926 this.responseElement = el;
2927 break;
2928 }
2929 if (scheme.equals("xmpp")) {
2930 expectingRemoval = true;
2931 final Intent intent = new Intent(getView().getContext(), UriHandlerActivity.class);
2932 intent.setAction(Intent.ACTION_VIEW);
2933 intent.setData(Uri.parse(url));
2934 getView().getContext().startActivity(intent);
2935 break;
2936 }
2937 }
2938 }
2939 if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2940 this.responseElement = el;
2941 break;
2942 }
2943 }
2944
2945 if (responseElement == null && command.getAttribute("status") != null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
2946 if ("jabber:iq:register".equals(mNode) && "canceled".equals(command.getAttribute("status"))) {
2947 if (xmppConnectionService.isOnboarding()) {
2948 if (xmppConnectionService.getPreferences().contains("onboarding_action")) {
2949 xmppConnectionService.deleteAccount(getAccount());
2950 } else {
2951 if (xmppConnectionService.getPreferences().getBoolean("onboarding_continued", false)) {
2952 removeSession(this);
2953 return;
2954 } else {
2955 xmppConnectionService.getPreferences().edit().putString("onboarding_action", "cancel").commit();
2956 xmppConnectionService.deleteAccount(getAccount());
2957 }
2958 }
2959 }
2960 xmppConnectionService.archiveConversation(Conversation.this);
2961 }
2962
2963 expectingRemoval = true;
2964 removeSession(this);
2965 return;
2966 }
2967
2968 if ("executing".equals(command.getAttribute("status")) && actionsAdapter.countExceptCancel() < 1 && !actionsCleared) {
2969 // No actions have been given, but we are not done?
2970 // This is probably a spec violation, but we should do *something*
2971 actionsAdapter.add(Pair.create("execute", "execute"));
2972 }
2973
2974 if (!actionsAdapter.isEmpty() || fillableFieldCount > 0) {
2975 if ("completed".equals(command.getAttribute("status")) || "canceled".equals(command.getAttribute("status"))) {
2976 actionsAdapter.add(Pair.create("close", "close"));
2977 } else if (actionsAdapter.getPosition("cancel") < 0 && !xmppConnectionService.isOnboarding()) {
2978 actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
2979 }
2980 }
2981 }
2982
2983 if (actionsAdapter.isEmpty()) {
2984 actionsAdapter.add(Pair.create("close", "close"));
2985 }
2986
2987 actionsAdapter.sort((x, y) -> {
2988 if (x.first.equals("cancel")) return -1;
2989 if (y.first.equals("cancel")) return 1;
2990 if (x.first.equals("prev") && xmppConnectionService.isOnboarding()) return -1;
2991 if (y.first.equals("prev") && xmppConnectionService.isOnboarding()) return 1;
2992 return 0;
2993 });
2994
2995 Data dataForm = null;
2996 if (responseElement != null && responseElement.getName().equals("x") && responseElement.getNamespace().equals("jabber:x:data")) dataForm = Data.parse(responseElement);
2997 if (mNode.equals("jabber:iq:register") &&
2998 xmppConnectionService.getPreferences().contains("onboarding_action") &&
2999 dataForm != null && dataForm.getFieldByName("gateway-jid") != null) {
3000
3001
3002 dataForm.put("gateway-jid", xmppConnectionService.getPreferences().getString("onboarding_action", ""));
3003 execute();
3004 }
3005 xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
3006 notifyDataSetChanged();
3007 }
3008
3009 protected void setupReported(Element el) {
3010 if (el == null) {
3011 reported = null;
3012 return;
3013 }
3014
3015 reported = new ArrayList<>();
3016 for (Element fieldEl : el.getChildren()) {
3017 if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
3018 reported.add(mkField(fieldEl));
3019 }
3020 }
3021
3022 @Override
3023 public int getItemCount() {
3024 if (loading) return 1;
3025 if (response == null) return 0;
3026 if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
3027 int i = 0;
3028 for (Element el : responseElement.getChildren()) {
3029 if (!el.getNamespace().equals("jabber:x:data")) continue;
3030 if (el.getName().equals("title")) continue;
3031 if (el.getName().equals("field")) {
3032 String type = el.getAttribute("type");
3033 if (type != null && type.equals("hidden")) continue;
3034 if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
3035 }
3036
3037 if (el.getName().equals("reported") || el.getName().equals("item")) {
3038 if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
3039 if (el.getName().equals("reported")) continue;
3040 i += 1;
3041 } else {
3042 if (reported != null) i += reported.size();
3043 }
3044 continue;
3045 }
3046
3047 i++;
3048 }
3049 return i;
3050 }
3051 return 1;
3052 }
3053
3054 public Item getItem(int position) {
3055 if (loading) return new Item(null, TYPE_PROGRESSBAR);
3056 if (items.get(position) != null) return items.get(position);
3057 if (response == null) return null;
3058
3059 if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null) {
3060 if (responseElement.getNamespace().equals("jabber:x:data")) {
3061 int i = 0;
3062 for (Element el : responseElement.getChildren()) {
3063 if (!el.getNamespace().equals("jabber:x:data")) continue;
3064 if (el.getName().equals("title")) continue;
3065 if (el.getName().equals("field")) {
3066 String type = el.getAttribute("type");
3067 if (type != null && type.equals("hidden")) continue;
3068 if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
3069 }
3070
3071 if (el.getName().equals("reported") || el.getName().equals("item")) {
3072 Cell cell = null;
3073
3074 if (reported != null) {
3075 if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
3076 if (el.getName().equals("reported")) continue;
3077 if (i == position) {
3078 items.put(position, new Item(el, TYPE_ITEM_CARD));
3079 return items.get(position);
3080 }
3081 } else {
3082 if (reported.size() > position - i) {
3083 Field reportedField = reported.get(position - i);
3084 Element itemField = null;
3085 if (el.getName().equals("item")) {
3086 for (Element subel : el.getChildren()) {
3087 if (subel.getAttribute("var").equals(reportedField.getVar())) {
3088 itemField = subel;
3089 break;
3090 }
3091 }
3092 }
3093 cell = new Cell(reportedField, itemField);
3094 } else {
3095 i += reported.size();
3096 continue;
3097 }
3098 }
3099 }
3100
3101 if (cell != null) {
3102 items.put(position, cell);
3103 return cell;
3104 }
3105 }
3106
3107 if (i < position) {
3108 i++;
3109 continue;
3110 }
3111
3112 return mkItem(el, position);
3113 }
3114 }
3115 }
3116
3117 return mkItem(responseElement == null ? response : responseElement, position);
3118 }
3119
3120 @Override
3121 public int getItemViewType(int position) {
3122 return getItem(position).viewType;
3123 }
3124
3125 @Override
3126 public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
3127 switch(viewType) {
3128 case TYPE_ERROR: {
3129 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3130 return new ErrorViewHolder(binding);
3131 }
3132 case TYPE_NOTE: {
3133 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3134 return new NoteViewHolder(binding);
3135 }
3136 case TYPE_WEB: {
3137 CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
3138 return new WebViewHolder(binding);
3139 }
3140 case TYPE_RESULT_FIELD: {
3141 CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
3142 return new ResultFieldViewHolder(binding);
3143 }
3144 case TYPE_RESULT_CELL: {
3145 CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
3146 return new ResultCellViewHolder(binding);
3147 }
3148 case TYPE_ITEM_CARD: {
3149 CommandItemCardBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_item_card, container, false);
3150 return new ItemCardViewHolder(binding);
3151 }
3152 case TYPE_CHECKBOX_FIELD: {
3153 CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
3154 return new CheckboxFieldViewHolder(binding);
3155 }
3156 case TYPE_SEARCH_LIST_FIELD: {
3157 CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
3158 return new SearchListFieldViewHolder(binding);
3159 }
3160 case TYPE_RADIO_EDIT_FIELD: {
3161 CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
3162 return new RadioEditFieldViewHolder(binding);
3163 }
3164 case TYPE_SPINNER_FIELD: {
3165 CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
3166 return new SpinnerFieldViewHolder(binding);
3167 }
3168 case TYPE_BUTTON_GRID_FIELD: {
3169 CommandButtonGridFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_button_grid_field, container, false);
3170 return new ButtonGridFieldViewHolder(binding);
3171 }
3172 case TYPE_TEXT_FIELD: {
3173 CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
3174 return new TextFieldViewHolder(binding);
3175 }
3176 case TYPE_SLIDER_FIELD: {
3177 CommandSliderFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_slider_field, container, false);
3178 return new SliderFieldViewHolder(binding);
3179 }
3180 case TYPE_PROGRESSBAR: {
3181 CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
3182 return new ProgressBarViewHolder(binding);
3183 }
3184 default:
3185 if (expectingRemoval) {
3186 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3187 return new NoteViewHolder(binding);
3188 }
3189
3190 throw new IllegalArgumentException("Unknown viewType: " + viewType + " based on: " + response + ", " + responseElement + ", " + expectingRemoval);
3191 }
3192 }
3193
3194 @Override
3195 public void onBindViewHolder(ViewHolder viewHolder, int position) {
3196 viewHolder.bind(getItem(position));
3197 }
3198
3199 public View getView() {
3200 if (mBinding == null) return null;
3201 return mBinding.getRoot();
3202 }
3203
3204 public boolean validate() {
3205 int count = getItemCount();
3206 boolean isValid = true;
3207 for (int i = 0; i < count; i++) {
3208 boolean oneIsValid = getItem(i).validate();
3209 isValid = isValid && oneIsValid;
3210 }
3211 notifyDataSetChanged();
3212 return isValid;
3213 }
3214
3215 public boolean execute() {
3216 return execute("execute");
3217 }
3218
3219 public boolean execute(int actionPosition) {
3220 return execute(actionsAdapter.getItem(actionPosition).first);
3221 }
3222
3223 public synchronized boolean execute(String action) {
3224 if (!"cancel".equals(action) && executing) {
3225 loadingHasBeenLong = true;
3226 notifyDataSetChanged();
3227 return false;
3228 }
3229 if (!action.equals("cancel") && !action.equals("prev") && !validate()) return false;
3230
3231 if (response == null) return true;
3232 Element command = response.findChild("command", "http://jabber.org/protocol/commands");
3233 if (command == null) return true;
3234 String status = command.getAttribute("status");
3235 if (status == null || (!status.equals("executing") && !action.equals("prev"))) return true;
3236
3237 if (actionToWebview != null && !action.equals("cancel") && Build.VERSION.SDK_INT >= 23) {
3238 actionToWebview.postWebMessage(new WebMessage("xmpp_xep0050/" + action), Uri.parse("*"));
3239 return false;
3240 }
3241
3242 final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
3243 packet.setTo(response.getFrom());
3244 final Element c = packet.addChild("command", Namespace.COMMANDS);
3245 c.setAttribute("node", mNode);
3246 c.setAttribute("sessionid", command.getAttribute("sessionid"));
3247
3248 String formType = responseElement == null ? null : responseElement.getAttribute("type");
3249 if (!action.equals("cancel") &&
3250 !action.equals("prev") &&
3251 responseElement != null &&
3252 responseElement.getName().equals("x") &&
3253 responseElement.getNamespace().equals("jabber:x:data") &&
3254 formType != null && formType.equals("form")) {
3255
3256 Data form = Data.parse(responseElement);
3257 eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
3258 if (actionList != null) {
3259 actionList.setValue(action);
3260 c.setAttribute("action", "execute");
3261 }
3262
3263 if (mNode.equals("jabber:iq:register") && xmppConnectionService.isOnboarding() && form.getFieldByName("gateway-jid") != null) {
3264 if (form.getValue("gateway-jid") == null) {
3265 xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
3266 } else {
3267 xmppConnectionService.getPreferences().edit().putString("onboarding_action", form.getValue("gateway-jid")).commit();
3268 }
3269 }
3270
3271 responseElement.setAttribute("type", "submit");
3272 Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
3273 if (rsm != null) {
3274 Element max = new Element("max", "http://jabber.org/protocol/rsm");
3275 max.setContent("1000");
3276 rsm.addChild(max);
3277 }
3278
3279 c.addChild(responseElement);
3280 }
3281
3282 if (c.getAttribute("action") == null) c.setAttribute("action", action);
3283
3284 executing = true;
3285 xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
3286 updateWithResponse(iq);
3287 }, 120L);
3288
3289 loading();
3290 return false;
3291 }
3292
3293 public void refresh() {
3294 synchronized(this) {
3295 if (waitingForRefresh) notifyDataSetChanged();
3296 }
3297 }
3298
3299 protected void loading() {
3300 View v = getView();
3301 try {
3302 loadingTimer.schedule(new TimerTask() {
3303 @Override
3304 public void run() {
3305 View v2 = getView();
3306 loading = true;
3307
3308 try {
3309 loadingTimer.schedule(new TimerTask() {
3310 @Override
3311 public void run() {
3312 loadingHasBeenLong = true;
3313 if (v == null && v2 == null) return;
3314 (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3315 }
3316 }, 3000);
3317 } catch (final IllegalStateException e) { }
3318
3319 if (v == null && v2 == null) return;
3320 (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3321 }
3322 }, 500);
3323 } catch (final IllegalStateException e) { }
3324 }
3325
3326 protected GridLayoutManager setupLayoutManager() {
3327 int spanCount = 1;
3328
3329 Context ctx = mPager == null ? getView().getContext() : mPager.getContext();
3330 if (reported != null) {
3331 float screenWidth = ctx.getResources().getDisplayMetrics().widthPixels;
3332 TextPaint paint = ((TextView) LayoutInflater.from(mPager.getContext()).inflate(R.layout.command_result_cell, null)).getPaint();
3333 float tableHeaderWidth = reported.stream().reduce(
3334 0f,
3335 (total, field) -> total + StaticLayout.getDesiredWidth(field.getLabel().or("--------") + "\t", paint),
3336 (a, b) -> a + b
3337 );
3338
3339 spanCount = tableHeaderWidth > 0.59 * screenWidth ? 1 : this.reported.size();
3340 }
3341
3342 if (layoutManager != null && layoutManager.getSpanCount() != spanCount) {
3343 items.clear();
3344 notifyDataSetChanged();
3345 }
3346
3347 layoutManager = new GridLayoutManager(ctx, spanCount);
3348 layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
3349 @Override
3350 public int getSpanSize(int position) {
3351 if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
3352 return 1;
3353 }
3354 });
3355 return layoutManager;
3356 }
3357
3358 protected void setBinding(CommandPageBinding b) {
3359 mBinding = b;
3360 // https://stackoverflow.com/a/32350474/8611
3361 mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
3362 @Override
3363 public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
3364 if(rv.getChildCount() > 0) {
3365 int[] location = new int[2];
3366 rv.getLocationOnScreen(location);
3367 View childView = rv.findChildViewUnder(e.getX(), e.getY());
3368 if (childView instanceof ViewGroup) {
3369 childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
3370 }
3371 int action = e.getAction();
3372 switch (action) {
3373 case MotionEvent.ACTION_DOWN:
3374 if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(1)) || childView instanceof WebView) {
3375 rv.requestDisallowInterceptTouchEvent(true);
3376 }
3377 case MotionEvent.ACTION_UP:
3378 if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(-11)) || childView instanceof WebView) {
3379 rv.requestDisallowInterceptTouchEvent(true);
3380 }
3381 }
3382 }
3383
3384 return false;
3385 }
3386
3387 @Override
3388 public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }
3389
3390 @Override
3391 public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
3392 });
3393 mBinding.form.setLayoutManager(setupLayoutManager());
3394 mBinding.form.setAdapter(this);
3395 mBinding.actions.setAdapter(actionsAdapter);
3396 mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
3397 if (execute(pos)) {
3398 removeSession(CommandSession.this);
3399 }
3400 });
3401
3402 actionsAdapter.notifyDataSetChanged();
3403
3404 if (pendingResponsePacket != null) {
3405 final IqPacket pending = pendingResponsePacket;
3406 pendingResponsePacket = null;
3407 updateWithResponseUiThread(pending);
3408 }
3409 }
3410
3411 private Drawable getDrawableForSVG(SVG svg, Element svgElement, int size) {
3412 if (svgElement != null && svgElement.getChildren().size() == 1 && svgElement.getChildren().get(0).getName().equals("image")) {
3413 return getDrawableForUrl(svgElement.getChildren().get(0).getAttribute("href"));
3414 } else {
3415 return xmppConnectionService.getFileBackend().drawSVG(svg, size);
3416 }
3417 }
3418
3419 private Drawable getDrawableForUrl(final String url) {
3420 final LruCache<String, Drawable> cache = xmppConnectionService.getDrawableCache();
3421 final HttpConnectionManager httpManager = xmppConnectionService.getHttpConnectionManager();
3422 final Drawable d = cache.get(url);
3423 if (Build.VERSION.SDK_INT >= 28 && d instanceof AnimatedImageDrawable) ((AnimatedImageDrawable) d).start();
3424 if (d == null) {
3425 synchronized (CommandSession.this) {
3426 waitingForRefresh = true;
3427 }
3428 int size = (int)(xmppConnectionService.getResources().getDisplayMetrics().density * 288);
3429 Message dummy = new Message(Conversation.this, url, Message.ENCRYPTION_NONE);
3430 dummy.setFileParams(new Message.FileParams(url));
3431 httpManager.createNewDownloadConnection(dummy, true, (file) -> {
3432 if (file == null) {
3433 dummy.getTransferable().start();
3434 } else {
3435 try {
3436 xmppConnectionService.getFileBackend().getThumbnail(file, xmppConnectionService.getResources(), size, false, url);
3437 } catch (final Exception e) { }
3438 }
3439 });
3440 }
3441 return d;
3442 }
3443
3444 public View inflateUi(Context context, Consumer<ConversationPage> remover) {
3445 CommandPageBinding binding = DataBindingUtil.inflate(LayoutInflater.from(context), R.layout.command_page, null, false);
3446 setBinding(binding);
3447 return binding.getRoot();
3448 }
3449
3450 // https://stackoverflow.com/a/36037991/8611
3451 private View findViewAt(ViewGroup viewGroup, float x, float y) {
3452 for(int i = 0; i < viewGroup.getChildCount(); i++) {
3453 View child = viewGroup.getChildAt(i);
3454 if (child instanceof ViewGroup && !(child instanceof AbsListView) && !(child instanceof WebView)) {
3455 View foundView = findViewAt((ViewGroup) child, x, y);
3456 if (foundView != null && foundView.isShown()) {
3457 return foundView;
3458 }
3459 } else {
3460 int[] location = new int[2];
3461 child.getLocationOnScreen(location);
3462 Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
3463 if (rect.contains((int)x, (int)y)) {
3464 return child;
3465 }
3466 }
3467 }
3468
3469 return null;
3470 }
3471 }
3472
3473 class MucConfigSession extends CommandSession {
3474 MucConfigSession(XmppConnectionService xmppConnectionService) {
3475 super("Configure Channel", null, xmppConnectionService);
3476 }
3477
3478 @Override
3479 protected void updateWithResponseUiThread(final IqPacket iq) {
3480 Timer oldTimer = this.loadingTimer;
3481 this.loadingTimer = new Timer();
3482 oldTimer.cancel();
3483 this.executing = false;
3484 this.loading = false;
3485 this.loadingHasBeenLong = false;
3486 this.responseElement = null;
3487 this.fillableFieldCount = 0;
3488 this.reported = null;
3489 this.response = iq;
3490 this.items.clear();
3491 this.actionsAdapter.clear();
3492 layoutManager.setSpanCount(1);
3493
3494 final Element query = iq.findChild("query", "http://jabber.org/protocol/muc#owner");
3495 if (iq.getType() == IqPacket.TYPE.RESULT && query != null) {
3496 final Data form = Data.parse(query.findChild("x", "jabber:x:data"));
3497 final String title = form.getTitle();
3498 if (title != null) {
3499 mTitle = title;
3500 ConversationPagerAdapter.this.notifyDataSetChanged();
3501 }
3502
3503 this.responseElement = form;
3504 setupReported(form.findChild("reported", "jabber:x:data"));
3505 if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager());
3506
3507 if (actionsAdapter.countExceptCancel() < 1) {
3508 actionsAdapter.add(Pair.create("save", "Save"));
3509 }
3510
3511 if (actionsAdapter.getPosition("cancel") < 0) {
3512 actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
3513 }
3514 } else if (iq.getType() == IqPacket.TYPE.RESULT) {
3515 expectingRemoval = true;
3516 removeSession(this);
3517 return;
3518 } else {
3519 actionsAdapter.add(Pair.create("close", "close"));
3520 }
3521
3522 notifyDataSetChanged();
3523 }
3524
3525 @Override
3526 public synchronized boolean execute(String action) {
3527 if ("cancel".equals(action)) {
3528 final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
3529 packet.setTo(response.getFrom());
3530 final Element form = packet
3531 .addChild("query", "http://jabber.org/protocol/muc#owner")
3532 .addChild("x", "jabber:x:data");
3533 form.setAttribute("type", "cancel");
3534 xmppConnectionService.sendIqPacket(getAccount(), packet, null);
3535 return true;
3536 }
3537
3538 if (!"save".equals(action)) return true;
3539
3540 final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
3541 packet.setTo(response.getFrom());
3542
3543 String formType = responseElement == null ? null : responseElement.getAttribute("type");
3544 if (responseElement != null &&
3545 responseElement.getName().equals("x") &&
3546 responseElement.getNamespace().equals("jabber:x:data") &&
3547 formType != null && formType.equals("form")) {
3548
3549 responseElement.setAttribute("type", "submit");
3550 packet
3551 .addChild("query", "http://jabber.org/protocol/muc#owner")
3552 .addChild(responseElement);
3553 }
3554
3555 executing = true;
3556 xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
3557 updateWithResponse(iq);
3558 }, 120L);
3559
3560 loading();
3561
3562 return false;
3563 }
3564 }
3565 }
3566
3567 public static class Thread {
3568 protected Message subject = null;
3569 protected Message first = null;
3570 protected Message last = null;
3571 protected final String threadId;
3572
3573 protected Thread(final String threadId) {
3574 this.threadId = threadId;
3575 }
3576
3577 public String getThreadId() {
3578 return threadId;
3579 }
3580
3581 public String getSubject() {
3582 if (subject == null) return null;
3583
3584 return subject.getSubject();
3585 }
3586
3587 public String getDisplay() {
3588 final String s = getSubject();
3589 if (s != null) return s;
3590
3591 if (first != null) {
3592 return first.getBody();
3593 }
3594
3595 return "";
3596 }
3597
3598 public long getLastTime() {
3599 if (last == null) return 0;
3600
3601 return last.getTimeSent();
3602 }
3603 }
3604}