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		final Message next = this.next();
420		if (this.mergeable(next)) {
421			return getBody().trim() + MERGE_SEPARATOR + next.getMergedBody();
422		}
423		return getBody().trim();
424	}
425
426	public boolean hasMeCommand() {
427		return getMergedBody().startsWith(ME_COMMAND);
428	}
429
430	public int getMergedStatus() {
431		final Message next = this.next();
432		if (this.mergeable(next)) {
433			return next.getStatus();
434		}
435		return getStatus();
436	}
437
438	public long getMergedTimeSent() {
439		Message next = this.next();
440		if (this.mergeable(next)) {
441			return next.getMergedTimeSent();
442		} else {
443			return getTimeSent();
444		}
445	}
446
447	public boolean wasMergedIntoPrevious() {
448		Message prev = this.prev();
449		return prev != null && prev.mergeable(this);
450	}
451
452	public boolean trusted() {
453		Contact contact = this.getContact();
454		return (status > STATUS_RECEIVED || (contact != null && contact.trusted()));
455	}
456
457	public boolean fixCounterpart() {
458		Presences presences = conversation.getContact().getPresences();
459		if (counterpart != null && presences.has(counterpart.getResourcepart())) {
460			return true;
461		} else if (presences.size() >= 1) {
462			try {
463				counterpart = Jid.fromParts(conversation.getJid().getLocalpart(),
464						conversation.getJid().getDomainpart(),
465						presences.asStringArray()[0]);
466				return true;
467			} catch (InvalidJidException e) {
468				counterpart = null;
469				return false;
470			}
471		} else {
472			counterpart = null;
473			return false;
474		}
475	}
476
477	public enum Decision {
478		MUST,
479		SHOULD,
480		NEVER,
481	}
482
483	private static String extractRelevantExtension(URL url) {
484		String path = url.getPath();
485		if (path == null || path.isEmpty()) {
486			return null;
487		}
488		String filename = path.substring(path.lastIndexOf('/') + 1).toLowerCase();
489		String[] extensionParts = filename.split("\\.");
490		if (extensionParts.length == 2) {
491			return extensionParts[extensionParts.length - 1];
492		} else if (extensionParts.length == 3 && Arrays
493				.asList(Transferable.VALID_CRYPTO_EXTENSIONS)
494				.contains(extensionParts[extensionParts.length - 1])) {
495			return extensionParts[extensionParts.length -2];
496		}
497		return null;
498	}
499
500	public String getMimeType() {
501		if (relativeFilePath != null) {
502			int start = relativeFilePath.lastIndexOf('.') + 1;
503			if (start < relativeFilePath.length()) {
504				return MimeUtils.guessMimeTypeFromExtension(relativeFilePath.substring(start));
505			} else {
506				return null;
507			}
508		} else {
509			try {
510				return MimeUtils.guessMimeTypeFromExtension(extractRelevantExtension(new URL(body.trim())));
511			} catch (MalformedURLException e) {
512				return null;
513			}
514		}
515	}
516
517	public Decision treatAsDownloadable() {
518		if (body.trim().contains(" ")) {
519			return Decision.NEVER;
520		}
521		try {
522			URL url = new URL(body);
523			if (!url.getProtocol().equalsIgnoreCase("http") && !url.getProtocol().equalsIgnoreCase("https")) {
524				return Decision.NEVER;
525			}
526			String extension = extractRelevantExtension(url);
527			if (extension == null) {
528				return Decision.NEVER;
529			}
530			String ref = url.getRef();
531			boolean encrypted = ref != null && ref.matches("([A-Fa-f0-9]{2}){48}");
532
533			if (encrypted) {
534				if (MimeUtils.guessMimeTypeFromExtension(extension) != null) {
535					return Decision.MUST;
536				} else {
537					return Decision.NEVER;
538				}
539			} else if (Arrays.asList(Transferable.VALID_IMAGE_EXTENSIONS).contains(extension)
540					|| Arrays.asList(Transferable.WELL_KNOWN_EXTENSIONS).contains(extension)) {
541				return Decision.SHOULD;
542			} else {
543				return Decision.NEVER;
544			}
545
546		} catch (MalformedURLException e) {
547			return Decision.NEVER;
548		}
549	}
550
551	public boolean bodyIsHeart() {
552		return body != null && UIHelper.HEARTS.contains(body.trim());
553	}
554
555	public FileParams getFileParams() {
556		FileParams params = getLegacyFileParams();
557		if (params != null) {
558			return params;
559		}
560		params = new FileParams();
561		if (this.transferable != null) {
562			params.size = this.transferable.getFileSize();
563		}
564		if (body == null) {
565			return params;
566		}
567		String parts[] = body.split("\\|");
568		switch (parts.length) {
569			case 1:
570				try {
571					params.size = Long.parseLong(parts[0]);
572				} catch (NumberFormatException e) {
573					try {
574						params.url = new URL(parts[0]);
575					} catch (MalformedURLException e1) {
576						params.url = null;
577					}
578				}
579				break;
580			case 2:
581			case 4:
582				try {
583					params.url = new URL(parts[0]);
584				} catch (MalformedURLException e1) {
585					params.url = null;
586				}
587				try {
588					params.size = Long.parseLong(parts[1]);
589				} catch (NumberFormatException e) {
590					params.size = 0;
591				}
592				try {
593					params.width = Integer.parseInt(parts[2]);
594				} catch (NumberFormatException | ArrayIndexOutOfBoundsException e) {
595					params.width = 0;
596				}
597				try {
598					params.height = Integer.parseInt(parts[3]);
599				} catch (NumberFormatException | ArrayIndexOutOfBoundsException e) {
600					params.height = 0;
601				}
602				break;
603			case 3:
604				try {
605					params.size = Long.parseLong(parts[0]);
606				} catch (NumberFormatException e) {
607					params.size = 0;
608				}
609				try {
610					params.width = Integer.parseInt(parts[1]);
611				} catch (NumberFormatException e) {
612					params.width = 0;
613				}
614				try {
615					params.height = Integer.parseInt(parts[2]);
616				} catch (NumberFormatException e) {
617					params.height = 0;
618				}
619				break;
620		}
621		return params;
622	}
623
624	public FileParams getLegacyFileParams() {
625		FileParams params = new FileParams();
626		if (body == null) {
627			return params;
628		}
629		String parts[] = body.split(",");
630		if (parts.length == 3) {
631			try {
632				params.size = Long.parseLong(parts[0]);
633			} catch (NumberFormatException e) {
634				return null;
635			}
636			try {
637				params.width = Integer.parseInt(parts[1]);
638			} catch (NumberFormatException e) {
639				return null;
640			}
641			try {
642				params.height = Integer.parseInt(parts[2]);
643			} catch (NumberFormatException e) {
644				return null;
645			}
646			return params;
647		} else {
648			return null;
649		}
650	}
651
652	public void untie() {
653		this.mNextMessage = null;
654		this.mPreviousMessage = null;
655	}
656
657	public boolean isFileOrImage() {
658		return type == TYPE_FILE || type == TYPE_IMAGE;
659	}
660
661	public boolean hasFileOnRemoteHost() {
662		return isFileOrImage() && getFileParams().url != null;
663	}
664
665	public boolean needsUploading() {
666		return isFileOrImage() && getFileParams().url == null;
667	}
668
669	public class FileParams {
670		public URL url;
671		public long size = 0;
672		public int width = 0;
673		public int height = 0;
674	}
675
676	public void setAxolotlFingerprint(String fingerprint) {
677		this.axolotlFingerprint = fingerprint;
678	}
679
680	public String getAxolotlFingerprint() {
681		return axolotlFingerprint;
682	}
683
684	public boolean isTrusted() {
685		return conversation.getAccount().getAxolotlService().getFingerprintTrust(axolotlFingerprint)
686				== AxolotlService.SQLiteAxolotlStore.Trust.TRUSTED;
687	}
688}