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 return extractRelevantExtension(path);
510 }
511
512 private static String extractRelevantExtension(String path) {
513 if (path == null || path.isEmpty()) {
514 return null;
515 }
516
517 String filename = path.substring(path.lastIndexOf('/') + 1).toLowerCase();
518 int dotPosition = filename.lastIndexOf(".");
519
520 if (dotPosition != -1) {
521 String extension = filename.substring(dotPosition + 1);
522 // we want the real file extension, not the crypto one
523 if (Arrays.asList(Transferable.VALID_CRYPTO_EXTENSIONS).contains(extension)) {
524 return extractRelevantExtension(path.substring(0,dotPosition));
525 } else {
526 return extension;
527 }
528 }
529 return null;
530 }
531
532 public String getMimeType() {
533 if (relativeFilePath != null) {
534 int start = relativeFilePath.lastIndexOf('.') + 1;
535 if (start < relativeFilePath.length()) {
536 return MimeUtils.guessMimeTypeFromExtension(relativeFilePath.substring(start));
537 } else {
538 return null;
539 }
540 } else {
541 try {
542 return MimeUtils.guessMimeTypeFromExtension(extractRelevantExtension(new URL(body.trim())));
543 } catch (MalformedURLException e) {
544 return null;
545 }
546 }
547 }
548
549 public Decision treatAsDownloadable() {
550 if (body.trim().contains(" ")) {
551 return Decision.NEVER;
552 }
553 try {
554 URL url = new URL(body);
555 if (!url.getProtocol().equalsIgnoreCase("http") && !url.getProtocol().equalsIgnoreCase("https")) {
556 return Decision.NEVER;
557 }
558 String extension = extractRelevantExtension(url);
559 if (extension == null) {
560 return Decision.NEVER;
561 }
562 String ref = url.getRef();
563 boolean encrypted = ref != null && ref.matches("([A-Fa-f0-9]{2}){48}");
564
565 if (encrypted) {
566 if (MimeUtils.guessMimeTypeFromExtension(extension) != null) {
567 return Decision.MUST;
568 } else {
569 return Decision.NEVER;
570 }
571 } else if (Arrays.asList(Transferable.VALID_IMAGE_EXTENSIONS).contains(extension)
572 || Arrays.asList(Transferable.WELL_KNOWN_EXTENSIONS).contains(extension)) {
573 return Decision.SHOULD;
574 } else {
575 return Decision.NEVER;
576 }
577
578 } catch (MalformedURLException e) {
579 return Decision.NEVER;
580 }
581 }
582
583 public boolean bodyIsHeart() {
584 return body != null && UIHelper.HEARTS.contains(body.trim());
585 }
586
587 public FileParams getFileParams() {
588 FileParams params = getLegacyFileParams();
589 if (params != null) {
590 return params;
591 }
592 params = new FileParams();
593 if (this.transferable != null) {
594 params.size = this.transferable.getFileSize();
595 }
596 if (body == null) {
597 return params;
598 }
599 String parts[] = body.split("\\|");
600 switch (parts.length) {
601 case 1:
602 try {
603 params.size = Long.parseLong(parts[0]);
604 } catch (NumberFormatException e) {
605 try {
606 params.url = new URL(parts[0]);
607 } catch (MalformedURLException e1) {
608 params.url = null;
609 }
610 }
611 break;
612 case 2:
613 case 4:
614 try {
615 params.url = new URL(parts[0]);
616 } catch (MalformedURLException e1) {
617 params.url = null;
618 }
619 try {
620 params.size = Long.parseLong(parts[1]);
621 } catch (NumberFormatException e) {
622 params.size = 0;
623 }
624 try {
625 params.width = Integer.parseInt(parts[2]);
626 } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) {
627 params.width = 0;
628 }
629 try {
630 params.height = Integer.parseInt(parts[3]);
631 } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) {
632 params.height = 0;
633 }
634 break;
635 case 3:
636 try {
637 params.size = Long.parseLong(parts[0]);
638 } catch (NumberFormatException e) {
639 params.size = 0;
640 }
641 try {
642 params.width = Integer.parseInt(parts[1]);
643 } catch (NumberFormatException e) {
644 params.width = 0;
645 }
646 try {
647 params.height = Integer.parseInt(parts[2]);
648 } catch (NumberFormatException e) {
649 params.height = 0;
650 }
651 break;
652 }
653 return params;
654 }
655
656 public FileParams getLegacyFileParams() {
657 FileParams params = new FileParams();
658 if (body == null) {
659 return params;
660 }
661 String parts[] = body.split(",");
662 if (parts.length == 3) {
663 try {
664 params.size = Long.parseLong(parts[0]);
665 } catch (NumberFormatException e) {
666 return null;
667 }
668 try {
669 params.width = Integer.parseInt(parts[1]);
670 } catch (NumberFormatException e) {
671 return null;
672 }
673 try {
674 params.height = Integer.parseInt(parts[2]);
675 } catch (NumberFormatException e) {
676 return null;
677 }
678 return params;
679 } else {
680 return null;
681 }
682 }
683
684 public void untie() {
685 this.mNextMessage = null;
686 this.mPreviousMessage = null;
687 }
688
689 public boolean isFileOrImage() {
690 return type == TYPE_FILE || type == TYPE_IMAGE;
691 }
692
693 public boolean hasFileOnRemoteHost() {
694 return isFileOrImage() && getFileParams().url != null;
695 }
696
697 public boolean needsUploading() {
698 return isFileOrImage() && getFileParams().url == null;
699 }
700
701 public class FileParams {
702 public URL url;
703 public long size = 0;
704 public int width = 0;
705 public int height = 0;
706 }
707
708 public void setAxolotlFingerprint(String fingerprint) {
709 this.axolotlFingerprint = fingerprint;
710 }
711
712 public String getAxolotlFingerprint() {
713 return axolotlFingerprint;
714 }
715
716 public boolean isTrusted() {
717 return conversation.getAccount().getAxolotlService().getFingerprintTrust(axolotlFingerprint)
718 == XmppAxolotlSession.Trust.TRUSTED;
719 }
720
721 private int getPreviousEncryption() {
722 for (Message iterator = this.prev(); iterator != null; iterator = iterator.prev()){
723 if( iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED ) {
724 continue;
725 }
726 return iterator.getEncryption();
727 }
728 return ENCRYPTION_NONE;
729 }
730
731 private int getNextEncryption() {
732 for (Message iterator = this.next(); iterator != null; iterator = iterator.next()){
733 if( iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED ) {
734 continue;
735 }
736 return iterator.getEncryption();
737 }
738 return conversation.getNextEncryption();
739 }
740
741 public boolean isValidInSession() {
742 int pastEncryption = this.getPreviousEncryption();
743 int futureEncryption = this.getNextEncryption();
744
745 boolean inUnencryptedSession = pastEncryption == ENCRYPTION_NONE
746 || futureEncryption == ENCRYPTION_NONE
747 || pastEncryption != futureEncryption;
748
749 return inUnencryptedSession || this.getEncryption() == pastEncryption;
750 }
751}