Message.java

  1package eu.siacs.conversations.entities;
  2
  3import android.content.ContentValues;
  4import android.database.Cursor;
  5
  6import java.net.MalformedURLException;
  7import java.net.URL;
  8import java.util.Arrays;
  9
 10import eu.siacs.conversations.Config;
 11import eu.siacs.conversations.crypto.axolotl.XmppAxolotlSession;
 12import eu.siacs.conversations.utils.GeoHelper;
 13import eu.siacs.conversations.utils.MimeUtils;
 14import eu.siacs.conversations.utils.UIHelper;
 15import eu.siacs.conversations.xmpp.jid.InvalidJidException;
 16import eu.siacs.conversations.xmpp.jid.Jid;
 17
 18public class Message extends AbstractEntity {
 19
 20	public static final String TABLENAME = "messages";
 21
 22	public static final String MERGE_SEPARATOR = " \u200B\n\n";
 23
 24	public static final int STATUS_RECEIVED = 0;
 25	public static final int STATUS_UNSEND = 1;
 26	public static final int STATUS_SEND = 2;
 27	public static final int STATUS_SEND_FAILED = 3;
 28	public static final int STATUS_WAITING = 5;
 29	public static final int STATUS_OFFERED = 6;
 30	public static final int STATUS_SEND_RECEIVED = 7;
 31	public static final int STATUS_SEND_DISPLAYED = 8;
 32
 33	public static final int ENCRYPTION_NONE = 0;
 34	public static final int ENCRYPTION_PGP = 1;
 35	public static final int ENCRYPTION_OTR = 2;
 36	public static final int ENCRYPTION_DECRYPTED = 3;
 37	public static final int ENCRYPTION_DECRYPTION_FAILED = 4;
 38	public static final int ENCRYPTION_AXOLOTL = 5;
 39
 40	public static final int TYPE_TEXT = 0;
 41	public static final int TYPE_IMAGE = 1;
 42	public static final int TYPE_FILE = 2;
 43	public static final int TYPE_STATUS = 3;
 44	public static final int TYPE_PRIVATE = 4;
 45
 46	public static final String CONVERSATION = "conversationUuid";
 47	public static final String COUNTERPART = "counterpart";
 48	public static final String TRUE_COUNTERPART = "trueCounterpart";
 49	public static final String BODY = "body";
 50	public static final String TIME_SENT = "timeSent";
 51	public static final String ENCRYPTION = "encryption";
 52	public static final String STATUS = "status";
 53	public static final String TYPE = "type";
 54	public static final String CARBON = "carbon";
 55	public static final String REMOTE_MSG_ID = "remoteMsgId";
 56	public static final String SERVER_MSG_ID = "serverMsgId";
 57	public static final String RELATIVE_FILE_PATH = "relativeFilePath";
 58	public static final String FINGERPRINT = "axolotl_fingerprint";
 59	public static final String READ = "read";
 60	public static final String ME_COMMAND = "/me ";
 61
 62
 63	public boolean markable = false;
 64	protected String conversationUuid;
 65	protected Jid counterpart;
 66	protected Jid trueCounterpart;
 67	protected String body;
 68	protected String encryptedBody;
 69	protected long timeSent;
 70	protected int encryption;
 71	protected int status;
 72	protected int type;
 73	protected boolean carbon = false;
 74	protected String relativeFilePath;
 75	protected boolean read = true;
 76	protected String remoteMsgId = null;
 77	protected String serverMsgId = null;
 78	protected Conversation conversation = null;
 79	protected Transferable transferable = null;
 80	private Message mNextMessage = null;
 81	private Message mPreviousMessage = null;
 82	private String axolotlFingerprint = null;
 83
 84	private Message() {
 85
 86	}
 87
 88	public Message(Conversation conversation, String body, int encryption) {
 89		this(conversation, body, encryption, STATUS_UNSEND);
 90	}
 91
 92	public Message(Conversation conversation, String body, int encryption, int status) {
 93		this(java.util.UUID.randomUUID().toString(),
 94				conversation.getUuid(),
 95				conversation.getJid() == null ? null : conversation.getJid().toBareJid(),
 96				null,
 97				body,
 98				System.currentTimeMillis(),
 99				encryption,
100				status,
101				TYPE_TEXT,
102				false,
103				null,
104				null,
105				null,
106				null,
107				true);
108		this.conversation = conversation;
109	}
110
111	private Message(final String uuid, final String conversationUUid, final Jid counterpart,
112					final Jid trueCounterpart, final String body, final long timeSent,
113					final int encryption, final int status, final int type, final boolean carbon,
114					final String remoteMsgId, final String relativeFilePath,
115					final String serverMsgId, final String fingerprint, final boolean read) {
116		this.uuid = uuid;
117		this.conversationUuid = conversationUUid;
118		this.counterpart = counterpart;
119		this.trueCounterpart = trueCounterpart;
120		this.body = body;
121		this.timeSent = timeSent;
122		this.encryption = encryption;
123		this.status = status;
124		this.type = type;
125		this.carbon = carbon;
126		this.remoteMsgId = remoteMsgId;
127		this.relativeFilePath = relativeFilePath;
128		this.serverMsgId = serverMsgId;
129		this.axolotlFingerprint = fingerprint;
130		this.read = read;
131	}
132
133	public static Message fromCursor(Cursor cursor) {
134		Jid jid;
135		try {
136			String value = cursor.getString(cursor.getColumnIndex(COUNTERPART));
137			if (value != null) {
138				jid = Jid.fromString(value, true);
139			} else {
140				jid = null;
141			}
142		} catch (InvalidJidException e) {
143			jid = null;
144		}
145		Jid trueCounterpart;
146		try {
147			String value = cursor.getString(cursor.getColumnIndex(TRUE_COUNTERPART));
148			if (value != null) {
149				trueCounterpart = Jid.fromString(value, true);
150			} else {
151				trueCounterpart = null;
152			}
153		} catch (InvalidJidException e) {
154			trueCounterpart = null;
155		}
156		return new Message(cursor.getString(cursor.getColumnIndex(UUID)),
157				cursor.getString(cursor.getColumnIndex(CONVERSATION)),
158				jid,
159				trueCounterpart,
160				cursor.getString(cursor.getColumnIndex(BODY)),
161				cursor.getLong(cursor.getColumnIndex(TIME_SENT)),
162				cursor.getInt(cursor.getColumnIndex(ENCRYPTION)),
163				cursor.getInt(cursor.getColumnIndex(STATUS)),
164				cursor.getInt(cursor.getColumnIndex(TYPE)),
165				cursor.getInt(cursor.getColumnIndex(CARBON))>0,
166				cursor.getString(cursor.getColumnIndex(REMOTE_MSG_ID)),
167				cursor.getString(cursor.getColumnIndex(RELATIVE_FILE_PATH)),
168				cursor.getString(cursor.getColumnIndex(SERVER_MSG_ID)),
169				cursor.getString(cursor.getColumnIndex(FINGERPRINT)),
170				cursor.getInt(cursor.getColumnIndex(READ)) > 0);
171	}
172
173	public static Message createStatusMessage(Conversation conversation, String body) {
174		final Message message = new Message();
175		message.setType(Message.TYPE_STATUS);
176		message.setConversation(conversation);
177		message.setBody(body);
178		return message;
179	}
180
181	@Override
182	public ContentValues getContentValues() {
183		ContentValues values = new ContentValues();
184		values.put(UUID, uuid);
185		values.put(CONVERSATION, conversationUuid);
186		if (counterpart == null) {
187			values.putNull(COUNTERPART);
188		} else {
189			values.put(COUNTERPART, counterpart.toString());
190		}
191		if (trueCounterpart == null) {
192			values.putNull(TRUE_COUNTERPART);
193		} else {
194			values.put(TRUE_COUNTERPART, trueCounterpart.toString());
195		}
196		values.put(BODY, body);
197		values.put(TIME_SENT, timeSent);
198		values.put(ENCRYPTION, encryption);
199		values.put(STATUS, status);
200		values.put(TYPE, type);
201		values.put(CARBON, carbon ? 1 : 0);
202		values.put(REMOTE_MSG_ID, remoteMsgId);
203		values.put(RELATIVE_FILE_PATH, relativeFilePath);
204		values.put(SERVER_MSG_ID, serverMsgId);
205		values.put(FINGERPRINT, axolotlFingerprint);
206		values.put(READ,read);
207		return values;
208	}
209
210	public String getConversationUuid() {
211		return conversationUuid;
212	}
213
214	public Conversation getConversation() {
215		return this.conversation;
216	}
217
218	public void setConversation(Conversation conv) {
219		this.conversation = conv;
220	}
221
222	public Jid getCounterpart() {
223		return counterpart;
224	}
225
226	public void setCounterpart(final Jid counterpart) {
227		this.counterpart = counterpart;
228	}
229
230	public Contact getContact() {
231		if (this.conversation.getMode() == Conversation.MODE_SINGLE) {
232			return this.conversation.getContact();
233		} else {
234			if (this.trueCounterpart == null) {
235				return null;
236			} else {
237				return this.conversation.getAccount().getRoster()
238						.getContactFromRoster(this.trueCounterpart);
239			}
240		}
241	}
242
243	public String getBody() {
244		return body;
245	}
246
247	public void setBody(String body) {
248		this.body = body;
249	}
250
251	public long getTimeSent() {
252		return timeSent;
253	}
254
255	public int getEncryption() {
256		return encryption;
257	}
258
259	public void setEncryption(int encryption) {
260		this.encryption = encryption;
261	}
262
263	public int getStatus() {
264		return status;
265	}
266
267	public void setStatus(int status) {
268		this.status = status;
269	}
270
271	public String getRelativeFilePath() {
272		return this.relativeFilePath;
273	}
274
275	public void setRelativeFilePath(String path) {
276		this.relativeFilePath = path;
277	}
278
279	public String getRemoteMsgId() {
280		return this.remoteMsgId;
281	}
282
283	public void setRemoteMsgId(String id) {
284		this.remoteMsgId = id;
285	}
286
287	public String getServerMsgId() {
288		return this.serverMsgId;
289	}
290
291	public void setServerMsgId(String id) {
292		this.serverMsgId = id;
293	}
294
295	public boolean isRead() {
296		return this.read;
297	}
298
299	public void markRead() {
300		this.read = true;
301	}
302
303	public void markUnread() {
304		this.read = false;
305	}
306
307	public void setTime(long time) {
308		this.timeSent = time;
309	}
310
311	public String getEncryptedBody() {
312		return this.encryptedBody;
313	}
314
315	public void setEncryptedBody(String body) {
316		this.encryptedBody = body;
317	}
318
319	public int getType() {
320		return this.type;
321	}
322
323	public void setType(int type) {
324		this.type = type;
325	}
326
327	public boolean isCarbon() {
328		return carbon;
329	}
330
331	public void setCarbon(boolean carbon) {
332		this.carbon = carbon;
333	}
334
335	public void setTrueCounterpart(Jid trueCounterpart) {
336		this.trueCounterpart = trueCounterpart;
337	}
338
339	public Transferable getTransferable() {
340		return this.transferable;
341	}
342
343	public void setTransferable(Transferable transferable) {
344		this.transferable = transferable;
345	}
346
347	public boolean equals(Message message) {
348		if (this.serverMsgId != null && message.getServerMsgId() != null) {
349			return this.serverMsgId.equals(message.getServerMsgId());
350		} else if (this.body == null || this.counterpart == null) {
351			return false;
352		} else {
353			String body, otherBody;
354			if (this.hasFileOnRemoteHost()) {
355				body = getFileParams().url.toString();
356				otherBody = message.body == null ? null : message.body.trim();
357			} else {
358				body = this.body;
359				otherBody = message.body;
360			}
361			if (message.getRemoteMsgId() != null) {
362				return (message.getRemoteMsgId().equals(this.remoteMsgId) || message.getRemoteMsgId().equals(this.uuid))
363						&& this.counterpart.equals(message.getCounterpart())
364						&& body.equals(otherBody);
365			} else {
366				return this.remoteMsgId == null
367						&& this.counterpart.equals(message.getCounterpart())
368						&& body.equals(otherBody)
369						&& Math.abs(this.getTimeSent() - message.getTimeSent()) < Config.MESSAGE_MERGE_WINDOW * 1000;
370			}
371		}
372	}
373
374	public Message next() {
375		synchronized (this.conversation.messages) {
376			if (this.mNextMessage == null) {
377				int index = this.conversation.messages.indexOf(this);
378				if (index < 0 || index >= this.conversation.messages.size() - 1) {
379					this.mNextMessage = null;
380				} else {
381					this.mNextMessage = this.conversation.messages.get(index + 1);
382				}
383			}
384			return this.mNextMessage;
385		}
386	}
387
388	public Message prev() {
389		synchronized (this.conversation.messages) {
390			if (this.mPreviousMessage == null) {
391				int index = this.conversation.messages.indexOf(this);
392				if (index <= 0 || index > this.conversation.messages.size()) {
393					this.mPreviousMessage = null;
394				} else {
395					this.mPreviousMessage = this.conversation.messages.get(index - 1);
396				}
397			}
398			return this.mPreviousMessage;
399		}
400	}
401
402	public boolean mergeable(final Message message) {
403		return message != null &&
404				(message.getType() == Message.TYPE_TEXT &&
405						this.getTransferable() == null &&
406						message.getTransferable() == null &&
407						message.getEncryption() != Message.ENCRYPTION_PGP &&
408						this.getType() == message.getType() &&
409						//this.getStatus() == message.getStatus() &&
410						isStatusMergeable(this.getStatus(), message.getStatus()) &&
411						this.getEncryption() == message.getEncryption() &&
412						this.getCounterpart() != null &&
413						this.getCounterpart().equals(message.getCounterpart()) &&
414						(message.getTimeSent() - this.getTimeSent()) <= (Config.MESSAGE_MERGE_WINDOW * 1000) &&
415						!GeoHelper.isGeoUri(message.getBody()) &&
416						!GeoHelper.isGeoUri(this.body) &&
417						message.treatAsDownloadable() == Decision.NEVER &&
418						this.treatAsDownloadable() == Decision.NEVER &&
419						!message.getBody().startsWith(ME_COMMAND) &&
420						!this.getBody().startsWith(ME_COMMAND) &&
421						!this.bodyIsHeart() &&
422						!message.bodyIsHeart() &&
423						this.isTrusted() == message.isTrusted()
424				);
425	}
426
427	private static boolean isStatusMergeable(int a, int b) {
428		return a == b || (
429				(a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_UNSEND)
430						|| (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_SEND)
431						|| (a == Message.STATUS_UNSEND && b == Message.STATUS_SEND)
432						|| (a == Message.STATUS_UNSEND && b == Message.STATUS_SEND_RECEIVED)
433						|| (a == Message.STATUS_SEND && b == Message.STATUS_UNSEND)
434						|| (a == Message.STATUS_SEND && b == Message.STATUS_SEND_RECEIVED)
435		);
436	}
437
438	public String getMergedBody() {
439		StringBuilder body = new StringBuilder(this.body.trim());
440		Message current = this;
441		while(current.mergeable(current.next())) {
442			current = current.next();
443			body.append(MERGE_SEPARATOR);
444			body.append(current.getBody().trim());
445		}
446		return body.toString();
447	}
448
449	public boolean hasMeCommand() {
450		return getMergedBody().startsWith(ME_COMMAND);
451	}
452
453	public int getMergedStatus() {
454		int status = this.status;
455		Message current = this;
456		while(current.mergeable(current.next())) {
457			current = current.next();
458			status = current.status;
459		}
460		return status;
461	}
462
463	public long getMergedTimeSent() {
464		long time = this.timeSent;
465		Message current = this;
466		while(current.mergeable(current.next())) {
467			current = current.next();
468			time = current.timeSent;
469		}
470		return time;
471	}
472
473	public boolean wasMergedIntoPrevious() {
474		Message prev = this.prev();
475		return prev != null && prev.mergeable(this);
476	}
477
478	public boolean trusted() {
479		Contact contact = this.getContact();
480		return (status > STATUS_RECEIVED || (contact != null && contact.trusted()));
481	}
482
483	public boolean fixCounterpart() {
484		Presences presences = conversation.getContact().getPresences();
485		if (counterpart != null && presences.has(counterpart.getResourcepart())) {
486			return true;
487		} else if (presences.size() >= 1) {
488			try {
489				counterpart = Jid.fromParts(conversation.getJid().getLocalpart(),
490						conversation.getJid().getDomainpart(),
491						presences.asStringArray()[0]);
492				return true;
493			} catch (InvalidJidException e) {
494				counterpart = null;
495				return false;
496			}
497		} else {
498			counterpart = null;
499			return false;
500		}
501	}
502
503	public enum Decision {
504		MUST,
505		SHOULD,
506		NEVER,
507	}
508
509	private static String extractRelevantExtension(URL url) {
510		String path = url.getPath();
511		return extractRelevantExtension(path);
512	}
513
514	private static String extractRelevantExtension(String path) {
515		if (path == null || path.isEmpty()) {
516			return null;
517		}
518		
519		String filename = path.substring(path.lastIndexOf('/') + 1).toLowerCase();
520		int dotPosition = filename.lastIndexOf(".");
521
522		if (dotPosition != -1) {
523			String extension = filename.substring(dotPosition + 1);
524			// we want the real file extension, not the crypto one
525			if (Arrays.asList(Transferable.VALID_CRYPTO_EXTENSIONS).contains(extension)) {
526				return extractRelevantExtension(filename.substring(0,dotPosition));
527			} else {
528				return extension;
529			}
530		}
531		return null;
532	}
533
534	public String getMimeType() {
535		if (relativeFilePath != null) {
536			int start = relativeFilePath.lastIndexOf('.') + 1;
537			if (start < relativeFilePath.length()) {
538				return MimeUtils.guessMimeTypeFromExtension(relativeFilePath.substring(start));
539			} else {
540				return null;
541			}
542		} else {
543			try {
544				return MimeUtils.guessMimeTypeFromExtension(extractRelevantExtension(new URL(body.trim())));
545			} catch (MalformedURLException e) {
546				return null;
547			}
548		}
549	}
550
551	public Decision treatAsDownloadable() {
552		if (body.trim().contains(" ")) {
553			return Decision.NEVER;
554		}
555		try {
556			URL url = new URL(body);
557			if (!url.getProtocol().equalsIgnoreCase("http") && !url.getProtocol().equalsIgnoreCase("https")) {
558				return Decision.NEVER;
559			}
560			String extension = extractRelevantExtension(url);
561			if (extension == null) {
562				return Decision.NEVER;
563			}
564			String ref = url.getRef();
565			boolean encrypted = ref != null && ref.matches("([A-Fa-f0-9]{2}){48}");
566
567			if (encrypted) {
568				if (MimeUtils.guessMimeTypeFromExtension(extension) != null) {
569					return Decision.MUST;
570				} else {
571					return Decision.NEVER;
572				}
573			} else if (Arrays.asList(Transferable.VALID_IMAGE_EXTENSIONS).contains(extension)
574					|| Arrays.asList(Transferable.WELL_KNOWN_EXTENSIONS).contains(extension)) {
575				return Decision.SHOULD;
576			} else {
577				return Decision.NEVER;
578			}
579
580		} catch (MalformedURLException e) {
581			return Decision.NEVER;
582		}
583	}
584
585	public boolean bodyIsHeart() {
586		return body != null && UIHelper.HEARTS.contains(body.trim());
587	}
588
589	public FileParams getFileParams() {
590		FileParams params = getLegacyFileParams();
591		if (params != null) {
592			return params;
593		}
594		params = new FileParams();
595		if (this.transferable != null) {
596			params.size = this.transferable.getFileSize();
597		}
598		if (body == null) {
599			return params;
600		}
601		String parts[] = body.split("\\|");
602		switch (parts.length) {
603			case 1:
604				try {
605					params.size = Long.parseLong(parts[0]);
606				} catch (NumberFormatException e) {
607					try {
608						params.url = new URL(parts[0]);
609					} catch (MalformedURLException e1) {
610						params.url = null;
611					}
612				}
613				break;
614			case 2:
615			case 4:
616				try {
617					params.url = new URL(parts[0]);
618				} catch (MalformedURLException e1) {
619					params.url = null;
620				}
621				try {
622					params.size = Long.parseLong(parts[1]);
623				} catch (NumberFormatException e) {
624					params.size = 0;
625				}
626				try {
627					params.width = Integer.parseInt(parts[2]);
628				} catch (NumberFormatException | ArrayIndexOutOfBoundsException e) {
629					params.width = 0;
630				}
631				try {
632					params.height = Integer.parseInt(parts[3]);
633				} catch (NumberFormatException | ArrayIndexOutOfBoundsException e) {
634					params.height = 0;
635				}
636				break;
637			case 3:
638				try {
639					params.size = Long.parseLong(parts[0]);
640				} catch (NumberFormatException e) {
641					params.size = 0;
642				}
643				try {
644					params.width = Integer.parseInt(parts[1]);
645				} catch (NumberFormatException e) {
646					params.width = 0;
647				}
648				try {
649					params.height = Integer.parseInt(parts[2]);
650				} catch (NumberFormatException e) {
651					params.height = 0;
652				}
653				break;
654		}
655		return params;
656	}
657
658	public FileParams getLegacyFileParams() {
659		FileParams params = new FileParams();
660		if (body == null) {
661			return params;
662		}
663		String parts[] = body.split(",");
664		if (parts.length == 3) {
665			try {
666				params.size = Long.parseLong(parts[0]);
667			} catch (NumberFormatException e) {
668				return null;
669			}
670			try {
671				params.width = Integer.parseInt(parts[1]);
672			} catch (NumberFormatException e) {
673				return null;
674			}
675			try {
676				params.height = Integer.parseInt(parts[2]);
677			} catch (NumberFormatException e) {
678				return null;
679			}
680			return params;
681		} else {
682			return null;
683		}
684	}
685
686	public void untie() {
687		this.mNextMessage = null;
688		this.mPreviousMessage = null;
689	}
690
691	public boolean isFileOrImage() {
692		return type == TYPE_FILE || type == TYPE_IMAGE;
693	}
694
695	public boolean hasFileOnRemoteHost() {
696		return isFileOrImage() && getFileParams().url != null;
697	}
698
699	public boolean needsUploading() {
700		return isFileOrImage() && getFileParams().url == null;
701	}
702
703	public class FileParams {
704		public URL url;
705		public long size = 0;
706		public int width = 0;
707		public int height = 0;
708	}
709
710	public void setAxolotlFingerprint(String fingerprint) {
711		this.axolotlFingerprint = fingerprint;
712	}
713
714	public String getAxolotlFingerprint() {
715		return axolotlFingerprint;
716	}
717
718	public boolean isTrusted() {
719		XmppAxolotlSession.Trust t = conversation.getAccount().getAxolotlService().getFingerprintTrust(axolotlFingerprint);
720		return t != null && t.trusted();
721	}
722
723	private  int getPreviousEncryption() {
724		for (Message iterator = this.prev(); iterator != null; iterator = iterator.prev()){
725			if( iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED ) {
726				continue;
727			}
728			return iterator.getEncryption();
729		}
730		return ENCRYPTION_NONE;
731	}
732
733	private int getNextEncryption() {
734		for (Message iterator = this.next(); iterator != null; iterator = iterator.next()){
735			if( iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED ) {
736				continue;
737			}
738			return iterator.getEncryption();
739		}
740		return conversation.getNextEncryption();
741	}
742
743	public boolean isValidInSession() {
744		int pastEncryption = this.getPreviousEncryption();
745		int futureEncryption = this.getNextEncryption();
746
747		boolean inUnencryptedSession = pastEncryption == ENCRYPTION_NONE
748				|| futureEncryption == ENCRYPTION_NONE
749				|| pastEncryption != futureEncryption;
750
751		return inUnencryptedSession || this.getEncryption() == pastEncryption;
752	}
753}