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