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