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