From 4b5b9fbde8c55a0996f45d80299abc33e12392aa Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Fri, 10 Jan 2020 01:08:39 -0500 Subject: [PATCH] Add an encrypted key-value store. SignalStore is backed by SQLCipher and is intended to be used instead of TextSecurePreferences moving forward. --- .../securesms/ApplicationContext.java | 5 +- .../securesms/database/DatabaseFactory.java | 6 + .../securesms/database/KeyValueDatabase.java | 135 ++++++++++++ .../database/helpers/SQLCipherOpenHelper.java | 12 +- .../dependencies/ApplicationDependencies.java | 13 ++ .../ApplicationDependencyProvider.java | 6 + .../securesms/keyvalue/KeyValueDataSet.java | 129 +++++++++++ .../securesms/keyvalue/KeyValueReader.java | 12 ++ .../securesms/keyvalue/KeyValueStore.java | 199 +++++++++++++++++ .../securesms/keyvalue/SignalStore.java | 50 +++++ .../SignalUncaughtExceptionHandler.java | 24 +++ .../logging/UncaughtExceptionLogger.java | 21 -- .../keyvalue/KeyValueDataSetTest.java | 204 ++++++++++++++++++ 13 files changed, 791 insertions(+), 25 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/KeyValueDatabase.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/keyvalue/KeyValueDataSet.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/keyvalue/KeyValueReader.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/keyvalue/KeyValueStore.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/logging/SignalUncaughtExceptionHandler.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/logging/UncaughtExceptionLogger.java create mode 100644 app/src/test/java/org/thoughtcrime/securesms/keyvalue/KeyValueDataSetTest.java diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index 525510d073..c4d7dfae08 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -52,7 +52,7 @@ import org.thoughtcrime.securesms.logging.AndroidLogger; import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.logging.PersistentLogger; -import org.thoughtcrime.securesms.logging.UncaughtExceptionLogger; +import org.thoughtcrime.securesms.logging.SignalUncaughtExceptionHandler; import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil; import org.thoughtcrime.securesms.migrations.ApplicationMigrations; import org.thoughtcrime.securesms.notifications.MessageNotifier; @@ -70,7 +70,6 @@ import org.thoughtcrime.securesms.service.RotateSenderCertificateListener; import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener; import org.thoughtcrime.securesms.service.UpdateApkRefreshListener; import org.thoughtcrime.securesms.stickers.BlessedPacks; -import org.thoughtcrime.securesms.util.FrameRateTracker; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper; @@ -217,7 +216,7 @@ public class ApplicationContext extends MultiDexApplication implements DefaultLi private void initializeCrashHandling() { final Thread.UncaughtExceptionHandler originalHandler = Thread.getDefaultUncaughtExceptionHandler(); - Thread.setDefaultUncaughtExceptionHandler(new UncaughtExceptionLogger(originalHandler)); + Thread.setDefaultUncaughtExceptionHandler(new SignalUncaughtExceptionHandler(originalHandler)); } private void initializeApplicationMigrations() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java index ec0b075fad..307157a3c2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java @@ -60,6 +60,7 @@ public class DatabaseFactory { private final JobDatabase jobDatabase; private final StickerDatabase stickerDatabase; private final StorageKeyDatabase storageKeyDatabase; + private final KeyValueDatabase keyValueDatabase; public static DatabaseFactory getInstance(Context context) { synchronized (lock) { @@ -150,6 +151,10 @@ public class DatabaseFactory { return getInstance(context).storageKeyDatabase; } + public static KeyValueDatabase getKeyValueDatabase(Context context) { + return getInstance(context).keyValueDatabase; + } + public static SQLiteDatabase getBackupDatabase(Context context) { return getInstance(context).databaseHelper.getReadableDatabase(); } @@ -187,6 +192,7 @@ public class DatabaseFactory { this.jobDatabase = new JobDatabase(context, databaseHelper); this.stickerDatabase = new StickerDatabase(context, databaseHelper, attachmentSecret); this.storageKeyDatabase = new StorageKeyDatabase(context, databaseHelper); + this.keyValueDatabase = new KeyValueDatabase(context, databaseHelper); } public void onApplicationLevelUpgrade(@NonNull Context context, @NonNull MasterSecret masterSecret, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/KeyValueDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/KeyValueDatabase.java new file mode 100644 index 0000000000..fb0282f6aa --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/KeyValueDatabase.java @@ -0,0 +1,135 @@ +package org.thoughtcrime.securesms.database; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; + +import androidx.annotation.NonNull; + +import net.sqlcipher.database.SQLiteDatabase; + +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; +import org.thoughtcrime.securesms.keyvalue.KeyValueDataSet; + +import java.util.Collection; +import java.util.Map; + +public class KeyValueDatabase extends Database { + + private static final String TABLE_NAME = "key_value"; + + private static final String ID = "_id"; + private static final String KEY = "key"; + private static final String VALUE = "value"; + private static final String TYPE = "type"; + + public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + "(" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + + KEY + " TEXT UNIQUE, " + + VALUE + " TEXT, " + + TYPE + " INTEGER)"; + + KeyValueDatabase(Context context, SQLCipherOpenHelper databaseHelper) { + super(context, databaseHelper); + } + + public @NonNull KeyValueDataSet getDataSet() { + KeyValueDataSet dataSet = new KeyValueDataSet(); + + try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, null, null, null, null, null)){ + while (cursor != null && cursor.moveToNext()) { + Type type = Type.fromId(cursor.getInt(cursor.getColumnIndexOrThrow(TYPE))); + String key = cursor.getString(cursor.getColumnIndexOrThrow(KEY)); + + switch (type) { + case BLOB: + dataSet.putBlob(key, cursor.getBlob(cursor.getColumnIndexOrThrow(VALUE))); + break; + case BOOLEAN: + dataSet.putBoolean(key, cursor.getInt(cursor.getColumnIndexOrThrow(VALUE)) == 1); + break; + case FLOAT: + dataSet.putFloat(key, cursor.getFloat(cursor.getColumnIndexOrThrow(VALUE))); + break; + case INTEGER: + dataSet.putInteger(key, cursor.getInt(cursor.getColumnIndexOrThrow(VALUE))); + break; + case LONG: + dataSet.putLong(key, cursor.getLong(cursor.getColumnIndexOrThrow(VALUE))); + break; + case STRING: + dataSet.putString(key, cursor.getString(cursor.getColumnIndexOrThrow(VALUE))); + break; + } + } + } + + return dataSet; + } + + public void writeDataSet(@NonNull KeyValueDataSet dataSet, @NonNull Collection removes) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + db.beginTransaction(); + try { + for (Map.Entry entry : dataSet.getValues().entrySet()) { + String key = entry.getKey(); + Object value = entry.getValue(); + Class type = dataSet.getType(key); + + ContentValues contentValues = new ContentValues(3); + contentValues.put(KEY, key); + + if (type == byte[].class) { + contentValues.put(VALUE, (byte[]) value); + contentValues.put(TYPE, Type.BLOB.getId()); + } else if (type == Boolean.class) { + contentValues.put(VALUE, (boolean) value); + contentValues.put(TYPE, Type.BOOLEAN.getId()); + } else if (type == Float.class) { + contentValues.put(VALUE, (float) value); + contentValues.put(TYPE, Type.FLOAT.getId()); + } else if (type == Integer.class) { + contentValues.put(VALUE, (int) value); + contentValues.put(TYPE, Type.INTEGER.getId()); + } else if (type == Long.class) { + contentValues.put(VALUE, (long) value); + contentValues.put(TYPE, Type.LONG.getId()); + } else if (type == String.class) { + contentValues.put(VALUE, (String) value); + contentValues.put(TYPE, Type.STRING.getId()); + } else { + throw new AssertionError("Unknown type: " + type); + } + + db.insertWithOnConflict(TABLE_NAME, null, contentValues, SQLiteDatabase.CONFLICT_REPLACE); + } + + String deleteQuery = KEY + " = ?"; + for (String remove : removes) { + db.delete(TABLE_NAME, deleteQuery, new String[] { remove }); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + private enum Type { + BLOB(0), BOOLEAN(1), FLOAT(2), INTEGER(3), LONG(4), STRING(5); + + final int id; + + Type(int id) { + this.id = id; + } + + public int getId() { + return id; + } + + public static Type fromId(int id) { + return values()[id]; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index f35b3198ec..38b6e32ba9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -29,6 +29,7 @@ import org.thoughtcrime.securesms.database.GroupDatabase; import org.thoughtcrime.securesms.database.GroupReceiptDatabase; import org.thoughtcrime.securesms.database.IdentityDatabase; import org.thoughtcrime.securesms.database.JobDatabase; +import org.thoughtcrime.securesms.database.KeyValueDatabase; import org.thoughtcrime.securesms.database.MmsDatabase; import org.thoughtcrime.securesms.database.OneTimePreKeyDatabase; import org.thoughtcrime.securesms.database.PushDatabase; @@ -99,8 +100,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { private static final int STORAGE_SERVICE = 38; private static final int REACTIONS_UNREAD_INDEX = 39; private static final int RESUMABLE_DOWNLOADS = 40; + private static final int KEY_VALUE_STORE = 41; - private static final int DATABASE_VERSION = 40; + private static final int DATABASE_VERSION = 41; private static final String DATABASE_NAME = "signal.db"; private final Context context; @@ -142,6 +144,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { db.execSQL(SessionDatabase.CREATE_TABLE); db.execSQL(StickerDatabase.CREATE_TABLE); db.execSQL(StorageKeyDatabase.CREATE_TABLE); + db.execSQL(KeyValueDatabase.CREATE_TABLE); executeStatements(db, SearchDatabase.CREATE_TABLE); executeStatements(db, JobDatabase.CREATE_TABLE); @@ -684,6 +687,13 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { db.execSQL("ALTER TABLE part ADD COLUMN transfer_file TEXT DEFAULT NULL"); } + if (oldVersion < KEY_VALUE_STORE) { + db.execSQL("CREATE TABLE key_value (_id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "key TEXT UNIQUE, " + + "value TEXT, " + + "type INTEGER)"); + } + db.setTransactionSuccessful(); } finally { db.endTransaction(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java index a3b790ec20..dcfefe9283 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java @@ -8,6 +8,7 @@ import org.thoughtcrime.securesms.BuildConfig; import org.thoughtcrime.securesms.IncomingMessageProcessor; import org.thoughtcrime.securesms.gcm.MessageRetriever; import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.keyvalue.KeyValueStore; import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess; import org.thoughtcrime.securesms.recipients.LiveRecipientCache; import org.thoughtcrime.securesms.service.IncomingMessageObserver; @@ -41,6 +42,7 @@ public class ApplicationDependencies { private static LiveRecipientCache recipientCache; private static JobManager jobManager; private static FrameRateTracker frameRateTracker; + private static KeyValueStore keyValueStore; public static synchronized void init(@NonNull Application application, @NonNull Provider provider) { if (ApplicationDependencies.application != null || ApplicationDependencies.provider != null) { @@ -157,6 +159,16 @@ public class ApplicationDependencies { return frameRateTracker; } + public static synchronized @NonNull KeyValueStore getKeyValueStore() { + assertInitialization(); + + if (keyValueStore == null) { + keyValueStore = provider.provideKeyValueStore(); + } + + return keyValueStore; + } + private static void assertInitialization() { if (application == null || provider == null) { throw new UninitializedException(); @@ -173,6 +185,7 @@ public class ApplicationDependencies { @NonNull LiveRecipientCache provideRecipientCache(); @NonNull JobManager provideJobManager(); @NonNull FrameRateTracker provideFrameRateTracker(); + @NonNull KeyValueStore provideKeyValueStore(); } private static class UninitializedException extends IllegalStateException { diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java index 15b87b4a01..3cdbf68d27 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java @@ -18,6 +18,7 @@ import org.thoughtcrime.securesms.jobmanager.JobMigrator; import org.thoughtcrime.securesms.jobmanager.impl.JsonDataSerializer; import org.thoughtcrime.securesms.jobs.FastJobStorage; import org.thoughtcrime.securesms.jobs.JobManagerFactories; +import org.thoughtcrime.securesms.keyvalue.KeyValueStore; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.push.SecurityEventListener; import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess; @@ -119,6 +120,11 @@ public class ApplicationDependencyProvider implements ApplicationDependencies.Pr return new FrameRateTracker(context); } + @Override + public @NonNull KeyValueStore provideKeyValueStore() { + return new KeyValueStore(context); + } + private static class DynamicCredentialsProvider implements CredentialsProvider { private final Context context; diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/KeyValueDataSet.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/KeyValueDataSet.java new file mode 100644 index 0000000000..e79b154fa3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/KeyValueDataSet.java @@ -0,0 +1,129 @@ +package org.thoughtcrime.securesms.keyvalue; + +import androidx.annotation.NonNull; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + +public class KeyValueDataSet implements KeyValueReader { + private final Map values = new HashMap<>(); + private final Map types = new HashMap<>(); + + public void putBlob(@NonNull String key, byte[] value) { + values.put(key, value); + types.put(key, byte[].class); + } + + public void putBoolean(@NonNull String key, boolean value) { + values.put(key, value); + types.put(key, Boolean.class); + } + + public void putFloat(@NonNull String key, float value) { + values.put(key, value); + types.put(key, Float.class); + } + + public void putInteger(@NonNull String key, int value) { + values.put(key, value); + types.put(key, Integer.class); + } + + public void putLong(@NonNull String key, long value) { + values.put(key, value); + types.put(key, Long.class); + } + + public void putString(@NonNull String key, String value) { + values.put(key, value); + types.put(key, String.class); + } + + void putAll(@NonNull KeyValueDataSet other) { + values.putAll(other.values); + types.putAll(other.types); + } + + void removeAll(@NonNull Collection removes) { + for (String remove : removes) { + values.remove(remove); + types.remove(remove); + } + } + + @Override + public byte[] getBlob(@NonNull String key, byte[] defaultValue) { + if (containsKey(key)) { + return readValueAsType(key, byte[].class, true); + } else { + return defaultValue; + } + } + + @Override + public boolean getBoolean(@NonNull String key, boolean defaultValue) { + if (containsKey(key)) { + return readValueAsType(key, Boolean.class, false); + } else { + return defaultValue; + } + } + + @Override + public float getFloat(@NonNull String key, float defaultValue) { + if (containsKey(key)) { + return readValueAsType(key, Float.class, false); + } else { + return defaultValue; + } + } + + @Override + public int getInteger(@NonNull String key, int defaultValue) { + if (containsKey(key)) { + return readValueAsType(key, Integer.class, false); + } else { + return defaultValue; + } + } + + @Override + public long getLong(@NonNull String key, long defaultValue) { + if (containsKey(key)) { + return readValueAsType(key, Long.class, false); + } else { + return defaultValue; + } + } + + @Override + public String getString(@NonNull String key, String defaultValue) { + if (containsKey(key)) { + return readValueAsType(key, String.class, true); + } else { + return defaultValue; + } + } + + public @NonNull Map getValues() { + return values; + } + + public Class getType(@NonNull String key) { + return types.get(key); + } + + public boolean containsKey(@NonNull String key) { + return values.containsKey(key); + } + + private E readValueAsType(@NonNull String key, Class type, boolean nullable) { + Object value = values.get(key); + if ((value == null && nullable) || (value != null && value.getClass() == type)) { + return type.cast(value); + } else { + throw new IllegalArgumentException("Type mismatch!"); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/KeyValueReader.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/KeyValueReader.java new file mode 100644 index 0000000000..95a2c2393b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/KeyValueReader.java @@ -0,0 +1,12 @@ +package org.thoughtcrime.securesms.keyvalue; + +import androidx.annotation.NonNull; + +interface KeyValueReader { + byte[] getBlob(@NonNull String key, byte[] defaultValue); + boolean getBoolean(@NonNull String key, boolean defaultValue); + float getFloat(@NonNull String key, float defaultValue); + int getInteger(@NonNull String key, int defaultValue); + long getLong(@NonNull String key, long defaultValue); + String getString(@NonNull String key, String defaultValue); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/KeyValueStore.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/KeyValueStore.java new file mode 100644 index 0000000000..19c4e6eafa --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/KeyValueStore.java @@ -0,0 +1,199 @@ +package org.thoughtcrime.securesms.keyvalue; + +import android.content.Context; + +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.KeyValueDatabase; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.logging.SignalUncaughtExceptionHandler; +import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; + +/** + * An replacement for {@link android.content.SharedPreferences} that stores key-value pairs in our + * encrypted database. + * + * Implemented as a write-through cache that is safe to read and write to on the main thread. + * + * Writes are enqueued on a separate executor, but writes are finished up in + * {@link SignalUncaughtExceptionHandler}, meaning all write should finish barring a native crash + * or the system killing us unexpectedly (i.e. a force-stop). + */ +public final class KeyValueStore implements KeyValueReader { + + private static final String TAG = Log.tag(KeyValueStore.class); + + private final ExecutorService executor; + private final KeyValueDatabase database; + + private KeyValueDataSet dataSet; + + public KeyValueStore(@NonNull Context context) { + this.executor = SignalExecutors.newCachedSingleThreadExecutor("signal-KeyValueStore"); + this.database = DatabaseFactory.getKeyValueDatabase(context); + } + + @AnyThread + @Override + public synchronized byte[] getBlob(@NonNull String key, byte[] defaultValue) { + initializeIfNecessary(); + return dataSet.getBlob(key, defaultValue); + } + + @AnyThread + @Override + public synchronized boolean getBoolean(@NonNull String key, boolean defaultValue) { + initializeIfNecessary(); + return dataSet.getBoolean(key, defaultValue); + } + + @AnyThread + @Override + public synchronized float getFloat(@NonNull String key, float defaultValue) { + initializeIfNecessary(); + return dataSet.getFloat(key, defaultValue); + } + + @AnyThread + @Override + public synchronized int getInteger(@NonNull String key, int defaultValue) { + initializeIfNecessary(); + return dataSet.getInteger(key, defaultValue); + } + + @AnyThread + @Override + public synchronized long getLong(@NonNull String key, long defaultValue) { + initializeIfNecessary(); + return dataSet.getLong(key, defaultValue); + } + + @AnyThread + @Override + public synchronized String getString(@NonNull String key, String defaultValue) { + initializeIfNecessary(); + return dataSet.getString(key, defaultValue); + } + + /** + * @return A writer that allows writing and removing multiple entries in a single atomic + * transaction. + */ + @AnyThread + @NonNull Writer beginWrite() { + return new Writer(); + } + + /** + * @return A reader that lets you read from an immutable snapshot of the store, ensuring that data + * is consistent between reads. If you're only reading a single value, it is more + * efficient to use the various get* methods instead. + */ + @AnyThread + synchronized @NonNull KeyValueReader beginRead() { + initializeIfNecessary(); + + KeyValueDataSet copy = new KeyValueDataSet(); + copy.putAll(dataSet); + + return copy; + } + + /** + * Ensures that any pending writes (such as those made via {@link Writer#apply()}) are finished. + */ + @AnyThread + synchronized void blockUntilAllWritesFinished() { + CountDownLatch latch = new CountDownLatch(1); + + executor.execute(latch::countDown); + + try { + latch.await(); + } catch (InterruptedException e) { + Log.w(TAG, "Failed to wait for all writes."); + } + } + + + private synchronized void write(@NonNull KeyValueDataSet newDataSet, @NonNull Collection removes) { + initializeIfNecessary(); + + dataSet.putAll(newDataSet); + dataSet.removeAll(removes); + + executor.execute(() -> database.writeDataSet(newDataSet, removes)); + } + + private void initializeIfNecessary() { + if (dataSet != null) return; + this.dataSet = database.getDataSet(); + } + + class Writer { + private final KeyValueDataSet dataSet = new KeyValueDataSet(); + private final Set removes = new HashSet<>(); + + @NonNull Writer putBlob(@NonNull String key, @Nullable byte[] value) { + dataSet.putBlob(key, value); + return this; + } + + @NonNull Writer putBoolean(@NonNull String key, boolean value) { + dataSet.putBoolean(key, value); + return this; + } + + @NonNull Writer putFloat(@NonNull String key, float value) { + dataSet.putFloat(key, value); + return this; + } + + @NonNull Writer putInteger(@NonNull String key, int value) { + dataSet.putInteger(key, value); + return this; + } + + @NonNull Writer putLong(@NonNull String key, long value) { + dataSet.putLong(key, value); + return this; + } + + @NonNull Writer putString(@NonNull String key, String value) { + dataSet.putString(key, value); + return this; + } + + @NonNull Writer remove(@NonNull String key) { + removes.add(key); + return this; + } + + @AnyThread + void apply() { + for (String key : removes) { + if (dataSet.containsKey(key)) { + throw new IllegalStateException("Tried to remove a key while also setting it!"); + } + } + + write(dataSet, removes); + } + + @WorkerThread + void commit() { + apply(); + blockUntilAllWritesFinished(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java new file mode 100644 index 0000000000..7897e91888 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java @@ -0,0 +1,50 @@ +package org.thoughtcrime.securesms.keyvalue; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.logging.SignalUncaughtExceptionHandler; + +/** + * Simple, encrypted key-value store. + */ +public final class SignalStore { + + private SignalStore() {} + + /** + * Ensures any pending writes are finished. Only intended to be called by + * {@link SignalUncaughtExceptionHandler}. + */ + public static void blockUntilAllWritesFinished() { + getStore().blockUntilAllWritesFinished(); + } + + private static @NonNull KeyValueStore getStore() { + return ApplicationDependencies.getKeyValueStore(); + } + + private static void putBlob(@NonNull String key, byte[] value) { + getStore().beginWrite().putBlob(key, value).apply(); + } + + private static void putBoolean(@NonNull String key, boolean value) { + getStore().beginWrite().putBoolean(key, value).apply(); + } + + private static void putFloat(@NonNull String key, float value) { + getStore().beginWrite().putFloat(key, value).apply(); + } + + private static void putInteger(@NonNull String key, int value) { + getStore().beginWrite().putInteger(key, value).apply(); + } + + private static void putLong(@NonNull String key, long value) { + getStore().beginWrite().putLong(key, value).apply(); + } + + private static void putString(@NonNull String key, String value) { + getStore().beginWrite().putString(key, value).apply(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/logging/SignalUncaughtExceptionHandler.java b/app/src/main/java/org/thoughtcrime/securesms/logging/SignalUncaughtExceptionHandler.java new file mode 100644 index 0000000000..c17d491494 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/logging/SignalUncaughtExceptionHandler.java @@ -0,0 +1,24 @@ +package org.thoughtcrime.securesms.logging; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.keyvalue.SignalStore; + +public class SignalUncaughtExceptionHandler implements Thread.UncaughtExceptionHandler { + + private static final String TAG = SignalUncaughtExceptionHandler.class.getSimpleName(); + + private final Thread.UncaughtExceptionHandler originalHandler; + + public SignalUncaughtExceptionHandler(@NonNull Thread.UncaughtExceptionHandler originalHandler) { + this.originalHandler = originalHandler; + } + + @Override + public void uncaughtException(Thread t, Throwable e) { + Log.e(TAG, "", e); + SignalStore.blockUntilAllWritesFinished(); + Log.blockUntilAllWritesFinished(); + originalHandler.uncaughtException(t, e); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/logging/UncaughtExceptionLogger.java b/app/src/main/java/org/thoughtcrime/securesms/logging/UncaughtExceptionLogger.java deleted file mode 100644 index 0196132108..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/logging/UncaughtExceptionLogger.java +++ /dev/null @@ -1,21 +0,0 @@ -package org.thoughtcrime.securesms.logging; - -import androidx.annotation.NonNull; - -public class UncaughtExceptionLogger implements Thread.UncaughtExceptionHandler { - - private static final String TAG = UncaughtExceptionLogger.class.getSimpleName(); - - private final Thread.UncaughtExceptionHandler originalHandler; - - public UncaughtExceptionLogger(@NonNull Thread.UncaughtExceptionHandler originalHandler) { - this.originalHandler = originalHandler; - } - - @Override - public void uncaughtException(Thread t, Throwable e) { - Log.e(TAG, "", e); - Log.blockUntilAllWritesFinished(); - originalHandler.uncaughtException(t, e); - } -} diff --git a/app/src/test/java/org/thoughtcrime/securesms/keyvalue/KeyValueDataSetTest.java b/app/src/test/java/org/thoughtcrime/securesms/keyvalue/KeyValueDataSetTest.java new file mode 100644 index 0000000000..b3e72ce1ee --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/keyvalue/KeyValueDataSetTest.java @@ -0,0 +1,204 @@ +package org.thoughtcrime.securesms.keyvalue; + +import org.junit.Test; + +import java.util.Collections; + +import static junit.framework.TestCase.assertTrue; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; + +public class KeyValueDataSetTest { + + @Test + public void containsKey_generic() { + KeyValueDataSet subject = new KeyValueDataSet(); + subject.putBlob("a", new byte[0]); + subject.putBoolean("b", true); + subject.putFloat("c", 1.1f); + subject.putInteger("d", 3); + subject.putLong("e", 7L); + subject.putString("f", "spiderman"); + + assertTrue(subject.containsKey("a")); + assertTrue(subject.containsKey("b")); + assertTrue(subject.containsKey("c")); + assertTrue(subject.containsKey("d")); + assertTrue(subject.containsKey("e")); + assertTrue(subject.containsKey("f")); + assertFalse(subject.containsKey("venom")); + } + + @Test + public void getBlob_positive_nonNull() { + KeyValueDataSet subject = new KeyValueDataSet(); + subject.putBlob("key", new byte[] { 1 }); + + assertEquals(1, subject.getBlob("key", null).length); + assertEquals(1, subject.getBlob("key", null)[0]); + assertEquals(byte[].class, subject.getType("key")); + } + + @Test + public void getBlob_positive_null() { + KeyValueDataSet subject = new KeyValueDataSet(); + subject.putBlob("key", null); + + assertNull(subject.getBlob("key", new byte[0])); + assertEquals(byte[].class, subject.getType("key")); + } + + @Test + public void getBlob_default() { + KeyValueDataSet subject = new KeyValueDataSet(); + byte[] value = subject.getBlob("key", new byte[]{1}); + + assertEquals(1, value.length); + assertEquals(1, value[0]); + } + + @Test(expected = IllegalArgumentException.class) + public void getBlob_negative() { + KeyValueDataSet subject = new KeyValueDataSet(); + subject.putBlob("key", new byte[0]); + + subject.getInteger("key", 0); + } + + @Test + public void getBoolean_positive() { + KeyValueDataSet subject = new KeyValueDataSet(); + subject.putBoolean("key", true); + + assertTrue(subject.getBoolean("key", false)); + assertEquals(Boolean.class, subject.getType("key")); + } + + @Test(expected = IllegalArgumentException.class) + public void getBoolean_negative() { + KeyValueDataSet subject = new KeyValueDataSet(); + subject.putBoolean("key", true); + + subject.getInteger("key", 0); + } + + @Test + public void getBoolean_default() { + KeyValueDataSet subject = new KeyValueDataSet(); + assertTrue(subject.getBoolean("key", true)); + } + + @Test + public void getFloat_positive() { + KeyValueDataSet subject = new KeyValueDataSet(); + subject.putFloat("key", 1.1f); + + assertEquals(1.1f, subject.getFloat("key", 0), 0.1f); + assertEquals(Float.class, subject.getType("key")); + } + + @Test(expected = IllegalArgumentException.class) + public void getFloat_negative() { + KeyValueDataSet subject = new KeyValueDataSet(); + subject.putFloat("key", 1.1f); + + subject.getInteger("key", 0); + } + + @Test + public void getFloat_default() { + KeyValueDataSet subject = new KeyValueDataSet(); + assertEquals(1.1f, subject.getFloat("key", 1.1f), 0.1f); + } + + @Test + public void getInteger_positive() { + KeyValueDataSet subject = new KeyValueDataSet(); + subject.putInteger("key", 1); + + assertEquals(1, subject.getInteger("key", 0)); + assertEquals(Integer.class, subject.getType("key")); + } + + @Test(expected = IllegalArgumentException.class) + public void getInteger_negative() { + KeyValueDataSet subject = new KeyValueDataSet(); + subject.putInteger("key", 1); + + subject.getLong("key", 0); + } + + @Test + public void getInteger_default() { + KeyValueDataSet subject = new KeyValueDataSet(); + assertEquals(1, subject.getInteger("key", 1)); + } + + @Test + public void getLong_positive() { + KeyValueDataSet subject = new KeyValueDataSet(); + subject.putLong("key", 1); + + assertEquals(1, subject.getLong("key", 0)); + assertEquals(Long.class, subject.getType("key")); + } + + @Test(expected = IllegalArgumentException.class) + public void getLong_negative() { + KeyValueDataSet subject = new KeyValueDataSet(); + subject.putLong("key", 1L); + + subject.getInteger("key", 0); + } + + @Test + public void getLong_default() { + KeyValueDataSet subject = new KeyValueDataSet(); + assertEquals(1, subject.getLong("key", 1)); + } + + @Test + public void getString_positive_nonNull() { + KeyValueDataSet subject = new KeyValueDataSet(); + subject.putString("key", "spiderman"); + + assertEquals("spiderman", subject.getString("key", null)); + assertEquals(String.class, subject.getType("key")); + } + + @Test + public void getString_positive_null() { + KeyValueDataSet subject = new KeyValueDataSet(); + subject.putString("key", null); + + assertNull(subject.getString("key", "something")); + assertEquals(String.class, subject.getType("key")); + } + + @Test(expected = IllegalArgumentException.class) + public void getString_negative() { + KeyValueDataSet subject = new KeyValueDataSet(); + subject.putString("key", "spiderman"); + + subject.getInteger("key", 0); + } + + @Test + public void getString_default() { + KeyValueDataSet subject = new KeyValueDataSet(); + assertEquals("spiderman", subject.getString("key", "spiderman")); + } + + @Test + public void remove_generic() { + KeyValueDataSet subject = new KeyValueDataSet(); + subject.putInteger("key", 1); + + assertTrue(subject.containsKey("key")); + + subject.removeAll(Collections.singletonList("key")); + + assertFalse(subject.containsKey("key")); + } +}