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