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