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