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