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