Add initial sms exporter integration behind a feature flag.
This commit is contained in:
parent
1cc39fb89b
commit
936212e684
40 changed files with 1218 additions and 75 deletions
|
@ -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
|
||||
|
|
|
@ -670,6 +670,11 @@
|
|||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".exporter.flow.SmsExportActivity"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<service android:enabled="true" android:name=".exporter.SignalSmsExportService" android:foregroundServiceType="dataSync" />
|
||||
<service android:enabled="true" android:name=".service.webrtc.WebRtcCallService" android:foregroundServiceType="camera|microphone"/>
|
||||
<service android:enabled="true" android:name=".service.ApplicationMigrationService"/>
|
||||
<service android:enabled="true" android:exported="false" android:name=".service.KeyCachingService"/>
|
||||
|
|
|
@ -1,16 +1,24 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.chats.sms
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.navigation.Navigation
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.exporter.flow.SmsExportActivity
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.SmsUtil
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
|
@ -22,6 +30,7 @@ private const val SMS_REQUEST_CODE: Short = 1234
|
|||
class SmsSettingsFragment : DSLSettingsFragment(R.string.preferences__sms_mms) {
|
||||
|
||||
private lateinit var viewModel: SmsSettingsViewModel
|
||||
private lateinit var smsExportLauncher: ActivityResultLauncher<Intent>
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
@ -29,6 +38,12 @@ class SmsSettingsFragment : DSLSettingsFragment(R.string.preferences__sms_mms) {
|
|||
}
|
||||
|
||||
override fun bindAdapter(adapter: MappingAdapter) {
|
||||
smsExportLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
if (it.resultCode == Activity.RESULT_OK) {
|
||||
showSmsRemovalDialog()
|
||||
}
|
||||
}
|
||||
|
||||
viewModel = ViewModelProvider(this)[SmsSettingsViewModel::class.java]
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) {
|
||||
|
@ -42,6 +57,32 @@ class SmsSettingsFragment : DSLSettingsFragment(R.string.preferences__sms_mms) {
|
|||
|
||||
private fun getConfiguration(state: SmsSettingsState): DSLConfiguration {
|
||||
return configure {
|
||||
when (state.smsExportState) {
|
||||
SmsSettingsState.SmsExportState.FETCHING -> Unit
|
||||
SmsSettingsState.SmsExportState.HAS_UNEXPORTED_MESSAGES -> {
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.SmsSettingsFragment__export_sms_messages),
|
||||
onClick = {
|
||||
smsExportLauncher.launch(SmsExportActivity.createIntent(requireContext()))
|
||||
}
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
}
|
||||
SmsSettingsState.SmsExportState.ALL_MESSAGES_EXPORTED -> {
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.SmsSettingsFragment__remove_sms_messages),
|
||||
onClick = {
|
||||
showSmsRemovalDialog()
|
||||
}
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
}
|
||||
SmsSettingsState.SmsExportState.NO_SMS_MESSAGES_IN_DATABASE -> Unit
|
||||
SmsSettingsState.SmsExportState.NOT_AVAILABLE -> Unit
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.SmsSettingsFragment__use_as_default_sms_app),
|
||||
|
@ -96,4 +137,19 @@ class SmsSettingsFragment : DSLSettingsFragment(R.string.preferences__sms_mms) {
|
|||
|
||||
startActivityForResult(intent, SMS_REQUEST_CODE.toInt())
|
||||
}
|
||||
|
||||
private fun showSmsRemovalDialog() {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.RemoveSmsMessagesDialogFragment__remove_sms_messages)
|
||||
.setMessage(R.string.RemoveSmsMessagesDialogFragment__you_have_changed)
|
||||
.setPositiveButton(R.string.RemoveSmsMessagesDialogFragment__keep_messages) { _, _ -> }
|
||||
.setNegativeButton(R.string.RemoveSmsMessagesDialogFragment__remove_messages) { _, _ ->
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
SignalDatabase.sms.deleteExportedMessages()
|
||||
SignalDatabase.mms.deleteExportedMessages()
|
||||
}
|
||||
Snackbar.make(requireView(), R.string.SmsSettingsFragment__sms_messages_removed, Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.chats.sms
|
||||
|
||||
import androidx.annotation.WorkerThread
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
|
||||
class SmsSettingsRepository {
|
||||
fun getSmsExportState(): Single<SmsSettingsState.SmsExportState> {
|
||||
if (!FeatureFlags.smsExporter()) {
|
||||
return Single.just(SmsSettingsState.SmsExportState.NOT_AVAILABLE)
|
||||
}
|
||||
|
||||
return Single.fromCallable {
|
||||
checkInsecureMessageCount() ?: checkUnexportedInsecureMessageCount()
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun checkInsecureMessageCount(): SmsSettingsState.SmsExportState? {
|
||||
val smsCount = SignalDatabase.sms.insecureMessageCount
|
||||
val mmsCount = SignalDatabase.mms.insecureMessageCount
|
||||
val totalSmsMmsCount = smsCount + mmsCount
|
||||
|
||||
return if (totalSmsMmsCount == 0) {
|
||||
SmsSettingsState.SmsExportState.NO_SMS_MESSAGES_IN_DATABASE
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun checkUnexportedInsecureMessageCount(): SmsSettingsState.SmsExportState {
|
||||
val unexportedSmsCount = SignalDatabase.sms.unexportedInsecureMessages.use { it.count }
|
||||
val unexportedMmsCount = SignalDatabase.mms.unexportedInsecureMessages.use { it.count }
|
||||
val totalUnexportedCount = unexportedSmsCount + unexportedMmsCount
|
||||
|
||||
return if (totalUnexportedCount > 0) {
|
||||
SmsSettingsState.SmsExportState.HAS_UNEXPORTED_MESSAGES
|
||||
} else {
|
||||
SmsSettingsState.SmsExportState.ALL_MESSAGES_EXPORTED
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,6 +2,8 @@ package org.thoughtcrime.securesms.components.settings.app.chats.sms
|
|||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
|
@ -9,6 +11,9 @@ import org.thoughtcrime.securesms.util.livedata.Store
|
|||
|
||||
class SmsSettingsViewModel : ViewModel() {
|
||||
|
||||
private val repository = SmsSettingsRepository()
|
||||
|
||||
private val disposables = CompositeDisposable()
|
||||
private val store = Store(
|
||||
SmsSettingsState(
|
||||
useAsDefaultSmsApp = Util.isDefaultSmsProvider(ApplicationDependencies.getApplication()),
|
||||
|
@ -19,6 +24,16 @@ class SmsSettingsViewModel : ViewModel() {
|
|||
|
||||
val state: LiveData<SmsSettingsState> = store.stateLiveData
|
||||
|
||||
init {
|
||||
disposables += repository.getSmsExportState().subscribe { state ->
|
||||
store.update { it.copy(smsExportState = state) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
fun setSmsDeliveryReportsEnabled(enabled: Boolean) {
|
||||
store.update { it.copy(smsDeliveryReportsEnabled = enabled) }
|
||||
SignalStore.settings().isSmsDeliveryReportsEnabled = enabled
|
||||
|
|
|
@ -25,6 +25,7 @@ import org.thoughtcrime.securesms.database.model.ParentStoryId;
|
|||
import org.thoughtcrime.securesms.database.model.SmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.StoryResult;
|
||||
import org.thoughtcrime.securesms.database.model.StoryViewState;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExportState;
|
||||
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange;
|
||||
import org.thoughtcrime.securesms.insights.InsightsConstants;
|
||||
import org.thoughtcrime.securesms.mms.IncomingMediaMessage;
|
||||
|
@ -94,6 +95,9 @@ public abstract class MessageDatabase extends Database implements MmsSmsColumns
|
|||
public abstract boolean isSent(long messageId);
|
||||
public abstract List<MessageRecord> getProfileChangeDetailsRecords(long threadId, long afterTimestamp);
|
||||
public abstract Set<Long> getAllRateLimitedMessageIds();
|
||||
public abstract Cursor getUnexportedInsecureMessages();
|
||||
public abstract int getInsecureMessageCount();
|
||||
public abstract void deleteExportedMessages();
|
||||
|
||||
public abstract void markExpireStarted(long messageId);
|
||||
public abstract void markExpireStarted(long messageId, long startTime);
|
||||
|
@ -353,6 +357,14 @@ public abstract class MessageDatabase extends Database implements MmsSmsColumns
|
|||
return String.format(Locale.ENGLISH, "(%s OR %s) AND %s", isSent, isReceived, isSecure);
|
||||
}
|
||||
|
||||
protected String getInsecureMessageClause() {
|
||||
String isSent = "(" + getTypeField() + " & " + Types.BASE_TYPE_MASK + ") = " + Types.BASE_SENT_TYPE;
|
||||
String isReceived = "(" + getTypeField() + " & " + Types.BASE_TYPE_MASK + ") = " + Types.BASE_INBOX_TYPE;
|
||||
String isSecure = "(" + getTypeField() + " & " + (Types.SECURE_MESSAGE_BIT | Types.PUSH_MESSAGE_BIT) + ")";
|
||||
|
||||
return String.format(Locale.ENGLISH, "(%s OR %s) AND NOT %s", isSent, isReceived, isSecure);
|
||||
}
|
||||
|
||||
public void setReactionsSeen(long threadId, long sinceTimestamp) {
|
||||
SQLiteDatabase db = databaseHelper.getSignalWritableDatabase();
|
||||
ContentValues values = new ContentValues();
|
||||
|
@ -803,6 +815,11 @@ public abstract class MessageDatabase extends Database implements MmsSmsColumns
|
|||
@Deprecated
|
||||
MessageRecord getCurrent();
|
||||
|
||||
/**
|
||||
* Pulls the export state out of the query, if it is present.
|
||||
*/
|
||||
@NonNull MessageExportState getMessageExportStateForCurrentRecord();
|
||||
|
||||
/**
|
||||
* From the {@link Closeable} interface, removing the IOException requirement.
|
||||
*/
|
||||
|
|
|
@ -63,6 +63,7 @@ import org.thoughtcrime.securesms.database.model.StoryType;
|
|||
import org.thoughtcrime.securesms.database.model.StoryViewState;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExportState;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange;
|
||||
import org.thoughtcrime.securesms.jobs.TrimThreadJob;
|
||||
|
@ -189,7 +190,9 @@ public class MmsDatabase extends MessageDatabase {
|
|||
RECEIPT_TIMESTAMP + " INTEGER DEFAULT -1, " +
|
||||
MESSAGE_RANGES + " BLOB DEFAULT NULL, " +
|
||||
STORY_TYPE + " INTEGER DEFAULT 0, " +
|
||||
PARENT_STORY_ID + " INTEGER DEFAULT 0);";
|
||||
PARENT_STORY_ID + " INTEGER DEFAULT 0, " +
|
||||
EXPORT_STATE + " BLOB DEFAULT NULL, " +
|
||||
EXPORTED + " INTEGER DEFAULT 0);";
|
||||
|
||||
public static final String[] CREATE_INDEXS = {
|
||||
"CREATE INDEX IF NOT EXISTS mms_read_and_notified_and_thread_id_index ON " + TABLE_NAME + "(" + READ + "," + NOTIFIED + "," + THREAD_ID + ");",
|
||||
|
@ -1216,8 +1219,12 @@ public class MmsDatabase extends MessageDatabase {
|
|||
}
|
||||
|
||||
private Cursor rawQuery(@NonNull String where, @Nullable String[] arguments, boolean reverse, long limit) {
|
||||
return rawQuery(MMS_PROJECTION, where, arguments, reverse, limit);
|
||||
}
|
||||
|
||||
private Cursor rawQuery(@NonNull String[] projection, @NonNull String where, @Nullable String[] arguments, boolean reverse, long limit) {
|
||||
SQLiteDatabase database = databaseHelper.getSignalReadableDatabase();
|
||||
String rawQueryString = "SELECT " + Util.join(MMS_PROJECTION, ",") +
|
||||
String rawQueryString = "SELECT " + Util.join(projection, ",") +
|
||||
" FROM " + MmsDatabase.TABLE_NAME + " LEFT OUTER JOIN " + AttachmentDatabase.TABLE_NAME +
|
||||
" ON (" + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " = " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + ")" +
|
||||
" WHERE " + where + " GROUP BY " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID;
|
||||
|
@ -2416,6 +2423,53 @@ public class MmsDatabase extends MessageDatabase {
|
|||
return ids;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Cursor getUnexportedInsecureMessages() {
|
||||
return rawQuery(
|
||||
SqlUtil.appendArg(MMS_PROJECTION, EXPORT_STATE),
|
||||
getInsecureMessageClause() + " AND NOT " + EXPORTED,
|
||||
null,
|
||||
false,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getInsecureMessageCount() {
|
||||
try (Cursor cursor = getWritableDatabase().query(TABLE_NAME, SqlUtil.COUNT, getInsecureMessageClause(), null, null, null, null)) {
|
||||
if (cursor.moveToFirst()) {
|
||||
return cursor.getInt(0);
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteExportedMessages() {
|
||||
beginTransaction();
|
||||
try {
|
||||
List<Long> threadsToUpdate = new LinkedList<>();
|
||||
try (Cursor cursor = getReadableDatabase().query(TABLE_NAME, THREAD_ID_PROJECTION, EXPORTED + " = ?", SqlUtil.buildArgs(1), THREAD_ID, null, null, null)) {
|
||||
while (cursor.moveToNext()) {
|
||||
threadsToUpdate.add(CursorUtil.requireLong(cursor, THREAD_ID));
|
||||
}
|
||||
}
|
||||
|
||||
getWritableDatabase().delete(TABLE_NAME, EXPORTED + " = ?", SqlUtil.buildArgs(1));
|
||||
|
||||
for (final long threadId : threadsToUpdate) {
|
||||
SignalDatabase.threads().update(threadId, false);
|
||||
}
|
||||
|
||||
SignalDatabase.attachments().deleteAbandonedAttachmentFiles();
|
||||
|
||||
setTransactionSuccessful();
|
||||
} finally {
|
||||
endTransaction();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
void deleteThreads(@NonNull Set<Long> threadIds) {
|
||||
Log.d(TAG, "deleteThreads(count: " + threadIds.size() + ")");
|
||||
|
@ -2664,6 +2718,25 @@ public class MmsDatabase extends MessageDatabase {
|
|||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull MessageExportState getMessageExportStateForCurrentRecord() {
|
||||
byte[] messageExportState = CursorUtil.requireBlob(cursor, MmsDatabase.EXPORT_STATE);
|
||||
if (messageExportState == null) {
|
||||
return MessageExportState.getDefaultInstance();
|
||||
}
|
||||
|
||||
try {
|
||||
return MessageExportState.parseFrom(messageExportState);
|
||||
} catch (InvalidProtocolBufferException e) {
|
||||
return MessageExportState.getDefaultInstance();
|
||||
}
|
||||
}
|
||||
|
||||
public int getCount() {
|
||||
if (cursor == null) return 0;
|
||||
else return cursor.getCount();
|
||||
}
|
||||
|
||||
private NotificationMmsMessageRecord getNotificationMmsMessageRecord(Cursor cursor) {
|
||||
long id = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.ID));
|
||||
long dateSent = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.NORMALIZED_DATE_SENT));
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
*/
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
|
||||
|
@ -24,21 +25,23 @@ import androidx.annotation.Nullable;
|
|||
import androidx.annotation.VisibleForTesting;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
import com.google.protobuf.InvalidProtocolBufferException;
|
||||
|
||||
import net.zetetic.database.sqlcipher.SQLiteQueryBuilder;
|
||||
|
||||
import org.signal.core.util.CursorUtil;
|
||||
import org.signal.core.util.SqlUtil;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.libsignal.protocol.util.Pair;
|
||||
import org.thoughtcrime.securesms.database.MessageDatabase.MessageUpdate;
|
||||
import org.thoughtcrime.securesms.database.MessageDatabase.SyncMessageId;
|
||||
import org.thoughtcrime.securesms.database.model.MessageId;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExportState;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.notifications.v2.DefaultMessageNotifier;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.signal.core.util.CursorUtil;
|
||||
import org.signal.core.util.SqlUtil;
|
||||
import org.whispersystems.signalservice.api.messages.multidevice.ReadMessage;
|
||||
|
||||
import java.io.Closeable;
|
||||
|
@ -50,6 +53,7 @@ import java.util.LinkedList;
|
|||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.thoughtcrime.securesms.database.MmsSmsColumns.Types.GROUP_V2_LEAVE_BITS;
|
||||
|
@ -613,6 +617,63 @@ public class MmsSmsDatabase extends Database {
|
|||
SignalDatabase.mms().updateViewedStories(syncMessageIds);
|
||||
}
|
||||
|
||||
private @NonNull MessageExportState getMessageExportState(@NonNull MessageId messageId) throws NoSuchMessageException {
|
||||
String table = messageId.isMms() ? MmsDatabase.TABLE_NAME : SmsDatabase.TABLE_NAME;
|
||||
String[] projection = SqlUtil.buildArgs(MmsSmsColumns.EXPORT_STATE);
|
||||
String[] args = SqlUtil.buildArgs(messageId.getId());
|
||||
|
||||
try (Cursor cursor = getReadableDatabase().query(table, projection, ID_WHERE, args, null, null, null, null)) {
|
||||
if (cursor.moveToFirst()) {
|
||||
byte[] bytes = CursorUtil.requireBlob(cursor, MmsSmsColumns.EXPORT_STATE);
|
||||
if (bytes == null) {
|
||||
return MessageExportState.getDefaultInstance();
|
||||
} else {
|
||||
try {
|
||||
return MessageExportState.parseFrom(bytes);
|
||||
} catch (InvalidProtocolBufferException e) {
|
||||
return MessageExportState.getDefaultInstance();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new NoSuchMessageException("The requested message does not exist.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void updateMessageExportState(@NonNull MessageId messageId, @NonNull Function<MessageExportState, MessageExportState> transform) throws NoSuchMessageException {
|
||||
SQLiteDatabase database = getWritableDatabase();
|
||||
|
||||
database.beginTransaction();
|
||||
try {
|
||||
MessageExportState oldState = getMessageExportState(messageId);
|
||||
MessageExportState newState = transform.apply(oldState);
|
||||
|
||||
setMessageExportState(messageId, newState);
|
||||
|
||||
database.setTransactionSuccessful();
|
||||
} finally {
|
||||
database.endTransaction();
|
||||
}
|
||||
}
|
||||
|
||||
public void markMessageExported(@NonNull MessageId messageId) {
|
||||
String table = messageId.isMms() ? MmsDatabase.TABLE_NAME : SmsDatabase.TABLE_NAME;
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
|
||||
contentValues.put(MmsSmsColumns.EXPORTED, 1);
|
||||
|
||||
getWritableDatabase().update(table, contentValues, ID_WHERE, SqlUtil.buildArgs(messageId.getId()));
|
||||
}
|
||||
|
||||
private void setMessageExportState(@NonNull MessageId messageId, @NonNull MessageExportState messageExportState) {
|
||||
String table = messageId.isMms() ? MmsDatabase.TABLE_NAME : SmsDatabase.TABLE_NAME;
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
|
||||
contentValues.put(MmsSmsColumns.EXPORT_STATE, messageExportState.toByteArray());
|
||||
|
||||
getWritableDatabase().update(table, contentValues, ID_WHERE, SqlUtil.buildArgs(messageId.getId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Unhandled ids
|
||||
*/
|
||||
|
|
|
@ -29,6 +29,7 @@ import androidx.annotation.VisibleForTesting;
|
|||
import com.annimon.stream.Stream;
|
||||
import com.google.android.mms.pdu_alt.NotificationInd;
|
||||
import com.google.protobuf.ByteString;
|
||||
import com.google.protobuf.InvalidProtocolBufferException;
|
||||
|
||||
import net.zetetic.database.sqlcipher.SQLiteStatement;
|
||||
|
||||
|
@ -47,6 +48,7 @@ import org.thoughtcrime.securesms.database.model.SmsMessageRecord;
|
|||
import org.thoughtcrime.securesms.database.model.StoryResult;
|
||||
import org.thoughtcrime.securesms.database.model.StoryViewState;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GroupCallUpdateDetails;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExportState;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange;
|
||||
|
@ -66,15 +68,16 @@ import org.thoughtcrime.securesms.util.JsonUtils;
|
|||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.NoSuchElementException;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
@ -131,7 +134,9 @@ public class SmsDatabase extends MessageDatabase {
|
|||
REMOTE_DELETED + " INTEGER DEFAULT 0, " +
|
||||
NOTIFIED_TIMESTAMP + " INTEGER DEFAULT 0, " +
|
||||
SERVER_GUID + " TEXT DEFAULT NULL, " +
|
||||
RECEIPT_TIMESTAMP + " INTEGER DEFAULT -1);";
|
||||
RECEIPT_TIMESTAMP + " INTEGER DEFAULT -1, " +
|
||||
EXPORT_STATE + " BLOB DEFAULT NULL, " +
|
||||
EXPORTED + " INTEGER DEFAULT 0);";
|
||||
|
||||
public static final String[] CREATE_INDEXS = {
|
||||
"CREATE INDEX IF NOT EXISTS sms_read_and_notified_and_thread_id_index ON " + TABLE_NAME + "(" + READ + "," + NOTIFIED + "," + THREAD_ID + ");",
|
||||
|
@ -901,6 +906,51 @@ public class SmsDatabase extends MessageDatabase {
|
|||
return ids;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Cursor getUnexportedInsecureMessages() {
|
||||
return queryMessages(
|
||||
SqlUtil.appendArg(MESSAGE_PROJECTION, EXPORT_STATE),
|
||||
getInsecureMessageClause() + " AND NOT " + EXPORTED,
|
||||
null,
|
||||
false,
|
||||
-1
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getInsecureMessageCount() {
|
||||
try (Cursor cursor = getWritableDatabase().query(TABLE_NAME, SqlUtil.COUNT, getInsecureMessageClause(), null, null, null, null)) {
|
||||
if (cursor.moveToFirst()) {
|
||||
return cursor.getInt(0);
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteExportedMessages() {
|
||||
beginTransaction();
|
||||
try {
|
||||
List<Long> threadsToUpdate = new LinkedList<>();
|
||||
try (Cursor cursor = getReadableDatabase().query(TABLE_NAME, THREAD_ID_PROJECTION, EXPORTED + " = ?", SqlUtil.buildArgs(1), THREAD_ID, null, null, null)) {
|
||||
while (cursor.moveToNext()) {
|
||||
threadsToUpdate.add(CursorUtil.requireLong(cursor, THREAD_ID));
|
||||
}
|
||||
}
|
||||
|
||||
getWritableDatabase().delete(TABLE_NAME, EXPORTED + " = ?", SqlUtil.buildArgs(1));
|
||||
|
||||
for (final long threadId : threadsToUpdate) {
|
||||
SignalDatabase.threads().update(threadId, false);
|
||||
}
|
||||
|
||||
setTransactionSuccessful();
|
||||
} finally {
|
||||
endTransaction();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<MessageRecord> getProfileChangeDetailsRecords(long threadId, long afterTimestamp) {
|
||||
String where = THREAD_ID + " = ? AND " + DATE_RECEIVED + " >= ? AND " + TYPE + " = ?";
|
||||
|
@ -1541,11 +1591,15 @@ public class SmsDatabase extends MessageDatabase {
|
|||
}
|
||||
}
|
||||
|
||||
private Cursor queryMessages(@NonNull String where, @NonNull String[] args, boolean reverse, long limit) {
|
||||
private Cursor queryMessages(@NonNull String where, @Nullable String[] args, boolean reverse, long limit) {
|
||||
return queryMessages(MESSAGE_PROJECTION, where, args, reverse, limit);
|
||||
}
|
||||
|
||||
private Cursor queryMessages(@NonNull String[] projection, @NonNull String where, @Nullable String[] args, boolean reverse, long limit) {
|
||||
SQLiteDatabase db = databaseHelper.getSignalReadableDatabase();
|
||||
|
||||
return db.query(TABLE_NAME,
|
||||
MESSAGE_PROJECTION,
|
||||
projection,
|
||||
where,
|
||||
args,
|
||||
null,
|
||||
|
@ -1776,7 +1830,7 @@ public class SmsDatabase extends MessageDatabase {
|
|||
}
|
||||
}
|
||||
|
||||
public static class Reader implements Closeable {
|
||||
public static class Reader implements MessageDatabase.Reader {
|
||||
|
||||
private final Cursor cursor;
|
||||
private final Context context;
|
||||
|
@ -1798,6 +1852,20 @@ public class SmsDatabase extends MessageDatabase {
|
|||
else return cursor.getCount();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull MessageExportState getMessageExportStateForCurrentRecord() {
|
||||
byte[] messageExportState = CursorUtil.requireBlob(cursor, SmsDatabase.EXPORT_STATE);
|
||||
if (messageExportState == null) {
|
||||
return MessageExportState.getDefaultInstance();
|
||||
}
|
||||
|
||||
try {
|
||||
return MessageExportState.parseFrom(messageExportState);
|
||||
} catch (InvalidProtocolBufferException e) {
|
||||
return MessageExportState.getDefaultInstance();
|
||||
}
|
||||
}
|
||||
|
||||
public SmsMessageRecord getCurrent() {
|
||||
long messageId = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.ID));
|
||||
long recipientId = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.RECIPIENT_ID));
|
||||
|
@ -1853,6 +1921,28 @@ public class SmsDatabase extends MessageDatabase {
|
|||
public void close() {
|
||||
cursor.close();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Iterator<MessageRecord> iterator() {
|
||||
return new ReaderIterator();
|
||||
}
|
||||
|
||||
private class ReaderIterator implements Iterator<MessageRecord> {
|
||||
@Override
|
||||
public boolean hasNext() {
|
||||
return cursor != null && cursor.getCount() != 0 && !cursor.isLast();
|
||||
}
|
||||
|
||||
@Override
|
||||
public MessageRecord next() {
|
||||
MessageRecord record = getNext();
|
||||
if (record == null) {
|
||||
throw new NoSuchElementException();
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
|
@ -0,0 +1,136 @@
|
|||
package org.thoughtcrime.securesms.exporter
|
||||
|
||||
import android.database.Cursor
|
||||
import org.signal.smsexporter.ExportableMessage
|
||||
import org.signal.smsexporter.SmsExportState
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
|
||||
import org.thoughtcrime.securesms.database.MmsDatabase
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.SmsDatabase
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExportState
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.JsonUtils
|
||||
import java.io.Closeable
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class SignalSmsExportReader(
|
||||
smsCursor: Cursor,
|
||||
mmsCursor: Cursor
|
||||
) : Iterable<ExportableMessage>, Closeable {
|
||||
|
||||
private val smsReader = SmsDatabase.readerFor(smsCursor)
|
||||
private val mmsReader = MmsDatabase.readerFor(mmsCursor)
|
||||
|
||||
override fun iterator(): Iterator<ExportableMessage> {
|
||||
return ExportableMessageIterator()
|
||||
}
|
||||
|
||||
fun getCount(): Int {
|
||||
return smsReader.count + mmsReader.count
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
smsReader.close()
|
||||
mmsReader.close()
|
||||
}
|
||||
|
||||
private inner class ExportableMessageIterator : Iterator<ExportableMessage> {
|
||||
|
||||
private val smsIterator = smsReader.iterator()
|
||||
private val mmsIterator = mmsReader.iterator()
|
||||
|
||||
override fun hasNext(): Boolean {
|
||||
return smsIterator.hasNext() || mmsIterator.hasNext()
|
||||
}
|
||||
|
||||
override fun next(): ExportableMessage {
|
||||
return if (smsIterator.hasNext()) {
|
||||
readExportableSmsMessageFromRecord(smsIterator.next())
|
||||
} else if (mmsIterator.hasNext()) {
|
||||
readExportableMmsMessageFromRecord(mmsIterator.next())
|
||||
} else {
|
||||
throw NoSuchElementException()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun readExportableMmsMessageFromRecord(record: MessageRecord): ExportableMessage {
|
||||
val threadRecipient = SignalDatabase.threads.getRecipientForThreadId(record.threadId)!!
|
||||
val addresses = if (threadRecipient.isMmsGroup) {
|
||||
Recipient.resolvedList(threadRecipient.participantIds).map { it.requireSmsAddress() }.toSet()
|
||||
} else {
|
||||
setOf(threadRecipient.requireSmsAddress())
|
||||
}
|
||||
|
||||
val parts: MutableList<ExportableMessage.Mms.Part> = mutableListOf()
|
||||
if (record.body.isNotBlank()) {
|
||||
parts.add(ExportableMessage.Mms.Part.Text(record.body))
|
||||
}
|
||||
|
||||
if (record is MmsMessageRecord) {
|
||||
val slideDeck = record.slideDeck
|
||||
slideDeck.slides.forEach {
|
||||
parts.add(
|
||||
ExportableMessage.Mms.Part.Stream(
|
||||
id = JsonUtils.toJson((it.asAttachment() as DatabaseAttachment).attachmentId),
|
||||
contentType = it.contentType
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val sender = if (record.isOutgoing) Recipient.self().requireSmsAddress() else record.individualRecipient.requireSmsAddress()
|
||||
|
||||
return ExportableMessage.Mms(
|
||||
id = record.id.toString(),
|
||||
exportState = mapExportState(mmsReader.messageExportStateForCurrentRecord),
|
||||
addresses = addresses,
|
||||
dateReceived = record.dateReceived.milliseconds,
|
||||
dateSent = record.dateSent.milliseconds,
|
||||
isRead = true,
|
||||
isOutgoing = record.isOutgoing,
|
||||
parts = parts,
|
||||
sender = sender
|
||||
)
|
||||
}
|
||||
|
||||
private fun readExportableSmsMessageFromRecord(record: MessageRecord): ExportableMessage {
|
||||
val threadRecipient = SignalDatabase.threads.getRecipientForThreadId(record.threadId)!!
|
||||
|
||||
return if (threadRecipient.isMmsGroup) {
|
||||
readExportableMmsMessageFromRecord(record)
|
||||
} else {
|
||||
ExportableMessage.Sms(
|
||||
id = record.id.toString(),
|
||||
exportState = mapExportState(smsReader.messageExportStateForCurrentRecord),
|
||||
address = record.recipient.requireSmsAddress(),
|
||||
dateReceived = record.dateReceived.milliseconds,
|
||||
dateSent = record.dateSent.milliseconds,
|
||||
isRead = true,
|
||||
isOutgoing = record.isOutgoing,
|
||||
body = record.body
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapExportState(messageExportState: MessageExportState): SmsExportState {
|
||||
return SmsExportState(
|
||||
messageId = messageExportState.messageId,
|
||||
startedRecipients = messageExportState.startedRecipientsList.toSet(),
|
||||
completedRecipients = messageExportState.completedRecipientsList.toSet(),
|
||||
startedAttachments = messageExportState.startedAttachmentsList.toSet(),
|
||||
completedAttachments = messageExportState.completedAttachmentsList.toSet(),
|
||||
progress = messageExportState.progress.let {
|
||||
when (it) {
|
||||
MessageExportState.Progress.INIT -> SmsExportState.Progress.INIT
|
||||
MessageExportState.Progress.STARTED -> SmsExportState.Progress.STARTED
|
||||
MessageExportState.Progress.COMPLETED -> SmsExportState.Progress.COMPLETED
|
||||
MessageExportState.Progress.UNRECOGNIZED -> SmsExportState.Progress.INIT
|
||||
null -> SmsExportState.Progress.INIT
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,143 @@
|
|||
package org.thoughtcrime.securesms.exporter
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import org.signal.smsexporter.ExportableMessage
|
||||
import org.signal.smsexporter.SmsExportService
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.MessageId
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExportState
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels
|
||||
import org.thoughtcrime.securesms.notifications.NotificationIds
|
||||
import org.thoughtcrime.securesms.util.JsonUtils
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* Service which integrates the SMS exporter functionality.
|
||||
*/
|
||||
class SignalSmsExportService : SmsExportService() {
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Launches the export service and immediately begins exporting messages.
|
||||
*/
|
||||
fun start(context: Context) {
|
||||
ContextCompat.startForegroundService(context, Intent(context, SignalSmsExportService::class.java))
|
||||
}
|
||||
}
|
||||
|
||||
private var reader: SignalSmsExportReader? = null
|
||||
|
||||
override fun getNotification(progress: Int, total: Int): ExportNotification {
|
||||
return ExportNotification(
|
||||
NotificationIds.SMS_EXPORT_SERVICE,
|
||||
NotificationCompat.Builder(this, NotificationChannels.BACKUPS)
|
||||
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
||||
.setContentTitle(getString(R.string.SignalSmsExportService__exporting_messages))
|
||||
.setProgress(total, progress, false)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
override fun getUnexportedMessageCount(): Int {
|
||||
ensureReader()
|
||||
return reader!!.getCount()
|
||||
}
|
||||
|
||||
override fun getUnexportedMessages(): Iterable<ExportableMessage> {
|
||||
ensureReader()
|
||||
return reader!!
|
||||
}
|
||||
|
||||
override fun onMessageExportStarted(exportableMessage: ExportableMessage) {
|
||||
SignalDatabase.mmsSms.updateMessageExportState(exportableMessage.getMessageId()) {
|
||||
it.toBuilder().setProgress(MessageExportState.Progress.STARTED).build()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessageExportSucceeded(exportableMessage: ExportableMessage) {
|
||||
SignalDatabase.mmsSms.updateMessageExportState(exportableMessage.getMessageId()) {
|
||||
it.toBuilder().setProgress(MessageExportState.Progress.COMPLETED).build()
|
||||
}
|
||||
|
||||
SignalDatabase.mmsSms.markMessageExported(exportableMessage.getMessageId())
|
||||
}
|
||||
|
||||
override fun onMessageExportFailed(exportableMessage: ExportableMessage) {
|
||||
SignalDatabase.mmsSms.updateMessageExportState(exportableMessage.getMessageId()) {
|
||||
it.toBuilder().setProgress(MessageExportState.Progress.INIT).build()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessageIdCreated(exportableMessage: ExportableMessage, messageId: Long) {
|
||||
SignalDatabase.mmsSms.updateMessageExportState(exportableMessage.getMessageId()) {
|
||||
it.toBuilder().setMessageId(messageId).build()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAttachmentPartExportStarted(exportableMessage: ExportableMessage, part: ExportableMessage.Mms.Part) {
|
||||
SignalDatabase.mmsSms.updateMessageExportState(exportableMessage.getMessageId()) {
|
||||
it.toBuilder().addStartedAttachments(part.contentId).build()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAttachmentPartExportSucceeded(exportableMessage: ExportableMessage, part: ExportableMessage.Mms.Part) {
|
||||
SignalDatabase.mmsSms.updateMessageExportState(exportableMessage.getMessageId()) {
|
||||
it.toBuilder().addCompletedAttachments(part.contentId).build()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAttachmentPartExportFailed(exportableMessage: ExportableMessage, part: ExportableMessage.Mms.Part) {
|
||||
SignalDatabase.mmsSms.updateMessageExportState(exportableMessage.getMessageId()) {
|
||||
val startedAttachments = it.startedAttachmentsList - part.contentId
|
||||
it.toBuilder().clearStartedAttachments().addAllStartedAttachments(startedAttachments).build()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRecipientExportStarted(exportableMessage: ExportableMessage, recipient: String) {
|
||||
SignalDatabase.mmsSms.updateMessageExportState(exportableMessage.getMessageId()) {
|
||||
it.toBuilder().addStartedRecipients(recipient).build()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRecipientExportSucceeded(exportableMessage: ExportableMessage, recipient: String) {
|
||||
SignalDatabase.mmsSms.updateMessageExportState(exportableMessage.getMessageId()) {
|
||||
it.toBuilder().addCompletedRecipients(recipient).build()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRecipientExportFailed(exportableMessage: ExportableMessage, recipient: String) {
|
||||
SignalDatabase.mmsSms.updateMessageExportState(exportableMessage.getMessageId()) {
|
||||
val startedAttachments = it.startedRecipientsList - recipient
|
||||
it.toBuilder().clearStartedRecipients().addAllStartedRecipients(startedAttachments).build()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getInputStream(part: ExportableMessage.Mms.Part): InputStream {
|
||||
return SignalDatabase.attachments.getAttachmentStream(JsonUtils.fromJson(part.contentId, AttachmentId::class.java), 0)
|
||||
}
|
||||
|
||||
override fun onExportPassCompleted() {
|
||||
reader?.close()
|
||||
}
|
||||
|
||||
private fun ExportableMessage.getMessageId(): MessageId {
|
||||
return when (this) {
|
||||
is ExportableMessage.Mms -> MessageId(id.toLong(), true)
|
||||
is ExportableMessage.Sms -> MessageId(id.toLong(), false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureReader() {
|
||||
if (reader == null) {
|
||||
reader = SignalSmsExportReader(
|
||||
smsCursor = SignalDatabase.sms.unexportedInsecureMessages,
|
||||
mmsCursor = SignalDatabase.mms.unexportedInsecureMessages
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -102,6 +102,7 @@ public final class FeatureFlags {
|
|||
private static final String CAMERAX_MODEL_BLOCKLIST = "android.cameraXModelBlockList";
|
||||
private static final String RECIPIENT_MERGE_V2 = "android.recipientMergeV2";
|
||||
private static final String CDS_V2_LOAD_TEST = "android.cdsV2LoadTest";
|
||||
private static final String SMS_EXPORTER = "android.sms.exporter";
|
||||
|
||||
/**
|
||||
* We will only store remote values for flags in this set. If you want a flag to be controllable
|
||||
|
@ -156,7 +157,8 @@ public final class FeatureFlags {
|
|||
TELECOM_MODEL_BLOCKLIST,
|
||||
CAMERAX_MODEL_BLOCKLIST,
|
||||
RECIPIENT_MERGE_V2,
|
||||
CDS_V2_LOAD_TEST
|
||||
CDS_V2_LOAD_TEST,
|
||||
SMS_EXPORTER
|
||||
);
|
||||
|
||||
@VisibleForTesting
|
||||
|
@ -555,6 +557,16 @@ public final class FeatureFlags {
|
|||
return getBoolean(CDS_V2_LOAD_TEST, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether or not we should enable the SMS exporter
|
||||
*
|
||||
* WARNING: This feature is under active development and is off for a reason. The exporter writes messages out to your
|
||||
* system SMS / MMS database, and hasn't been adequately tested for public use. Don't enable this. You've been warned.
|
||||
*/
|
||||
public static boolean smsExporter() {
|
||||
return getBoolean(SMS_EXPORTER, false);
|
||||
}
|
||||
|
||||
/** Only for rendering debug info. */
|
||||
public static synchronized @NonNull Map<String, Object> getMemoryValues() {
|
||||
return new TreeMap<>(REMOTE_VALUES);
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/hero"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="84dp"
|
||||
android:importantForAccessibility="no"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/headline"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="32dp"
|
||||
android:layout_marginTop="40dp"
|
||||
android:gravity="center"
|
||||
android:text="@string/ChooseANewDefaultSmsAppFragment__choose_a_new"
|
||||
android:textAppearance="@style/Signal.Text.HeadlineLarge"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/hero" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/description"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="32dp"
|
||||
android:layout_marginTop="24dp"
|
||||
android:gravity="center"
|
||||
android:textAppearance="@style/Signal.Text.BodyLarge"
|
||||
android:textColor="@color/signal_colorOnSurfaceVariant"
|
||||
app:layout_constraintTop_toBottomOf="@id/headline"
|
||||
tools:text="An explanation about choosing a new default sms app. This will remove SMS conversations from Signal." />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/continue_button"
|
||||
style="@style/Signal.Widget.Button.Large.Tonal"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="44dp"
|
||||
android:minWidth="221dp"
|
||||
android:text="@string/ChooseANewDefaultSmsAppFragment__continue"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/description"
|
||||
app:layout_constraintVertical_bias="1" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
@ -0,0 +1,63 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<androidx.appcompat.widget.Toolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="64dp"
|
||||
android:minHeight="64dp"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:navigationIcon="@drawable/ic_arrow_left_24" />
|
||||
|
||||
<!-- TODO [alex] - Image -->
|
||||
<ImageView
|
||||
android:id="@+id/image"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="centerInside"
|
||||
app:layout_constraintTop_toBottomOf="@id/toolbar" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/headline"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="32dp"
|
||||
android:layout_marginTop="40dp"
|
||||
android:gravity="center"
|
||||
android:text="@string/ExportYourSmsMessagesFragment__export_your_sms_messages"
|
||||
android:textAppearance="@style/Signal.Text.HeadlineLarge"
|
||||
app:layout_constraintTop_toBottomOf="@id/image" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/description"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="32dp"
|
||||
android:layout_marginTop="24dp"
|
||||
android:gravity="center"
|
||||
tools:text="WIP Placeholder text"
|
||||
android:textColor="@color/signal_colorOnSurfaceVariant"
|
||||
android:textAppearance="@style/Signal.Text.BodyLarge"
|
||||
app:layout_constraintTop_toBottomOf="@id/headline" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/continue_button"
|
||||
style="@style/Signal.Widget.Button.Large.Tonal"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="44dp"
|
||||
android:minWidth="221dp"
|
||||
android:text="@string/ExportYourSmsMessagesFragment__continue"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/description"
|
||||
app:layout_constraintVertical_bias="1" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
58
app/src/main/res/layout/exporting_sms_messages_fragment.xml
Normal file
58
app/src/main/res/layout/exporting_sms_messages_fragment.xml
Normal file
|
@ -0,0 +1,58 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/headline"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="32dp"
|
||||
android:layout_marginTop="64dp"
|
||||
android:gravity="center"
|
||||
android:text="@string/ExportingSmsMessagesFragment__exporting_sms_messages"
|
||||
android:textAppearance="@style/Signal.Text.HeadlineLarge"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<!-- TODO [alex] - Final text -->
|
||||
<TextView
|
||||
android:id="@+id/description"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="32dp"
|
||||
android:layout_marginTop="24dp"
|
||||
android:gravity="center"
|
||||
tools:text="Please don't close the app..."
|
||||
android:textAppearance="@style/Signal.Text.BodyLarge"
|
||||
android:textColor="@color/signal_colorOnSurfaceVariant"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/headline" />
|
||||
|
||||
<com.google.android.material.progressindicator.LinearProgressIndicator
|
||||
android:id="@+id/progress"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintWidth_max="260dp" />
|
||||
|
||||
<TextView
|
||||
app:layout_constraintTop_toBottomOf="@id/progress"
|
||||
android:gravity="center"
|
||||
android:layout_marginHorizontal="32dp"
|
||||
android:layout_marginTop="11dp"
|
||||
tools:text="Exporting 5 of 264..."
|
||||
android:id="@+id/progress_label"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="@style/Signal.Text.BodySmall"
|
||||
android:textColor="@color/signal_colorOnSurfaceVariant" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
42
app/src/main/res/navigation/sms_export.xml
Normal file
42
app/src/main/res/navigation/sms_export.xml
Normal file
|
@ -0,0 +1,42 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/sms_export"
|
||||
app:startDestination="@id/exportYourSmsMessagesFragment">
|
||||
|
||||
<fragment
|
||||
android:id="@+id/exportYourSmsMessagesFragment"
|
||||
android:name="org.thoughtcrime.securesms.exporter.flow.ExportYourSmsMessagesFragment"
|
||||
android:label="fragment_export_your_sms_messages"
|
||||
tools:layout="@layout/export_your_sms_messages_fragment">
|
||||
<action
|
||||
android:id="@+id/action_exportYourSmsMessagesFragment_to_exportingSmsMessagesFragment"
|
||||
app:destination="@id/exportingSmsMessagesFragment"
|
||||
app:enterAnim="@anim/fragment_open_enter"
|
||||
app:exitAnim="@anim/fragment_open_exit"
|
||||
app:popEnterAnim="@anim/fragment_close_enter"
|
||||
app:popExitAnim="@anim/fragment_close_exit" />
|
||||
</fragment>
|
||||
|
||||
<fragment
|
||||
android:id="@+id/exportingSmsMessagesFragment"
|
||||
android:name="org.thoughtcrime.securesms.exporter.flow.ExportingSmsMessagesFragment"
|
||||
android:label="fragment_exporting_sms_messages"
|
||||
tools:layout="@layout/exporting_sms_messages_fragment">
|
||||
<action
|
||||
android:id="@+id/action_exportingSmsMessagesFragment_to_chooseANewDefaultSmsAppFragment"
|
||||
app:destination="@id/chooseANewDefaultSmsAppFragment"
|
||||
app:enterAnim="@anim/fragment_open_enter"
|
||||
app:exitAnim="@anim/fragment_open_exit"
|
||||
app:popEnterAnim="@anim/fragment_close_enter"
|
||||
app:popExitAnim="@anim/fragment_close_exit" />
|
||||
</fragment>
|
||||
|
||||
<fragment
|
||||
android:id="@+id/chooseANewDefaultSmsAppFragment"
|
||||
android:name="org.thoughtcrime.securesms.exporter.flow.ChooseANewDefaultSmsAppFragment"
|
||||
android:label="fragment_choose_a_new_default_sms_app"
|
||||
tools:layout="@layout/choose_a_new_default_sms_app_fragment" />
|
||||
|
||||
</navigation>
|
|
@ -3915,6 +3915,12 @@
|
|||
|
||||
<!--SmsSettingsFragment -->
|
||||
<string name="SmsSettingsFragment__use_as_default_sms_app">Use as default SMS app</string>
|
||||
<!-- Preference title to export sms -->
|
||||
<string name="SmsSettingsFragment__export_sms_messages">Export SMS messages</string>
|
||||
<!-- Preference title to delete sms -->
|
||||
<string name="SmsSettingsFragment__remove_sms_messages">Remove SMS messages</string>
|
||||
<!-- Snackbar text to confirm deletion -->
|
||||
<string name="SmsSettingsFragment__sms_messages_removed">SMS messages removed</string>
|
||||
|
||||
<!-- NotificationsSettingsFragment -->
|
||||
<string name="NotificationsSettingsFragment__messages">Messages</string>
|
||||
|
@ -5158,6 +5164,38 @@
|
|||
<!-- Generic title for overflow menus -->
|
||||
<string name="OverflowMenu__overflow_menu">Overflow menu</string>
|
||||
|
||||
<!-- SMS Export Service -->
|
||||
<!-- Displayed in the notification while export is running -->
|
||||
<string name="SignalSmsExportService__exporting_messages">Exporting messages…</string>
|
||||
|
||||
<!-- ExportYourSmsMessagesFragment -->
|
||||
<!-- Title of the screen -->
|
||||
<string name="ExportYourSmsMessagesFragment__export_your_sms_messages">Export your SMS messages</string>
|
||||
<!-- Button label to begin export -->
|
||||
<string name="ExportYourSmsMessagesFragment__continue">Continue</string>
|
||||
|
||||
<!-- ExportingSmsMessagesFragment -->
|
||||
<!-- Title of the screen -->
|
||||
<string name="ExportingSmsMessagesFragment__exporting_sms_messages">Exporting SMS messages</string>
|
||||
<!-- Progress indicator for export -->
|
||||
<string name="ExportingSmsMessagesFragment__exporting_d_of_d">Exporting %1$d of %2$d…</string>
|
||||
|
||||
<!-- ChooseANewDefaultSmsAppFragment -->
|
||||
<!-- Title of the screen -->
|
||||
<string name="ChooseANewDefaultSmsAppFragment__choose_a_new">Choose a new default SMS app</string>
|
||||
<!-- Button label to launch picker -->
|
||||
<string name="ChooseANewDefaultSmsAppFragment__continue">Continue</string>
|
||||
|
||||
<!-- RemoveSmsMessagesDialogFragment -->
|
||||
<!-- Action button to keep messages -->
|
||||
<string name="RemoveSmsMessagesDialogFragment__keep_messages">Keep messages</string>
|
||||
<!-- Action button to remove messages -->
|
||||
<string name="RemoveSmsMessagesDialogFragment__remove_messages">Remove messages</string>
|
||||
<!-- Title of dialog -->
|
||||
<string name="RemoveSmsMessagesDialogFragment__remove_sms_messages">Remove SMS messages from Signal?</string>
|
||||
<!-- Message of dialog -->
|
||||
<string name="RemoveSmsMessagesDialogFragment__you_have_changed">You have changed the default SMS app, do you want to remove SMS messages from Signal?</string>
|
||||
|
||||
<!-- EOF -->
|
||||
|
||||
</resources>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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<String>,
|
||||
val dateReceived: Long,
|
||||
val dateSent: Long,
|
||||
val dateReceived: Duration,
|
||||
val dateSent: Duration,
|
||||
val isRead: Boolean,
|
||||
val isOutgoing: Boolean,
|
||||
val parts: List<Part>,
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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<Unit?> {
|
||||
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)
|
||||
|
|
|
@ -10,7 +10,6 @@ data class SmsExportState(
|
|||
val completedRecipients: Set<String> = emptySet(),
|
||||
val startedAttachments: Set<String> = emptySet(),
|
||||
val completedAttachments: Set<String> = emptySet(),
|
||||
val copiedAttachments: Set<String> = emptySet(),
|
||||
val progress: Progress = Progress.INIT
|
||||
) {
|
||||
enum class Progress {
|
||||
|
|
|
@ -19,7 +19,7 @@ import org.signal.smsexporter.ReleaseSmsAppFailure
|
|||
internal object ReleaseDefaultSmsUseCase {
|
||||
fun execute(context: Context): Result<Intent, ReleaseSmsAppFailure> {
|
||||
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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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()
|
||||
)
|
||||
|
|
|
@ -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<String> = setOf("+15555060177"),
|
||||
dateReceived: Long = 2,
|
||||
dateSent: Long = 1,
|
||||
dateReceived: Duration = 2.seconds,
|
||||
dateSent: Duration = 1.seconds,
|
||||
isRead: Boolean = false,
|
||||
isOutgoing: Boolean = false,
|
||||
parts: List<ExportableMessage.Mms.Part> = 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() {
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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))
|
||||
|
|
Loading…
Add table
Reference in a new issue