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