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