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