diff --git a/app/build.gradle b/app/build.gradle index c34f7ea338..8e2a5a4a37 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -202,6 +202,12 @@ android { debuggable false matchingFallbacks = ['debug'] } + mock { + initWith debug + isDefault false + minifyEnabled false + matchingFallbacks = ['debug'] + } } productFlavors { @@ -227,6 +233,15 @@ android { buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl" } + study { + dimension 'distribution' + + applicationIdSuffix ".study" + ext.websiteUpdateUrl = "null" + buildConfigField "boolean", "PLAY_STORE_DISABLED", "false" + buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl" + } + prod { dimension 'environment' @@ -266,6 +281,18 @@ android { } } + android.variantFilter { variant -> + def distribution = variant.getFlavors().get(0).name + def environment = variant.getFlavors().get(1).name + def buildType = variant.buildType.name + + if (distribution == 'study' && buildType != 'perf' && buildType != 'mock') { + variant.setIgnore(true) + } else if (distribution != 'study' && buildType == 'mock') { + variant.setIgnore(true) + } + } + lintOptions { abortOnError true baseline file("lint-baseline.xml") diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/KeyValueStore.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/KeyValueStore.java index 953e500e26..1706173d08 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/KeyValueStore.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/KeyValueStore.java @@ -6,6 +6,7 @@ import android.content.Context; import androidx.annotation.AnyThread; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import androidx.annotation.WorkerThread; import org.signal.core.util.concurrent.SignalExecutors; @@ -126,6 +127,15 @@ public final class KeyValueStore implements KeyValueReader { } } + /** + * Forces the store to re-fetch all of it's data from the database. + * Should only be used for testing! + */ + @VisibleForTesting + synchronized void resetCache() { + dataSet = null; + initializeIfNecessary(); + } private synchronized void write(@NonNull KeyValueDataSet newDataSet, @NonNull Collection removes) { initializeIfNecessary(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java index aaab35b719..8db6b32255 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java @@ -1,9 +1,9 @@ package org.thoughtcrime.securesms.keyvalue; import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; import androidx.preference.PreferenceDataStore; -import org.thoughtcrime.securesms.database.model.databaseprotos.Wallpaper; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler; @@ -70,6 +70,15 @@ public final class SignalStore { proxy().onFirstEverAppLaunch(); } + /** + * Forces the store to re-fetch all of it's data from the database. + * Should only be used for testing! + */ + @VisibleForTesting + public static void resetCache() { + INSTANCE.store.resetCache(); + } + public static @NonNull KbsValues kbsValues() { return INSTANCE.kbsValues; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SqlUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/SqlUtil.java index 506df0fddb..56c1f88c56 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SqlUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SqlUtil.java @@ -5,6 +5,8 @@ import android.database.Cursor; import androidx.annotation.NonNull; +import com.annimon.stream.Stream; + import net.sqlcipher.database.SQLiteDatabase; import org.thoughtcrime.securesms.recipients.RecipientId; @@ -13,13 +15,14 @@ import org.whispersystems.libsignal.util.guava.Preconditions; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; +import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Set; public final class SqlUtil { - private SqlUtil() {} + private SqlUtil() {} public static boolean tableExists(@NonNull SQLiteDatabase db, @NonNull String table) { try (Cursor cursor = db.rawQuery("SELECT name FROM sqlite_master WHERE type=? AND name=?", new String[] { "table", table })) { @@ -27,6 +30,28 @@ public final class SqlUtil { } } + public static @NonNull List getAllTables(@NonNull SQLiteDatabase db) { + List tables = new LinkedList<>(); + + try (Cursor cursor = db.rawQuery("SELECT name FROM sqlite_master WHERE type=?", new String[] { "table" })) { + while (cursor.moveToNext()) { + tables.add(cursor.getString(0)); + } + } + + return tables; + } + + /** + * Splits a multi-statement SQL block into independent statements. It is assumed that there is + * only one statement per line, and that each statement is terminated by a semi-colon. + */ + public static @NonNull List splitStatements(@NonNull String sql) { + return Stream.of(Arrays.asList(sql.split(";\n"))) + .map(String::trim) + .toList(); + } + public static boolean isEmpty(@NonNull SQLiteDatabase db, @NonNull String table) { try (Cursor cursor = db.rawQuery("SELECT COUNT(*) FROM " + table, null)) { if (cursor.moveToFirst()) { diff --git a/app/src/mock/AndroidManifest.xml b/app/src/mock/AndroidManifest.xml new file mode 100644 index 0000000000..8b035c9643 --- /dev/null +++ b/app/src/mock/AndroidManifest.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/app/src/mock/java/org/thoughtcrime/securesms/MockAppDataInitializer.java b/app/src/mock/java/org/thoughtcrime/securesms/MockAppDataInitializer.java new file mode 100644 index 0000000000..6194667476 --- /dev/null +++ b/app/src/mock/java/org/thoughtcrime/securesms/MockAppDataInitializer.java @@ -0,0 +1,101 @@ +package org.thoughtcrime.securesms; + +import android.app.Application; +import android.content.Context; + +import androidx.annotation.NonNull; + +import net.sqlcipher.database.SQLiteDatabase; + +import org.signal.core.util.StreamUtil; +import org.thoughtcrime.securesms.crypto.IdentityKeyUtil; +import org.thoughtcrime.securesms.crypto.MasterSecret; +import org.thoughtcrime.securesms.crypto.MasterSecretUtil; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.KeyValueDatabase; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.SetUtil; +import org.thoughtcrime.securesms.util.SqlUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.util.List; +import java.util.Set; + +/** + * Helper to initialize app state with the right database contents, shared prefs, etc. + */ +final class MockAppDataInitializer { + + private static final Set IGNORED_TABLES = SetUtil.newHashSet( + "sqlite_sequence", + "sms_fts", + "sms_fts_data", + "sms_fts_idx", + "sms_fts_docsize", + "sms_fts_config", + "mms_fts", + "mms_fts_data", + "mms_fts_idx", + "mms_fts_docsize", + "mms_fts_config" + ); + + public static void initialize(@NonNull Application application, @NonNull File sqlDirectory) throws IOException { + String localE164 = StreamUtil.readFullyAsString(new FileInputStream(new File(sqlDirectory, "e164.txt"))).trim(); + String mainSql = StreamUtil.readFullyAsString(new FileInputStream(new File(sqlDirectory, "signal.sql"))); + String keyValueSql = StreamUtil.readFullyAsString(new FileInputStream(new File(sqlDirectory, "signal-key-value.sql"))); + + initializeDatabase(DatabaseFactory.getInstance(application).getRawDatabase(), mainSql); + initializeDatabase(KeyValueDatabase.getInstance(application).getSqlCipherDatabase(), keyValueSql); + + initializePreferences(application, localE164); + + SignalStore.resetCache(); + } + + private static void initializeDatabase(@NonNull SQLiteDatabase db, @NonNull String sql) { + db.beginTransaction(); + try { + clearAllTables(db); + execStatements(db, sql); + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + private static void clearAllTables(@NonNull SQLiteDatabase db) { + List tables = SqlUtil.getAllTables(db); + + for (String table : tables) { + if (!IGNORED_TABLES.contains(table)) { + db.execSQL("DELETE FROM " + table); + } + } + } + + private static void execStatements(@NonNull SQLiteDatabase db, @NonNull String sql) { + List statements = SqlUtil.splitStatements(sql); + + for (String statement : statements) { + db.execSQL(statement); + } + } + + private static void initializePreferences(@NonNull Context context, @NonNull String localE164) { + MasterSecret masterSecret = MasterSecretUtil.generateMasterSecret(context, MasterSecretUtil.UNENCRYPTED_PASSPHRASE); + + MasterSecretUtil.generateAsymmetricMasterSecret(context, masterSecret); + IdentityKeyUtil.generateIdentityKeys(context); + + TextSecurePreferences.setPromptedPushRegistration(context, true); + TextSecurePreferences.setLocalNumber(context, localE164); + TextSecurePreferences.setLocalUuid(context, Recipient.external(context, localE164).requireUuid()); + TextSecurePreferences.setPushRegistered(context, true); + } +} diff --git a/app/src/mock/java/org/thoughtcrime/securesms/MockApplicationContext.java b/app/src/mock/java/org/thoughtcrime/securesms/MockApplicationContext.java new file mode 100644 index 0000000000..5686bccefe --- /dev/null +++ b/app/src/mock/java/org/thoughtcrime/securesms/MockApplicationContext.java @@ -0,0 +1,18 @@ +package org.thoughtcrime.securesms; + +import java.io.File; +import java.io.IOException; + +public class MockApplicationContext extends ApplicationContext { + + @Override + public void onCreate() { + super.onCreate(); + + try { + MockAppDataInitializer.initialize(this, new File(getExternalFilesDir(null), "mock-data")); + } catch (IOException e) { + throw new IllegalStateException("Failed to initialize mock data!", e); + } + } +} diff --git a/app/src/study/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/study/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000000..df74352da7 --- /dev/null +++ b/app/src/study/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/test/java/org/thoughtcrime/securesms/util/SqlUtilTest.java b/app/src/test/java/org/thoughtcrime/securesms/util/SqlUtilTest.java index ef874393b4..ba8f209f24 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/util/SqlUtilTest.java +++ b/app/src/test/java/org/thoughtcrime/securesms/util/SqlUtilTest.java @@ -10,6 +10,7 @@ import org.robolectric.annotation.Config; import java.util.Arrays; import java.util.Collections; +import java.util.List; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; @@ -114,4 +115,22 @@ public final class SqlUtilTest { public void buildCollectionQuery_none() { SqlUtil.buildCollectionQuery("a", Collections.emptyList()); } + + @Test + public void splitStatements_singleStatement() { + List result = SqlUtil.splitStatements("SELECT * FROM foo;\n"); + assertEquals(Arrays.asList("SELECT * FROM foo"), result); + } + + @Test + public void splitStatements_twoStatements() { + List result = SqlUtil.splitStatements("SELECT * FROM foo;\nSELECT * FROM bar;\n"); + assertEquals(Arrays.asList("SELECT * FROM foo", "SELECT * FROM bar"), result); + } + + @Test + public void splitStatements_twoStatementsSeparatedByNewLines() { + List result = SqlUtil.splitStatements("SELECT * FROM foo;\n\nSELECT * FROM bar;\n"); + assertEquals(Arrays.asList("SELECT * FROM foo", "SELECT * FROM bar"), result); + } }