Conversation.java

  1package eu.siacs.conversations.entities;
  2
  3import android.content.ContentValues;
  4import android.database.Cursor;
  5import android.support.annotation.NonNull;
  6import android.support.annotation.Nullable;
  7import android.text.TextUtils;
  8
  9import org.json.JSONArray;
 10import org.json.JSONException;
 11import org.json.JSONObject;
 12
 13import java.util.ArrayList;
 14import java.util.Collections;
 15import java.util.Comparator;
 16import java.util.Iterator;
 17import java.util.List;
 18import java.util.ListIterator;
 19import java.util.Locale;
 20import java.util.concurrent.atomic.AtomicBoolean;
 21
 22import eu.siacs.conversations.Config;
 23import eu.siacs.conversations.crypto.OmemoSetting;
 24import eu.siacs.conversations.crypto.PgpDecryptionService;
 25import eu.siacs.conversations.crypto.axolotl.AxolotlService;
 26import eu.siacs.conversations.services.QuickConversationsService;
 27import eu.siacs.conversations.utils.JidHelper;
 28import eu.siacs.conversations.xmpp.InvalidJid;
 29import eu.siacs.conversations.xmpp.chatstate.ChatState;
 30import eu.siacs.conversations.xmpp.mam.MamReference;
 31import rocks.xmpp.addr.Jid;
 32
 33import static eu.siacs.conversations.entities.Bookmark.printableValue;
 34
 35
 36public class Conversation extends AbstractEntity implements Blockable, Comparable<Conversation>, Conversational {
 37	public static final String TABLENAME = "conversations";
 38
 39	public static final int STATUS_AVAILABLE = 0;
 40	public static final int STATUS_ARCHIVED = 1;
 41
 42	public static final String NAME = "name";
 43	public static final String ACCOUNT = "accountUuid";
 44	public static final String CONTACT = "contactUuid";
 45	public static final String CONTACTJID = "contactJid";
 46	public static final String STATUS = "status";
 47	public static final String CREATED = "created";
 48	public static final String MODE = "mode";
 49	public static final String ATTRIBUTES = "attributes";
 50
 51	public static final String ATTRIBUTE_MUTED_TILL = "muted_till";
 52	public static final String ATTRIBUTE_ALWAYS_NOTIFY = "always_notify";
 53	public static final String ATTRIBUTE_LAST_CLEAR_HISTORY = "last_clear_history";
 54	static final String ATTRIBUTE_MUC_PASSWORD = "muc_password";
 55	private static final String ATTRIBUTE_NEXT_MESSAGE = "next_message";
 56	private static final String ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP = "next_message_timestamp";
 57	private static final String ATTRIBUTE_CRYPTO_TARGETS = "crypto_targets";
 58	private static final String ATTRIBUTE_NEXT_ENCRYPTION = "next_encryption";
 59	public static final String ATTRIBUTE_MEMBERS_ONLY = "members_only";
 60	public static final String ATTRIBUTE_MODERATED = "moderated";
 61	public static final String ATTRIBUTE_NON_ANONYMOUS = "non_anonymous";
 62	protected final ArrayList<Message> messages = new ArrayList<>();
 63	public AtomicBoolean messagesLoaded = new AtomicBoolean(true);
 64	protected Account account = null;
 65	private String draftMessage;
 66	private String name;
 67	private String contactUuid;
 68	private String accountUuid;
 69	private Jid contactJid;
 70	private int status;
 71	private long created;
 72	private int mode;
 73	private JSONObject attributes = new JSONObject();
 74	private Jid nextCounterpart;
 75	private transient MucOptions mucOptions = null;
 76	private boolean messagesLeftOnServer = true;
 77	private ChatState mOutgoingChatState = Config.DEFAULT_CHATSTATE;
 78	private ChatState mIncomingChatState = Config.DEFAULT_CHATSTATE;
 79	private String mFirstMamReference = null;
 80	private Message correctingMessage;
 81
 82	public Conversation(final String name, final Account account, final Jid contactJid,
 83	                    final int mode) {
 84		this(java.util.UUID.randomUUID().toString(), name, null, account
 85						.getUuid(), contactJid, System.currentTimeMillis(),
 86				STATUS_AVAILABLE, mode, "");
 87		this.account = account;
 88	}
 89
 90	public Conversation(final String uuid, final String name, final String contactUuid,
 91	                    final String accountUuid, final Jid contactJid, final long created, final int status,
 92	                    final int mode, final String attributes) {
 93		this.uuid = uuid;
 94		this.name = name;
 95		this.contactUuid = contactUuid;
 96		this.accountUuid = accountUuid;
 97		this.contactJid = contactJid;
 98		this.created = created;
 99		this.status = status;
100		this.mode = mode;
101		try {
102			this.attributes = new JSONObject(attributes == null ? "" : attributes);
103		} catch (JSONException e) {
104			this.attributes = new JSONObject();
105		}
106	}
107
108	public static Conversation fromCursor(Cursor cursor) {
109		return new Conversation(cursor.getString(cursor.getColumnIndex(UUID)),
110				cursor.getString(cursor.getColumnIndex(NAME)),
111				cursor.getString(cursor.getColumnIndex(CONTACT)),
112				cursor.getString(cursor.getColumnIndex(ACCOUNT)),
113				JidHelper.parseOrFallbackToInvalid(cursor.getString(cursor.getColumnIndex(CONTACTJID))),
114				cursor.getLong(cursor.getColumnIndex(CREATED)),
115				cursor.getInt(cursor.getColumnIndex(STATUS)),
116				cursor.getInt(cursor.getColumnIndex(MODE)),
117				cursor.getString(cursor.getColumnIndex(ATTRIBUTES)));
118	}
119
120	public boolean hasMessagesLeftOnServer() {
121		return messagesLeftOnServer;
122	}
123
124	public void setHasMessagesLeftOnServer(boolean value) {
125		this.messagesLeftOnServer = value;
126	}
127
128	public Message getFirstUnreadMessage() {
129		Message first = null;
130		synchronized (this.messages) {
131			for (int i = messages.size() - 1; i >= 0; --i) {
132				if (messages.get(i).isRead()) {
133					return first;
134				} else {
135					first = messages.get(i);
136				}
137			}
138		}
139		return first;
140	}
141
142	public Message findUnsentMessageWithUuid(String uuid) {
143		synchronized (this.messages) {
144			for (final Message message : this.messages) {
145				final int s = message.getStatus();
146				if ((s == Message.STATUS_UNSEND || s == Message.STATUS_WAITING) && message.getUuid().equals(uuid)) {
147					return message;
148				}
149			}
150		}
151		return null;
152	}
153
154	public void findWaitingMessages(OnMessageFound onMessageFound) {
155		final ArrayList<Message> results = new ArrayList<>();
156		synchronized (this.messages) {
157			for (Message message : this.messages) {
158				if (message.getStatus() == Message.STATUS_WAITING) {
159					results.add(message);
160				}
161			}
162		}
163		for(Message result : results) {
164			onMessageFound.onMessageFound(result);
165		}
166	}
167
168	public void findUnreadMessages(OnMessageFound onMessageFound) {
169		final ArrayList<Message> results = new ArrayList<>();
170		synchronized (this.messages) {
171			for (Message message : this.messages) {
172				if (!message.isRead()) {
173					results.add(message);
174				}
175			}
176		}
177		for(Message result : results) {
178			onMessageFound.onMessageFound(result);
179		}
180	}
181
182	public void findMessagesWithFiles(final OnMessageFound onMessageFound) {
183		final ArrayList<Message> results = new ArrayList<>();
184		synchronized (this.messages) {
185			for (final Message m : this.messages) {
186				if (m.isFileOrImage() && m.getEncryption() != Message.ENCRYPTION_PGP) {
187					results.add(m);
188				}
189			}
190		}
191		for(Message result : results) {
192			onMessageFound.onMessageFound(result);
193		}
194	}
195
196	public Message findMessageWithFileAndUuid(final String uuid) {
197		synchronized (this.messages) {
198			for (final Message message : this.messages) {
199				if (message.getUuid().equals(uuid)
200						&& message.getEncryption() != Message.ENCRYPTION_PGP
201						&& (message.getType() == Message.TYPE_IMAGE || message.getType() == Message.TYPE_FILE || message.treatAsDownloadable())) {
202					return message;
203				}
204			}
205		}
206		return null;
207	}
208
209	public void clearMessages() {
210		synchronized (this.messages) {
211			this.messages.clear();
212		}
213	}
214
215	public boolean setIncomingChatState(ChatState state) {
216		if (this.mIncomingChatState == state) {
217			return false;
218		}
219		this.mIncomingChatState = state;
220		return true;
221	}
222
223	public ChatState getIncomingChatState() {
224		return this.mIncomingChatState;
225	}
226
227	public boolean setOutgoingChatState(ChatState state) {
228		if (mode == MODE_SINGLE && !getContact().isSelf() || (isPrivateAndNonAnonymous() && getNextCounterpart() == null)) {
229			if (this.mOutgoingChatState != state) {
230				this.mOutgoingChatState = state;
231				return true;
232			}
233		}
234		return false;
235	}
236
237	public ChatState getOutgoingChatState() {
238		return this.mOutgoingChatState;
239	}
240
241	public void trim() {
242		synchronized (this.messages) {
243			final int size = messages.size();
244			final int maxsize = Config.PAGE_SIZE * Config.MAX_NUM_PAGES;
245			if (size > maxsize) {
246				List<Message> discards = this.messages.subList(0, size - maxsize);
247				final PgpDecryptionService pgpDecryptionService = account.getPgpDecryptionService();
248				if (pgpDecryptionService != null) {
249					pgpDecryptionService.discard(discards);
250				}
251				discards.clear();
252				untieMessages();
253			}
254		}
255	}
256
257	public void findUnsentTextMessages(OnMessageFound onMessageFound) {
258		final ArrayList<Message> results = new ArrayList<>();
259		synchronized (this.messages) {
260			for (Message message : this.messages) {
261				if (message.getType() != Message.TYPE_IMAGE && message.getStatus() == Message.STATUS_UNSEND) {
262					results.add(message);
263				}
264			}
265		}
266		for(Message result : results) {
267			onMessageFound.onMessageFound(result);
268		}
269	}
270
271	public Message findSentMessageWithUuidOrRemoteId(String id) {
272		synchronized (this.messages) {
273			for (Message message : this.messages) {
274				if (id.equals(message.getUuid())
275						|| (message.getStatus() >= Message.STATUS_SEND
276						&& id.equals(message.getRemoteMsgId()))) {
277					return message;
278				}
279			}
280		}
281		return null;
282	}
283
284	public Message findMessageWithRemoteIdAndCounterpart(String id, Jid counterpart, boolean received, boolean carbon) {
285		synchronized (this.messages) {
286			for (int i = this.messages.size() - 1; i >= 0; --i) {
287				Message message = messages.get(i);
288				if (counterpart.equals(message.getCounterpart())
289						&& ((message.getStatus() == Message.STATUS_RECEIVED) == received)
290						&& (carbon == message.isCarbon() || received)) {
291					if (id.equals(message.getRemoteMsgId()) && !message.isFileOrImage() && !message.treatAsDownloadable()) {
292						return message;
293					} else {
294						return null;
295					}
296				}
297			}
298		}
299		return null;
300	}
301
302	public Message findSentMessageWithUuid(String id) {
303		synchronized (this.messages) {
304			for (Message message : this.messages) {
305				if (id.equals(message.getUuid())) {
306					return message;
307				}
308			}
309		}
310		return null;
311	}
312
313	public Message findMessageWithRemoteId(String id, Jid counterpart) {
314		synchronized (this.messages) {
315			for (Message message : this.messages) {
316				if (counterpart.equals(message.getCounterpart())
317						&& (id.equals(message.getRemoteMsgId()) || id.equals(message.getUuid()))) {
318					return message;
319				}
320			}
321		}
322		return null;
323	}
324
325	public boolean hasMessageWithCounterpart(Jid counterpart) {
326		synchronized (this.messages) {
327			for (Message message : this.messages) {
328				if (counterpart.equals(message.getCounterpart())) {
329					return true;
330				}
331			}
332		}
333		return false;
334	}
335
336	public void populateWithMessages(final List<Message> messages) {
337		synchronized (this.messages) {
338			messages.clear();
339			messages.addAll(this.messages);
340		}
341		for (Iterator<Message> iterator = messages.iterator(); iterator.hasNext(); ) {
342			if (iterator.next().wasMergedIntoPrevious()) {
343				iterator.remove();
344			}
345		}
346	}
347
348	@Override
349	public boolean isBlocked() {
350		return getContact().isBlocked();
351	}
352
353	@Override
354	public boolean isDomainBlocked() {
355		return getContact().isDomainBlocked();
356	}
357
358	@Override
359	public Jid getBlockedJid() {
360		return getContact().getBlockedJid();
361	}
362
363	public int countMessages() {
364		synchronized (this.messages) {
365			return this.messages.size();
366		}
367	}
368
369	public String getFirstMamReference() {
370		return this.mFirstMamReference;
371	}
372
373	public void setFirstMamReference(String reference) {
374		this.mFirstMamReference = reference;
375	}
376
377	public void setLastClearHistory(long time, String reference) {
378		if (reference != null) {
379			setAttribute(ATTRIBUTE_LAST_CLEAR_HISTORY, String.valueOf(time) + ":" + reference);
380		} else {
381			setAttribute(ATTRIBUTE_LAST_CLEAR_HISTORY, time);
382		}
383	}
384
385	public MamReference getLastClearHistory() {
386		return MamReference.fromAttribute(getAttribute(ATTRIBUTE_LAST_CLEAR_HISTORY));
387	}
388
389	public List<Jid> getAcceptedCryptoTargets() {
390		if (mode == MODE_SINGLE) {
391			return Collections.singletonList(getJid().asBareJid());
392		} else {
393			return getJidListAttribute(ATTRIBUTE_CRYPTO_TARGETS);
394		}
395	}
396
397	public void setAcceptedCryptoTargets(List<Jid> acceptedTargets) {
398		setAttribute(ATTRIBUTE_CRYPTO_TARGETS, acceptedTargets);
399	}
400
401	public boolean setCorrectingMessage(Message correctingMessage) {
402		this.correctingMessage = correctingMessage;
403		return correctingMessage == null && draftMessage != null;
404	}
405
406	public Message getCorrectingMessage() {
407		return this.correctingMessage;
408	}
409
410	public boolean withSelf() {
411		return getContact().isSelf();
412	}
413
414	@Override
415	public int compareTo(@NonNull Conversation another) {
416		return Long.compare(another.getSortableTime(), getSortableTime());
417	}
418
419	private long getSortableTime() {
420		Draft draft = getDraft();
421		long messageTime = getLatestMessage().getTimeSent();
422		if (draft == null) {
423			return messageTime;
424		} else {
425			return Math.max(messageTime, draft.getTimestamp());
426		}
427	}
428
429	public String getDraftMessage() {
430		return draftMessage;
431	}
432
433	public void setDraftMessage(String draftMessage) {
434		this.draftMessage = draftMessage;
435	}
436
437	public boolean isRead() {
438		return (this.messages.size() == 0) || this.messages.get(this.messages.size() - 1).isRead();
439	}
440
441	public List<Message> markRead(String upToUuid) {
442		final List<Message> unread = new ArrayList<>();
443		synchronized (this.messages) {
444			for (Message message : this.messages) {
445				if (!message.isRead()) {
446					message.markRead();
447					unread.add(message);
448				}
449				if (message.getUuid().equals(upToUuid)) {
450					return unread;
451				}
452			}
453		}
454		return unread;
455	}
456
457	public static Message getLatestMarkableMessage(final List<Message> messages, boolean isPrivateAndNonAnonymousMuc) {
458			for (int i = messages.size() - 1; i >= 0; --i) {
459				final Message message = messages.get(i);
460				if (message.getStatus() <= Message.STATUS_RECEIVED
461						&& (message.markable || isPrivateAndNonAnonymousMuc)
462						&& message.getType() != Message.TYPE_PRIVATE) {
463					return message;
464				}
465			}
466		return null;
467	}
468
469	public Message getLatestMessage() {
470		synchronized (this.messages) {
471			if (this.messages.size() == 0) {
472				Message message = new Message(this, "", Message.ENCRYPTION_NONE);
473				message.setType(Message.TYPE_STATUS);
474				message.setTime(Math.max(getCreated(), getLastClearHistory().getTimestamp()));
475				return message;
476			} else {
477				return this.messages.get(this.messages.size() - 1);
478			}
479		}
480	}
481
482	public @NonNull CharSequence getName() {
483		if (getMode() == MODE_MULTI) {
484			final String roomName = getMucOptions().getName();
485			final String subject = getMucOptions().getSubject();
486			final Bookmark bookmark = getBookmark();
487			final String bookmarkName = bookmark != null ? bookmark.getBookmarkName() : null;
488			if (printableValue(roomName)) {
489				return roomName;
490			} else if (printableValue(subject)) {
491				return subject;
492			} else if (printableValue(bookmarkName, false)) {
493				return bookmarkName;
494			} else {
495				final String generatedName = getMucOptions().createNameFromParticipants();
496				if (printableValue(generatedName)) {
497					return generatedName;
498				} else {
499					return contactJid.getLocal() != null ? contactJid.getLocal() : contactJid;
500				}
501			}
502		} else if ((QuickConversationsService.isConversations() || !Config.QUICKSY_DOMAIN.equals(contactJid.getDomain())) && isWithStranger()) {
503			return contactJid;
504		} else {
505			return this.getContact().getDisplayName();
506		}
507	}
508
509	public String getAccountUuid() {
510		return this.accountUuid;
511	}
512
513	public Account getAccount() {
514		return this.account;
515	}
516
517	public void setAccount(final Account account) {
518		this.account = account;
519	}
520
521	public Contact getContact() {
522		return this.account.getRoster().getContact(this.contactJid);
523	}
524
525	@Override
526	public Jid getJid() {
527		return this.contactJid;
528	}
529
530	public int getStatus() {
531		return this.status;
532	}
533
534	public void setStatus(int status) {
535		this.status = status;
536	}
537
538	public long getCreated() {
539		return this.created;
540	}
541
542	public ContentValues getContentValues() {
543		ContentValues values = new ContentValues();
544		values.put(UUID, uuid);
545		values.put(NAME, name);
546		values.put(CONTACT, contactUuid);
547		values.put(ACCOUNT, accountUuid);
548		values.put(CONTACTJID, contactJid.toString());
549		values.put(CREATED, created);
550		values.put(STATUS, status);
551		values.put(MODE, mode);
552		values.put(ATTRIBUTES, attributes.toString());
553		return values;
554	}
555
556	public int getMode() {
557		return this.mode;
558	}
559
560	public void setMode(int mode) {
561		this.mode = mode;
562	}
563
564	/**
565	 * short for is Private and Non-anonymous
566	 */
567	public boolean isSingleOrPrivateAndNonAnonymous() {
568		return mode == MODE_SINGLE || isPrivateAndNonAnonymous();
569	}
570
571	public boolean isPrivateAndNonAnonymous() {
572		return getMucOptions().isPrivateAndNonAnonymous();
573	}
574
575	public synchronized MucOptions getMucOptions() {
576		if (this.mucOptions == null) {
577			this.mucOptions = new MucOptions(this);
578		}
579		return this.mucOptions;
580	}
581
582	public void resetMucOptions() {
583		this.mucOptions = null;
584	}
585
586	public void setContactJid(final Jid jid) {
587		this.contactJid = jid;
588	}
589
590	public Jid getNextCounterpart() {
591		return this.nextCounterpart;
592	}
593
594	public void setNextCounterpart(Jid jid) {
595		this.nextCounterpart = jid;
596	}
597
598	public int getNextEncryption() {
599		if (!Config.supportOmemo() && !Config.supportOpenPgp()) {
600			return Message.ENCRYPTION_NONE;
601		}
602		if (OmemoSetting.isAlways()) {
603			return suitableForOmemoByDefault(this) ? Message.ENCRYPTION_AXOLOTL : Message.ENCRYPTION_NONE;
604		}
605		final int defaultEncryption;
606		if (suitableForOmemoByDefault(this)) {
607			defaultEncryption = OmemoSetting.getEncryption();
608		} else {
609			defaultEncryption = Message.ENCRYPTION_NONE;
610		}
611		int encryption = this.getIntAttribute(ATTRIBUTE_NEXT_ENCRYPTION, defaultEncryption);
612		if (encryption == Message.ENCRYPTION_OTR || encryption < 0) {
613			return defaultEncryption;
614		} else {
615			return encryption;
616		}
617	}
618
619	private static boolean suitableForOmemoByDefault(final Conversation conversation) {
620		if (conversation.getJid().asBareJid().equals(Config.BUG_REPORTS)) {
621			return false;
622		}
623		if (conversation.getContact().isOwnServer()) {
624			return false;
625		}
626		final String contact = conversation.getJid().getDomain();
627		final String account = conversation.getAccount().getServer();
628		if (Config.OMEMO_EXCEPTIONS.CONTACT_DOMAINS.contains(contact) || Config.OMEMO_EXCEPTIONS.ACCOUNT_DOMAINS.contains(account)) {
629			return false;
630		}
631		final AxolotlService axolotlService = conversation.getAccount().getAxolotlService();
632		return axolotlService != null && axolotlService.isConversationAxolotlCapable(conversation);
633	}
634
635	public void setNextEncryption(int encryption) {
636		this.setAttribute(ATTRIBUTE_NEXT_ENCRYPTION, String.valueOf(encryption));
637	}
638
639	public String getNextMessage() {
640		final String nextMessage = getAttribute(ATTRIBUTE_NEXT_MESSAGE);
641		return nextMessage == null ? "" : nextMessage;
642	}
643
644	public @Nullable
645	Draft getDraft() {
646		long timestamp = getLongAttribute(ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP, 0);
647		if (timestamp > getLatestMessage().getTimeSent()) {
648			String message = getAttribute(ATTRIBUTE_NEXT_MESSAGE);
649			if (!TextUtils.isEmpty(message) && timestamp != 0) {
650				return new Draft(message, timestamp);
651			}
652		}
653		return null;
654	}
655
656	public boolean setNextMessage(final String input) {
657		final String message = input == null || input.trim().isEmpty() ? null : input;
658		boolean changed = !getNextMessage().equals(message);
659		this.setAttribute(ATTRIBUTE_NEXT_MESSAGE, message);
660		if (changed) {
661			this.setAttribute(ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP, message == null ? 0 : System.currentTimeMillis());
662		}
663		return changed;
664	}
665
666	public Bookmark getBookmark() {
667		return this.account.getBookmark(this.contactJid);
668	}
669
670	public Message findDuplicateMessage(Message message) {
671		synchronized (this.messages) {
672			for (int i = this.messages.size() - 1; i >= 0; --i) {
673				if (this.messages.get(i).similar(message)) {
674					return this.messages.get(i);
675				}
676			}
677		}
678		return null;
679	}
680
681	public boolean hasDuplicateMessage(Message message) {
682		return findDuplicateMessage(message) != null;
683	}
684
685	public Message findSentMessageWithBody(String body) {
686		synchronized (this.messages) {
687			for (int i = this.messages.size() - 1; i >= 0; --i) {
688				Message message = this.messages.get(i);
689				if (message.getStatus() == Message.STATUS_UNSEND || message.getStatus() == Message.STATUS_SEND) {
690					String otherBody;
691					if (message.hasFileOnRemoteHost()) {
692						otherBody = message.getFileParams().url.toString();
693					} else {
694						otherBody = message.body;
695					}
696					if (otherBody != null && otherBody.equals(body)) {
697						return message;
698					}
699				}
700			}
701			return null;
702		}
703	}
704
705	public boolean possibleDuplicate(final String serverMsgId, final String remoteMsgId) {
706		if (serverMsgId == null || remoteMsgId == null) {
707			return false;
708		}
709		synchronized (this.messages) {
710			for(Message message : this.messages) {
711				if (serverMsgId.equals(message.getServerMsgId()) || remoteMsgId.equals(message.getRemoteMsgId())) {
712					return true;
713				}
714			}
715		}
716		return false;
717	}
718
719	public MamReference getLastMessageTransmitted() {
720		final MamReference lastClear = getLastClearHistory();
721		MamReference lastReceived = new MamReference(0);
722		synchronized (this.messages) {
723			for (int i = this.messages.size() - 1; i >= 0; --i) {
724				final Message message = this.messages.get(i);
725				if (message.getType() == Message.TYPE_PRIVATE) {
726					continue; //it's unsafe to use private messages as anchor. They could be coming from user archive
727				}
728				if (message.getStatus() == Message.STATUS_RECEIVED || message.isCarbon() || message.getServerMsgId() != null) {
729					lastReceived = new MamReference(message.getTimeSent(), message.getServerMsgId());
730					break;
731				}
732			}
733		}
734		return MamReference.max(lastClear, lastReceived);
735	}
736
737	public void setMutedTill(long value) {
738		this.setAttribute(ATTRIBUTE_MUTED_TILL, String.valueOf(value));
739	}
740
741	public boolean isMuted() {
742		return System.currentTimeMillis() < this.getLongAttribute(ATTRIBUTE_MUTED_TILL, 0);
743	}
744
745	public boolean alwaysNotify() {
746		return mode == MODE_SINGLE || getBooleanAttribute(ATTRIBUTE_ALWAYS_NOTIFY, Config.ALWAYS_NOTIFY_BY_DEFAULT || isPrivateAndNonAnonymous());
747	}
748
749	public boolean setAttribute(String key, boolean value) {
750		boolean prev = getBooleanAttribute(key,false);
751		setAttribute(key,Boolean.toString(value));
752		return prev != value;
753	}
754
755	private boolean setAttribute(String key, long value) {
756		return setAttribute(key, Long.toString(value));
757	}
758
759	public boolean setAttribute(String key, String value) {
760		synchronized (this.attributes) {
761			try {
762				if (value == null) {
763					if (this.attributes.has(key)) {
764						this.attributes.remove(key);
765						return true;
766					} else {
767						return false;
768					}
769				} else {
770					final String prev = this.attributes.optString(key, null);
771					this.attributes.put(key, value);
772					return !value.equals(prev);
773				}
774			} catch (JSONException e) {
775				throw new AssertionError(e);
776			}
777		}
778	}
779
780	public boolean setAttribute(String key, List<Jid> jids) {
781		JSONArray array = new JSONArray();
782		for (Jid jid : jids) {
783			array.put(jid.asBareJid().toString());
784		}
785		synchronized (this.attributes) {
786			try {
787				this.attributes.put(key, array);
788				return true;
789			} catch (JSONException e) {
790				return false;
791			}
792		}
793	}
794
795	public String getAttribute(String key) {
796		synchronized (this.attributes) {
797		    return this.attributes.optString(key, null);
798		}
799	}
800
801	private List<Jid> getJidListAttribute(String key) {
802		ArrayList<Jid> list = new ArrayList<>();
803		synchronized (this.attributes) {
804			try {
805				JSONArray array = this.attributes.getJSONArray(key);
806				for (int i = 0; i < array.length(); ++i) {
807					try {
808						list.add(Jid.of(array.getString(i)));
809					} catch (IllegalArgumentException e) {
810						//ignored
811					}
812				}
813			} catch (JSONException e) {
814				//ignored
815			}
816		}
817		return list;
818	}
819
820	private int getIntAttribute(String key, int defaultValue) {
821		String value = this.getAttribute(key);
822		if (value == null) {
823			return defaultValue;
824		} else {
825			try {
826				return Integer.parseInt(value);
827			} catch (NumberFormatException e) {
828				return defaultValue;
829			}
830		}
831	}
832
833	public long getLongAttribute(String key, long defaultValue) {
834		String value = this.getAttribute(key);
835		if (value == null) {
836			return defaultValue;
837		} else {
838			try {
839				return Long.parseLong(value);
840			} catch (NumberFormatException e) {
841				return defaultValue;
842			}
843		}
844	}
845
846	public boolean getBooleanAttribute(String key, boolean defaultValue) {
847		String value = this.getAttribute(key);
848		if (value == null) {
849			return defaultValue;
850		} else {
851			return Boolean.parseBoolean(value);
852		}
853	}
854
855	public void add(Message message) {
856		synchronized (this.messages) {
857			this.messages.add(message);
858		}
859	}
860
861	public void prepend(int offset, Message message) {
862		synchronized (this.messages) {
863			this.messages.add(Math.min(offset, this.messages.size()), message);
864		}
865	}
866
867	public void addAll(int index, List<Message> messages) {
868		synchronized (this.messages) {
869			this.messages.addAll(index, messages);
870		}
871		account.getPgpDecryptionService().decrypt(messages);
872	}
873
874	public void expireOldMessages(long timestamp) {
875		synchronized (this.messages) {
876			for (ListIterator<Message> iterator = this.messages.listIterator(); iterator.hasNext(); ) {
877				if (iterator.next().getTimeSent() < timestamp) {
878					iterator.remove();
879				}
880			}
881			untieMessages();
882		}
883	}
884
885	public void sort() {
886		synchronized (this.messages) {
887			Collections.sort(this.messages, (left, right) -> {
888				if (left.getTimeSent() < right.getTimeSent()) {
889					return -1;
890				} else if (left.getTimeSent() > right.getTimeSent()) {
891					return 1;
892				} else {
893					return 0;
894				}
895			});
896			untieMessages();
897		}
898	}
899
900	private void untieMessages() {
901		for (Message message : this.messages) {
902			message.untie();
903		}
904	}
905
906	public int unreadCount() {
907		synchronized (this.messages) {
908			int count = 0;
909			for (int i = this.messages.size() - 1; i >= 0; --i) {
910				if (this.messages.get(i).isRead()) {
911					return count;
912				}
913				++count;
914			}
915			return count;
916		}
917	}
918
919	public int receivedMessagesCount() {
920		int count = 0;
921		synchronized (this.messages) {
922			for (Message message : messages) {
923				if (message.getStatus() == Message.STATUS_RECEIVED) {
924					++count;
925				}
926			}
927		}
928		return count;
929	}
930
931	public int sentMessagesCount() {
932		int count = 0;
933		synchronized (this.messages) {
934			for (Message message : messages) {
935				if (message.getStatus() != Message.STATUS_RECEIVED) {
936					++count;
937				}
938			}
939		}
940		return count;
941	}
942
943	public boolean isWithStranger() {
944		final Contact contact = getContact();
945		return mode == MODE_SINGLE
946				&& !contact.isOwnServer()
947				&& !contact.showInContactList()
948				&& !contact.isSelf()
949				&& !Config.QUICKSY_DOMAIN.equals(contact.getJid().toEscapedString())
950				&& sentMessagesCount() == 0;
951	}
952
953	public int getReceivedMessagesCountSinceUuid(String uuid) {
954		if (uuid == null) {
955			return  0;
956		}
957		int count = 0;
958		synchronized (this.messages) {
959			for (int i = messages.size() - 1; i >= 0; i--) {
960				final Message message = messages.get(i);
961				if (uuid.equals(message.getUuid())) {
962					return count;
963				}
964				if (message.getStatus() <= Message.STATUS_RECEIVED) {
965					++count;
966				}
967			}
968		}
969		return 0;
970	}
971
972	public interface OnMessageFound {
973		void onMessageFound(final Message message);
974	}
975
976	public static class Draft {
977		private final String message;
978		private final long timestamp;
979
980		private Draft(String message, long timestamp) {
981			this.message = message;
982			this.timestamp = timestamp;
983		}
984
985		public long getTimestamp() {
986			return timestamp;
987		}
988
989		public String getMessage() {
990			return message;
991		}
992	}
993}