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) {
2469 binding.slider.setValue(Float.valueOf(field.getValue().getContent()));
2470 } else {
2471 binding.slider.setValue(min == null ? Float.MIN_VALUE : min);
2472 }
2473 binding.slider.setValueFrom(min == null ? Float.MIN_VALUE : min);
2474 binding.slider.setValueTo(max == null ? Float.MAX_VALUE : max);
2475 if ("xs:integer".equals(datatype) || "xs:int".equals(datatype) || "xs:long".equals(datatype) || "xs:short".equals(datatype) || "xs:byte".equals(datatype)) {
2476 binding.slider.setStepSize(1);
2477 } else {
2478 binding.slider.setStepSize(0);
2479 }
2480
2481 if (options.size() > 0) {
2482 float step = -1;
2483 Float prev = null;
2484 for (final Float option : options) {
2485 if (prev != null) {
2486 float nextStep = option - prev;
2487 if (step > 0 && step != nextStep) {
2488 step = -1;
2489 break;
2490 }
2491 step = nextStep;
2492 }
2493 prev = option;
2494 }
2495 if (step > 0) binding.slider.setStepSize(step);
2496 }
2497
2498 binding.slider.addOnChangeListener((slider, value, fromUser) -> {
2499 field.setValues(List.of(new DecimalFormat().format(value)));
2500 });
2501 }
2502 }
2503
2504 class WebViewHolder extends ViewHolder<CommandWebviewBinding> {
2505 public WebViewHolder(CommandWebviewBinding binding) { super(binding); }
2506 protected String boundUrl = "";
2507
2508 @Override
2509 public void bind(Item oob) {
2510 setTextOrHide(binding.desc, Optional.fromNullable(oob.el.findChildContent("desc", "jabber:x:oob")));
2511 binding.webview.getSettings().setJavaScriptEnabled(true);
2512 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");
2513 binding.webview.getSettings().setDatabaseEnabled(true);
2514 binding.webview.getSettings().setDomStorageEnabled(true);
2515 binding.webview.setWebChromeClient(new WebChromeClient() {
2516 @Override
2517 public void onProgressChanged(WebView view, int newProgress) {
2518 binding.progressbar.setVisibility(newProgress < 100 ? View.VISIBLE : View.GONE);
2519 binding.progressbar.setProgress(newProgress);
2520 }
2521 });
2522 binding.webview.setWebViewClient(new WebViewClient() {
2523 @Override
2524 public void onPageFinished(WebView view, String url) {
2525 super.onPageFinished(view, url);
2526 mTitle = view.getTitle();
2527 ConversationPagerAdapter.this.notifyDataSetChanged();
2528 }
2529 });
2530 final String url = oob.el.findChildContent("url", "jabber:x:oob");
2531 if (!boundUrl.equals(url)) {
2532 binding.webview.addJavascriptInterface(new JsObject(), "xmpp_xep0050");
2533 binding.webview.loadUrl(url);
2534 boundUrl = url;
2535 }
2536 }
2537
2538 class JsObject {
2539 @JavascriptInterface
2540 public void execute() { execute("execute"); }
2541
2542 @JavascriptInterface
2543 public void execute(String action) {
2544 getView().post(() -> {
2545 actionToWebview = null;
2546 if(CommandSession.this.execute(action)) {
2547 removeSession(CommandSession.this);
2548 }
2549 });
2550 }
2551
2552 @JavascriptInterface
2553 public void preventDefault() {
2554 actionToWebview = binding.webview;
2555 }
2556 }
2557 }
2558
2559 class ProgressBarViewHolder extends ViewHolder<CommandProgressBarBinding> {
2560 public ProgressBarViewHolder(CommandProgressBarBinding binding) { super(binding); }
2561
2562 @Override
2563 public void bind(Item item) {
2564 binding.text.setVisibility(loadingHasBeenLong ? View.VISIBLE : View.GONE);
2565 }
2566 }
2567
2568 class Item {
2569 protected Element el;
2570 protected int viewType;
2571 protected String error = null;
2572
2573 Item(Element el, int viewType) {
2574 this.el = el;
2575 this.viewType = viewType;
2576 }
2577
2578 public boolean validate() {
2579 error = null;
2580 return true;
2581 }
2582 }
2583
2584 class Field extends Item {
2585 Field(eu.siacs.conversations.xmpp.forms.Field el, int viewType) { super(el, viewType); }
2586
2587 @Override
2588 public boolean validate() {
2589 if (!super.validate()) return false;
2590 if (el.findChild("required", "jabber:x:data") == null) return true;
2591 if (getValue().getContent() != null && !getValue().getContent().equals("")) return true;
2592
2593 error = "this value is required";
2594 return false;
2595 }
2596
2597 public String getVar() {
2598 return el.getAttribute("var");
2599 }
2600
2601 public Optional<String> getType() {
2602 return Optional.fromNullable(el.getAttribute("type"));
2603 }
2604
2605 public Optional<String> getLabel() {
2606 String label = el.getAttribute("label");
2607 if (label == null) label = getVar();
2608 return Optional.fromNullable(label);
2609 }
2610
2611 public Optional<String> getDesc() {
2612 return Optional.fromNullable(el.findChildContent("desc", "jabber:x:data"));
2613 }
2614
2615 public Element getValue() {
2616 Element value = el.findChild("value", "jabber:x:data");
2617 if (value == null) {
2618 value = el.addChild("value", "jabber:x:data");
2619 }
2620 return value;
2621 }
2622
2623 public void setValues(Collection<String> values) {
2624 for(Element child : el.getChildren()) {
2625 if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
2626 el.removeChild(child);
2627 }
2628 }
2629
2630 for (String value : values) {
2631 el.addChild("value", "jabber:x:data").setContent(value);
2632 }
2633 }
2634
2635 public List<String> getValues() {
2636 List<String> values = new ArrayList<>();
2637 for(Element child : el.getChildren()) {
2638 if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
2639 values.add(child.getContent());
2640 }
2641 }
2642 return values;
2643 }
2644
2645 public List<Option> getOptions() {
2646 return Option.forField(el);
2647 }
2648 }
2649
2650 class Cell extends Item {
2651 protected Field reported;
2652
2653 Cell(Field reported, Element item) {
2654 super(item, TYPE_RESULT_CELL);
2655 this.reported = reported;
2656 }
2657 }
2658
2659 protected Field mkField(Element el) {
2660 int viewType = -1;
2661
2662 String formType = responseElement.getAttribute("type");
2663 if (formType != null) {
2664 String fieldType = el.getAttribute("type");
2665 if (fieldType == null) fieldType = "text-single";
2666
2667 if (formType.equals("result") || fieldType.equals("fixed")) {
2668 viewType = TYPE_RESULT_FIELD;
2669 } else if (formType.equals("form")) {
2670 final Element validate = el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2671 final String datatype = validate == null ? null : validate.getAttribute("datatype");
2672 final Element range = validate == null ? null : validate.findChild("range", "http://jabber.org/protocol/xdata-validate");
2673 if (fieldType.equals("boolean")) {
2674 if (fillableFieldCount == 1 && actionsAdapter.countProceed() < 1) {
2675 viewType = TYPE_BUTTON_GRID_FIELD;
2676 } else {
2677 viewType = TYPE_CHECKBOX_FIELD;
2678 }
2679 } else if (
2680 range != null && range.getAttribute("min") != null && range.getAttribute("max") != null && (
2681 "xs:integer".equals(datatype) || "xs:int".equals(datatype) || "xs:long".equals(datatype) || "xs:short".equals(datatype) || "xs:byte".equals(datatype) ||
2682 "xs:decimal".equals(datatype) || "xs:double".equals(datatype)
2683 )
2684 ) {
2685 // has a range and is numeric, use a slider
2686 viewType = TYPE_SLIDER_FIELD;
2687 } else if (fieldType.equals("list-single")) {
2688 if (fillableFieldCount == 1 && actionsAdapter.countProceed() < 1 && Option.forField(el).size() < 50) {
2689 viewType = TYPE_BUTTON_GRID_FIELD;
2690 } else if (Option.forField(el).size() > 9) {
2691 viewType = TYPE_SEARCH_LIST_FIELD;
2692 } else if (el.findChild("value", "jabber:x:data") == null || (validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null)) {
2693 viewType = TYPE_RADIO_EDIT_FIELD;
2694 } else {
2695 viewType = TYPE_SPINNER_FIELD;
2696 }
2697 } else if (fieldType.equals("list-multi")) {
2698 viewType = TYPE_SEARCH_LIST_FIELD;
2699 } else {
2700 viewType = TYPE_TEXT_FIELD;
2701 }
2702 }
2703
2704 return new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), viewType);
2705 }
2706
2707 return null;
2708 }
2709
2710 protected Item mkItem(Element el, int pos) {
2711 int viewType = TYPE_ERROR;
2712
2713 if (response != null && response.getType() == IqPacket.TYPE.RESULT) {
2714 if (el.getName().equals("note")) {
2715 viewType = TYPE_NOTE;
2716 } else if (el.getNamespace().equals("jabber:x:oob")) {
2717 viewType = TYPE_WEB;
2718 } else if (el.getName().equals("instructions") && el.getNamespace().equals("jabber:x:data")) {
2719 viewType = TYPE_NOTE;
2720 } else if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data")) {
2721 Field field = mkField(el);
2722 if (field != null) {
2723 items.put(pos, field);
2724 return field;
2725 }
2726 }
2727 }
2728
2729 Item item = new Item(el, viewType);
2730 items.put(pos, item);
2731 return item;
2732 }
2733
2734 class ActionsAdapter extends ArrayAdapter<Pair<String, String>> {
2735 protected Context ctx;
2736
2737 public ActionsAdapter(Context ctx) {
2738 super(ctx, R.layout.simple_list_item);
2739 this.ctx = ctx;
2740 }
2741
2742 @Override
2743 public View getView(int position, View convertView, ViewGroup parent) {
2744 View v = super.getView(position, convertView, parent);
2745 TextView tv = (TextView) v.findViewById(android.R.id.text1);
2746 tv.setGravity(Gravity.CENTER);
2747 tv.setText(getItem(position).second);
2748 int resId = ctx.getResources().getIdentifier("action_" + getItem(position).first, "string" , ctx.getPackageName());
2749 if (resId != 0 && getItem(position).second.equals(getItem(position).first)) tv.setText(ctx.getResources().getString(resId));
2750 tv.setTextColor(ContextCompat.getColor(ctx, R.color.white));
2751 tv.setBackgroundColor(UIHelper.getColorForName(getItem(position).first));
2752 return v;
2753 }
2754
2755 public int getPosition(String s) {
2756 for(int i = 0; i < getCount(); i++) {
2757 if (getItem(i).first.equals(s)) return i;
2758 }
2759 return -1;
2760 }
2761
2762 public int countProceed() {
2763 int count = 0;
2764 for(int i = 0; i < getCount(); i++) {
2765 if (!"cancel".equals(getItem(i).first) && !"prev".equals(getItem(i).first)) count++;
2766 }
2767 return count;
2768 }
2769
2770 public int countExceptCancel() {
2771 int count = 0;
2772 for(int i = 0; i < getCount(); i++) {
2773 if (!getItem(i).first.equals("cancel")) count++;
2774 }
2775 return count;
2776 }
2777
2778 public void clearProceed() {
2779 Pair<String,String> cancelItem = null;
2780 Pair<String,String> prevItem = null;
2781 for(int i = 0; i < getCount(); i++) {
2782 if (getItem(i).first.equals("cancel")) cancelItem = getItem(i);
2783 if (getItem(i).first.equals("prev")) prevItem = getItem(i);
2784 }
2785 clear();
2786 if (cancelItem != null) add(cancelItem);
2787 if (prevItem != null) add(prevItem);
2788 }
2789 }
2790
2791 final int TYPE_ERROR = 1;
2792 final int TYPE_NOTE = 2;
2793 final int TYPE_WEB = 3;
2794 final int TYPE_RESULT_FIELD = 4;
2795 final int TYPE_TEXT_FIELD = 5;
2796 final int TYPE_CHECKBOX_FIELD = 6;
2797 final int TYPE_SPINNER_FIELD = 7;
2798 final int TYPE_RADIO_EDIT_FIELD = 8;
2799 final int TYPE_RESULT_CELL = 9;
2800 final int TYPE_PROGRESSBAR = 10;
2801 final int TYPE_SEARCH_LIST_FIELD = 11;
2802 final int TYPE_ITEM_CARD = 12;
2803 final int TYPE_BUTTON_GRID_FIELD = 13;
2804 final int TYPE_SLIDER_FIELD = 14;
2805
2806 protected boolean executing = false;
2807 protected boolean loading = false;
2808 protected boolean loadingHasBeenLong = false;
2809 protected Timer loadingTimer = new Timer();
2810 protected String mTitle;
2811 protected String mNode;
2812 protected CommandPageBinding mBinding = null;
2813 protected IqPacket response = null;
2814 protected Element responseElement = null;
2815 protected boolean expectingRemoval = false;
2816 protected List<Field> reported = null;
2817 protected SparseArray<Item> items = new SparseArray<>();
2818 protected XmppConnectionService xmppConnectionService;
2819 protected ActionsAdapter actionsAdapter;
2820 protected GridLayoutManager layoutManager;
2821 protected WebView actionToWebview = null;
2822 protected int fillableFieldCount = 0;
2823 protected IqPacket pendingResponsePacket = null;
2824 protected boolean waitingForRefresh = false;
2825
2826 CommandSession(String title, String node, XmppConnectionService xmppConnectionService) {
2827 loading();
2828 mTitle = title;
2829 mNode = node;
2830 this.xmppConnectionService = xmppConnectionService;
2831 if (mPager != null) setupLayoutManager();
2832 actionsAdapter = new ActionsAdapter(xmppConnectionService);
2833 actionsAdapter.registerDataSetObserver(new DataSetObserver() {
2834 @Override
2835 public void onChanged() {
2836 if (mBinding == null) return;
2837
2838 mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
2839 }
2840
2841 @Override
2842 public void onInvalidated() {}
2843 });
2844 }
2845
2846 public String getTitle() {
2847 return mTitle;
2848 }
2849
2850 public String getNode() {
2851 return mNode;
2852 }
2853
2854 public void updateWithResponse(final IqPacket iq) {
2855 if (getView() != null && getView().isAttachedToWindow()) {
2856 getView().post(() -> updateWithResponseUiThread(iq));
2857 } else {
2858 pendingResponsePacket = iq;
2859 }
2860 }
2861
2862 protected void updateWithResponseUiThread(final IqPacket iq) {
2863 Timer oldTimer = this.loadingTimer;
2864 this.loadingTimer = new Timer();
2865 oldTimer.cancel();
2866 this.executing = false;
2867 this.loading = false;
2868 this.loadingHasBeenLong = false;
2869 this.responseElement = null;
2870 this.fillableFieldCount = 0;
2871 this.reported = null;
2872 this.response = iq;
2873 this.items.clear();
2874 this.actionsAdapter.clear();
2875 layoutManager.setSpanCount(1);
2876
2877 boolean actionsCleared = false;
2878 Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
2879 if (iq.getType() == IqPacket.TYPE.RESULT && command != null) {
2880 if (mNode.equals("jabber:iq:register") && command.getAttribute("status") != null && command.getAttribute("status").equals("completed")) {
2881 xmppConnectionService.createContact(getAccount().getRoster().getContact(iq.getFrom()), true);
2882 }
2883
2884 if (xmppConnectionService.isOnboarding() && mNode.equals("jabber:iq:register") && !"canceled".equals(command.getAttribute("status")) && xmppConnectionService.getPreferences().contains("onboarding_action")) {
2885 xmppConnectionService.getPreferences().edit().putBoolean("onboarding_continued", true).commit();
2886 }
2887
2888 Element actions = command.findChild("actions", "http://jabber.org/protocol/commands");
2889 if (actions != null) {
2890 for (Element action : actions.getChildren()) {
2891 if (!"http://jabber.org/protocol/commands".equals(action.getNamespace())) continue;
2892 if ("execute".equals(action.getName())) continue;
2893
2894 actionsAdapter.add(Pair.create(action.getName(), action.getName()));
2895 }
2896 }
2897
2898 for (Element el : command.getChildren()) {
2899 if ("x".equals(el.getName()) && "jabber:x:data".equals(el.getNamespace())) {
2900 Data form = Data.parse(el);
2901 String title = form.getTitle();
2902 if (title != null) {
2903 mTitle = title;
2904 ConversationPagerAdapter.this.notifyDataSetChanged();
2905 }
2906
2907 if ("result".equals(el.getAttribute("type")) || "form".equals(el.getAttribute("type"))) {
2908 this.responseElement = el;
2909 setupReported(el.findChild("reported", "jabber:x:data"));
2910 if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager());
2911 }
2912
2913 eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
2914 if (actionList != null) {
2915 actionsAdapter.clear();
2916
2917 for (Option action : actionList.getOptions()) {
2918 actionsAdapter.add(Pair.create(action.getValue(), action.toString()));
2919 }
2920 }
2921
2922 eu.siacs.conversations.xmpp.forms.Field fillableField = null;
2923 for (eu.siacs.conversations.xmpp.forms.Field field : form.getFields()) {
2924 if ((field.getType() == null || (!field.getType().equals("hidden") && !field.getType().equals("fixed"))) && field.getFieldName() != null && !field.getFieldName().equals("http://jabber.org/protocol/commands#actions")) {
2925 fillableField = field;
2926 fillableFieldCount++;
2927 }
2928 }
2929
2930 if (fillableFieldCount == 1 && actionsAdapter.countProceed() < 2 && (("list-single".equals(fillableField.getType()) && Option.forField(fillableField).size() < 50) || ("boolean".equals(fillableField.getType()) && fillableField.getValue() == null))) {
2931 actionsCleared = true;
2932 actionsAdapter.clearProceed();
2933 }
2934 break;
2935 }
2936 if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
2937 String url = el.findChildContent("url", "jabber:x:oob");
2938 if (url != null) {
2939 String scheme = Uri.parse(url).getScheme();
2940 if (scheme.equals("http") || scheme.equals("https")) {
2941 this.responseElement = el;
2942 break;
2943 }
2944 if (scheme.equals("xmpp")) {
2945 expectingRemoval = true;
2946 final Intent intent = new Intent(getView().getContext(), UriHandlerActivity.class);
2947 intent.setAction(Intent.ACTION_VIEW);
2948 intent.setData(Uri.parse(url));
2949 getView().getContext().startActivity(intent);
2950 break;
2951 }
2952 }
2953 }
2954 if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2955 this.responseElement = el;
2956 break;
2957 }
2958 }
2959
2960 if (responseElement == null && command.getAttribute("status") != null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
2961 if ("jabber:iq:register".equals(mNode) && "canceled".equals(command.getAttribute("status"))) {
2962 if (xmppConnectionService.isOnboarding()) {
2963 if (xmppConnectionService.getPreferences().contains("onboarding_action")) {
2964 xmppConnectionService.deleteAccount(getAccount());
2965 } else {
2966 if (xmppConnectionService.getPreferences().getBoolean("onboarding_continued", false)) {
2967 removeSession(this);
2968 return;
2969 } else {
2970 xmppConnectionService.getPreferences().edit().putString("onboarding_action", "cancel").commit();
2971 xmppConnectionService.deleteAccount(getAccount());
2972 }
2973 }
2974 }
2975 xmppConnectionService.archiveConversation(Conversation.this);
2976 }
2977
2978 expectingRemoval = true;
2979 removeSession(this);
2980 return;
2981 }
2982
2983 if ("executing".equals(command.getAttribute("status")) && actionsAdapter.countExceptCancel() < 1 && !actionsCleared) {
2984 // No actions have been given, but we are not done?
2985 // This is probably a spec violation, but we should do *something*
2986 actionsAdapter.add(Pair.create("execute", "execute"));
2987 }
2988
2989 if (!actionsAdapter.isEmpty() || fillableFieldCount > 0) {
2990 if ("completed".equals(command.getAttribute("status")) || "canceled".equals(command.getAttribute("status"))) {
2991 actionsAdapter.add(Pair.create("close", "close"));
2992 } else if (actionsAdapter.getPosition("cancel") < 0 && !xmppConnectionService.isOnboarding()) {
2993 actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
2994 }
2995 }
2996 }
2997
2998 if (actionsAdapter.isEmpty()) {
2999 actionsAdapter.add(Pair.create("close", "close"));
3000 }
3001
3002 actionsAdapter.sort((x, y) -> {
3003 if (x.first.equals("cancel")) return -1;
3004 if (y.first.equals("cancel")) return 1;
3005 if (x.first.equals("prev") && xmppConnectionService.isOnboarding()) return -1;
3006 if (y.first.equals("prev") && xmppConnectionService.isOnboarding()) return 1;
3007 return 0;
3008 });
3009
3010 Data dataForm = null;
3011 if (responseElement != null && responseElement.getName().equals("x") && responseElement.getNamespace().equals("jabber:x:data")) dataForm = Data.parse(responseElement);
3012 if (mNode.equals("jabber:iq:register") &&
3013 xmppConnectionService.getPreferences().contains("onboarding_action") &&
3014 dataForm != null && dataForm.getFieldByName("gateway-jid") != null) {
3015
3016
3017 dataForm.put("gateway-jid", xmppConnectionService.getPreferences().getString("onboarding_action", ""));
3018 execute();
3019 }
3020 xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
3021 notifyDataSetChanged();
3022 }
3023
3024 protected void setupReported(Element el) {
3025 if (el == null) {
3026 reported = null;
3027 return;
3028 }
3029
3030 reported = new ArrayList<>();
3031 for (Element fieldEl : el.getChildren()) {
3032 if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
3033 reported.add(mkField(fieldEl));
3034 }
3035 }
3036
3037 @Override
3038 public int getItemCount() {
3039 if (loading) return 1;
3040 if (response == null) return 0;
3041 if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
3042 int i = 0;
3043 for (Element el : responseElement.getChildren()) {
3044 if (!el.getNamespace().equals("jabber:x:data")) continue;
3045 if (el.getName().equals("title")) continue;
3046 if (el.getName().equals("field")) {
3047 String type = el.getAttribute("type");
3048 if (type != null && type.equals("hidden")) continue;
3049 if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
3050 }
3051
3052 if (el.getName().equals("reported") || el.getName().equals("item")) {
3053 if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
3054 if (el.getName().equals("reported")) continue;
3055 i += 1;
3056 } else {
3057 if (reported != null) i += reported.size();
3058 }
3059 continue;
3060 }
3061
3062 i++;
3063 }
3064 return i;
3065 }
3066 return 1;
3067 }
3068
3069 public Item getItem(int position) {
3070 if (loading) return new Item(null, TYPE_PROGRESSBAR);
3071 if (items.get(position) != null) return items.get(position);
3072 if (response == null) return null;
3073
3074 if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null) {
3075 if (responseElement.getNamespace().equals("jabber:x:data")) {
3076 int i = 0;
3077 for (Element el : responseElement.getChildren()) {
3078 if (!el.getNamespace().equals("jabber:x:data")) continue;
3079 if (el.getName().equals("title")) continue;
3080 if (el.getName().equals("field")) {
3081 String type = el.getAttribute("type");
3082 if (type != null && type.equals("hidden")) continue;
3083 if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
3084 }
3085
3086 if (el.getName().equals("reported") || el.getName().equals("item")) {
3087 Cell cell = null;
3088
3089 if (reported != null) {
3090 if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
3091 if (el.getName().equals("reported")) continue;
3092 if (i == position) {
3093 items.put(position, new Item(el, TYPE_ITEM_CARD));
3094 return items.get(position);
3095 }
3096 } else {
3097 if (reported.size() > position - i) {
3098 Field reportedField = reported.get(position - i);
3099 Element itemField = null;
3100 if (el.getName().equals("item")) {
3101 for (Element subel : el.getChildren()) {
3102 if (subel.getAttribute("var").equals(reportedField.getVar())) {
3103 itemField = subel;
3104 break;
3105 }
3106 }
3107 }
3108 cell = new Cell(reportedField, itemField);
3109 } else {
3110 i += reported.size();
3111 continue;
3112 }
3113 }
3114 }
3115
3116 if (cell != null) {
3117 items.put(position, cell);
3118 return cell;
3119 }
3120 }
3121
3122 if (i < position) {
3123 i++;
3124 continue;
3125 }
3126
3127 return mkItem(el, position);
3128 }
3129 }
3130 }
3131
3132 return mkItem(responseElement == null ? response : responseElement, position);
3133 }
3134
3135 @Override
3136 public int getItemViewType(int position) {
3137 return getItem(position).viewType;
3138 }
3139
3140 @Override
3141 public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
3142 switch(viewType) {
3143 case TYPE_ERROR: {
3144 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3145 return new ErrorViewHolder(binding);
3146 }
3147 case TYPE_NOTE: {
3148 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3149 return new NoteViewHolder(binding);
3150 }
3151 case TYPE_WEB: {
3152 CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
3153 return new WebViewHolder(binding);
3154 }
3155 case TYPE_RESULT_FIELD: {
3156 CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
3157 return new ResultFieldViewHolder(binding);
3158 }
3159 case TYPE_RESULT_CELL: {
3160 CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
3161 return new ResultCellViewHolder(binding);
3162 }
3163 case TYPE_ITEM_CARD: {
3164 CommandItemCardBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_item_card, container, false);
3165 return new ItemCardViewHolder(binding);
3166 }
3167 case TYPE_CHECKBOX_FIELD: {
3168 CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
3169 return new CheckboxFieldViewHolder(binding);
3170 }
3171 case TYPE_SEARCH_LIST_FIELD: {
3172 CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
3173 return new SearchListFieldViewHolder(binding);
3174 }
3175 case TYPE_RADIO_EDIT_FIELD: {
3176 CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
3177 return new RadioEditFieldViewHolder(binding);
3178 }
3179 case TYPE_SPINNER_FIELD: {
3180 CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
3181 return new SpinnerFieldViewHolder(binding);
3182 }
3183 case TYPE_BUTTON_GRID_FIELD: {
3184 CommandButtonGridFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_button_grid_field, container, false);
3185 return new ButtonGridFieldViewHolder(binding);
3186 }
3187 case TYPE_TEXT_FIELD: {
3188 CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
3189 return new TextFieldViewHolder(binding);
3190 }
3191 case TYPE_SLIDER_FIELD: {
3192 CommandSliderFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_slider_field, container, false);
3193 return new SliderFieldViewHolder(binding);
3194 }
3195 case TYPE_PROGRESSBAR: {
3196 CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
3197 return new ProgressBarViewHolder(binding);
3198 }
3199 default:
3200 if (expectingRemoval) {
3201 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3202 return new NoteViewHolder(binding);
3203 }
3204
3205 throw new IllegalArgumentException("Unknown viewType: " + viewType + " based on: " + response + ", " + responseElement + ", " + expectingRemoval);
3206 }
3207 }
3208
3209 @Override
3210 public void onBindViewHolder(ViewHolder viewHolder, int position) {
3211 viewHolder.bind(getItem(position));
3212 }
3213
3214 public View getView() {
3215 if (mBinding == null) return null;
3216 return mBinding.getRoot();
3217 }
3218
3219 public boolean validate() {
3220 int count = getItemCount();
3221 boolean isValid = true;
3222 for (int i = 0; i < count; i++) {
3223 boolean oneIsValid = getItem(i).validate();
3224 isValid = isValid && oneIsValid;
3225 }
3226 notifyDataSetChanged();
3227 return isValid;
3228 }
3229
3230 public boolean execute() {
3231 return execute("execute");
3232 }
3233
3234 public boolean execute(int actionPosition) {
3235 return execute(actionsAdapter.getItem(actionPosition).first);
3236 }
3237
3238 public synchronized boolean execute(String action) {
3239 if (!"cancel".equals(action) && executing) {
3240 loadingHasBeenLong = true;
3241 notifyDataSetChanged();
3242 return false;
3243 }
3244 if (!action.equals("cancel") && !action.equals("prev") && !validate()) return false;
3245
3246 if (response == null) return true;
3247 Element command = response.findChild("command", "http://jabber.org/protocol/commands");
3248 if (command == null) return true;
3249 String status = command.getAttribute("status");
3250 if (status == null || (!status.equals("executing") && !action.equals("prev"))) return true;
3251
3252 if (actionToWebview != null && !action.equals("cancel") && Build.VERSION.SDK_INT >= 23) {
3253 actionToWebview.postWebMessage(new WebMessage("xmpp_xep0050/" + action), Uri.parse("*"));
3254 return false;
3255 }
3256
3257 final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
3258 packet.setTo(response.getFrom());
3259 final Element c = packet.addChild("command", Namespace.COMMANDS);
3260 c.setAttribute("node", mNode);
3261 c.setAttribute("sessionid", command.getAttribute("sessionid"));
3262
3263 String formType = responseElement == null ? null : responseElement.getAttribute("type");
3264 if (!action.equals("cancel") &&
3265 !action.equals("prev") &&
3266 responseElement != null &&
3267 responseElement.getName().equals("x") &&
3268 responseElement.getNamespace().equals("jabber:x:data") &&
3269 formType != null && formType.equals("form")) {
3270
3271 Data form = Data.parse(responseElement);
3272 eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
3273 if (actionList != null) {
3274 actionList.setValue(action);
3275 c.setAttribute("action", "execute");
3276 }
3277
3278 if (mNode.equals("jabber:iq:register") && xmppConnectionService.isOnboarding() && form.getFieldByName("gateway-jid") != null) {
3279 if (form.getValue("gateway-jid") == null) {
3280 xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
3281 } else {
3282 xmppConnectionService.getPreferences().edit().putString("onboarding_action", form.getValue("gateway-jid")).commit();
3283 }
3284 }
3285
3286 responseElement.setAttribute("type", "submit");
3287 Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
3288 if (rsm != null) {
3289 Element max = new Element("max", "http://jabber.org/protocol/rsm");
3290 max.setContent("1000");
3291 rsm.addChild(max);
3292 }
3293
3294 c.addChild(responseElement);
3295 }
3296
3297 if (c.getAttribute("action") == null) c.setAttribute("action", action);
3298
3299 executing = true;
3300 xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
3301 updateWithResponse(iq);
3302 }, 120L);
3303
3304 loading();
3305 return false;
3306 }
3307
3308 public void refresh() {
3309 synchronized(this) {
3310 if (waitingForRefresh) notifyDataSetChanged();
3311 }
3312 }
3313
3314 protected void loading() {
3315 View v = getView();
3316 try {
3317 loadingTimer.schedule(new TimerTask() {
3318 @Override
3319 public void run() {
3320 View v2 = getView();
3321 loading = true;
3322
3323 try {
3324 loadingTimer.schedule(new TimerTask() {
3325 @Override
3326 public void run() {
3327 loadingHasBeenLong = true;
3328 if (v == null && v2 == null) return;
3329 (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3330 }
3331 }, 3000);
3332 } catch (final IllegalStateException e) { }
3333
3334 if (v == null && v2 == null) return;
3335 (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3336 }
3337 }, 500);
3338 } catch (final IllegalStateException e) { }
3339 }
3340
3341 protected GridLayoutManager setupLayoutManager() {
3342 int spanCount = 1;
3343
3344 Context ctx = mPager == null ? getView().getContext() : mPager.getContext();
3345 if (reported != null) {
3346 float screenWidth = ctx.getResources().getDisplayMetrics().widthPixels;
3347 TextPaint paint = ((TextView) LayoutInflater.from(mPager.getContext()).inflate(R.layout.command_result_cell, null)).getPaint();
3348 float tableHeaderWidth = reported.stream().reduce(
3349 0f,
3350 (total, field) -> total + StaticLayout.getDesiredWidth(field.getLabel().or("--------") + "\t", paint),
3351 (a, b) -> a + b
3352 );
3353
3354 spanCount = tableHeaderWidth > 0.59 * screenWidth ? 1 : this.reported.size();
3355 }
3356
3357 if (layoutManager != null && layoutManager.getSpanCount() != spanCount) {
3358 items.clear();
3359 notifyDataSetChanged();
3360 }
3361
3362 layoutManager = new GridLayoutManager(ctx, spanCount);
3363 layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
3364 @Override
3365 public int getSpanSize(int position) {
3366 if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
3367 return 1;
3368 }
3369 });
3370 return layoutManager;
3371 }
3372
3373 protected void setBinding(CommandPageBinding b) {
3374 mBinding = b;
3375 // https://stackoverflow.com/a/32350474/8611
3376 mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
3377 @Override
3378 public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
3379 if(rv.getChildCount() > 0) {
3380 int[] location = new int[2];
3381 rv.getLocationOnScreen(location);
3382 View childView = rv.findChildViewUnder(e.getX(), e.getY());
3383 if (childView instanceof ViewGroup) {
3384 childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
3385 }
3386 int action = e.getAction();
3387 switch (action) {
3388 case MotionEvent.ACTION_DOWN:
3389 if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(1)) || childView instanceof WebView) {
3390 rv.requestDisallowInterceptTouchEvent(true);
3391 }
3392 case MotionEvent.ACTION_UP:
3393 if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(-11)) || childView instanceof WebView) {
3394 rv.requestDisallowInterceptTouchEvent(true);
3395 }
3396 }
3397 }
3398
3399 return false;
3400 }
3401
3402 @Override
3403 public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }
3404
3405 @Override
3406 public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
3407 });
3408 mBinding.form.setLayoutManager(setupLayoutManager());
3409 mBinding.form.setAdapter(this);
3410 mBinding.actions.setAdapter(actionsAdapter);
3411 mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
3412 if (execute(pos)) {
3413 removeSession(CommandSession.this);
3414 }
3415 });
3416
3417 actionsAdapter.notifyDataSetChanged();
3418
3419 if (pendingResponsePacket != null) {
3420 final IqPacket pending = pendingResponsePacket;
3421 pendingResponsePacket = null;
3422 updateWithResponseUiThread(pending);
3423 }
3424 }
3425
3426 private Drawable getDrawableForSVG(SVG svg, Element svgElement, int size) {
3427 if (svgElement != null && svgElement.getChildren().size() == 1 && svgElement.getChildren().get(0).getName().equals("image")) {
3428 return getDrawableForUrl(svgElement.getChildren().get(0).getAttribute("href"));
3429 } else {
3430 return xmppConnectionService.getFileBackend().drawSVG(svg, size);
3431 }
3432 }
3433
3434 private Drawable getDrawableForUrl(final String url) {
3435 final LruCache<String, Drawable> cache = xmppConnectionService.getDrawableCache();
3436 final HttpConnectionManager httpManager = xmppConnectionService.getHttpConnectionManager();
3437 final Drawable d = cache.get(url);
3438 if (Build.VERSION.SDK_INT >= 28 && d instanceof AnimatedImageDrawable) ((AnimatedImageDrawable) d).start();
3439 if (d == null) {
3440 synchronized (CommandSession.this) {
3441 waitingForRefresh = true;
3442 }
3443 int size = (int)(xmppConnectionService.getResources().getDisplayMetrics().density * 288);
3444 Message dummy = new Message(Conversation.this, url, Message.ENCRYPTION_NONE);
3445 dummy.setFileParams(new Message.FileParams(url));
3446 httpManager.createNewDownloadConnection(dummy, true, (file) -> {
3447 if (file == null) {
3448 dummy.getTransferable().start();
3449 } else {
3450 try {
3451 xmppConnectionService.getFileBackend().getThumbnail(file, xmppConnectionService.getResources(), size, false, url);
3452 } catch (final Exception e) { }
3453 }
3454 });
3455 }
3456 return d;
3457 }
3458
3459 public View inflateUi(Context context, Consumer<ConversationPage> remover) {
3460 CommandPageBinding binding = DataBindingUtil.inflate(LayoutInflater.from(context), R.layout.command_page, null, false);
3461 setBinding(binding);
3462 return binding.getRoot();
3463 }
3464
3465 // https://stackoverflow.com/a/36037991/8611
3466 private View findViewAt(ViewGroup viewGroup, float x, float y) {
3467 for(int i = 0; i < viewGroup.getChildCount(); i++) {
3468 View child = viewGroup.getChildAt(i);
3469 if (child instanceof ViewGroup && !(child instanceof AbsListView) && !(child instanceof WebView)) {
3470 View foundView = findViewAt((ViewGroup) child, x, y);
3471 if (foundView != null && foundView.isShown()) {
3472 return foundView;
3473 }
3474 } else {
3475 int[] location = new int[2];
3476 child.getLocationOnScreen(location);
3477 Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
3478 if (rect.contains((int)x, (int)y)) {
3479 return child;
3480 }
3481 }
3482 }
3483
3484 return null;
3485 }
3486 }
3487
3488 class MucConfigSession extends CommandSession {
3489 MucConfigSession(XmppConnectionService xmppConnectionService) {
3490 super("Configure Channel", null, xmppConnectionService);
3491 }
3492
3493 @Override
3494 protected void updateWithResponseUiThread(final IqPacket iq) {
3495 Timer oldTimer = this.loadingTimer;
3496 this.loadingTimer = new Timer();
3497 oldTimer.cancel();
3498 this.executing = false;
3499 this.loading = false;
3500 this.loadingHasBeenLong = false;
3501 this.responseElement = null;
3502 this.fillableFieldCount = 0;
3503 this.reported = null;
3504 this.response = iq;
3505 this.items.clear();
3506 this.actionsAdapter.clear();
3507 layoutManager.setSpanCount(1);
3508
3509 final Element query = iq.findChild("query", "http://jabber.org/protocol/muc#owner");
3510 if (iq.getType() == IqPacket.TYPE.RESULT && query != null) {
3511 final Data form = Data.parse(query.findChild("x", "jabber:x:data"));
3512 final String title = form.getTitle();
3513 if (title != null) {
3514 mTitle = title;
3515 ConversationPagerAdapter.this.notifyDataSetChanged();
3516 }
3517
3518 this.responseElement = form;
3519 setupReported(form.findChild("reported", "jabber:x:data"));
3520 if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager());
3521
3522 if (actionsAdapter.countExceptCancel() < 1) {
3523 actionsAdapter.add(Pair.create("save", "Save"));
3524 }
3525
3526 if (actionsAdapter.getPosition("cancel") < 0) {
3527 actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
3528 }
3529 } else if (iq.getType() == IqPacket.TYPE.RESULT) {
3530 expectingRemoval = true;
3531 removeSession(this);
3532 return;
3533 } else {
3534 actionsAdapter.add(Pair.create("close", "close"));
3535 }
3536
3537 notifyDataSetChanged();
3538 }
3539
3540 @Override
3541 public synchronized boolean execute(String action) {
3542 if ("cancel".equals(action)) {
3543 final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
3544 packet.setTo(response.getFrom());
3545 final Element form = packet
3546 .addChild("query", "http://jabber.org/protocol/muc#owner")
3547 .addChild("x", "jabber:x:data");
3548 form.setAttribute("type", "cancel");
3549 xmppConnectionService.sendIqPacket(getAccount(), packet, null);
3550 return true;
3551 }
3552
3553 if (!"save".equals(action)) return true;
3554
3555 final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
3556 packet.setTo(response.getFrom());
3557
3558 String formType = responseElement == null ? null : responseElement.getAttribute("type");
3559 if (responseElement != null &&
3560 responseElement.getName().equals("x") &&
3561 responseElement.getNamespace().equals("jabber:x:data") &&
3562 formType != null && formType.equals("form")) {
3563
3564 responseElement.setAttribute("type", "submit");
3565 packet
3566 .addChild("query", "http://jabber.org/protocol/muc#owner")
3567 .addChild(responseElement);
3568 }
3569
3570 executing = true;
3571 xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
3572 updateWithResponse(iq);
3573 }, 120L);
3574
3575 loading();
3576
3577 return false;
3578 }
3579 }
3580 }
3581
3582 public static class Thread {
3583 protected Message subject = null;
3584 protected Message first = null;
3585 protected Message last = null;
3586 protected final String threadId;
3587
3588 protected Thread(final String threadId) {
3589 this.threadId = threadId;
3590 }
3591
3592 public String getThreadId() {
3593 return threadId;
3594 }
3595
3596 public String getSubject() {
3597 if (subject == null) return null;
3598
3599 return subject.getSubject();
3600 }
3601
3602 public String getDisplay() {
3603 final String s = getSubject();
3604 if (s != null) return s;
3605
3606 if (first != null) {
3607 return first.getBody();
3608 }
3609
3610 return "";
3611 }
3612
3613 public long getLastTime() {
3614 if (last == null) return 0;
3615
3616 return last.getTimeSent();
3617 }
3618 }
3619}