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