package com.fsck.k9.mailstore;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.util.Date;
import android.content.ContentValues;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.support.annotation.VisibleForTesting;
import timber.log.Timber;
import com.fsck.k9.Account;
import com.fsck.k9.BuildConfig;
import com.fsck.k9.K9;
import com.fsck.k9.activity.MessageReference;
import com.fsck.k9.mail.Address;
import com.fsck.k9.mail.Flag;
import com.fsck.k9.mail.Folder;
import com.fsck.k9.mail.MessagingException;
import com.fsck.k9.mail.internet.MimeMessage;
import com.fsck.k9.mail.message.MessageHeaderParser;
import com.fsck.k9.mailstore.LockableDatabase.DbCallback;
import com.fsck.k9.mailstore.LockableDatabase.WrappedException;
import com.fsck.k9.message.extractors.PreviewResult.PreviewType;
public class LocalMessage extends MimeMessage {
protected MessageReference mReference;
private final LocalStore localStore;
private long mId;
private int mAttachmentCount;
private String mSubject;
private String mPreview = "";
private long mThreadId;
private long mRootId;
private long messagePartId;
private String mimeType;
private PreviewType previewType;
private boolean headerNeedsUpdating = false;
private LocalMessage(LocalStore localStore) {
this.localStore = localStore;
}
LocalMessage(LocalStore localStore, String uid, Folder folder) {
this.localStore = localStore;
this.mUid = uid;
this.mFolder = folder;
}
void populateFromGetMessageCursor(Cursor cursor) throws MessagingException {
final String subject = cursor.getString(0);
this.setSubject(subject == null ? "" : subject);
Address[] from = Address.unpack(cursor.getString(1));
if (from.length > 0) {
this.setFrom(from[0]);
}
this.setInternalSentDate(new Date(cursor.getLong(2)));
this.setUid(cursor.getString(3));
String flagList = cursor.getString(4);
if (flagList != null && flagList.length() > 0) {
String[] flags = flagList.split(",");
for (String flag : flags) {
try {
this.setFlagInternal(Flag.valueOf(flag), true);
}
catch (Exception e) {
if (!"X_BAD_FLAG".equals(flag)) {
Timber.w("Unable to parse flag %s", flag);
}
}
}
}
this.mId = cursor.getLong(5);
this.setRecipients(RecipientType.TO, Address.unpack(cursor.getString(6)));
this.setRecipients(RecipientType.CC, Address.unpack(cursor.getString(7)));
this.setRecipients(RecipientType.BCC, Address.unpack(cursor.getString(8)));
this.setReplyTo(Address.unpack(cursor.getString(9)));
this.mAttachmentCount = cursor.getInt(10);
this.setInternalDate(new Date(cursor.getLong(11)));
this.setMessageId(cursor.getString(12));
String previewTypeString = cursor.getString(24);
DatabasePreviewType databasePreviewType = DatabasePreviewType.fromDatabaseValue(previewTypeString);
previewType = databasePreviewType.getPreviewType();
if (previewType == PreviewType.TEXT) {
mPreview = cursor.getString(14);
} else {
mPreview = "";
}
if (this.mFolder == null) {
LocalFolder f = new LocalFolder(this.localStore, cursor.getInt(13));
f.open(LocalFolder.OPEN_MODE_RW);
this.mFolder = f;
}
mThreadId = (cursor.isNull(15)) ? -1 : cursor.getLong(15);
mRootId = (cursor.isNull(16)) ? -1 : cursor.getLong(16);
boolean deleted = (cursor.getInt(17) == 1);
boolean read = (cursor.getInt(18) == 1);
boolean flagged = (cursor.getInt(19) == 1);
boolean answered = (cursor.getInt(20) == 1);
boolean forwarded = (cursor.getInt(21) == 1);
setFlagInternal(Flag.DELETED, deleted);
setFlagInternal(Flag.SEEN, read);
setFlagInternal(Flag.FLAGGED, flagged);
setFlagInternal(Flag.ANSWERED, answered);
setFlagInternal(Flag.FORWARDED, forwarded);
setMessagePartId(cursor.getLong(22));
mimeType = cursor.getString(23);
byte[] header = cursor.getBlob(25);
if (header != null) {
MessageHeaderParser.parse(this, new ByteArrayInputStream(header));
} else {
Timber.d("No headers available for this message!");
}
headerNeedsUpdating = false;
}
@VisibleForTesting
void setMessagePartId(long messagePartId) {
this.messagePartId = messagePartId;
}
public long getMessagePartId() {
return messagePartId;
}
@Override
public String getMimeType() {
return mimeType;
}
/* Custom version of writeTo that updates the MIME message based on localMessage
* changes.
*/
public PreviewType getPreviewType() {
return previewType;
}
public String getPreview() {
return mPreview;
}
@Override
public String getSubject() {
return mSubject;
}
@Override
public void setSubject(String subject) {
mSubject = subject;
headerNeedsUpdating = true;
}
@Override
public void setMessageId(String messageId) {
mMessageId = messageId;
headerNeedsUpdating = true;
}
@Override
public void setUid(String uid) {
super.setUid(uid);
this.mReference = null;
}
@Override
public boolean hasAttachments() {
return (mAttachmentCount > 0);
}
public int getAttachmentCount() {
return mAttachmentCount;
}
@Override
public void setFrom(Address from) {
this.mFrom = new Address[] { from };
headerNeedsUpdating = true;
}
@Override
public void setReplyTo(Address[] replyTo) {
if (replyTo == null || replyTo.length == 0) {
mReplyTo = null;
} else {
mReplyTo = replyTo;
}
headerNeedsUpdating = true;
}
/*
* For performance reasons, we add headers instead of setting them (see super implementation)
* which removes (expensive) them before adding them
*/
@Override
public void setRecipients(RecipientType type, Address[] addresses) {
if (type == RecipientType.TO) {
if (addresses == null || addresses.length == 0) {
this.mTo = null;
} else {
this.mTo = addresses;
}
} else if (type == RecipientType.CC) {
if (addresses == null || addresses.length == 0) {
this.mCc = null;
} else {
this.mCc = addresses;
}
} else if (type == RecipientType.BCC) {
if (addresses == null || addresses.length == 0) {
this.mBcc = null;
} else {
this.mBcc = addresses;
}
} else {
throw new IllegalArgumentException("Unrecognized recipient type.");
}
headerNeedsUpdating = true;
}
public void setFlagInternal(Flag flag, boolean set) throws MessagingException {
super.setFlag(flag, set);
}
@Override
public long getId() {
return mId;
}
@Override
public void setFlag(final Flag flag, final boolean set) throws MessagingException {
try {
this.localStore.database.execute(true, new DbCallback<Void>() {
@Override
public Void doDbWork(final SQLiteDatabase db) throws WrappedException, UnavailableStorageException {
try {
if (flag == Flag.DELETED && set) {
delete();
}
LocalMessage.super.setFlag(flag, set);
} catch (MessagingException e) {
throw new WrappedException(e);
}
/*
* Set the flags on the message.
*/
ContentValues cv = new ContentValues();
cv.put("flags", LocalMessage.this.localStore.serializeFlags(getFlags()));
cv.put("read", isSet(Flag.SEEN) ? 1 : 0);
cv.put("flagged", isSet(Flag.FLAGGED) ? 1 : 0);
cv.put("answered", isSet(Flag.ANSWERED) ? 1 : 0);
cv.put("forwarded", isSet(Flag.FORWARDED) ? 1 : 0);
db.update("messages", cv, "id = ?", new String[] { Long.toString(mId) });
return null;
}
});
} catch (WrappedException e) {
throw(MessagingException) e.getCause();
}
this.localStore.notifyChange();
}
/*
* If a message is being marked as deleted we want to clear out its content. Delete will not actually remove the
* row since we need to retain the UID for synchronization purposes.
*/
private void delete() throws MessagingException {
try {
localStore.database.execute(true, new DbCallback<Void>() {
@Override
public Void doDbWork(final SQLiteDatabase db) throws WrappedException, UnavailableStorageException {
ContentValues cv = new ContentValues();
cv.put("deleted", 1);
cv.put("empty", 1);
cv.putNull("subject");
cv.putNull("sender_list");
cv.putNull("date");
cv.putNull("to_list");
cv.putNull("cc_list");
cv.putNull("bcc_list");
cv.putNull("preview");
cv.putNull("reply_to_list");
cv.putNull("message_part_id");
db.update("messages", cv, "id = ?", new String[] { Long.toString(mId) });
try {
((LocalFolder) mFolder).deleteMessagePartsAndDataFromDisk(messagePartId);
} catch (MessagingException e) {
throw new WrappedException(e);
}
deleteFulltextIndexEntry(db, mId);
return null;
}
});
} catch (WrappedException e) {
throw (MessagingException) e.getCause();
}
localStore.notifyChange();
}
public void debugClearLocalData() throws MessagingException {
if (!BuildConfig.DEBUG) {
throw new AssertionError("method must only be used in debug build!");
}
try {
localStore.database.execute(true, new DbCallback<Void>() {
@Override
public Void doDbWork(final SQLiteDatabase db) throws WrappedException, MessagingException {
ContentValues cv = new ContentValues();
cv.putNull("message_part_id");
db.update("messages", cv, "id = ?", new String[] { Long.toString(mId) });
try {
((LocalFolder) mFolder).deleteMessagePartsAndDataFromDisk(messagePartId);
} catch (MessagingException e) {
throw new WrappedException(e);
}
setFlag(Flag.X_DOWNLOADED_FULL, false);
setFlag(Flag.X_DOWNLOADED_PARTIAL, false);
return null;
}
});
} catch (WrappedException e) {
throw (MessagingException) e.getCause();
}
localStore.notifyChange();
}
/*
* Completely remove a message from the local database
*
* TODO: document how this updates the thread structure
*/
@Override
public void destroy() throws MessagingException {
try {
this.localStore.database.execute(true, new DbCallback<Void>() {
@Override
public Void doDbWork(final SQLiteDatabase db) throws WrappedException,
UnavailableStorageException {
try {
LocalFolder localFolder = (LocalFolder) mFolder;
localFolder.deleteMessagePartsAndDataFromDisk(messagePartId);
deleteFulltextIndexEntry(db, mId);
if (hasThreadChildren(db, mId)) {
// This message has children in the thread structure so we need to
// make it an empty message.
ContentValues cv = new ContentValues();
cv.put("id", mId);
cv.put("folder_id", localFolder.getId());
cv.put("deleted", 0);
cv.put("message_id", getMessageId());
cv.put("empty", 1);
db.replace("messages", null, cv);
// Nothing else to do
return null;
}
// Get the message ID of the parent message if it's empty
long currentId = getEmptyThreadParent(db, mId);
// Delete the placeholder message
deleteMessageRow(db, mId);
/*
* Walk the thread tree to delete all empty parents without children
*/
while (currentId != -1) {
if (hasThreadChildren(db, currentId)) {
// We made sure there are no empty leaf nodes and can stop now.
break;
}
// Get ID of the (empty) parent for the next iteration
long newId = getEmptyThreadParent(db, currentId);
// Delete the empty message
deleteMessageRow(db, currentId);
currentId = newId;
}
} catch (MessagingException e) {
throw new WrappedException(e);
}
return null;
}
});
} catch (WrappedException e) {
throw(MessagingException) e.getCause();
}
this.localStore.notifyChange();
}
/**
* Get ID of the the given message's parent if the parent is an empty message.
*
* @param db
* {@link SQLiteDatabase} instance to access the database.
* @param messageId
* The database ID of the message to get the parent for.
*
* @return Message ID of the parent message if there exists a parent and it is empty.
* Otherwise {@code -1}.
*/
private long getEmptyThreadParent(SQLiteDatabase db, long messageId) {
Cursor cursor = db.rawQuery(
"SELECT m.id " +
"FROM threads t1 " +
"JOIN threads t2 ON (t1.parent = t2.id) " +
"LEFT JOIN messages m ON (t2.message_id = m.id) " +
"WHERE t1.message_id = ? AND m.empty = 1",
new String[] { Long.toString(messageId) });
try {
return (cursor.moveToFirst() && !cursor.isNull(0)) ? cursor.getLong(0) : -1;
} finally {
cursor.close();
}
}
/**
* Check whether or not a message has child messages in the thread structure.
*
* @param db
* {@link SQLiteDatabase} instance to access the database.
* @param messageId
* The database ID of the message to get the children for.
*
* @return {@code true} if the message has children. {@code false} otherwise.
*/
private boolean hasThreadChildren(SQLiteDatabase db, long messageId) {
Cursor cursor = db.rawQuery(
"SELECT COUNT(t2.id) " +
"FROM threads t1 " +
"JOIN threads t2 ON (t2.parent = t1.id) " +
"WHERE t1.message_id = ?",
new String[] { Long.toString(messageId) });
try {
return (cursor.moveToFirst() && !cursor.isNull(0) && cursor.getLong(0) > 0L);
} finally {
cursor.close();
}
}
private void deleteFulltextIndexEntry(SQLiteDatabase db, long messageId) {
String[] idArg = { Long.toString(messageId) };
db.delete("messages_fulltext", "docid = ?", idArg);
}
/**
* Delete a message from the 'messages' and 'threads' tables.
*
* @param db
* {@link SQLiteDatabase} instance to access the database.
* @param messageId
* The database ID of the message to delete.
*/
private void deleteMessageRow(SQLiteDatabase db, long messageId) {
String[] idArg = { Long.toString(messageId) };
// Delete the message
db.delete("messages", "id = ?", idArg);
// Delete row in 'threads' table
// TODO: create trigger for 'messages' table to get rid of the row in 'threads' table
db.delete("threads", "message_id = ?", idArg);
}
@Override
public LocalMessage clone() {
LocalMessage message = new LocalMessage(localStore);
super.copy(message);
message.mReference = mReference;
message.mId = mId;
message.mAttachmentCount = mAttachmentCount;
message.mSubject = mSubject;
message.mPreview = mPreview;
message.mThreadId = mThreadId;
message.mRootId = mRootId;
message.messagePartId = messagePartId;
message.mimeType = mimeType;
message.previewType = previewType;
message.headerNeedsUpdating = headerNeedsUpdating;
return message;
}
public long getThreadId() {
return mThreadId;
}
public long getRootId() {
return mRootId;
}
public Account getAccount() {
return localStore.getAccount();
}
public MessageReference makeMessageReference() {
if (mReference == null) {
mReference = new MessageReference(getFolder().getAccountUuid(), getFolder().getName(), mUid, null);
}
return mReference;
}
@Override
public LocalFolder getFolder() {
return (LocalFolder) super.getFolder();
}
public String getUri() {
return "email://messages/" + getAccount().getAccountNumber() + "/" + getFolder().getName() + "/" + getUid();
}
@Override
public void writeTo(OutputStream out) throws IOException, MessagingException {
if (headerNeedsUpdating) {
updateHeader();
}
super.writeTo(out);
}
private void updateHeader() {
super.setSubject(mSubject);
super.setReplyTo(mReplyTo);
super.setRecipients(RecipientType.TO, mTo);
super.setRecipients(RecipientType.CC, mCc);
super.setRecipients(RecipientType.BCC, mBcc);
if (mFrom != null && mFrom.length > 0) {
super.setFrom(mFrom[0]);
}
if (mMessageId != null) {
super.setMessageId(mMessageId);
}
headerNeedsUpdating = false;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
if (!super.equals(o)) {
return false;
}
// distinguish by account uuid, in addition to what Message.equals() does above
String thisAccountUuid = getAccountUuid();
String thatAccountUuid = ((LocalMessage) o).getAccountUuid();
return thisAccountUuid != null ? thisAccountUuid.equals(thatAccountUuid) : thatAccountUuid == null;
}
@Override
public int hashCode() {
int result = super.hashCode();
result = 31 * result + (getAccountUuid() != null ? getAccountUuid().hashCode() : 0);
return result;
}
private String getAccountUuid() {
return getAccount().getUuid();
}
public boolean isBodyMissing() {
return getBody() == null;
}
}