diff --git a/app/build.gradle b/app/build.gradle
index e243bd4ff..f1b5d8bec 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -470,6 +470,7 @@ dependencies {
implementation project(':donations')
implementation project(':contacts')
implementation project(':qr')
+ implementation project(':sms-exporter')
implementation libs.libsignal.android
implementation libs.google.protobuf.javalite
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a89067178..31ca4d9dd 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -670,6 +670,11 @@
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
+
+
+
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/sms/SmsSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/sms/SmsSettingsFragment.kt
index d08fb9244..ccbc051dc 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/sms/SmsSettingsFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/sms/SmsSettingsFragment.kt
@@ -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
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()
+ }
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/sms/SmsSettingsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/sms/SmsSettingsRepository.kt
new file mode 100644
index 000000000..a500e5fa8
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/sms/SmsSettingsRepository.kt
@@ -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 {
+ 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
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/sms/SmsSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/sms/SmsSettingsState.kt
index 98184b7c9..cdc7ac343 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/sms/SmsSettingsState.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/sms/SmsSettingsState.kt
@@ -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
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/sms/SmsSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/sms/SmsSettingsViewModel.kt
index 54faaea65..96faadd34 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/sms/SmsSettingsViewModel.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/sms/SmsSettingsViewModel.kt
@@ -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 = 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
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java
index 0e0c1577d..dc3a3e8af 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java
@@ -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 getProfileChangeDetailsRecords(long threadId, long afterTimestamp);
public abstract Set 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.
*/
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java
index c9ebd6a80..9182628d9 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java
@@ -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 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 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));
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java
index 6a4952799..885ba5b62 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java
@@ -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
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java
index a09896037..2816e3875 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java
@@ -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 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
*/
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java
index 5b49381a5..7518cf24a 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java
@@ -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 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 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 iterator() {
+ return new ReaderIterator();
+ }
+
+ private class ReaderIterator implements Iterator {
+ @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
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt
index 8f45e52b4..503174968 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt
@@ -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
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/SmsExporterMigration.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/SmsExporterMigration.kt
new file mode 100644
index 000000000..9453f66b0
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/SmsExporterMigration.kt
@@ -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")
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/exporter/SignalSmsExportReader.kt b/app/src/main/java/org/thoughtcrime/securesms/exporter/SignalSmsExportReader.kt
new file mode 100644
index 000000000..30af01eb6
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/exporter/SignalSmsExportReader.kt
@@ -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, Closeable {
+
+ private val smsReader = SmsDatabase.readerFor(smsCursor)
+ private val mmsReader = MmsDatabase.readerFor(mmsCursor)
+
+ override fun iterator(): Iterator {
+ return ExportableMessageIterator()
+ }
+
+ fun getCount(): Int {
+ return smsReader.count + mmsReader.count
+ }
+
+ override fun close() {
+ smsReader.close()
+ mmsReader.close()
+ }
+
+ private inner class ExportableMessageIterator : Iterator {
+
+ 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 = 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
+ }
+ }
+ )
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/exporter/SignalSmsExportService.kt b/app/src/main/java/org/thoughtcrime/securesms/exporter/SignalSmsExportService.kt
new file mode 100644
index 000000000..4cb6f8797
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/exporter/SignalSmsExportService.kt
@@ -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 {
+ 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
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/exporter/flow/ChooseANewDefaultSmsAppFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/exporter/flow/ChooseANewDefaultSmsAppFragment.kt
new file mode 100644
index 000000000..2a1144fb0
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/exporter/flow/ChooseANewDefaultSmsAppFragment.kt
@@ -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()
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/exporter/flow/ExportYourSmsMessagesFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/exporter/flow/ExportYourSmsMessagesFragment.kt
new file mode 100644
index 000000000..6080abf9d
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/exporter/flow/ExportYourSmsMessagesFragment.kt
@@ -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())
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/exporter/flow/ExportingSmsMessagesFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/exporter/flow/ExportingSmsMessagesFragment.kt
new file mode 100644
index 000000000..25b5d2373
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/exporter/flow/ExportingSmsMessagesFragment.kt
@@ -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())
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/exporter/flow/SmsExportActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/exporter/flow/SmsExportActivity.kt
new file mode 100644
index 000000000..7be09db06
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/exporter/flow/SmsExportActivity.kt
@@ -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)
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java
index 703e649c8..095cdef95 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java
@@ -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;
diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java
index 0592753c0..385c9a68d 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java
@@ -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 getMemoryValues() {
return new TreeMap<>(REMOTE_VALUES);
diff --git a/app/src/main/proto/Database.proto b/app/src/main/proto/Database.proto
index 70ed54c67..92698e710 100644
--- a/app/src/main/proto/Database.proto
+++ b/app/src/main/proto/Database.proto
@@ -246,3 +246,19 @@ message PendingChangeNumberMetadata {
int32 pniRegistrationId = 3;
int32 pniSignedPreKeyId = 4;
}
+
+message MessageExportState {
+
+ enum Progress {
+ INIT = 0;
+ STARTED = 1;
+ COMPLETED = 2;
+ }
+
+ int64 messageId = 1;
+ repeated string startedRecipients = 2;
+ repeated string completedRecipients = 3;
+ repeated string startedAttachments = 4;
+ repeated string completedAttachments = 5;
+ Progress progress = 6;
+}
\ No newline at end of file
diff --git a/app/src/main/res/layout/choose_a_new_default_sms_app_fragment.xml b/app/src/main/res/layout/choose_a_new_default_sms_app_fragment.xml
new file mode 100644
index 000000000..c128a6b2d
--- /dev/null
+++ b/app/src/main/res/layout/choose_a_new_default_sms_app_fragment.xml
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/export_your_sms_messages_fragment.xml b/app/src/main/res/layout/export_your_sms_messages_fragment.xml
new file mode 100644
index 000000000..9b54526fe
--- /dev/null
+++ b/app/src/main/res/layout/export_your_sms_messages_fragment.xml
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/exporting_sms_messages_fragment.xml b/app/src/main/res/layout/exporting_sms_messages_fragment.xml
new file mode 100644
index 000000000..f833c64e0
--- /dev/null
+++ b/app/src/main/res/layout/exporting_sms_messages_fragment.xml
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/navigation/sms_export.xml b/app/src/main/res/navigation/sms_export.xml
new file mode 100644
index 000000000..82f3e20d2
--- /dev/null
+++ b/app/src/main/res/navigation/sms_export.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index ddc3b8be4..bcd42c614 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -3915,6 +3915,12 @@
Use as default SMS app
+
+ Export SMS messages
+
+ Remove SMS messages
+
+ SMS messages removed
Messages
@@ -5158,6 +5164,38 @@
Overflow menu
+
+
+ Exporting messages…
+
+
+
+ Export your SMS messages
+
+ Continue
+
+
+
+ Exporting SMS messages
+
+ Exporting %1$d of %2$d…
+
+
+
+ Choose a new default SMS app
+
+ Continue
+
+
+
+ Keep messages
+
+ Remove messages
+
+ Remove SMS messages from Signal?
+
+ You have changed the default SMS app, do you want to remove SMS messages from Signal?
+
diff --git a/sms-exporter/app/src/main/java/org/signal/smsexporter/app/MainActivity.kt b/sms-exporter/app/src/main/java/org/signal/smsexporter/app/MainActivity.kt
index 93e9c46a5..a32fd2503 100644
--- a/sms-exporter/app/src/main/java/org/signal/smsexporter/app/MainActivity.kt
+++ b/sms-exporter/app/src/main/java/org/signal/smsexporter/app/MainActivity.kt
@@ -70,7 +70,7 @@ class MainActivity : AppCompatActivity(R.layout.main_activity) {
DefaultSmsHelper.releaseDefaultSms(this).either(
onFailure = {
when (it) {
- ReleaseSmsAppFailure.APP_IS_INELIGABLE_TO_RELEASE_SMS_SELECTION -> onAppIsIneligableForReleaseSmsSelection()
+ ReleaseSmsAppFailure.APP_IS_INELIGIBLE_TO_RELEASE_SMS_SELECTION -> onAppIsIneligibleForReleaseSmsSelection()
ReleaseSmsAppFailure.NO_METHOD_TO_RELEASE_SMS_AVIALABLE -> onNoMethodToReleaseSmsAvailable()
}
},
@@ -126,7 +126,7 @@ class MainActivity : AppCompatActivity(R.layout.main_activity) {
startActivityForResult(intent, 2)
}
- private fun onAppIsIneligableForReleaseSmsSelection() {
+ private fun onAppIsIneligibleForReleaseSmsSelection() {
if (!DefaultSmsHelper.isDefaultSms(this)) {
Toast.makeText(this, "Already not the SMS manager.", Toast.LENGTH_SHORT).show()
} else {
diff --git a/sms-exporter/app/src/main/java/org/signal/smsexporter/app/TestSmsExportService.kt b/sms-exporter/app/src/main/java/org/signal/smsexporter/app/TestSmsExportService.kt
index 7aecd9d53..868598916 100644
--- a/sms-exporter/app/src/main/java/org/signal/smsexporter/app/TestSmsExportService.kt
+++ b/sms-exporter/app/src/main/java/org/signal/smsexporter/app/TestSmsExportService.kt
@@ -8,6 +8,7 @@ import org.signal.smsexporter.ExportableMessage
import org.signal.smsexporter.SmsExportService
import org.signal.smsexporter.SmsExportState
import java.io.InputStream
+import kotlin.time.Duration.Companion.seconds
class TestSmsExportService : SmsExportService() {
@@ -32,10 +33,6 @@ class TestSmsExportService : SmsExportService() {
)
}
- override fun getExportState(exportableMessage: ExportableMessage): SmsExportState {
- return SmsExportState()
- }
-
override fun getUnexportedMessageCount(): Int {
return 50
}
@@ -92,10 +89,6 @@ class TestSmsExportService : SmsExportService() {
return BitmapGenerator.getStream()
}
- override fun onAttachmentWrittenToDisk(exportableMessage: ExportableMessage, part: ExportableMessage.Mms.Part) {
- Log.d(TAG, "onAttachmentWrittenToDisk() called with: exportableMessage = $exportableMessage, attachment = $part")
- }
-
override fun onExportPassCompleted() {
Log.d(TAG, "onExportPassCompleted() called")
}
@@ -137,9 +130,10 @@ class TestSmsExportService : SmsExportService() {
val address = addresses.random()
return ExportableMessage.Mms(
id = "$it",
+ exportState = SmsExportState(),
addresses = addresses,
- dateSent = startTime + it - 1,
- dateReceived = startTime + it,
+ dateSent = (startTime + it - 1).seconds,
+ dateReceived = (startTime + it).seconds,
isRead = true,
isOutgoing = address == me,
sender = address,
@@ -153,10 +147,11 @@ class TestSmsExportService : SmsExportService() {
private fun getSmsMessage(it: Int): ExportableMessage.Sms {
return ExportableMessage.Sms(
id = it.toString(),
+ exportState = SmsExportState(),
address = "+15065550102",
body = "Hello, World! $it",
- dateSent = startTime + it - 1,
- dateReceived = startTime + it,
+ dateSent = (startTime + it - 1).seconds,
+ dateReceived = (startTime + it).seconds,
isRead = true,
isOutgoing = it % 4 == 0
)
diff --git a/sms-exporter/lib/src/main/java/org/signal/smsexporter/ExportableMessage.kt b/sms-exporter/lib/src/main/java/org/signal/smsexporter/ExportableMessage.kt
index 2e763178d..3ff59d180 100644
--- a/sms-exporter/lib/src/main/java/org/signal/smsexporter/ExportableMessage.kt
+++ b/sms-exporter/lib/src/main/java/org/signal/smsexporter/ExportableMessage.kt
@@ -1,18 +1,27 @@
package org.signal.smsexporter
+import kotlin.time.Duration
+
/**
* Represents an exportable MMS or SMS message
*/
sealed interface ExportableMessage {
+ /**
+ * This represents the initial exportState of the message, and it is *not* updated as
+ * the message moves through processing.
+ */
+ val exportState: SmsExportState
+
/**
* An exportable SMS message
*/
data class Sms(
val id: String,
+ override val exportState: SmsExportState,
val address: String,
- val dateReceived: Long,
- val dateSent: Long,
+ val dateReceived: Duration,
+ val dateSent: Duration,
val isRead: Boolean,
val isOutgoing: Boolean,
val body: String
@@ -23,9 +32,10 @@ sealed interface ExportableMessage {
*/
data class Mms(
val id: String,
+ override val exportState: SmsExportState,
val addresses: Set,
- val dateReceived: Long,
- val dateSent: Long,
+ val dateReceived: Duration,
+ val dateSent: Duration,
val isRead: Boolean,
val isOutgoing: Boolean,
val parts: List,
diff --git a/sms-exporter/lib/src/main/java/org/signal/smsexporter/ReleaseSmsAppFailure.kt b/sms-exporter/lib/src/main/java/org/signal/smsexporter/ReleaseSmsAppFailure.kt
index fa3f52ffa..80809ed15 100644
--- a/sms-exporter/lib/src/main/java/org/signal/smsexporter/ReleaseSmsAppFailure.kt
+++ b/sms-exporter/lib/src/main/java/org/signal/smsexporter/ReleaseSmsAppFailure.kt
@@ -4,7 +4,7 @@ enum class ReleaseSmsAppFailure {
/**
* Occurs when we are not the default sms app
*/
- APP_IS_INELIGABLE_TO_RELEASE_SMS_SELECTION,
+ APP_IS_INELIGIBLE_TO_RELEASE_SMS_SELECTION,
/**
* No good way to release sms. Have to instruct user manually.
diff --git a/sms-exporter/lib/src/main/java/org/signal/smsexporter/SmsExportService.kt b/sms-exporter/lib/src/main/java/org/signal/smsexporter/SmsExportService.kt
index b5cf31b67..c49625000 100644
--- a/sms-exporter/lib/src/main/java/org/signal/smsexporter/SmsExportService.kt
+++ b/sms-exporter/lib/src/main/java/org/signal/smsexporter/SmsExportService.kt
@@ -48,6 +48,7 @@ abstract class SmsExportService : Service() {
private fun startExport() {
if (isStarted) {
+ Log.d(TAG, "Already running exporter.")
return
}
@@ -61,7 +62,7 @@ abstract class SmsExportService : Service() {
executor.execute {
val totalCount = getUnexportedMessageCount()
getUnexportedMessages().forEach { message ->
- val exportState = getExportState(message)
+ val exportState = message.exportState
if (exportState.progress != SmsExportState.Progress.COMPLETED) {
when (message) {
is ExportableMessage.Sms -> exportSms(exportState, message)
@@ -94,13 +95,6 @@ abstract class SmsExportService : Service() {
*/
protected abstract fun getNotification(progress: Int, total: Int): ExportNotification
- /**
- * Gets the initial export state. This is only called once per message, before any processing
- * is done. It is used as a "known" state value, and via the onX methods below, it is up to the
- * application to properly update the underlying data structure when changes occur.
- */
- protected abstract fun getExportState(exportableMessage: ExportableMessage): SmsExportState
-
/**
* Gets the total number of messages to process. This is only used for the notification and
* progress events.
@@ -118,7 +112,9 @@ abstract class SmsExportService : Service() {
protected abstract fun onMessageExportStarted(exportableMessage: ExportableMessage)
/**
- * We've completely succeeded exporting a given MMS / SMS message
+ * We've completely succeeded exporting a given MMS / SMS message. This is only
+ * called when all parts of the message (including recipients and attachments) have
+ * been completely exported.
*/
protected abstract fun onMessageExportSucceeded(exportableMessage: ExportableMessage)
@@ -138,7 +134,8 @@ abstract class SmsExportService : Service() {
protected abstract fun onAttachmentPartExportStarted(exportableMessage: ExportableMessage, part: ExportableMessage.Mms.Part)
/**
- * We've successfully exported the attachment part for a given message
+ * We've successfully exported the attachment part for a given message and written the
+ * attachment file to the local filesystem.
*/
protected abstract fun onAttachmentPartExportSucceeded(exportableMessage: ExportableMessage, part: ExportableMessage.Mms.Part)
@@ -167,14 +164,11 @@ abstract class SmsExportService : Service() {
*/
protected abstract fun getInputStream(part: ExportableMessage.Mms.Part): InputStream
- /**
- * Called after the attachment is successfully written to disk.
- */
- protected abstract fun onAttachmentWrittenToDisk(exportableMessage: ExportableMessage, part: ExportableMessage.Mms.Part)
-
/**
* Called when an export pass completes. It is up to the implementation to determine whether
- * there are still messages to export.
+ * there are still messages to export. This is where the system could initiate a multiple-pass
+ * system to ensure all messages are exported, though an approach like this can have data races
+ * and other pitfalls.
*/
protected abstract fun onExportPassCompleted()
@@ -247,7 +241,6 @@ abstract class SmsExportService : Service() {
onAttachmentPartExportStarted(exportMmsOutput.mms, attachment)
ExportMmsPartsUseCase.execute(this, attachment, exportMmsOutput, smsExportState.startedAttachments.contains(attachment.contentId)).either(
onSuccess = {
- onAttachmentPartExportSucceeded(exportMmsOutput.mms, attachment)
it
},
onFailure = {
@@ -261,7 +254,7 @@ abstract class SmsExportService : Service() {
}
private fun exportMmsRecipients(smsExportState: SmsExportState, exportMmsOutput: ExportMmsMessagesUseCase.Output): List {
- val recipients = exportMmsOutput.mms.addresses.map { it.toString() }.toSet()
+ val recipients = exportMmsOutput.mms.addresses.map { it }.toSet()
return if (recipients.isEmpty()) {
emptyList()
} else {
@@ -287,8 +280,8 @@ abstract class SmsExportService : Service() {
}
if (output.part is ExportableMessage.Mms.Part.Text) {
- onAttachmentWrittenToDisk(output.message, output.part)
- Try.success(Unit)
+ onAttachmentPartExportSucceeded(output.message, output.part)
+ return Try.success(Unit)
}
return try {
@@ -297,7 +290,8 @@ abstract class SmsExportService : Service() {
it.copyTo(out)
}
}
- onAttachmentWrittenToDisk(output.message, output.part)
+
+ onAttachmentPartExportSucceeded(output.message, output.part)
Try.success(Unit)
} catch (e: Exception) {
Log.d(TAG, "Failed to write attachment to disk.", e)
diff --git a/sms-exporter/lib/src/main/java/org/signal/smsexporter/SmsExportState.kt b/sms-exporter/lib/src/main/java/org/signal/smsexporter/SmsExportState.kt
index efca3194d..853657eac 100644
--- a/sms-exporter/lib/src/main/java/org/signal/smsexporter/SmsExportState.kt
+++ b/sms-exporter/lib/src/main/java/org/signal/smsexporter/SmsExportState.kt
@@ -10,7 +10,6 @@ data class SmsExportState(
val completedRecipients: Set = emptySet(),
val startedAttachments: Set = emptySet(),
val completedAttachments: Set = emptySet(),
- val copiedAttachments: Set = emptySet(),
val progress: Progress = Progress.INIT
) {
enum class Progress {
diff --git a/sms-exporter/lib/src/main/java/org/signal/smsexporter/internal/ReleaseDefaultSmsUseCase.kt b/sms-exporter/lib/src/main/java/org/signal/smsexporter/internal/ReleaseDefaultSmsUseCase.kt
index 0d58cf653..2d5406ef5 100644
--- a/sms-exporter/lib/src/main/java/org/signal/smsexporter/internal/ReleaseDefaultSmsUseCase.kt
+++ b/sms-exporter/lib/src/main/java/org/signal/smsexporter/internal/ReleaseDefaultSmsUseCase.kt
@@ -19,7 +19,7 @@ import org.signal.smsexporter.ReleaseSmsAppFailure
internal object ReleaseDefaultSmsUseCase {
fun execute(context: Context): Result {
return if (!IsDefaultSms.checkIsDefaultSms(context)) {
- Result.failure(ReleaseSmsAppFailure.APP_IS_INELIGABLE_TO_RELEASE_SMS_SELECTION)
+ Result.failure(ReleaseSmsAppFailure.APP_IS_INELIGIBLE_TO_RELEASE_SMS_SELECTION)
} else if (Build.VERSION.SDK_INT >= 24) {
Result.success(
Intent(Settings.ACTION_MANAGE_DEFAULT_APPS_SETTINGS)
diff --git a/sms-exporter/lib/src/main/java/org/signal/smsexporter/internal/mms/ExportMmsMessagesUseCase.kt b/sms-exporter/lib/src/main/java/org/signal/smsexporter/internal/mms/ExportMmsMessagesUseCase.kt
index bf4a5d5ae..1a1d9d5e9 100644
--- a/sms-exporter/lib/src/main/java/org/signal/smsexporter/internal/mms/ExportMmsMessagesUseCase.kt
+++ b/sms-exporter/lib/src/main/java/org/signal/smsexporter/internal/mms/ExportMmsMessagesUseCase.kt
@@ -41,8 +41,8 @@ internal object ExportMmsMessagesUseCase {
val mmsContentValues = contentValuesOf(
Telephony.Mms.THREAD_ID to threadId,
- Telephony.Mms.DATE to mms.dateReceived,
- Telephony.Mms.DATE_SENT to mms.dateSent,
+ Telephony.Mms.DATE to mms.dateReceived.inWholeSeconds,
+ Telephony.Mms.DATE_SENT to mms.dateSent.inWholeSeconds,
Telephony.Mms.MESSAGE_BOX to if (mms.isOutgoing) Telephony.Mms.MESSAGE_BOX_SENT else Telephony.Mms.MESSAGE_BOX_INBOX,
Telephony.Mms.READ to if (mms.isRead) 1 else 0,
Telephony.Mms.CONTENT_TYPE to "application/vnd.wap.multipart.related",
@@ -52,7 +52,8 @@ internal object ExportMmsMessagesUseCase {
Telephony.Mms.PRIORITY to PduHeaders.PRIORITY_NORMAL,
Telephony.Mms.TRANSACTION_ID to transactionId,
Telephony.Mms.RESPONSE_STATUS to PduHeaders.RESPONSE_STATUS_OK,
- Telephony.Mms.SEEN to 1
+ Telephony.Mms.SEEN to 1,
+ Telephony.Mms.TEXT_ONLY to if (mms.parts.all { it is ExportableMessage.Mms.Part.Text }) 1 else 0
)
val uri = context.contentResolver.insert(Telephony.Mms.CONTENT_URI, mmsContentValues)
@@ -72,8 +73,11 @@ internal object ExportMmsMessagesUseCase {
arrayOf(transactionId),
null
)?.use {
- it.moveToFirst()
- it.getLong(0)
+ if (it.moveToFirst()) {
+ it.getLong(0)
+ } else {
+ -1L
+ }
} ?: -1L
}
diff --git a/sms-exporter/lib/src/main/java/org/signal/smsexporter/internal/sms/ExportSmsMessagesUseCase.kt b/sms-exporter/lib/src/main/java/org/signal/smsexporter/internal/sms/ExportSmsMessagesUseCase.kt
index d16051c24..c095cdc55 100644
--- a/sms-exporter/lib/src/main/java/org/signal/smsexporter/internal/sms/ExportSmsMessagesUseCase.kt
+++ b/sms-exporter/lib/src/main/java/org/signal/smsexporter/internal/sms/ExportSmsMessagesUseCase.kt
@@ -19,7 +19,7 @@ internal object ExportSmsMessagesUseCase {
Telephony.Sms.CONTENT_URI,
arrayOf("_id"),
"${Telephony.Sms.ADDRESS} = ? AND ${Telephony.Sms.DATE_SENT} = ?",
- arrayOf(sms.address, sms.dateSent.toString()),
+ arrayOf(sms.address, sms.dateSent.inWholeMilliseconds.toString()),
null
)?.use {
it.count > 0
@@ -33,8 +33,8 @@ internal object ExportSmsMessagesUseCase {
val contentValues = contentValuesOf(
Telephony.Sms.ADDRESS to sms.address,
Telephony.Sms.BODY to sms.body,
- Telephony.Sms.DATE to sms.dateReceived,
- Telephony.Sms.DATE_SENT to sms.dateSent,
+ Telephony.Sms.DATE to sms.dateReceived.inWholeMilliseconds,
+ Telephony.Sms.DATE_SENT to sms.dateSent.inWholeMilliseconds,
Telephony.Sms.READ to if (sms.isRead) 1 else 0,
Telephony.Sms.TYPE to if (sms.isOutgoing) Telephony.Sms.MESSAGE_TYPE_SENT else Telephony.Sms.MESSAGE_TYPE_INBOX
)
diff --git a/sms-exporter/lib/src/test/java/org/signal/smsexporter/InMemoryContentProvider.kt b/sms-exporter/lib/src/test/java/org/signal/smsexporter/InMemoryContentProvider.kt
index 4bf9a0ce4..1a0a2a42c 100644
--- a/sms-exporter/lib/src/test/java/org/signal/smsexporter/InMemoryContentProvider.kt
+++ b/sms-exporter/lib/src/test/java/org/signal/smsexporter/InMemoryContentProvider.kt
@@ -80,7 +80,8 @@ class InMemoryContentProvider : ContentProvider() {
${Telephony.Mms.PRIORITY} INTEGER,
${Telephony.Mms.TRANSACTION_ID} TEXT,
${Telephony.Mms.RESPONSE_STATUS} INTEGER,
- ${Telephony.Mms.SEEN} INTEGER
+ ${Telephony.Mms.SEEN} INTEGER,
+ ${Telephony.Mms.TEXT_ONLY} INTEGER
);
""".trimIndent()
)
diff --git a/sms-exporter/lib/src/test/java/org/signal/smsexporter/TestUtils.kt b/sms-exporter/lib/src/test/java/org/signal/smsexporter/TestUtils.kt
index 1b607c9e5..a17e62969 100644
--- a/sms-exporter/lib/src/test/java/org/signal/smsexporter/TestUtils.kt
+++ b/sms-exporter/lib/src/test/java/org/signal/smsexporter/TestUtils.kt
@@ -3,31 +3,33 @@ package org.signal.smsexporter
import android.provider.Telephony
import org.robolectric.shadows.ShadowContentResolver
import java.util.UUID
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
object TestUtils {
fun generateSmsMessage(
id: String = UUID.randomUUID().toString(),
address: String = "+15555060177",
- dateReceived: Long = 2,
- dateSent: Long = 1,
+ dateReceived: Duration = 2.seconds,
+ dateSent: Duration = 1.seconds,
isRead: Boolean = false,
isOutgoing: Boolean = false,
body: String = "Hello, $id"
): ExportableMessage.Sms {
- return ExportableMessage.Sms(id, address, dateReceived, dateSent, isRead, isOutgoing, body)
+ return ExportableMessage.Sms(id, SmsExportState(), address, dateReceived, dateSent, isRead, isOutgoing, body)
}
fun generateMmsMessage(
id: String = UUID.randomUUID().toString(),
addresses: Set = setOf("+15555060177"),
- dateReceived: Long = 2,
- dateSent: Long = 1,
+ dateReceived: Duration = 2.seconds,
+ dateSent: Duration = 1.seconds,
isRead: Boolean = false,
isOutgoing: Boolean = false,
parts: List = listOf(ExportableMessage.Mms.Part.Text("Hello, $id")),
sender: CharSequence = "+15555060177"
): ExportableMessage.Mms {
- return ExportableMessage.Mms(id, addresses, dateReceived, dateSent, isRead, isOutgoing, parts, sender)
+ return ExportableMessage.Mms(id, SmsExportState(), addresses, dateReceived, dateSent, isRead, isOutgoing, parts, sender)
}
fun setUpSmsContentProviderAndResolver() {
diff --git a/sms-exporter/lib/src/test/java/org/signal/smsexporter/internal/mms/ExportMmsMessagesUseCaseTest.kt b/sms-exporter/lib/src/test/java/org/signal/smsexporter/internal/mms/ExportMmsMessagesUseCaseTest.kt
index 8c86c7771..eef3e98df 100644
--- a/sms-exporter/lib/src/test/java/org/signal/smsexporter/internal/mms/ExportMmsMessagesUseCaseTest.kt
+++ b/sms-exporter/lib/src/test/java/org/signal/smsexporter/internal/mms/ExportMmsMessagesUseCaseTest.kt
@@ -123,8 +123,8 @@ class ExportMmsMessagesUseCaseTest {
it.moveToFirst()
assertEquals(expectedRowCount, it.count)
assertEquals(threadId, CursorUtil.requireLong(it, Telephony.Mms.THREAD_ID))
- assertEquals(mms.dateReceived, CursorUtil.requireLong(it, Telephony.Mms.DATE))
- assertEquals(mms.dateSent, CursorUtil.requireLong(it, Telephony.Mms.DATE_SENT))
+ assertEquals(mms.dateReceived.inWholeSeconds, CursorUtil.requireLong(it, Telephony.Mms.DATE))
+ assertEquals(mms.dateSent.inWholeSeconds, CursorUtil.requireLong(it, Telephony.Mms.DATE_SENT))
assertEquals(if (mms.isOutgoing) Telephony.Mms.MESSAGE_BOX_SENT else Telephony.Mms.MESSAGE_BOX_INBOX, CursorUtil.requireInt(it, Telephony.Mms.MESSAGE_BOX))
assertEquals(mms.isRead, CursorUtil.requireBoolean(it, Telephony.Mms.READ))
assertEquals(transactionId, CursorUtil.requireString(it, Telephony.Mms.TRANSACTION_ID))
diff --git a/sms-exporter/lib/src/test/java/org/signal/smsexporter/internal/sms/ExportSmsMessagesUseCaseTest.kt b/sms-exporter/lib/src/test/java/org/signal/smsexporter/internal/sms/ExportSmsMessagesUseCaseTest.kt
index 042d473d2..901d7c1a1 100644
--- a/sms-exporter/lib/src/test/java/org/signal/smsexporter/internal/sms/ExportSmsMessagesUseCaseTest.kt
+++ b/sms-exporter/lib/src/test/java/org/signal/smsexporter/internal/sms/ExportSmsMessagesUseCaseTest.kt
@@ -110,15 +110,15 @@ class ExportSmsMessagesUseCaseTest {
baseUri,
null,
"${Telephony.Sms.ADDRESS} = ? AND ${Telephony.Sms.DATE_SENT} = ?",
- arrayOf(sms.address, sms.dateSent.toString()),
+ arrayOf(sms.address, sms.dateSent.inWholeMilliseconds.toString()),
null,
null
)?.use {
it.moveToFirst()
assertEquals(expectedRowCount, it.count)
assertEquals(sms.address, CursorUtil.requireString(it, Telephony.Sms.ADDRESS))
- assertEquals(sms.dateSent, CursorUtil.requireLong(it, Telephony.Sms.DATE_SENT))
- assertEquals(sms.dateReceived, CursorUtil.requireLong(it, Telephony.Sms.DATE))
+ assertEquals(sms.dateSent.inWholeMilliseconds, CursorUtil.requireLong(it, Telephony.Sms.DATE_SENT))
+ assertEquals(sms.dateReceived.inWholeMilliseconds, CursorUtil.requireLong(it, Telephony.Sms.DATE))
assertEquals(sms.isRead, CursorUtil.requireBoolean(it, Telephony.Sms.READ))
assertEquals(sms.body, CursorUtil.requireString(it, Telephony.Sms.BODY))
assertEquals(if (sms.isOutgoing) Telephony.Sms.MESSAGE_TYPE_SENT else Telephony.Sms.MESSAGE_TYPE_INBOX, CursorUtil.requireInt(it, Telephony.Sms.TYPE))