Add support for resuming compose drafts.

This commit is contained in:
Moxie Marlinspike 2013-02-04 00:13:07 -08:00
parent 209711ae40
commit bf92de394b
5 changed files with 252 additions and 22 deletions

View File

@ -50,11 +50,15 @@ import org.thoughtcrime.securesms.crypto.AuthenticityCalculator;
import org.thoughtcrime.securesms.crypto.KeyExchangeInitiator; import org.thoughtcrime.securesms.crypto.KeyExchangeInitiator;
import org.thoughtcrime.securesms.crypto.KeyExchangeProcessor; import org.thoughtcrime.securesms.crypto.KeyExchangeProcessor;
import org.thoughtcrime.securesms.crypto.KeyUtil; import org.thoughtcrime.securesms.crypto.KeyUtil;
import org.thoughtcrime.securesms.crypto.MasterCipher;
import org.thoughtcrime.securesms.crypto.MasterSecret; import org.thoughtcrime.securesms.crypto.MasterSecret;
import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.DraftDatabase;
import org.thoughtcrime.securesms.database.DraftDatabase.Draft;
import org.thoughtcrime.securesms.mms.AttachmentManager; import org.thoughtcrime.securesms.mms.AttachmentManager;
import org.thoughtcrime.securesms.mms.AttachmentTypeSelectorAdapter; import org.thoughtcrime.securesms.mms.AttachmentTypeSelectorAdapter;
import org.thoughtcrime.securesms.mms.MediaTooLargeException; import org.thoughtcrime.securesms.mms.MediaTooLargeException;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideDeck; import org.thoughtcrime.securesms.mms.SlideDeck;
import org.thoughtcrime.securesms.protocol.Tag; import org.thoughtcrime.securesms.protocol.Tag;
import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.Recipient;
@ -67,6 +71,7 @@ import org.thoughtcrime.securesms.util.CharacterCalculator;
import org.thoughtcrime.securesms.util.EncryptedCharacterCalculator; import org.thoughtcrime.securesms.util.EncryptedCharacterCalculator;
import org.thoughtcrime.securesms.util.InvalidMessageException; import org.thoughtcrime.securesms.util.InvalidMessageException;
import org.thoughtcrime.securesms.util.MemoryCleaner; import org.thoughtcrime.securesms.util.MemoryCleaner;
import org.thoughtcrime.securesms.util.Util;
import ws.com.google.android.mms.MmsException; import ws.com.google.android.mms.MmsException;
@ -171,6 +176,7 @@ public class ConversationActivity extends SherlockFragmentActivity
protected void onDestroy() { protected void onDestroy() {
unregisterReceiver(killActivityReceiver); unregisterReceiver(killActivityReceiver);
unregisterReceiver(securityUpdateReceiver); unregisterReceiver(securityUpdateReceiver);
saveDraft();
MemoryCleaner.clean(masterSecret); MemoryCleaner.clean(masterSecret);
super.onDestroy(); super.onDestroy();
} }
@ -422,6 +428,35 @@ public class ConversationActivity extends SherlockFragmentActivity
if (draftText != null) composeText.setText(draftText); if (draftText != null) composeText.setText(draftText);
if (draftImage != null) addAttachmentImage(draftImage); if (draftImage != null) addAttachmentImage(draftImage);
if (draftAudio != null) addAttachmentAudio(draftAudio); if (draftAudio != null) addAttachmentAudio(draftAudio);
if (draftText == null && draftImage == null && draftAudio == null) {
initializeDraftFromDatabase();
}
}
private void initializeDraftFromDatabase() {
new AsyncTask<Void, Void, List<Draft>>() {
@Override
protected List<Draft> doInBackground(Void... params) {
MasterCipher masterCipher = new MasterCipher(masterSecret);
DraftDatabase draftDatabase = DatabaseFactory.getDraftDatabase(ConversationActivity.this);
List<Draft> results = draftDatabase.getDrafts(masterCipher, threadId);
draftDatabase.clearDrafts(threadId);
return results;
}
@Override
protected void onPostExecute(List<Draft> drafts) {
for (Draft draft : drafts) {
if (draft.getType().equals(Draft.TEXT)) composeText.setText(draft.getValue());
else if (draft.getType().equals(Draft.IMAGE)) addAttachmentImage(Uri.parse(draft.getValue()));
else if (draft.getType().equals(Draft.AUDIO)) addAttachmentAudio(Uri.parse(draft.getValue()));
else if (draft.getType().equals(Draft.VIDEO)) addAttachmentVideo(Uri.parse(draft.getValue()));
}
}
}.execute();
} }
private void initializeSecurity() { private void initializeSecurity() {
@ -571,6 +606,50 @@ public class ConversationActivity extends SherlockFragmentActivity
} }
} }
private List<Draft> getDraftsForCurrentState() {
List<Draft> drafts = new LinkedList<Draft>();
if (!Util.isEmpty(composeText)) {
drafts.add(new Draft(Draft.TEXT, composeText.getText().toString()));
}
for (Slide slide : attachmentManager.getSlideDeck().getSlides()) {
if (slide.hasImage()) drafts.add(new Draft(Draft.IMAGE, slide.getUri().toString()));
else if (slide.hasAudio()) drafts.add(new Draft(Draft.AUDIO, slide.getUri().toString()));
else if (slide.hasVideo()) drafts.add(new Draft(Draft.VIDEO, slide.getUri().toString()));
}
return drafts;
}
private void saveDraft() {
if (this.threadId <= 0 || this.recipients == null || this.recipients.isEmpty())
return;
final List<Draft> drafts = getDraftsForCurrentState();
if (drafts.size() <= 0)
return;
final long thisThreadId = this.threadId;
final MasterSecret thisMasterSecret = this.masterSecret.parcelClone();
new AsyncTask<Void, Void, Void>() {
@Override
protected void onPreExecute() {
Toast.makeText(ConversationActivity.this, "Saving draft...", Toast.LENGTH_SHORT).show();
}
@Override
protected Void doInBackground(Void... params) {
MasterCipher masterCipher = new MasterCipher(thisMasterSecret);
DatabaseFactory.getDraftDatabase(ConversationActivity.this).insertDrafts(masterCipher, thisThreadId, drafts);
MemoryCleaner.clean(thisMasterSecret);
return null;
}
}.execute();
}
private void calculateCharactersRemaining() { private void calculateCharactersRemaining() {
int charactersSpent = composeText.getText().length(); int charactersSpent = composeText.getText().length();
CharacterCalculator.CharacterState characterState = characterCalculator.calculateCharacters(charactersSpent); CharacterCalculator.CharacterState characterState = characterCalculator.calculateCharacters(charactersSpent);

View File

@ -1,6 +1,6 @@
/** /**
* Copyright (C) 2011 Whisper Systems * Copyright (C) 2011 Whisper Systems
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or * the Free Software Foundation, either version 3 of the License, or
@ -10,89 +10,111 @@
* but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details. * GNU General Public License for more details.
* *
* You should have received a copy of the GNU General Public License * You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
package org.thoughtcrime.securesms.crypto; package org.thoughtcrime.securesms.crypto;
import javax.crypto.spec.SecretKeySpec; import android.os.Parcel;
import android.os.Parcelable;
import org.bouncycastle.util.Arrays; import org.bouncycastle.util.Arrays;
import android.os.Parcel; import javax.crypto.spec.SecretKeySpec;
import android.os.Parcelable;
import android.util.Log;
/** /**
* When a user first initializes TextSecure, a few secrets * When a user first initializes TextSecure, a few secrets
* are generated. These are: * are generated. These are:
* *
* 1) A 128bit symmetric encryption key. * 1) A 128bit symmetric encryption key.
* 2) A 160bit symmetric MAC key. * 2) A 160bit symmetric MAC key.
* 3) An ECC keypair. * 3) An ECC keypair.
* *
* The first two, along with the ECC keypair's private key, are * The first two, along with the ECC keypair's private key, are
* then encrypted on disk using PBE. * then encrypted on disk using PBE.
* *
* This class represents 1 and 2. * This class represents 1 and 2.
* *
* @author Moxie Marlinspike * @author Moxie Marlinspike
*/ */
public class MasterSecret implements Parcelable { public class MasterSecret implements Parcelable {
private final SecretKeySpec encryptionKey; private final SecretKeySpec encryptionKey;
private final SecretKeySpec macKey; private final SecretKeySpec macKey;
public static final Parcelable.Creator<MasterSecret> CREATOR = new Parcelable.Creator<MasterSecret>() { public static final Parcelable.Creator<MasterSecret> CREATOR = new Parcelable.Creator<MasterSecret>() {
@Override
public MasterSecret createFromParcel(Parcel in) { public MasterSecret createFromParcel(Parcel in) {
return new MasterSecret(in); return new MasterSecret(in);
} }
@Override
public MasterSecret[] newArray(int size) { public MasterSecret[] newArray(int size) {
return new MasterSecret[size]; return new MasterSecret[size];
} }
}; };
public MasterSecret(SecretKeySpec encryptionKey, SecretKeySpec macKey) { public MasterSecret(SecretKeySpec encryptionKey, SecretKeySpec macKey) {
this.encryptionKey = encryptionKey; this.encryptionKey = encryptionKey;
this.macKey = macKey; this.macKey = macKey;
} }
private MasterSecret(Parcel in) { private MasterSecret(Parcel in) {
byte[] encryptionKeyBytes = new byte[in.readInt()]; byte[] encryptionKeyBytes = new byte[in.readInt()];
in.readByteArray(encryptionKeyBytes); in.readByteArray(encryptionKeyBytes);
byte[] macKeyBytes = new byte[in.readInt()]; byte[] macKeyBytes = new byte[in.readInt()];
in.readByteArray(macKeyBytes); in.readByteArray(macKeyBytes);
this.encryptionKey = new SecretKeySpec(encryptionKeyBytes, "AES"); this.encryptionKey = new SecretKeySpec(encryptionKeyBytes, "AES");
this.macKey = new SecretKeySpec(macKeyBytes, "HmacSHA1"); this.macKey = new SecretKeySpec(macKeyBytes, "HmacSHA1");
// SecretKeySpec does an internal copy in its constructor. // SecretKeySpec does an internal copy in its constructor.
Arrays.fill(encryptionKeyBytes, (byte)0x00); Arrays.fill(encryptionKeyBytes, (byte)0x00);
Arrays.fill(macKeyBytes, (byte)0x00); Arrays.fill(macKeyBytes, (byte)0x00);
} }
public SecretKeySpec getEncryptionKey() { public SecretKeySpec getEncryptionKey() {
return this.encryptionKey; return this.encryptionKey;
} }
public SecretKeySpec getMacKey() { public SecretKeySpec getMacKey() {
return this.macKey; return this.macKey;
} }
@Override
public void writeToParcel(Parcel out, int flags) { public void writeToParcel(Parcel out, int flags) {
out.writeInt(encryptionKey.getEncoded().length); out.writeInt(encryptionKey.getEncoded().length);
out.writeByteArray(encryptionKey.getEncoded()); out.writeByteArray(encryptionKey.getEncoded());
out.writeInt(macKey.getEncoded().length); out.writeInt(macKey.getEncoded().length);
out.writeByteArray(macKey.getEncoded()); out.writeByteArray(macKey.getEncoded());
} }
@Override
public int describeContents() { public int describeContents() {
return 0; return 0;
} }
public MasterSecret parcelClone() {
Parcel thisParcel = Parcel.obtain();
Parcel thatParcel = Parcel.obtain();
byte[] bytes = null;
thisParcel.writeValue(this);
bytes = thisParcel.marshall();
thatParcel.unmarshall(bytes, 0, bytes.length);
thatParcel.setDataPosition(0);
MasterSecret that = (MasterSecret)thatParcel.readValue(MasterSecret.class.getClassLoader());
thisParcel.recycle();
thatParcel.recycle();
return that;
}
} }

View File

@ -28,7 +28,8 @@ public class DatabaseFactory {
private static final int INTRODUCED_IDENTITIES_VERSION = 2; private static final int INTRODUCED_IDENTITIES_VERSION = 2;
private static final int INTRODUCED_INDEXES_VERSION = 3; private static final int INTRODUCED_INDEXES_VERSION = 3;
private static final int INTRODUCED_DATE_SENT_VERSION = 4; private static final int INTRODUCED_DATE_SENT_VERSION = 4;
private static final int DATABASE_VERSION = 4; private static final int INTRODUCED_DRAFTS_VERSION = 5;
private static final int DATABASE_VERSION = 5;
private static final String DATABASE_NAME = "messages.db"; private static final String DATABASE_NAME = "messages.db";
private static final Object lock = new Object(); private static final Object lock = new Object();
@ -48,6 +49,7 @@ public class DatabaseFactory {
private final MmsAddressDatabase mmsAddress; private final MmsAddressDatabase mmsAddress;
private final MmsSmsDatabase mmsSmsDatabase; private final MmsSmsDatabase mmsSmsDatabase;
private final IdentityDatabase identityDatabase; private final IdentityDatabase identityDatabase;
private final DraftDatabase draftDatabase;
public static DatabaseFactory getInstance(Context context) { public static DatabaseFactory getInstance(Context context) {
synchronized (lock) { synchronized (lock) {
@ -116,6 +118,10 @@ public class DatabaseFactory {
return getInstance(context).identityDatabase; return getInstance(context).identityDatabase;
} }
public static DraftDatabase getDraftDatabase(Context context) {
return getInstance(context).draftDatabase;
}
private DatabaseFactory(Context context) { private DatabaseFactory(Context context) {
this.databaseHelper = new DatabaseHelper(context, DATABASE_NAME, null, DATABASE_VERSION); this.databaseHelper = new DatabaseHelper(context, DATABASE_NAME, null, DATABASE_VERSION);
this.sms = new SmsDatabase(context, databaseHelper); this.sms = new SmsDatabase(context, databaseHelper);
@ -127,6 +133,7 @@ public class DatabaseFactory {
this.mmsAddress = new MmsAddressDatabase(context, databaseHelper); this.mmsAddress = new MmsAddressDatabase(context, databaseHelper);
this.mmsSmsDatabase = new MmsSmsDatabase(context, databaseHelper); this.mmsSmsDatabase = new MmsSmsDatabase(context, databaseHelper);
this.identityDatabase = new IdentityDatabase(context, databaseHelper); this.identityDatabase = new IdentityDatabase(context, databaseHelper);
this.draftDatabase = new DraftDatabase(context, databaseHelper);
} }
public void close() { public void close() {
@ -149,12 +156,14 @@ public class DatabaseFactory {
db.execSQL(ThreadDatabase.CREATE_TABLE); db.execSQL(ThreadDatabase.CREATE_TABLE);
db.execSQL(MmsAddressDatabase.CREATE_TABLE); db.execSQL(MmsAddressDatabase.CREATE_TABLE);
db.execSQL(IdentityDatabase.CREATE_TABLE); db.execSQL(IdentityDatabase.CREATE_TABLE);
db.execSQL(DraftDatabase.CREATE_TABLE);
executeStatements(db, SmsDatabase.CREATE_INDEXS); executeStatements(db, SmsDatabase.CREATE_INDEXS);
executeStatements(db, MmsDatabase.CREATE_INDEXS); executeStatements(db, MmsDatabase.CREATE_INDEXS);
executeStatements(db, PartDatabase.CREATE_INDEXS); executeStatements(db, PartDatabase.CREATE_INDEXS);
executeStatements(db, ThreadDatabase.CREATE_INDEXS); executeStatements(db, ThreadDatabase.CREATE_INDEXS);
executeStatements(db, MmsAddressDatabase.CREATE_INDEXS); executeStatements(db, MmsAddressDatabase.CREATE_INDEXS);
executeStatements(db, DraftDatabase.CREATE_INDEXS);
// db.execSQL(CanonicalAddress.CREATE_TABLE); // db.execSQL(CanonicalAddress.CREATE_TABLE);
} }
@ -187,6 +196,14 @@ public class DatabaseFactory {
db.setTransactionSuccessful(); db.setTransactionSuccessful();
db.endTransaction(); db.endTransaction();
} }
if (oldVersion < INTRODUCED_DRAFTS_VERSION) {
db.beginTransaction();
db.execSQL(DraftDatabase.CREATE_TABLE);
executeStatements(db, DraftDatabase.CREATE_INDEXS);
db.setTransactionSuccessful();
db.endTransaction();
}
} }
private void executeStatements(SQLiteDatabase db, String[] statements) { private void executeStatements(SQLiteDatabase db, String[] statements) {

View File

@ -0,0 +1,102 @@
package org.thoughtcrime.securesms.database;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import android.util.Log;
import org.thoughtcrime.securesms.crypto.MasterCipher;
import org.thoughtcrime.securesms.util.InvalidMessageException;
import java.util.LinkedList;
import java.util.List;
public class DraftDatabase extends Database {
private static final String TABLE_NAME = "drafts";
public static final String ID = "_id";
public static final String THREAD_ID = "thread_id";
public static final String DRAFT_TYPE = "type";
public static final String DRAFT_VALUE = "value";
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY, " +
THREAD_ID + " INTEGER, " + DRAFT_TYPE + " TEXT, " + DRAFT_VALUE + " TEXT);";
public static final String[] CREATE_INDEXS = {
"CREATE INDEX IF NOT EXISTS draft_thread_index ON " + TABLE_NAME + " (" + THREAD_ID + ");",
};
public DraftDatabase(Context context, SQLiteOpenHelper databaseHelper) {
super(context, databaseHelper);
}
public void insertDrafts(MasterCipher masterCipher, long threadId, List<Draft> drafts) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
for (Draft draft : drafts) {
ContentValues values = new ContentValues(3);
values.put(THREAD_ID, threadId);
values.put(DRAFT_TYPE, masterCipher.encryptBody(draft.getType()));
values.put(DRAFT_VALUE, masterCipher.encryptBody(draft.getValue()));
db.insert(TABLE_NAME, null, values);
}
}
public void clearDrafts(long threadId) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.delete(TABLE_NAME, THREAD_ID + " = ?", new String[] {threadId+""});
}
public List<Draft> getDrafts(MasterCipher masterCipher, long threadId) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
List<Draft> results = new LinkedList<Draft>();
Cursor cursor = null;
try {
cursor = db.query(TABLE_NAME, null, THREAD_ID + " = ?", new String[] {threadId+""}, null, null, null);
while (cursor != null && cursor.moveToNext()) {
try {
String encryptedType = cursor.getString(cursor.getColumnIndexOrThrow(DRAFT_TYPE));
String encryptedValue = cursor.getString(cursor.getColumnIndexOrThrow(DRAFT_VALUE));
results.add(new Draft(masterCipher.decryptBody(encryptedType),
masterCipher.decryptBody(encryptedValue)));
} catch (InvalidMessageException ime) {
Log.w("DraftDatabase", ime);
}
}
return results;
} finally {
if (cursor != null)
cursor.close();
}
}
public static class Draft {
public static final String TEXT = "text";
public static final String IMAGE = "image";
public static final String VIDEO = "video";
public static final String AUDIO = "audio";
private final String type;
private final String value;
public Draft(String type, String value) {
this.type = type;
this.value = value;
}
public String getType() {
return type;
}
public String getValue() {
return value;
}
}
}

View File

@ -16,6 +16,8 @@
*/ */
package org.thoughtcrime.securesms.util; package org.thoughtcrime.securesms.util;
import android.widget.EditText;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@ -80,6 +82,14 @@ public class Util {
return executor; return executor;
} }
public static boolean isEmpty(String value) {
return value == null || value.trim().length() == 0;
}
public static boolean isEmpty(EditText value) {
return value == null || value.getText() == null || isEmpty(value.getText().toString());
}
// public static Bitmap loadScaledBitmap(InputStream src, int targetWidth, int targetHeight) { // public static Bitmap loadScaledBitmap(InputStream src, int targetWidth, int targetHeight) {
// return BitmapFactory.decodeStream(src); // return BitmapFactory.decodeStream(src);
//// BitmapFactory.Options options = new BitmapFactory.Options(); //// BitmapFactory.Options options = new BitmapFactory.Options();