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))