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						||(message.getEncryption() == Message.ENCRYPTION_PGP
366						&&  message.getRemoteMsgId().matches("[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"))) ;
367			} else {
368				return this.remoteMsgId == null
369						&& this.counterpart.equals(message.getCounterpart())
370						&& body.equals(otherBody)
371						&& Math.abs(this.getTimeSent() - message.getTimeSent()) < Config.MESSAGE_MERGE_WINDOW * 1000;
372			}
373		}
374	}
375
376	public Message next() {
377		synchronized (this.conversation.messages) {
378			if (this.mNextMessage == null) {
379				int index = this.conversation.messages.indexOf(this);
380				if (index < 0 || index >= this.conversation.messages.size() - 1) {
381					this.mNextMessage = null;
382				} else {
383					this.mNextMessage = this.conversation.messages.get(index + 1);
384				}
385			}
386			return this.mNextMessage;
387		}
388	}
389
390	public Message prev() {
391		synchronized (this.conversation.messages) {
392			if (this.mPreviousMessage == null) {
393				int index = this.conversation.messages.indexOf(this);
394				if (index <= 0 || index > this.conversation.messages.size()) {
395					this.mPreviousMessage = null;
396				} else {
397					this.mPreviousMessage = this.conversation.messages.get(index - 1);
398				}
399			}
400			return this.mPreviousMessage;
401		}
402	}
403
404	public boolean mergeable(final Message message) {
405		return message != null &&
406				(message.getType() == Message.TYPE_TEXT &&
407						this.getTransferable() == null &&
408						message.getTransferable() == null &&
409						message.getEncryption() != Message.ENCRYPTION_PGP &&
410						this.getType() == message.getType() &&
411						//this.getStatus() == message.getStatus() &&
412						isStatusMergeable(this.getStatus(), message.getStatus()) &&
413						this.getEncryption() == message.getEncryption() &&
414						this.getCounterpart() != null &&
415						this.getCounterpart().equals(message.getCounterpart()) &&
416						(message.getTimeSent() - this.getTimeSent()) <= (Config.MESSAGE_MERGE_WINDOW * 1000) &&
417						!GeoHelper.isGeoUri(message.getBody()) &&
418						!GeoHelper.isGeoUri(this.body) &&
419						message.treatAsDownloadable() == Decision.NEVER &&
420						this.treatAsDownloadable() == Decision.NEVER &&
421						!message.getBody().startsWith(ME_COMMAND) &&
422						!this.getBody().startsWith(ME_COMMAND) &&
423						!this.bodyIsHeart() &&
424						!message.bodyIsHeart() &&
425						this.isTrusted() == message.isTrusted()
426				);
427	}
428
429	private static boolean isStatusMergeable(int a, int b) {
430		return a == b || (
431				(a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_UNSEND)
432						|| (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_SEND)
433						|| (a == Message.STATUS_UNSEND && b == Message.STATUS_SEND)
434						|| (a == Message.STATUS_UNSEND && b == Message.STATUS_SEND_RECEIVED)
435						|| (a == Message.STATUS_SEND && b == Message.STATUS_UNSEND)
436						|| (a == Message.STATUS_SEND && b == Message.STATUS_SEND_RECEIVED)
437		);
438	}
439
440	public String getMergedBody() {
441		StringBuilder body = new StringBuilder(this.body.trim());
442		Message current = this;
443		while(current.mergeable(current.next())) {
444			current = current.next();
445			body.append(MERGE_SEPARATOR);
446			body.append(current.getBody().trim());
447		}
448		return body.toString();
449	}
450
451	public boolean hasMeCommand() {
452		return getMergedBody().startsWith(ME_COMMAND);
453	}
454
455	public int getMergedStatus() {
456		int status = this.status;
457		Message current = this;
458		while(current.mergeable(current.next())) {
459			current = current.next();
460			status = current.status;
461		}
462		return status;
463	}
464
465	public long getMergedTimeSent() {
466		long time = this.timeSent;
467		Message current = this;
468		while(current.mergeable(current.next())) {
469			current = current.next();
470			time = current.timeSent;
471		}
472		return time;
473	}
474
475	public boolean wasMergedIntoPrevious() {
476		Message prev = this.prev();
477		return prev != null && prev.mergeable(this);
478	}
479
480	public boolean trusted() {
481		Contact contact = this.getContact();
482		return (status > STATUS_RECEIVED || (contact != null && contact.trusted()));
483	}
484
485	public boolean fixCounterpart() {
486		Presences presences = conversation.getContact().getPresences();
487		if (counterpart != null && presences.has(counterpart.getResourcepart())) {
488			return true;
489		} else if (presences.size() >= 1) {
490			try {
491				counterpart = Jid.fromParts(conversation.getJid().getLocalpart(),
492						conversation.getJid().getDomainpart(),
493						presences.asStringArray()[0]);
494				return true;
495			} catch (InvalidJidException e) {
496				counterpart = null;
497				return false;
498			}
499		} else {
500			counterpart = null;
501			return false;
502		}
503	}
504
505	public enum Decision {
506		MUST,
507		SHOULD,
508		NEVER,
509	}
510
511	private static String extractRelevantExtension(URL url) {
512		String path = url.getPath();
513		return extractRelevantExtension(path);
514	}
515
516	private static String extractRelevantExtension(String path) {
517		if (path == null || path.isEmpty()) {
518			return null;
519		}
520		
521		String filename = path.substring(path.lastIndexOf('/') + 1).toLowerCase();
522		int dotPosition = filename.lastIndexOf(".");
523
524		if (dotPosition != -1) {
525			String extension = filename.substring(dotPosition + 1);
526			// we want the real file extension, not the crypto one
527			if (Arrays.asList(Transferable.VALID_CRYPTO_EXTENSIONS).contains(extension)) {
528				return extractRelevantExtension(filename.substring(0,dotPosition));
529			} else {
530				return extension;
531			}
532		}
533		return null;
534	}
535
536	public String getMimeType() {
537		if (relativeFilePath != null) {
538			int start = relativeFilePath.lastIndexOf('.') + 1;
539			if (start < relativeFilePath.length()) {
540				return MimeUtils.guessMimeTypeFromExtension(relativeFilePath.substring(start));
541			} else {
542				return null;
543			}
544		} else {
545			try {
546				return MimeUtils.guessMimeTypeFromExtension(extractRelevantExtension(new URL(body.trim())));
547			} catch (MalformedURLException e) {
548				return null;
549			}
550		}
551	}
552
553	public Decision treatAsDownloadable() {
554		if (body.trim().contains(" ")) {
555			return Decision.NEVER;
556		}
557		try {
558			URL url = new URL(body);
559			if (!url.getProtocol().equalsIgnoreCase("http") && !url.getProtocol().equalsIgnoreCase("https")) {
560				return Decision.NEVER;
561			}
562			String extension = extractRelevantExtension(url);
563			if (extension == null) {
564				return Decision.NEVER;
565			}
566			String ref = url.getRef();
567			boolean encrypted = ref != null && ref.matches("([A-Fa-f0-9]{2}){48}");
568
569			if (encrypted) {
570				if (MimeUtils.guessMimeTypeFromExtension(extension) != null) {
571					return Decision.MUST;
572				} else {
573					return Decision.NEVER;
574				}
575			} else if (Arrays.asList(Transferable.VALID_IMAGE_EXTENSIONS).contains(extension)
576					|| Arrays.asList(Transferable.WELL_KNOWN_EXTENSIONS).contains(extension)) {
577				return Decision.SHOULD;
578			} else {
579				return Decision.NEVER;
580			}
581
582		} catch (MalformedURLException e) {
583			return Decision.NEVER;
584		}
585	}
586
587	public boolean bodyIsHeart() {
588		return body != null && UIHelper.HEARTS.contains(body.trim());
589	}
590
591	public FileParams getFileParams() {
592		FileParams params = getLegacyFileParams();
593		if (params != null) {
594			return params;
595		}
596		params = new FileParams();
597		if (this.transferable != null) {
598			params.size = this.transferable.getFileSize();
599		}
600		if (body == null) {
601			return params;
602		}
603		String parts[] = body.split("\\|");
604		switch (parts.length) {
605			case 1:
606				try {
607					params.size = Long.parseLong(parts[0]);
608				} catch (NumberFormatException e) {
609					try {
610						params.url = new URL(parts[0]);
611					} catch (MalformedURLException e1) {
612						params.url = null;
613					}
614				}
615				break;
616			case 2:
617			case 4:
618				try {
619					params.url = new URL(parts[0]);
620				} catch (MalformedURLException e1) {
621					params.url = null;
622				}
623				try {
624					params.size = Long.parseLong(parts[1]);
625				} catch (NumberFormatException e) {
626					params.size = 0;
627				}
628				try {
629					params.width = Integer.parseInt(parts[2]);
630				} catch (NumberFormatException | ArrayIndexOutOfBoundsException e) {
631					params.width = 0;
632				}
633				try {
634					params.height = Integer.parseInt(parts[3]);
635				} catch (NumberFormatException | ArrayIndexOutOfBoundsException e) {
636					params.height = 0;
637				}
638				break;
639			case 3:
640				try {
641					params.size = Long.parseLong(parts[0]);
642				} catch (NumberFormatException e) {
643					params.size = 0;
644				}
645				try {
646					params.width = Integer.parseInt(parts[1]);
647				} catch (NumberFormatException e) {
648					params.width = 0;
649				}
650				try {
651					params.height = Integer.parseInt(parts[2]);
652				} catch (NumberFormatException e) {
653					params.height = 0;
654				}
655				break;
656		}
657		return params;
658	}
659
660	public FileParams getLegacyFileParams() {
661		FileParams params = new FileParams();
662		if (body == null) {
663			return params;
664		}
665		String parts[] = body.split(",");
666		if (parts.length == 3) {
667			try {
668				params.size = Long.parseLong(parts[0]);
669			} catch (NumberFormatException e) {
670				return null;
671			}
672			try {
673				params.width = Integer.parseInt(parts[1]);
674			} catch (NumberFormatException e) {
675				return null;
676			}
677			try {
678				params.height = Integer.parseInt(parts[2]);
679			} catch (NumberFormatException e) {
680				return null;
681			}
682			return params;
683		} else {
684			return null;
685		}
686	}
687
688	public void untie() {
689		this.mNextMessage = null;
690		this.mPreviousMessage = null;
691	}
692
693	public boolean isFileOrImage() {
694		return type == TYPE_FILE || type == TYPE_IMAGE;
695	}
696
697	public boolean hasFileOnRemoteHost() {
698		return isFileOrImage() && getFileParams().url != null;
699	}
700
701	public boolean needsUploading() {
702		return isFileOrImage() && getFileParams().url == null;
703	}
704
705	public class FileParams {
706		public URL url;
707		public long size = 0;
708		public int width = 0;
709		public int height = 0;
710	}
711
712	public void setAxolotlFingerprint(String fingerprint) {
713		this.axolotlFingerprint = fingerprint;
714	}
715
716	public String getAxolotlFingerprint() {
717		return axolotlFingerprint;
718	}
719
720	public boolean isTrusted() {
721		XmppAxolotlSession.Trust t = conversation.getAccount().getAxolotlService().getFingerprintTrust(axolotlFingerprint);
722		return t != null && t.trusted();
723	}
724
725	private  int getPreviousEncryption() {
726		for (Message iterator = this.prev(); iterator != null; iterator = iterator.prev()){
727			if( iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED ) {
728				continue;
729			}
730			return iterator.getEncryption();
731		}
732		return ENCRYPTION_NONE;
733	}
734
735	private int getNextEncryption() {
736		for (Message iterator = this.next(); iterator != null; iterator = iterator.next()){
737			if( iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED ) {
738				continue;
739			}
740			return iterator.getEncryption();
741		}
742		return conversation.getNextEncryption();
743	}
744
745	public boolean isValidInSession() {
746		int pastEncryption = this.getPreviousEncryption();
747		int futureEncryption = this.getNextEncryption();
748
749		boolean inUnencryptedSession = pastEncryption == ENCRYPTION_NONE
750				|| futureEncryption == ENCRYPTION_NONE
751				|| pastEncryption != futureEncryption;
752
753		return inUnencryptedSession || this.getEncryption() == pastEncryption;
754	}
755}