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