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 } else {
366 return this.remoteMsgId == null
367 && this.counterpart.equals(message.getCounterpart())
368 && body.equals(otherBody)
369 && Math.abs(this.getTimeSent() - message.getTimeSent()) < Config.MESSAGE_MERGE_WINDOW * 1000;
370 }
371 }
372 }
373
374 public Message next() {
375 synchronized (this.conversation.messages) {
376 if (this.mNextMessage == null) {
377 int index = this.conversation.messages.indexOf(this);
378 if (index < 0 || index >= this.conversation.messages.size() - 1) {
379 this.mNextMessage = null;
380 } else {
381 this.mNextMessage = this.conversation.messages.get(index + 1);
382 }
383 }
384 return this.mNextMessage;
385 }
386 }
387
388 public Message prev() {
389 synchronized (this.conversation.messages) {
390 if (this.mPreviousMessage == null) {
391 int index = this.conversation.messages.indexOf(this);
392 if (index <= 0 || index > this.conversation.messages.size()) {
393 this.mPreviousMessage = null;
394 } else {
395 this.mPreviousMessage = this.conversation.messages.get(index - 1);
396 }
397 }
398 return this.mPreviousMessage;
399 }
400 }
401
402 public boolean mergeable(final Message message) {
403 return message != null &&
404 (message.getType() == Message.TYPE_TEXT &&
405 this.getTransferable() == null &&
406 message.getTransferable() == null &&
407 message.getEncryption() != Message.ENCRYPTION_PGP &&
408 this.getType() == message.getType() &&
409 //this.getStatus() == message.getStatus() &&
410 isStatusMergeable(this.getStatus(), message.getStatus()) &&
411 this.getEncryption() == message.getEncryption() &&
412 this.getCounterpart() != null &&
413 this.getCounterpart().equals(message.getCounterpart()) &&
414 (message.getTimeSent() - this.getTimeSent()) <= (Config.MESSAGE_MERGE_WINDOW * 1000) &&
415 !GeoHelper.isGeoUri(message.getBody()) &&
416 !GeoHelper.isGeoUri(this.body) &&
417 message.treatAsDownloadable() == Decision.NEVER &&
418 this.treatAsDownloadable() == Decision.NEVER &&
419 !message.getBody().startsWith(ME_COMMAND) &&
420 !this.getBody().startsWith(ME_COMMAND) &&
421 !this.bodyIsHeart() &&
422 !message.bodyIsHeart() &&
423 this.isTrusted() == message.isTrusted()
424 );
425 }
426
427 private static boolean isStatusMergeable(int a, int b) {
428 return a == b || (
429 (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_UNSEND)
430 || (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_SEND)
431 || (a == Message.STATUS_UNSEND && b == Message.STATUS_SEND)
432 || (a == Message.STATUS_UNSEND && b == Message.STATUS_SEND_RECEIVED)
433 || (a == Message.STATUS_SEND && b == Message.STATUS_UNSEND)
434 || (a == Message.STATUS_SEND && b == Message.STATUS_SEND_RECEIVED)
435 );
436 }
437
438 public String getMergedBody() {
439 StringBuilder body = new StringBuilder(this.body.trim());
440 Message current = this;
441 while(current.mergeable(current.next())) {
442 current = current.next();
443 body.append(MERGE_SEPARATOR);
444 body.append(current.getBody().trim());
445 }
446 return body.toString();
447 }
448
449 public boolean hasMeCommand() {
450 return getMergedBody().startsWith(ME_COMMAND);
451 }
452
453 public int getMergedStatus() {
454 int status = this.status;
455 Message current = this;
456 while(current.mergeable(current.next())) {
457 current = current.next();
458 status = current.status;
459 }
460 return status;
461 }
462
463 public long getMergedTimeSent() {
464 long time = this.timeSent;
465 Message current = this;
466 while(current.mergeable(current.next())) {
467 current = current.next();
468 time = current.timeSent;
469 }
470 return time;
471 }
472
473 public boolean wasMergedIntoPrevious() {
474 Message prev = this.prev();
475 return prev != null && prev.mergeable(this);
476 }
477
478 public boolean trusted() {
479 Contact contact = this.getContact();
480 return (status > STATUS_RECEIVED || (contact != null && contact.trusted()));
481 }
482
483 public boolean fixCounterpart() {
484 Presences presences = conversation.getContact().getPresences();
485 if (counterpart != null && presences.has(counterpart.getResourcepart())) {
486 return true;
487 } else if (presences.size() >= 1) {
488 try {
489 counterpart = Jid.fromParts(conversation.getJid().getLocalpart(),
490 conversation.getJid().getDomainpart(),
491 presences.asStringArray()[0]);
492 return true;
493 } catch (InvalidJidException e) {
494 counterpart = null;
495 return false;
496 }
497 } else {
498 counterpart = null;
499 return false;
500 }
501 }
502
503 public enum Decision {
504 MUST,
505 SHOULD,
506 NEVER,
507 }
508
509 private static String extractRelevantExtension(URL url) {
510 String path = url.getPath();
511 return extractRelevantExtension(path);
512 }
513
514 private static String extractRelevantExtension(String path) {
515 if (path == null || path.isEmpty()) {
516 return null;
517 }
518
519 String filename = path.substring(path.lastIndexOf('/') + 1).toLowerCase();
520 int dotPosition = filename.lastIndexOf(".");
521
522 if (dotPosition != -1) {
523 String extension = filename.substring(dotPosition + 1);
524 // we want the real file extension, not the crypto one
525 if (Arrays.asList(Transferable.VALID_CRYPTO_EXTENSIONS).contains(extension)) {
526 return extractRelevantExtension(filename.substring(0,dotPosition));
527 } else {
528 return extension;
529 }
530 }
531 return null;
532 }
533
534 public String getMimeType() {
535 if (relativeFilePath != null) {
536 int start = relativeFilePath.lastIndexOf('.') + 1;
537 if (start < relativeFilePath.length()) {
538 return MimeUtils.guessMimeTypeFromExtension(relativeFilePath.substring(start));
539 } else {
540 return null;
541 }
542 } else {
543 try {
544 return MimeUtils.guessMimeTypeFromExtension(extractRelevantExtension(new URL(body.trim())));
545 } catch (MalformedURLException e) {
546 return null;
547 }
548 }
549 }
550
551 public Decision treatAsDownloadable() {
552 if (body.trim().contains(" ")) {
553 return Decision.NEVER;
554 }
555 try {
556 URL url = new URL(body);
557 if (!url.getProtocol().equalsIgnoreCase("http") && !url.getProtocol().equalsIgnoreCase("https")) {
558 return Decision.NEVER;
559 }
560 String extension = extractRelevantExtension(url);
561 if (extension == null) {
562 return Decision.NEVER;
563 }
564 String ref = url.getRef();
565 boolean encrypted = ref != null && ref.matches("([A-Fa-f0-9]{2}){48}");
566
567 if (encrypted) {
568 if (MimeUtils.guessMimeTypeFromExtension(extension) != null) {
569 return Decision.MUST;
570 } else {
571 return Decision.NEVER;
572 }
573 } else if (Arrays.asList(Transferable.VALID_IMAGE_EXTENSIONS).contains(extension)
574 || Arrays.asList(Transferable.WELL_KNOWN_EXTENSIONS).contains(extension)) {
575 return Decision.SHOULD;
576 } else {
577 return Decision.NEVER;
578 }
579
580 } catch (MalformedURLException e) {
581 return Decision.NEVER;
582 }
583 }
584
585 public boolean bodyIsHeart() {
586 return body != null && UIHelper.HEARTS.contains(body.trim());
587 }
588
589 public FileParams getFileParams() {
590 FileParams params = getLegacyFileParams();
591 if (params != null) {
592 return params;
593 }
594 params = new FileParams();
595 if (this.transferable != null) {
596 params.size = this.transferable.getFileSize();
597 }
598 if (body == null) {
599 return params;
600 }
601 String parts[] = body.split("\\|");
602 switch (parts.length) {
603 case 1:
604 try {
605 params.size = Long.parseLong(parts[0]);
606 } catch (NumberFormatException e) {
607 try {
608 params.url = new URL(parts[0]);
609 } catch (MalformedURLException e1) {
610 params.url = null;
611 }
612 }
613 break;
614 case 2:
615 case 4:
616 try {
617 params.url = new URL(parts[0]);
618 } catch (MalformedURLException e1) {
619 params.url = null;
620 }
621 try {
622 params.size = Long.parseLong(parts[1]);
623 } catch (NumberFormatException e) {
624 params.size = 0;
625 }
626 try {
627 params.width = Integer.parseInt(parts[2]);
628 } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) {
629 params.width = 0;
630 }
631 try {
632 params.height = Integer.parseInt(parts[3]);
633 } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) {
634 params.height = 0;
635 }
636 break;
637 case 3:
638 try {
639 params.size = Long.parseLong(parts[0]);
640 } catch (NumberFormatException e) {
641 params.size = 0;
642 }
643 try {
644 params.width = Integer.parseInt(parts[1]);
645 } catch (NumberFormatException e) {
646 params.width = 0;
647 }
648 try {
649 params.height = Integer.parseInt(parts[2]);
650 } catch (NumberFormatException e) {
651 params.height = 0;
652 }
653 break;
654 }
655 return params;
656 }
657
658 public FileParams getLegacyFileParams() {
659 FileParams params = new FileParams();
660 if (body == null) {
661 return params;
662 }
663 String parts[] = body.split(",");
664 if (parts.length == 3) {
665 try {
666 params.size = Long.parseLong(parts[0]);
667 } catch (NumberFormatException e) {
668 return null;
669 }
670 try {
671 params.width = Integer.parseInt(parts[1]);
672 } catch (NumberFormatException e) {
673 return null;
674 }
675 try {
676 params.height = Integer.parseInt(parts[2]);
677 } catch (NumberFormatException e) {
678 return null;
679 }
680 return params;
681 } else {
682 return null;
683 }
684 }
685
686 public void untie() {
687 this.mNextMessage = null;
688 this.mPreviousMessage = null;
689 }
690
691 public boolean isFileOrImage() {
692 return type == TYPE_FILE || type == TYPE_IMAGE;
693 }
694
695 public boolean hasFileOnRemoteHost() {
696 return isFileOrImage() && getFileParams().url != null;
697 }
698
699 public boolean needsUploading() {
700 return isFileOrImage() && getFileParams().url == null;
701 }
702
703 public class FileParams {
704 public URL url;
705 public long size = 0;
706 public int width = 0;
707 public int height = 0;
708 }
709
710 public void setAxolotlFingerprint(String fingerprint) {
711 this.axolotlFingerprint = fingerprint;
712 }
713
714 public String getAxolotlFingerprint() {
715 return axolotlFingerprint;
716 }
717
718 public boolean isTrusted() {
719 XmppAxolotlSession.Trust t = conversation.getAccount().getAxolotlService().getFingerprintTrust(axolotlFingerprint);
720 return t != null && t.trusted();
721 }
722
723 private int getPreviousEncryption() {
724 for (Message iterator = this.prev(); iterator != null; iterator = iterator.prev()){
725 if( iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED ) {
726 continue;
727 }
728 return iterator.getEncryption();
729 }
730 return ENCRYPTION_NONE;
731 }
732
733 private int getNextEncryption() {
734 for (Message iterator = this.next(); iterator != null; iterator = iterator.next()){
735 if( iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED ) {
736 continue;
737 }
738 return iterator.getEncryption();
739 }
740 return conversation.getNextEncryption();
741 }
742
743 public boolean isValidInSession() {
744 int pastEncryption = this.getPreviousEncryption();
745 int futureEncryption = this.getNextEncryption();
746
747 boolean inUnencryptedSession = pastEncryption == ENCRYPTION_NONE
748 || futureEncryption == ENCRYPTION_NONE
749 || pastEncryption != futureEncryption;
750
751 return inUnencryptedSession || this.getEncryption() == pastEncryption;
752 }
753}