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