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