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		if (path == null || path.isEmpty()) {
510			return null;
511		}
512		String filename = path.substring(path.lastIndexOf('/') + 1).toLowerCase();
513		String[] extensionParts = filename.split("\\.");
514		if (extensionParts.length == 2) {
515			return extensionParts[extensionParts.length - 1];
516		} else if (extensionParts.length == 3 && Arrays
517				.asList(Transferable.VALID_CRYPTO_EXTENSIONS)
518				.contains(extensionParts[extensionParts.length - 1])) {
519			return extensionParts[extensionParts.length -2];
520		}
521		return null;
522	}
523
524	public String getMimeType() {
525		if (relativeFilePath != null) {
526			int start = relativeFilePath.lastIndexOf('.') + 1;
527			if (start < relativeFilePath.length()) {
528				return MimeUtils.guessMimeTypeFromExtension(relativeFilePath.substring(start));
529			} else {
530				return null;
531			}
532		} else {
533			try {
534				return MimeUtils.guessMimeTypeFromExtension(extractRelevantExtension(new URL(body.trim())));
535			} catch (MalformedURLException e) {
536				return null;
537			}
538		}
539	}
540
541	public Decision treatAsDownloadable() {
542		if (body.trim().contains(" ")) {
543			return Decision.NEVER;
544		}
545		try {
546			URL url = new URL(body);
547			if (!url.getProtocol().equalsIgnoreCase("http") && !url.getProtocol().equalsIgnoreCase("https")) {
548				return Decision.NEVER;
549			}
550			String extension = extractRelevantExtension(url);
551			if (extension == null) {
552				return Decision.NEVER;
553			}
554			String ref = url.getRef();
555			boolean encrypted = ref != null && ref.matches("([A-Fa-f0-9]{2}){48}");
556
557			if (encrypted) {
558				if (MimeUtils.guessMimeTypeFromExtension(extension) != null) {
559					return Decision.MUST;
560				} else {
561					return Decision.NEVER;
562				}
563			} else if (Arrays.asList(Transferable.VALID_IMAGE_EXTENSIONS).contains(extension)
564					|| Arrays.asList(Transferable.WELL_KNOWN_EXTENSIONS).contains(extension)) {
565				return Decision.SHOULD;
566			} else {
567				return Decision.NEVER;
568			}
569
570		} catch (MalformedURLException e) {
571			return Decision.NEVER;
572		}
573	}
574
575	public boolean bodyIsHeart() {
576		return body != null && UIHelper.HEARTS.contains(body.trim());
577	}
578
579	public FileParams getFileParams() {
580		FileParams params = getLegacyFileParams();
581		if (params != null) {
582			return params;
583		}
584		params = new FileParams();
585		if (this.transferable != null) {
586			params.size = this.transferable.getFileSize();
587		}
588		if (body == null) {
589			return params;
590		}
591		String parts[] = body.split("\\|");
592		switch (parts.length) {
593			case 1:
594				try {
595					params.size = Long.parseLong(parts[0]);
596				} catch (NumberFormatException e) {
597					try {
598						params.url = new URL(parts[0]);
599					} catch (MalformedURLException e1) {
600						params.url = null;
601					}
602				}
603				break;
604			case 2:
605			case 4:
606				try {
607					params.url = new URL(parts[0]);
608				} catch (MalformedURLException e1) {
609					params.url = null;
610				}
611				try {
612					params.size = Long.parseLong(parts[1]);
613				} catch (NumberFormatException e) {
614					params.size = 0;
615				}
616				try {
617					params.width = Integer.parseInt(parts[2]);
618				} catch (NumberFormatException | ArrayIndexOutOfBoundsException e) {
619					params.width = 0;
620				}
621				try {
622					params.height = Integer.parseInt(parts[3]);
623				} catch (NumberFormatException | ArrayIndexOutOfBoundsException e) {
624					params.height = 0;
625				}
626				break;
627			case 3:
628				try {
629					params.size = Long.parseLong(parts[0]);
630				} catch (NumberFormatException e) {
631					params.size = 0;
632				}
633				try {
634					params.width = Integer.parseInt(parts[1]);
635				} catch (NumberFormatException e) {
636					params.width = 0;
637				}
638				try {
639					params.height = Integer.parseInt(parts[2]);
640				} catch (NumberFormatException e) {
641					params.height = 0;
642				}
643				break;
644		}
645		return params;
646	}
647
648	public FileParams getLegacyFileParams() {
649		FileParams params = new FileParams();
650		if (body == null) {
651			return params;
652		}
653		String parts[] = body.split(",");
654		if (parts.length == 3) {
655			try {
656				params.size = Long.parseLong(parts[0]);
657			} catch (NumberFormatException e) {
658				return null;
659			}
660			try {
661				params.width = Integer.parseInt(parts[1]);
662			} catch (NumberFormatException e) {
663				return null;
664			}
665			try {
666				params.height = Integer.parseInt(parts[2]);
667			} catch (NumberFormatException e) {
668				return null;
669			}
670			return params;
671		} else {
672			return null;
673		}
674	}
675
676	public void untie() {
677		this.mNextMessage = null;
678		this.mPreviousMessage = null;
679	}
680
681	public boolean isFileOrImage() {
682		return type == TYPE_FILE || type == TYPE_IMAGE;
683	}
684
685	public boolean hasFileOnRemoteHost() {
686		return isFileOrImage() && getFileParams().url != null;
687	}
688
689	public boolean needsUploading() {
690		return isFileOrImage() && getFileParams().url == null;
691	}
692
693	public class FileParams {
694		public URL url;
695		public long size = 0;
696		public int width = 0;
697		public int height = 0;
698	}
699
700	public void setAxolotlFingerprint(String fingerprint) {
701		this.axolotlFingerprint = fingerprint;
702	}
703
704	public String getAxolotlFingerprint() {
705		return axolotlFingerprint;
706	}
707
708	public boolean isTrusted() {
709		return conversation.getAccount().getAxolotlService().getFingerprintTrust(axolotlFingerprint)
710				== XmppAxolotlSession.Trust.TRUSTED;
711	}
712
713	private  int getPreviousEncryption() {
714		for (Message iterator = this.prev(); iterator != null; iterator = iterator.prev()){
715			if( iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED ) {
716				continue;
717			}
718			return iterator.getEncryption();
719		}
720		return ENCRYPTION_NONE;
721	}
722
723	private int getNextEncryption() {
724		for (Message iterator = this.next(); iterator != null; iterator = iterator.next()){
725			if( iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED ) {
726				continue;
727			}
728			return iterator.getEncryption();
729		}
730		return conversation.getNextEncryption();
731	}
732
733	public boolean isValidInSession() {
734		int pastEncryption = this.getPreviousEncryption();
735		int futureEncryption = this.getNextEncryption();
736
737		boolean inUnencryptedSession = pastEncryption == ENCRYPTION_NONE
738				|| futureEncryption == ENCRYPTION_NONE
739				|| pastEncryption != futureEncryption;
740
741		return inUnencryptedSession || this.getEncryption() == pastEncryption;
742	}
743}