Add initial sms exporter integration behind a feature flag.

This commit is contained in:
Alex Hart
2022-08-30 15:22:40 -03:00
committed by Greyson Parrelli
parent 1cc39fb89b
commit 936212e684
40 changed files with 1218 additions and 75 deletions

View File

@@ -1,16 +1,24 @@
package org.thoughtcrime.securesms.components.settings.app.chats.sms
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import android.os.Build
import android.provider.Settings
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.Navigation
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.exporter.flow.SmsExportActivity
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.SmsUtil
import org.thoughtcrime.securesms.util.Util
@@ -22,6 +30,7 @@ private const val SMS_REQUEST_CODE: Short = 1234
class SmsSettingsFragment : DSLSettingsFragment(R.string.preferences__sms_mms) {
private lateinit var viewModel: SmsSettingsViewModel
private lateinit var smsExportLauncher: ActivityResultLauncher<Intent>
override fun onResume() {
super.onResume()
@@ -29,6 +38,12 @@ class SmsSettingsFragment : DSLSettingsFragment(R.string.preferences__sms_mms) {
}
override fun bindAdapter(adapter: MappingAdapter) {
smsExportLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) {
showSmsRemovalDialog()
}
}
viewModel = ViewModelProvider(this)[SmsSettingsViewModel::class.java]
viewModel.state.observe(viewLifecycleOwner) {
@@ -42,6 +57,32 @@ class SmsSettingsFragment : DSLSettingsFragment(R.string.preferences__sms_mms) {
private fun getConfiguration(state: SmsSettingsState): DSLConfiguration {
return configure {
when (state.smsExportState) {
SmsSettingsState.SmsExportState.FETCHING -> Unit
SmsSettingsState.SmsExportState.HAS_UNEXPORTED_MESSAGES -> {
clickPref(
title = DSLSettingsText.from(R.string.SmsSettingsFragment__export_sms_messages),
onClick = {
smsExportLauncher.launch(SmsExportActivity.createIntent(requireContext()))
}
)
dividerPref()
}
SmsSettingsState.SmsExportState.ALL_MESSAGES_EXPORTED -> {
clickPref(
title = DSLSettingsText.from(R.string.SmsSettingsFragment__remove_sms_messages),
onClick = {
showSmsRemovalDialog()
}
)
dividerPref()
}
SmsSettingsState.SmsExportState.NO_SMS_MESSAGES_IN_DATABASE -> Unit
SmsSettingsState.SmsExportState.NOT_AVAILABLE -> Unit
}
@Suppress("DEPRECATION")
clickPref(
title = DSLSettingsText.from(R.string.SmsSettingsFragment__use_as_default_sms_app),
@@ -96,4 +137,19 @@ class SmsSettingsFragment : DSLSettingsFragment(R.string.preferences__sms_mms) {
startActivityForResult(intent, SMS_REQUEST_CODE.toInt())
}
private fun showSmsRemovalDialog() {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.RemoveSmsMessagesDialogFragment__remove_sms_messages)
.setMessage(R.string.RemoveSmsMessagesDialogFragment__you_have_changed)
.setPositiveButton(R.string.RemoveSmsMessagesDialogFragment__keep_messages) { _, _ -> }
.setNegativeButton(R.string.RemoveSmsMessagesDialogFragment__remove_messages) { _, _ ->
SignalExecutors.BOUNDED.execute {
SignalDatabase.sms.deleteExportedMessages()
SignalDatabase.mms.deleteExportedMessages()
}
Snackbar.make(requireView(), R.string.SmsSettingsFragment__sms_messages_removed, Snackbar.LENGTH_SHORT).show()
}
.show()
}
}

View File

@@ -0,0 +1,45 @@
package org.thoughtcrime.securesms.components.settings.app.chats.sms
import androidx.annotation.WorkerThread
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.util.FeatureFlags
class SmsSettingsRepository {
fun getSmsExportState(): Single<SmsSettingsState.SmsExportState> {
if (!FeatureFlags.smsExporter()) {
return Single.just(SmsSettingsState.SmsExportState.NOT_AVAILABLE)
}
return Single.fromCallable {
checkInsecureMessageCount() ?: checkUnexportedInsecureMessageCount()
}.subscribeOn(Schedulers.io())
}
@WorkerThread
private fun checkInsecureMessageCount(): SmsSettingsState.SmsExportState? {
val smsCount = SignalDatabase.sms.insecureMessageCount
val mmsCount = SignalDatabase.mms.insecureMessageCount
val totalSmsMmsCount = smsCount + mmsCount
return if (totalSmsMmsCount == 0) {
SmsSettingsState.SmsExportState.NO_SMS_MESSAGES_IN_DATABASE
} else {
null
}
}
@WorkerThread
private fun checkUnexportedInsecureMessageCount(): SmsSettingsState.SmsExportState {
val unexportedSmsCount = SignalDatabase.sms.unexportedInsecureMessages.use { it.count }
val unexportedMmsCount = SignalDatabase.mms.unexportedInsecureMessages.use { it.count }
val totalUnexportedCount = unexportedSmsCount + unexportedMmsCount
return if (totalUnexportedCount > 0) {
SmsSettingsState.SmsExportState.HAS_UNEXPORTED_MESSAGES
} else {
SmsSettingsState.SmsExportState.ALL_MESSAGES_EXPORTED
}
}
}

View File

@@ -3,5 +3,14 @@ package org.thoughtcrime.securesms.components.settings.app.chats.sms
data class SmsSettingsState(
val useAsDefaultSmsApp: Boolean,
val smsDeliveryReportsEnabled: Boolean,
val wifiCallingCompatibilityEnabled: Boolean
)
val wifiCallingCompatibilityEnabled: Boolean,
val smsExportState: SmsExportState = SmsExportState.FETCHING
) {
enum class SmsExportState {
FETCHING,
HAS_UNEXPORTED_MESSAGES,
ALL_MESSAGES_EXPORTED,
NO_SMS_MESSAGES_IN_DATABASE,
NOT_AVAILABLE
}
}

View File

@@ -2,6 +2,8 @@ package org.thoughtcrime.securesms.components.settings.app.chats.sms
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.Util
@@ -9,6 +11,9 @@ import org.thoughtcrime.securesms.util.livedata.Store
class SmsSettingsViewModel : ViewModel() {
private val repository = SmsSettingsRepository()
private val disposables = CompositeDisposable()
private val store = Store(
SmsSettingsState(
useAsDefaultSmsApp = Util.isDefaultSmsProvider(ApplicationDependencies.getApplication()),
@@ -19,6 +24,16 @@ class SmsSettingsViewModel : ViewModel() {
val state: LiveData<SmsSettingsState> = store.stateLiveData
init {
disposables += repository.getSmsExportState().subscribe { state ->
store.update { it.copy(smsExportState = state) }
}
}
override fun onCleared() {
disposables.clear()
}
fun setSmsDeliveryReportsEnabled(enabled: Boolean) {
store.update { it.copy(smsDeliveryReportsEnabled = enabled) }
SignalStore.settings().isSmsDeliveryReportsEnabled = enabled

View File

@@ -25,6 +25,7 @@ import org.thoughtcrime.securesms.database.model.ParentStoryId;
import org.thoughtcrime.securesms.database.model.SmsMessageRecord;
import org.thoughtcrime.securesms.database.model.StoryResult;
import org.thoughtcrime.securesms.database.model.StoryViewState;
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExportState;
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange;
import org.thoughtcrime.securesms.insights.InsightsConstants;
import org.thoughtcrime.securesms.mms.IncomingMediaMessage;
@@ -94,6 +95,9 @@ public abstract class MessageDatabase extends Database implements MmsSmsColumns
public abstract boolean isSent(long messageId);
public abstract List<MessageRecord> getProfileChangeDetailsRecords(long threadId, long afterTimestamp);
public abstract Set<Long> getAllRateLimitedMessageIds();
public abstract Cursor getUnexportedInsecureMessages();
public abstract int getInsecureMessageCount();
public abstract void deleteExportedMessages();
public abstract void markExpireStarted(long messageId);
public abstract void markExpireStarted(long messageId, long startTime);
@@ -353,6 +357,14 @@ public abstract class MessageDatabase extends Database implements MmsSmsColumns
return String.format(Locale.ENGLISH, "(%s OR %s) AND %s", isSent, isReceived, isSecure);
}
protected String getInsecureMessageClause() {
String isSent = "(" + getTypeField() + " & " + Types.BASE_TYPE_MASK + ") = " + Types.BASE_SENT_TYPE;
String isReceived = "(" + getTypeField() + " & " + Types.BASE_TYPE_MASK + ") = " + Types.BASE_INBOX_TYPE;
String isSecure = "(" + getTypeField() + " & " + (Types.SECURE_MESSAGE_BIT | Types.PUSH_MESSAGE_BIT) + ")";
return String.format(Locale.ENGLISH, "(%s OR %s) AND NOT %s", isSent, isReceived, isSecure);
}
public void setReactionsSeen(long threadId, long sinceTimestamp) {
SQLiteDatabase db = databaseHelper.getSignalWritableDatabase();
ContentValues values = new ContentValues();
@@ -803,6 +815,11 @@ public abstract class MessageDatabase extends Database implements MmsSmsColumns
@Deprecated
MessageRecord getCurrent();
/**
* Pulls the export state out of the query, if it is present.
*/
@NonNull MessageExportState getMessageExportStateForCurrentRecord();
/**
* From the {@link Closeable} interface, removing the IOException requirement.
*/

View File

@@ -63,6 +63,7 @@ import org.thoughtcrime.securesms.database.model.StoryType;
import org.thoughtcrime.securesms.database.model.StoryViewState;
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList;
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge;
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExportState;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange;
import org.thoughtcrime.securesms.jobs.TrimThreadJob;
@@ -189,7 +190,9 @@ public class MmsDatabase extends MessageDatabase {
RECEIPT_TIMESTAMP + " INTEGER DEFAULT -1, " +
MESSAGE_RANGES + " BLOB DEFAULT NULL, " +
STORY_TYPE + " INTEGER DEFAULT 0, " +
PARENT_STORY_ID + " INTEGER DEFAULT 0);";
PARENT_STORY_ID + " INTEGER DEFAULT 0, " +
EXPORT_STATE + " BLOB DEFAULT NULL, " +
EXPORTED + " INTEGER DEFAULT 0);";
public static final String[] CREATE_INDEXS = {
"CREATE INDEX IF NOT EXISTS mms_read_and_notified_and_thread_id_index ON " + TABLE_NAME + "(" + READ + "," + NOTIFIED + "," + THREAD_ID + ");",
@@ -1216,8 +1219,12 @@ public class MmsDatabase extends MessageDatabase {
}
private Cursor rawQuery(@NonNull String where, @Nullable String[] arguments, boolean reverse, long limit) {
return rawQuery(MMS_PROJECTION, where, arguments, reverse, limit);
}
private Cursor rawQuery(@NonNull String[] projection, @NonNull String where, @Nullable String[] arguments, boolean reverse, long limit) {
SQLiteDatabase database = databaseHelper.getSignalReadableDatabase();
String rawQueryString = "SELECT " + Util.join(MMS_PROJECTION, ",") +
String rawQueryString = "SELECT " + Util.join(projection, ",") +
" FROM " + MmsDatabase.TABLE_NAME + " LEFT OUTER JOIN " + AttachmentDatabase.TABLE_NAME +
" ON (" + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " = " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + ")" +
" WHERE " + where + " GROUP BY " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID;
@@ -2416,6 +2423,53 @@ public class MmsDatabase extends MessageDatabase {
return ids;
}
@Override
public Cursor getUnexportedInsecureMessages() {
return rawQuery(
SqlUtil.appendArg(MMS_PROJECTION, EXPORT_STATE),
getInsecureMessageClause() + " AND NOT " + EXPORTED,
null,
false,
0
);
}
@Override
public int getInsecureMessageCount() {
try (Cursor cursor = getWritableDatabase().query(TABLE_NAME, SqlUtil.COUNT, getInsecureMessageClause(), null, null, null, null)) {
if (cursor.moveToFirst()) {
return cursor.getInt(0);
}
}
return 0;
}
@Override
public void deleteExportedMessages() {
beginTransaction();
try {
List<Long> threadsToUpdate = new LinkedList<>();
try (Cursor cursor = getReadableDatabase().query(TABLE_NAME, THREAD_ID_PROJECTION, EXPORTED + " = ?", SqlUtil.buildArgs(1), THREAD_ID, null, null, null)) {
while (cursor.moveToNext()) {
threadsToUpdate.add(CursorUtil.requireLong(cursor, THREAD_ID));
}
}
getWritableDatabase().delete(TABLE_NAME, EXPORTED + " = ?", SqlUtil.buildArgs(1));
for (final long threadId : threadsToUpdate) {
SignalDatabase.threads().update(threadId, false);
}
SignalDatabase.attachments().deleteAbandonedAttachmentFiles();
setTransactionSuccessful();
} finally {
endTransaction();
}
}
@Override
void deleteThreads(@NonNull Set<Long> threadIds) {
Log.d(TAG, "deleteThreads(count: " + threadIds.size() + ")");
@@ -2664,6 +2718,25 @@ public class MmsDatabase extends MessageDatabase {
}
}
@Override
public @NonNull MessageExportState getMessageExportStateForCurrentRecord() {
byte[] messageExportState = CursorUtil.requireBlob(cursor, MmsDatabase.EXPORT_STATE);
if (messageExportState == null) {
return MessageExportState.getDefaultInstance();
}
try {
return MessageExportState.parseFrom(messageExportState);
} catch (InvalidProtocolBufferException e) {
return MessageExportState.getDefaultInstance();
}
}
public int getCount() {
if (cursor == null) return 0;
else return cursor.getCount();
}
private NotificationMmsMessageRecord getNotificationMmsMessageRecord(Cursor cursor) {
long id = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.ID));
long dateSent = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.NORMALIZED_DATE_SENT));

View File

@@ -29,6 +29,8 @@ public interface MmsSmsColumns {
public static final String REMOTE_DELETED = "remote_deleted";
public static final String SERVER_GUID = "server_guid";
public static final String RECEIPT_TIMESTAMP = "receipt_timestamp";
public static final String EXPORT_STATE = "export_state";
public static final String EXPORTED = "exported";
/**
* For storage efficiency, all types are stored within a single 64-bit integer column in the

View File

@@ -16,6 +16,7 @@
*/
package org.thoughtcrime.securesms.database;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
@@ -24,21 +25,23 @@ import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import com.annimon.stream.Stream;
import com.google.protobuf.InvalidProtocolBufferException;
import net.zetetic.database.sqlcipher.SQLiteQueryBuilder;
import org.signal.core.util.CursorUtil;
import org.signal.core.util.SqlUtil;
import org.signal.core.util.logging.Log;
import org.signal.libsignal.protocol.util.Pair;
import org.thoughtcrime.securesms.database.MessageDatabase.MessageUpdate;
import org.thoughtcrime.securesms.database.MessageDatabase.SyncMessageId;
import org.thoughtcrime.securesms.database.model.MessageId;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExportState;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.notifications.v2.DefaultMessageNotifier;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.signal.core.util.CursorUtil;
import org.signal.core.util.SqlUtil;
import org.whispersystems.signalservice.api.messages.multidevice.ReadMessage;
import java.io.Closeable;
@@ -50,6 +53,7 @@ import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import static org.thoughtcrime.securesms.database.MmsSmsColumns.Types.GROUP_V2_LEAVE_BITS;
@@ -613,6 +617,63 @@ public class MmsSmsDatabase extends Database {
SignalDatabase.mms().updateViewedStories(syncMessageIds);
}
private @NonNull MessageExportState getMessageExportState(@NonNull MessageId messageId) throws NoSuchMessageException {
String table = messageId.isMms() ? MmsDatabase.TABLE_NAME : SmsDatabase.TABLE_NAME;
String[] projection = SqlUtil.buildArgs(MmsSmsColumns.EXPORT_STATE);
String[] args = SqlUtil.buildArgs(messageId.getId());
try (Cursor cursor = getReadableDatabase().query(table, projection, ID_WHERE, args, null, null, null, null)) {
if (cursor.moveToFirst()) {
byte[] bytes = CursorUtil.requireBlob(cursor, MmsSmsColumns.EXPORT_STATE);
if (bytes == null) {
return MessageExportState.getDefaultInstance();
} else {
try {
return MessageExportState.parseFrom(bytes);
} catch (InvalidProtocolBufferException e) {
return MessageExportState.getDefaultInstance();
}
}
} else {
throw new NoSuchMessageException("The requested message does not exist.");
}
}
}
public void updateMessageExportState(@NonNull MessageId messageId, @NonNull Function<MessageExportState, MessageExportState> transform) throws NoSuchMessageException {
SQLiteDatabase database = getWritableDatabase();
database.beginTransaction();
try {
MessageExportState oldState = getMessageExportState(messageId);
MessageExportState newState = transform.apply(oldState);
setMessageExportState(messageId, newState);
database.setTransactionSuccessful();
} finally {
database.endTransaction();
}
}
public void markMessageExported(@NonNull MessageId messageId) {
String table = messageId.isMms() ? MmsDatabase.TABLE_NAME : SmsDatabase.TABLE_NAME;
ContentValues contentValues = new ContentValues(1);
contentValues.put(MmsSmsColumns.EXPORTED, 1);
getWritableDatabase().update(table, contentValues, ID_WHERE, SqlUtil.buildArgs(messageId.getId()));
}
private void setMessageExportState(@NonNull MessageId messageId, @NonNull MessageExportState messageExportState) {
String table = messageId.isMms() ? MmsDatabase.TABLE_NAME : SmsDatabase.TABLE_NAME;
ContentValues contentValues = new ContentValues(1);
contentValues.put(MmsSmsColumns.EXPORT_STATE, messageExportState.toByteArray());
getWritableDatabase().update(table, contentValues, ID_WHERE, SqlUtil.buildArgs(messageId.getId()));
}
/**
* @return Unhandled ids
*/

View File

@@ -29,6 +29,7 @@ import androidx.annotation.VisibleForTesting;
import com.annimon.stream.Stream;
import com.google.android.mms.pdu_alt.NotificationInd;
import com.google.protobuf.ByteString;
import com.google.protobuf.InvalidProtocolBufferException;
import net.zetetic.database.sqlcipher.SQLiteStatement;
@@ -47,6 +48,7 @@ import org.thoughtcrime.securesms.database.model.SmsMessageRecord;
import org.thoughtcrime.securesms.database.model.StoryResult;
import org.thoughtcrime.securesms.database.model.StoryViewState;
import org.thoughtcrime.securesms.database.model.databaseprotos.GroupCallUpdateDetails;
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExportState;
import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange;
@@ -66,15 +68,16 @@ import org.thoughtcrime.securesms.util.JsonUtils;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import java.io.Closeable;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
@@ -131,7 +134,9 @@ public class SmsDatabase extends MessageDatabase {
REMOTE_DELETED + " INTEGER DEFAULT 0, " +
NOTIFIED_TIMESTAMP + " INTEGER DEFAULT 0, " +
SERVER_GUID + " TEXT DEFAULT NULL, " +
RECEIPT_TIMESTAMP + " INTEGER DEFAULT -1);";
RECEIPT_TIMESTAMP + " INTEGER DEFAULT -1, " +
EXPORT_STATE + " BLOB DEFAULT NULL, " +
EXPORTED + " INTEGER DEFAULT 0);";
public static final String[] CREATE_INDEXS = {
"CREATE INDEX IF NOT EXISTS sms_read_and_notified_and_thread_id_index ON " + TABLE_NAME + "(" + READ + "," + NOTIFIED + "," + THREAD_ID + ");",
@@ -901,6 +906,51 @@ public class SmsDatabase extends MessageDatabase {
return ids;
}
@Override
public Cursor getUnexportedInsecureMessages() {
return queryMessages(
SqlUtil.appendArg(MESSAGE_PROJECTION, EXPORT_STATE),
getInsecureMessageClause() + " AND NOT " + EXPORTED,
null,
false,
-1
);
}
@Override
public int getInsecureMessageCount() {
try (Cursor cursor = getWritableDatabase().query(TABLE_NAME, SqlUtil.COUNT, getInsecureMessageClause(), null, null, null, null)) {
if (cursor.moveToFirst()) {
return cursor.getInt(0);
}
}
return 0;
}
@Override
public void deleteExportedMessages() {
beginTransaction();
try {
List<Long> threadsToUpdate = new LinkedList<>();
try (Cursor cursor = getReadableDatabase().query(TABLE_NAME, THREAD_ID_PROJECTION, EXPORTED + " = ?", SqlUtil.buildArgs(1), THREAD_ID, null, null, null)) {
while (cursor.moveToNext()) {
threadsToUpdate.add(CursorUtil.requireLong(cursor, THREAD_ID));
}
}
getWritableDatabase().delete(TABLE_NAME, EXPORTED + " = ?", SqlUtil.buildArgs(1));
for (final long threadId : threadsToUpdate) {
SignalDatabase.threads().update(threadId, false);
}
setTransactionSuccessful();
} finally {
endTransaction();
}
}
@Override
public List<MessageRecord> getProfileChangeDetailsRecords(long threadId, long afterTimestamp) {
String where = THREAD_ID + " = ? AND " + DATE_RECEIVED + " >= ? AND " + TYPE + " = ?";
@@ -1541,11 +1591,15 @@ public class SmsDatabase extends MessageDatabase {
}
}
private Cursor queryMessages(@NonNull String where, @NonNull String[] args, boolean reverse, long limit) {
private Cursor queryMessages(@NonNull String where, @Nullable String[] args, boolean reverse, long limit) {
return queryMessages(MESSAGE_PROJECTION, where, args, reverse, limit);
}
private Cursor queryMessages(@NonNull String[] projection, @NonNull String where, @Nullable String[] args, boolean reverse, long limit) {
SQLiteDatabase db = databaseHelper.getSignalReadableDatabase();
return db.query(TABLE_NAME,
MESSAGE_PROJECTION,
projection,
where,
args,
null,
@@ -1776,7 +1830,7 @@ public class SmsDatabase extends MessageDatabase {
}
}
public static class Reader implements Closeable {
public static class Reader implements MessageDatabase.Reader {
private final Cursor cursor;
private final Context context;
@@ -1798,6 +1852,20 @@ public class SmsDatabase extends MessageDatabase {
else return cursor.getCount();
}
@Override
public @NonNull MessageExportState getMessageExportStateForCurrentRecord() {
byte[] messageExportState = CursorUtil.requireBlob(cursor, SmsDatabase.EXPORT_STATE);
if (messageExportState == null) {
return MessageExportState.getDefaultInstance();
}
try {
return MessageExportState.parseFrom(messageExportState);
} catch (InvalidProtocolBufferException e) {
return MessageExportState.getDefaultInstance();
}
}
public SmsMessageRecord getCurrent() {
long messageId = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.ID));
long recipientId = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.RECIPIENT_ID));
@@ -1853,6 +1921,28 @@ public class SmsDatabase extends MessageDatabase {
public void close() {
cursor.close();
}
@Override
public @NonNull Iterator<MessageRecord> iterator() {
return new ReaderIterator();
}
private class ReaderIterator implements Iterator<MessageRecord> {
@Override
public boolean hasNext() {
return cursor != null && cursor.getCount() != 0 && !cursor.isLast();
}
@Override
public MessageRecord next() {
MessageRecord record = getNext();
if (record == null) {
throw new NoSuchElementException();
}
return record;
}
}
}
@VisibleForTesting

View File

@@ -29,6 +29,7 @@ import org.thoughtcrime.securesms.database.KeyValueDatabase
import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.database.helpers.migration.MyStoryMigration
import org.thoughtcrime.securesms.database.helpers.migration.PniSignaturesMigration
import org.thoughtcrime.securesms.database.helpers.migration.SmsExporterMigration
import org.thoughtcrime.securesms.database.helpers.migration.UrgentMslFlagMigration
import org.thoughtcrime.securesms.database.model.databaseprotos.ReactionList
import org.thoughtcrime.securesms.groups.GroupId
@@ -210,8 +211,9 @@ object SignalDatabaseMigrations {
private const val STORY_GROUP_TYPES = 152
private const val MY_STORY_MIGRATION_2 = 153
private const val PNI_SIGNATURES = 154
private const val SMS_EXPORTER = 155
const val DATABASE_VERSION = 154
const val DATABASE_VERSION = 155
@JvmStatic
fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
@@ -2696,6 +2698,10 @@ object SignalDatabaseMigrations {
if (oldVersion < PNI_SIGNATURES) {
PniSignaturesMigration.migrate(context, db, oldVersion, newVersion)
}
if (oldVersion < SMS_EXPORTER) {
SmsExporterMigration.migrate(context, db, oldVersion, newVersion)
}
}
@JvmStatic

View File

@@ -0,0 +1,16 @@
package org.thoughtcrime.securesms.database.helpers.migration
import android.app.Application
import net.zetetic.database.sqlcipher.SQLiteDatabase
/**
* Adds necessary book-keeping columns to SMS and MMS tables for SMS export.
*/
object SmsExporterMigration : SignalDatabaseMigration {
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
db.execSQL("ALTER TABLE mms ADD COLUMN export_state BLOB DEFAULT NULL")
db.execSQL("ALTER TABLE mms ADD COLUMN exported INTEGER DEFAULT 0")
db.execSQL("ALTER TABLE sms ADD COLUMN export_state BLOB DEFAULT NULL")
db.execSQL("ALTER TABLE sms ADD COLUMN exported INTEGER DEFAULT 0")
}
}

View File

@@ -0,0 +1,136 @@
package org.thoughtcrime.securesms.exporter
import android.database.Cursor
import org.signal.smsexporter.ExportableMessage
import org.signal.smsexporter.SmsExportState
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.database.MmsDatabase
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.SmsDatabase
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExportState
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.JsonUtils
import java.io.Closeable
import kotlin.time.Duration.Companion.milliseconds
class SignalSmsExportReader(
smsCursor: Cursor,
mmsCursor: Cursor
) : Iterable<ExportableMessage>, Closeable {
private val smsReader = SmsDatabase.readerFor(smsCursor)
private val mmsReader = MmsDatabase.readerFor(mmsCursor)
override fun iterator(): Iterator<ExportableMessage> {
return ExportableMessageIterator()
}
fun getCount(): Int {
return smsReader.count + mmsReader.count
}
override fun close() {
smsReader.close()
mmsReader.close()
}
private inner class ExportableMessageIterator : Iterator<ExportableMessage> {
private val smsIterator = smsReader.iterator()
private val mmsIterator = mmsReader.iterator()
override fun hasNext(): Boolean {
return smsIterator.hasNext() || mmsIterator.hasNext()
}
override fun next(): ExportableMessage {
return if (smsIterator.hasNext()) {
readExportableSmsMessageFromRecord(smsIterator.next())
} else if (mmsIterator.hasNext()) {
readExportableMmsMessageFromRecord(mmsIterator.next())
} else {
throw NoSuchElementException()
}
}
}
private fun readExportableMmsMessageFromRecord(record: MessageRecord): ExportableMessage {
val threadRecipient = SignalDatabase.threads.getRecipientForThreadId(record.threadId)!!
val addresses = if (threadRecipient.isMmsGroup) {
Recipient.resolvedList(threadRecipient.participantIds).map { it.requireSmsAddress() }.toSet()
} else {
setOf(threadRecipient.requireSmsAddress())
}
val parts: MutableList<ExportableMessage.Mms.Part> = mutableListOf()
if (record.body.isNotBlank()) {
parts.add(ExportableMessage.Mms.Part.Text(record.body))
}
if (record is MmsMessageRecord) {
val slideDeck = record.slideDeck
slideDeck.slides.forEach {
parts.add(
ExportableMessage.Mms.Part.Stream(
id = JsonUtils.toJson((it.asAttachment() as DatabaseAttachment).attachmentId),
contentType = it.contentType
)
)
}
}
val sender = if (record.isOutgoing) Recipient.self().requireSmsAddress() else record.individualRecipient.requireSmsAddress()
return ExportableMessage.Mms(
id = record.id.toString(),
exportState = mapExportState(mmsReader.messageExportStateForCurrentRecord),
addresses = addresses,
dateReceived = record.dateReceived.milliseconds,
dateSent = record.dateSent.milliseconds,
isRead = true,
isOutgoing = record.isOutgoing,
parts = parts,
sender = sender
)
}
private fun readExportableSmsMessageFromRecord(record: MessageRecord): ExportableMessage {
val threadRecipient = SignalDatabase.threads.getRecipientForThreadId(record.threadId)!!
return if (threadRecipient.isMmsGroup) {
readExportableMmsMessageFromRecord(record)
} else {
ExportableMessage.Sms(
id = record.id.toString(),
exportState = mapExportState(smsReader.messageExportStateForCurrentRecord),
address = record.recipient.requireSmsAddress(),
dateReceived = record.dateReceived.milliseconds,
dateSent = record.dateSent.milliseconds,
isRead = true,
isOutgoing = record.isOutgoing,
body = record.body
)
}
}
private fun mapExportState(messageExportState: MessageExportState): SmsExportState {
return SmsExportState(
messageId = messageExportState.messageId,
startedRecipients = messageExportState.startedRecipientsList.toSet(),
completedRecipients = messageExportState.completedRecipientsList.toSet(),
startedAttachments = messageExportState.startedAttachmentsList.toSet(),
completedAttachments = messageExportState.completedAttachmentsList.toSet(),
progress = messageExportState.progress.let {
when (it) {
MessageExportState.Progress.INIT -> SmsExportState.Progress.INIT
MessageExportState.Progress.STARTED -> SmsExportState.Progress.STARTED
MessageExportState.Progress.COMPLETED -> SmsExportState.Progress.COMPLETED
MessageExportState.Progress.UNRECOGNIZED -> SmsExportState.Progress.INIT
null -> SmsExportState.Progress.INIT
}
}
)
}
}

View File

@@ -0,0 +1,143 @@
package org.thoughtcrime.securesms.exporter
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import org.signal.smsexporter.ExportableMessage
import org.signal.smsexporter.SmsExportService
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExportState
import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.notifications.NotificationIds
import org.thoughtcrime.securesms.util.JsonUtils
import java.io.InputStream
/**
* Service which integrates the SMS exporter functionality.
*/
class SignalSmsExportService : SmsExportService() {
companion object {
/**
* Launches the export service and immediately begins exporting messages.
*/
fun start(context: Context) {
ContextCompat.startForegroundService(context, Intent(context, SignalSmsExportService::class.java))
}
}
private var reader: SignalSmsExportReader? = null
override fun getNotification(progress: Int, total: Int): ExportNotification {
return ExportNotification(
NotificationIds.SMS_EXPORT_SERVICE,
NotificationCompat.Builder(this, NotificationChannels.BACKUPS)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle(getString(R.string.SignalSmsExportService__exporting_messages))
.setProgress(total, progress, false)
.build()
)
}
override fun getUnexportedMessageCount(): Int {
ensureReader()
return reader!!.getCount()
}
override fun getUnexportedMessages(): Iterable<ExportableMessage> {
ensureReader()
return reader!!
}
override fun onMessageExportStarted(exportableMessage: ExportableMessage) {
SignalDatabase.mmsSms.updateMessageExportState(exportableMessage.getMessageId()) {
it.toBuilder().setProgress(MessageExportState.Progress.STARTED).build()
}
}
override fun onMessageExportSucceeded(exportableMessage: ExportableMessage) {
SignalDatabase.mmsSms.updateMessageExportState(exportableMessage.getMessageId()) {
it.toBuilder().setProgress(MessageExportState.Progress.COMPLETED).build()
}
SignalDatabase.mmsSms.markMessageExported(exportableMessage.getMessageId())
}
override fun onMessageExportFailed(exportableMessage: ExportableMessage) {
SignalDatabase.mmsSms.updateMessageExportState(exportableMessage.getMessageId()) {
it.toBuilder().setProgress(MessageExportState.Progress.INIT).build()
}
}
override fun onMessageIdCreated(exportableMessage: ExportableMessage, messageId: Long) {
SignalDatabase.mmsSms.updateMessageExportState(exportableMessage.getMessageId()) {
it.toBuilder().setMessageId(messageId).build()
}
}
override fun onAttachmentPartExportStarted(exportableMessage: ExportableMessage, part: ExportableMessage.Mms.Part) {
SignalDatabase.mmsSms.updateMessageExportState(exportableMessage.getMessageId()) {
it.toBuilder().addStartedAttachments(part.contentId).build()
}
}
override fun onAttachmentPartExportSucceeded(exportableMessage: ExportableMessage, part: ExportableMessage.Mms.Part) {
SignalDatabase.mmsSms.updateMessageExportState(exportableMessage.getMessageId()) {
it.toBuilder().addCompletedAttachments(part.contentId).build()
}
}
override fun onAttachmentPartExportFailed(exportableMessage: ExportableMessage, part: ExportableMessage.Mms.Part) {
SignalDatabase.mmsSms.updateMessageExportState(exportableMessage.getMessageId()) {
val startedAttachments = it.startedAttachmentsList - part.contentId
it.toBuilder().clearStartedAttachments().addAllStartedAttachments(startedAttachments).build()
}
}
override fun onRecipientExportStarted(exportableMessage: ExportableMessage, recipient: String) {
SignalDatabase.mmsSms.updateMessageExportState(exportableMessage.getMessageId()) {
it.toBuilder().addStartedRecipients(recipient).build()
}
}
override fun onRecipientExportSucceeded(exportableMessage: ExportableMessage, recipient: String) {
SignalDatabase.mmsSms.updateMessageExportState(exportableMessage.getMessageId()) {
it.toBuilder().addCompletedRecipients(recipient).build()
}
}
override fun onRecipientExportFailed(exportableMessage: ExportableMessage, recipient: String) {
SignalDatabase.mmsSms.updateMessageExportState(exportableMessage.getMessageId()) {
val startedAttachments = it.startedRecipientsList - recipient
it.toBuilder().clearStartedRecipients().addAllStartedRecipients(startedAttachments).build()
}
}
override fun getInputStream(part: ExportableMessage.Mms.Part): InputStream {
return SignalDatabase.attachments.getAttachmentStream(JsonUtils.fromJson(part.contentId, AttachmentId::class.java), 0)
}
override fun onExportPassCompleted() {
reader?.close()
}
private fun ExportableMessage.getMessageId(): MessageId {
return when (this) {
is ExportableMessage.Mms -> MessageId(id.toLong(), true)
is ExportableMessage.Sms -> MessageId(id.toLong(), false)
}
}
private fun ensureReader() {
if (reader == null) {
reader = SignalSmsExportReader(
smsCursor = SignalDatabase.sms.unexportedInsecureMessages,
mmsCursor = SignalDatabase.mms.unexportedInsecureMessages
)
}
}
}

View File

@@ -0,0 +1,52 @@
package org.thoughtcrime.securesms.exporter.flow
import android.app.Activity
import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
import org.signal.core.util.logging.Log
import org.signal.smsexporter.DefaultSmsHelper
import org.signal.smsexporter.ReleaseSmsAppFailure
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.databinding.ChooseANewDefaultSmsAppFragmentBinding
/**
* Fragment which can launch the user into picking an alternative
* SMS app, or give them instructions on how to do so manually.
*/
class ChooseANewDefaultSmsAppFragment : Fragment(R.layout.choose_a_new_default_sms_app_fragment) {
companion object {
private val TAG = Log.tag(ChooseANewDefaultSmsAppFragment::class.java)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val binding = ChooseANewDefaultSmsAppFragmentBinding.bind(view)
DefaultSmsHelper.releaseDefaultSms(requireContext()).either(
onSuccess = {
binding.continueButton.setOnClickListener { _ -> startActivity(it) }
},
onFailure = {
when (it) {
ReleaseSmsAppFailure.APP_IS_INELIGIBLE_TO_RELEASE_SMS_SELECTION -> {
Log.w(TAG, "App is ineligible to release sms selection")
binding.continueButton.setOnClickListener { requireActivity().finish() }
}
ReleaseSmsAppFailure.NO_METHOD_TO_RELEASE_SMS_AVIALABLE -> {
Log.w(TAG, "We can't navigate the user to a specific spot so we should display instructions instead.")
binding.continueButton.setOnClickListener { requireActivity().finish() }
}
}
}
)
}
override fun onResume() {
super.onResume()
if (!DefaultSmsHelper.isDefaultSms(requireContext())) {
requireActivity().setResult(Activity.RESULT_OK)
requireActivity().finish()
}
}
}

View File

@@ -0,0 +1,60 @@
package org.thoughtcrime.securesms.exporter.flow
import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import org.signal.smsexporter.BecomeSmsAppFailure
import org.signal.smsexporter.DefaultSmsHelper
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.databinding.ExportYourSmsMessagesFragmentBinding
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* "Welcome" screen for exporting sms
*/
class ExportYourSmsMessagesFragment : Fragment(R.layout.export_your_sms_messages_fragment) {
companion object {
private val REQUEST_CODE = 1
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val binding = ExportYourSmsMessagesFragmentBinding.bind(view)
binding.toolbar.setOnClickListener {
requireActivity().finish()
}
DefaultSmsHelper.becomeDefaultSms(requireContext()).either(
onSuccess = {
binding.continueButton.setOnClickListener { _ ->
startActivityForResult(it, REQUEST_CODE)
}
},
onFailure = {
when (it) {
BecomeSmsAppFailure.ALREADY_DEFAULT_SMS -> {
binding.continueButton.setOnClickListener {
navigateToExporter()
}
}
BecomeSmsAppFailure.ROLE_IS_NOT_AVAILABLE -> {
error("Should never happen.")
}
}
}
)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQUEST_CODE && DefaultSmsHelper.isDefaultSms(requireContext())) {
navigateToExporter()
}
}
private fun navigateToExporter() {
findNavController().safeNavigate(ExportYourSmsMessagesFragmentDirections.actionExportYourSmsMessagesFragmentToExportingSmsMessagesFragment())
}
}

View File

@@ -0,0 +1,46 @@
package org.thoughtcrime.securesms.exporter.flow
import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import org.signal.smsexporter.SmsExportProgress
import org.signal.smsexporter.SmsExportService
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.databinding.ExportingSmsMessagesFragmentBinding
import org.thoughtcrime.securesms.exporter.SignalSmsExportService
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* "Export in progress" fragment which should be displayed
* when we start exporting messages.
*/
class ExportingSmsMessagesFragment : Fragment(R.layout.exporting_sms_messages_fragment) {
private val lifecycleDisposable = LifecycleDisposable()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val binding = ExportingSmsMessagesFragmentBinding.bind(view)
lifecycleDisposable.bindTo(viewLifecycleOwner)
lifecycleDisposable += SmsExportService.progressState.observeOn(AndroidSchedulers.mainThread()).subscribe {
when (it) {
SmsExportProgress.Done -> {
findNavController().safeNavigate(ExportingSmsMessagesFragmentDirections.actionExportingSmsMessagesFragmentToChooseANewDefaultSmsAppFragment())
}
is SmsExportProgress.InProgress -> {
binding.progress.isIndeterminate = false
binding.progress.max = it.total
binding.progress.progress = it.progress
binding.progressLabel.text = getString(R.string.ExportingSmsMessagesFragment__exporting_d_of_d, it.progress, it.total)
}
SmsExportProgress.Init -> binding.progress.isIndeterminate = true
SmsExportProgress.Starting -> binding.progress.isIndeterminate = true
}
}
SignalSmsExportService.start(requireContext())
}
}

View File

@@ -0,0 +1,18 @@
package org.thoughtcrime.securesms.exporter.flow
import android.content.Context
import android.content.Intent
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.NavHostFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.FragmentWrapperActivity
class SmsExportActivity : FragmentWrapperActivity() {
override fun getFragment(): Fragment {
return NavHostFragment.create(R.navigation.sms_export)
}
companion object {
fun createIntent(context: Context): Intent = Intent(context, SmsExportActivity::class.java)
}
}

View File

@@ -19,6 +19,7 @@ public final class NotificationIds {
public static final int DEVICE_TRANSFER = 625420;
public static final int DONOR_BADGE_FAILURE = 630001;
public static final int FCM_FETCH = 630002;
public static final int SMS_EXPORT_SERVICE = 630003;
public static final int STORY_THREAD = 700000;
public static final int MESSAGE_DELIVERY_FAILURE = 800000;
public static final int STORY_MESSAGE_DELIVERY_FAILURE = 900000;

View File

@@ -102,6 +102,7 @@ public final class FeatureFlags {
private static final String CAMERAX_MODEL_BLOCKLIST = "android.cameraXModelBlockList";
private static final String RECIPIENT_MERGE_V2 = "android.recipientMergeV2";
private static final String CDS_V2_LOAD_TEST = "android.cdsV2LoadTest";
private static final String SMS_EXPORTER = "android.sms.exporter";
/**
* We will only store remote values for flags in this set. If you want a flag to be controllable
@@ -156,7 +157,8 @@ public final class FeatureFlags {
TELECOM_MODEL_BLOCKLIST,
CAMERAX_MODEL_BLOCKLIST,
RECIPIENT_MERGE_V2,
CDS_V2_LOAD_TEST
CDS_V2_LOAD_TEST,
SMS_EXPORTER
);
@VisibleForTesting
@@ -555,6 +557,16 @@ public final class FeatureFlags {
return getBoolean(CDS_V2_LOAD_TEST, false);
}
/**
* Whether or not we should enable the SMS exporter
*
* WARNING: This feature is under active development and is off for a reason. The exporter writes messages out to your
* system SMS / MMS database, and hasn't been adequately tested for public use. Don't enable this. You've been warned.
*/
public static boolean smsExporter() {
return getBoolean(SMS_EXPORTER, false);
}
/** Only for rendering debug info. */
public static synchronized @NonNull Map<String, Object> getMemoryValues() {
return new TreeMap<>(REMOTE_VALUES);