From bf92de394b2ce576d04700f2267edb7b46dbc30c Mon Sep 17 00:00:00 2001 From: Moxie Marlinspike Date: Mon, 4 Feb 2013 00:13:07 -0800 Subject: [PATCH] Add support for resuming compose drafts. --- .../securesms/ConversationActivity.java | 79 ++++++++++++++ .../securesms/crypto/MasterSecret.java | 64 +++++++---- .../securesms/database/DatabaseFactory.java | 19 +++- .../securesms/database/DraftDatabase.java | 102 ++++++++++++++++++ src/org/thoughtcrime/securesms/util/Util.java | 10 ++ 5 files changed, 252 insertions(+), 22 deletions(-) create mode 100644 src/org/thoughtcrime/securesms/database/DraftDatabase.java diff --git a/src/org/thoughtcrime/securesms/ConversationActivity.java b/src/org/thoughtcrime/securesms/ConversationActivity.java index 718350d94e..f3471194fa 100644 --- a/src/org/thoughtcrime/securesms/ConversationActivity.java +++ b/src/org/thoughtcrime/securesms/ConversationActivity.java @@ -50,11 +50,15 @@ import org.thoughtcrime.securesms.crypto.AuthenticityCalculator; import org.thoughtcrime.securesms.crypto.KeyExchangeInitiator; import org.thoughtcrime.securesms.crypto.KeyExchangeProcessor; import org.thoughtcrime.securesms.crypto.KeyUtil; +import org.thoughtcrime.securesms.crypto.MasterCipher; import org.thoughtcrime.securesms.crypto.MasterSecret; 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.AttachmentTypeSelectorAdapter; import org.thoughtcrime.securesms.mms.MediaTooLargeException; +import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.mms.SlideDeck; import org.thoughtcrime.securesms.protocol.Tag; 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.InvalidMessageException; import org.thoughtcrime.securesms.util.MemoryCleaner; +import org.thoughtcrime.securesms.util.Util; import ws.com.google.android.mms.MmsException; @@ -171,6 +176,7 @@ public class ConversationActivity extends SherlockFragmentActivity protected void onDestroy() { unregisterReceiver(killActivityReceiver); unregisterReceiver(securityUpdateReceiver); + saveDraft(); MemoryCleaner.clean(masterSecret); super.onDestroy(); } @@ -422,6 +428,35 @@ public class ConversationActivity extends SherlockFragmentActivity if (draftText != null) composeText.setText(draftText); if (draftImage != null) addAttachmentImage(draftImage); if (draftAudio != null) addAttachmentAudio(draftAudio); + + if (draftText == null && draftImage == null && draftAudio == null) { + initializeDraftFromDatabase(); + } + } + + private void initializeDraftFromDatabase() { + new AsyncTask>() { + @Override + protected List doInBackground(Void... params) { + MasterCipher masterCipher = new MasterCipher(masterSecret); + DraftDatabase draftDatabase = DatabaseFactory.getDraftDatabase(ConversationActivity.this); + List results = draftDatabase.getDrafts(masterCipher, threadId); + + draftDatabase.clearDrafts(threadId); + + return results; + } + + @Override + protected void onPostExecute(List 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() { @@ -571,6 +606,50 @@ public class ConversationActivity extends SherlockFragmentActivity } } + private List getDraftsForCurrentState() { + List drafts = new LinkedList(); + + 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 drafts = getDraftsForCurrentState(); + + if (drafts.size() <= 0) + return; + + final long thisThreadId = this.threadId; + final MasterSecret thisMasterSecret = this.masterSecret.parcelClone(); + + new AsyncTask() { + @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() { int charactersSpent = composeText.getText().length(); CharacterCalculator.CharacterState characterState = characterCalculator.calculateCharacters(charactersSpent); diff --git a/src/org/thoughtcrime/securesms/crypto/MasterSecret.java b/src/org/thoughtcrime/securesms/crypto/MasterSecret.java index e303810cb6..4f6259ca9f 100644 --- a/src/org/thoughtcrime/securesms/crypto/MasterSecret.java +++ b/src/org/thoughtcrime/securesms/crypto/MasterSecret.java @@ -1,6 +1,6 @@ -/** +/** * Copyright (C) 2011 Whisper Systems - * + * * 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 * 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 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. - * + * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package org.thoughtcrime.securesms.crypto; -import javax.crypto.spec.SecretKeySpec; +import android.os.Parcel; +import android.os.Parcelable; import org.bouncycastle.util.Arrays; -import android.os.Parcel; -import android.os.Parcelable; -import android.util.Log; +import javax.crypto.spec.SecretKeySpec; /** * When a user first initializes TextSecure, a few secrets * are generated. These are: - * + * * 1) A 128bit symmetric encryption key. * 2) A 160bit symmetric MAC key. * 3) An ECC keypair. - * + * * The first two, along with the ECC keypair's private key, are * then encrypted on disk using PBE. - * + * * This class represents 1 and 2. - * + * * @author Moxie Marlinspike */ public class MasterSecret implements Parcelable { - + private final SecretKeySpec encryptionKey; private final SecretKeySpec macKey; public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + @Override public MasterSecret createFromParcel(Parcel in) { return new MasterSecret(in); } + @Override public MasterSecret[] newArray(int size) { return new MasterSecret[size]; } }; - + public MasterSecret(SecretKeySpec encryptionKey, SecretKeySpec macKey) { this.encryptionKey = encryptionKey; this.macKey = macKey; } - + private MasterSecret(Parcel in) { byte[] encryptionKeyBytes = new byte[in.readInt()]; in.readByteArray(encryptionKeyBytes); byte[] macKeyBytes = new byte[in.readInt()]; in.readByteArray(macKeyBytes); - + this.encryptionKey = new SecretKeySpec(encryptionKeyBytes, "AES"); this.macKey = new SecretKeySpec(macKeyBytes, "HmacSHA1"); - + // SecretKeySpec does an internal copy in its constructor. Arrays.fill(encryptionKeyBytes, (byte)0x00); Arrays.fill(macKeyBytes, (byte)0x00); } - + public SecretKeySpec getEncryptionKey() { return this.encryptionKey; } - + public SecretKeySpec getMacKey() { return this.macKey; } - + + @Override public void writeToParcel(Parcel out, int flags) { out.writeInt(encryptionKey.getEncoded().length); out.writeByteArray(encryptionKey.getEncoded()); out.writeInt(macKey.getEncoded().length); out.writeByteArray(macKey.getEncoded()); } - + + @Override public int describeContents() { 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; + } + } diff --git a/src/org/thoughtcrime/securesms/database/DatabaseFactory.java b/src/org/thoughtcrime/securesms/database/DatabaseFactory.java index 91d4faf540..29a9f49995 100644 --- a/src/org/thoughtcrime/securesms/database/DatabaseFactory.java +++ b/src/org/thoughtcrime/securesms/database/DatabaseFactory.java @@ -28,7 +28,8 @@ public class DatabaseFactory { private static final int INTRODUCED_IDENTITIES_VERSION = 2; private static final int INTRODUCED_INDEXES_VERSION = 3; 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 Object lock = new Object(); @@ -48,6 +49,7 @@ public class DatabaseFactory { private final MmsAddressDatabase mmsAddress; private final MmsSmsDatabase mmsSmsDatabase; private final IdentityDatabase identityDatabase; + private final DraftDatabase draftDatabase; public static DatabaseFactory getInstance(Context context) { synchronized (lock) { @@ -116,6 +118,10 @@ public class DatabaseFactory { return getInstance(context).identityDatabase; } + public static DraftDatabase getDraftDatabase(Context context) { + return getInstance(context).draftDatabase; + } + private DatabaseFactory(Context context) { this.databaseHelper = new DatabaseHelper(context, DATABASE_NAME, null, DATABASE_VERSION); this.sms = new SmsDatabase(context, databaseHelper); @@ -127,6 +133,7 @@ public class DatabaseFactory { this.mmsAddress = new MmsAddressDatabase(context, databaseHelper); this.mmsSmsDatabase = new MmsSmsDatabase(context, databaseHelper); this.identityDatabase = new IdentityDatabase(context, databaseHelper); + this.draftDatabase = new DraftDatabase(context, databaseHelper); } public void close() { @@ -149,12 +156,14 @@ public class DatabaseFactory { db.execSQL(ThreadDatabase.CREATE_TABLE); db.execSQL(MmsAddressDatabase.CREATE_TABLE); db.execSQL(IdentityDatabase.CREATE_TABLE); + db.execSQL(DraftDatabase.CREATE_TABLE); executeStatements(db, SmsDatabase.CREATE_INDEXS); executeStatements(db, MmsDatabase.CREATE_INDEXS); executeStatements(db, PartDatabase.CREATE_INDEXS); executeStatements(db, ThreadDatabase.CREATE_INDEXS); executeStatements(db, MmsAddressDatabase.CREATE_INDEXS); + executeStatements(db, DraftDatabase.CREATE_INDEXS); // db.execSQL(CanonicalAddress.CREATE_TABLE); } @@ -187,6 +196,14 @@ public class DatabaseFactory { db.setTransactionSuccessful(); 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) { diff --git a/src/org/thoughtcrime/securesms/database/DraftDatabase.java b/src/org/thoughtcrime/securesms/database/DraftDatabase.java new file mode 100644 index 0000000000..e0d9c7810f --- /dev/null +++ b/src/org/thoughtcrime/securesms/database/DraftDatabase.java @@ -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 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 getDrafts(MasterCipher masterCipher, long threadId) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + List results = new LinkedList(); + 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; + } + } +} diff --git a/src/org/thoughtcrime/securesms/util/Util.java b/src/org/thoughtcrime/securesms/util/Util.java index d6c71a479e..05fe61b45b 100644 --- a/src/org/thoughtcrime/securesms/util/Util.java +++ b/src/org/thoughtcrime/securesms/util/Util.java @@ -16,6 +16,8 @@ */ package org.thoughtcrime.securesms.util; +import android.widget.EditText; + import java.util.concurrent.ExecutorService; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; @@ -80,6 +82,14 @@ public class Util { 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) { // return BitmapFactory.decodeStream(src); //// BitmapFactory.Options options = new BitmapFactory.Options();