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 );
404 }
405
406 private static boolean isStatusMergeable(int a, int b) {
407 return a == b || (
408 (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_UNSEND)
409 || (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_SEND)
410 || (a == Message.STATUS_UNSEND && b == Message.STATUS_SEND)
411 || (a == Message.STATUS_UNSEND && b == Message.STATUS_SEND_RECEIVED)
412 || (a == Message.STATUS_SEND && b == Message.STATUS_UNSEND)
413 || (a == Message.STATUS_SEND && b == Message.STATUS_SEND_RECEIVED)
414 );
415 }
416
417 public String getMergedBody() {
418 final Message next = this.next();
419 if (this.mergeable(next)) {
420 return getBody().trim() + MERGE_SEPARATOR + next.getMergedBody();
421 }
422 return getBody().trim();
423 }
424
425 public boolean hasMeCommand() {
426 return getMergedBody().startsWith(ME_COMMAND);
427 }
428
429 public int getMergedStatus() {
430 final Message next = this.next();
431 if (this.mergeable(next)) {
432 return next.getStatus();
433 }
434 return getStatus();
435 }
436
437 public long getMergedTimeSent() {
438 Message next = this.next();
439 if (this.mergeable(next)) {
440 return next.getMergedTimeSent();
441 } else {
442 return getTimeSent();
443 }
444 }
445
446 public boolean wasMergedIntoPrevious() {
447 Message prev = this.prev();
448 return prev != null && prev.mergeable(this);
449 }
450
451 public boolean trusted() {
452 Contact contact = this.getContact();
453 return (status > STATUS_RECEIVED || (contact != null && contact.trusted()));
454 }
455
456 public boolean fixCounterpart() {
457 Presences presences = conversation.getContact().getPresences();
458 if (counterpart != null && presences.has(counterpart.getResourcepart())) {
459 return true;
460 } else if (presences.size() >= 1) {
461 try {
462 counterpart = Jid.fromParts(conversation.getJid().getLocalpart(),
463 conversation.getJid().getDomainpart(),
464 presences.asStringArray()[0]);
465 return true;
466 } catch (InvalidJidException e) {
467 counterpart = null;
468 return false;
469 }
470 } else {
471 counterpart = null;
472 return false;
473 }
474 }
475
476 public enum Decision {
477 MUST,
478 SHOULD,
479 NEVER,
480 }
481
482 private static String extractRelevantExtension(URL url) {
483 String path = url.getPath();
484 if (path == null || path.isEmpty()) {
485 return null;
486 }
487 String filename = path.substring(path.lastIndexOf('/') + 1).toLowerCase();
488 String[] extensionParts = filename.split("\\.");
489 if (extensionParts.length == 2) {
490 return extensionParts[extensionParts.length - 1];
491 } else if (extensionParts.length == 3 && Arrays
492 .asList(Transferable.VALID_CRYPTO_EXTENSIONS)
493 .contains(extensionParts[extensionParts.length - 1])) {
494 return extensionParts[extensionParts.length -2];
495 }
496 return null;
497 }
498
499 public String getMimeType() {
500 if (relativeFilePath != null) {
501 int start = relativeFilePath.lastIndexOf('.') + 1;
502 if (start < relativeFilePath.length()) {
503 return MimeUtils.guessMimeTypeFromExtension(relativeFilePath.substring(start));
504 } else {
505 return null;
506 }
507 } else {
508 try {
509 return MimeUtils.guessMimeTypeFromExtension(extractRelevantExtension(new URL(body.trim())));
510 } catch (MalformedURLException e) {
511 return null;
512 }
513 }
514 }
515
516 public Decision treatAsDownloadable() {
517 if (body.trim().contains(" ")) {
518 return Decision.NEVER;
519 }
520 try {
521 URL url = new URL(body);
522 if (!url.getProtocol().equalsIgnoreCase("http") && !url.getProtocol().equalsIgnoreCase("https")) {
523 return Decision.NEVER;
524 }
525 String extension = extractRelevantExtension(url);
526 if (extension == null) {
527 return Decision.NEVER;
528 }
529 String ref = url.getRef();
530 boolean encrypted = ref != null && ref.matches("([A-Fa-f0-9]{2}){48}");
531
532 if (encrypted) {
533 if (MimeUtils.guessMimeTypeFromExtension(extension) != null) {
534 return Decision.MUST;
535 } else {
536 return Decision.NEVER;
537 }
538 } else if (Arrays.asList(Transferable.VALID_IMAGE_EXTENSIONS).contains(extension)
539 || Arrays.asList(Transferable.WELL_KNOWN_EXTENSIONS).contains(extension)) {
540 return Decision.SHOULD;
541 } else {
542 return Decision.NEVER;
543 }
544
545 } catch (MalformedURLException e) {
546 return Decision.NEVER;
547 }
548 }
549
550 public boolean bodyIsHeart() {
551 return body != null && UIHelper.HEARTS.contains(body.trim());
552 }
553
554 public FileParams getFileParams() {
555 FileParams params = getLegacyFileParams();
556 if (params != null) {
557 return params;
558 }
559 params = new FileParams();
560 if (this.transferable != null) {
561 params.size = this.transferable.getFileSize();
562 }
563 if (body == null) {
564 return params;
565 }
566 String parts[] = body.split("\\|");
567 switch (parts.length) {
568 case 1:
569 try {
570 params.size = Long.parseLong(parts[0]);
571 } catch (NumberFormatException e) {
572 try {
573 params.url = new URL(parts[0]);
574 } catch (MalformedURLException e1) {
575 params.url = null;
576 }
577 }
578 break;
579 case 2:
580 case 4:
581 try {
582 params.url = new URL(parts[0]);
583 } catch (MalformedURLException e1) {
584 params.url = null;
585 }
586 try {
587 params.size = Long.parseLong(parts[1]);
588 } catch (NumberFormatException e) {
589 params.size = 0;
590 }
591 try {
592 params.width = Integer.parseInt(parts[2]);
593 } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) {
594 params.width = 0;
595 }
596 try {
597 params.height = Integer.parseInt(parts[3]);
598 } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) {
599 params.height = 0;
600 }
601 break;
602 case 3:
603 try {
604 params.size = Long.parseLong(parts[0]);
605 } catch (NumberFormatException e) {
606 params.size = 0;
607 }
608 try {
609 params.width = Integer.parseInt(parts[1]);
610 } catch (NumberFormatException e) {
611 params.width = 0;
612 }
613 try {
614 params.height = Integer.parseInt(parts[2]);
615 } catch (NumberFormatException e) {
616 params.height = 0;
617 }
618 break;
619 }
620 return params;
621 }
622
623 public FileParams getLegacyFileParams() {
624 FileParams params = new FileParams();
625 if (body == null) {
626 return params;
627 }
628 String parts[] = body.split(",");
629 if (parts.length == 3) {
630 try {
631 params.size = Long.parseLong(parts[0]);
632 } catch (NumberFormatException e) {
633 return null;
634 }
635 try {
636 params.width = Integer.parseInt(parts[1]);
637 } catch (NumberFormatException e) {
638 return null;
639 }
640 try {
641 params.height = Integer.parseInt(parts[2]);
642 } catch (NumberFormatException e) {
643 return null;
644 }
645 return params;
646 } else {
647 return null;
648 }
649 }
650
651 public void untie() {
652 this.mNextMessage = null;
653 this.mPreviousMessage = null;
654 }
655
656 public boolean isFileOrImage() {
657 return type == TYPE_FILE || type == TYPE_IMAGE;
658 }
659
660 public boolean hasFileOnRemoteHost() {
661 return isFileOrImage() && getFileParams().url != null;
662 }
663
664 public boolean needsUploading() {
665 return isFileOrImage() && getFileParams().url == null;
666 }
667
668 public class FileParams {
669 public URL url;
670 public long size = 0;
671 public int width = 0;
672 public int height = 0;
673 }
674
675 public void setAxolotlFingerprint(String fingerprint) {
676 this.axolotlFingerprint = fingerprint;
677 }
678}