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