Round out sms/mms export process.

This commit is contained in:
Cody Henthorne
2022-09-28 10:47:37 -04:00
parent 0e4bec3977
commit 31f31534ce
31 changed files with 792 additions and 197 deletions

View File

@@ -141,14 +141,16 @@ class SmsSettingsFragment : DSLSettingsFragment(R.string.preferences__sms_mms) {
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) { _, _ -> }
.setMessage(R.string.RemoveSmsMessagesDialogFragment__you_can_now_remove_sms_messages_from_signal)
.setPositiveButton(R.string.RemoveSmsMessagesDialogFragment__keep_messages) { _, _ ->
Snackbar.make(requireView(), R.string.SmsSettingsFragment__you_can_remove_sms_messages_from_signal_in_settings, Snackbar.LENGTH_SHORT).show()
}
.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()
Snackbar.make(requireView(), R.string.SmsSettingsFragment__removing_sms_messages_from_signal, Snackbar.LENGTH_SHORT).show()
}
.show()
}

View File

@@ -3,10 +3,14 @@ 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.MessageDatabase
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.util.FeatureFlags
class SmsSettingsRepository {
class SmsSettingsRepository(
private val smsDatabase: MessageDatabase = SignalDatabase.sms,
private val mmsDatabase: MessageDatabase = SignalDatabase.mms
) {
fun getSmsExportState(): Single<SmsSettingsState.SmsExportState> {
if (!FeatureFlags.smsExporter()) {
return Single.just(SmsSettingsState.SmsExportState.NOT_AVAILABLE)
@@ -19,9 +23,7 @@ class SmsSettingsRepository {
@WorkerThread
private fun checkInsecureMessageCount(): SmsSettingsState.SmsExportState? {
val smsCount = SignalDatabase.sms.insecureMessageCount
val mmsCount = SignalDatabase.mms.insecureMessageCount
val totalSmsMmsCount = smsCount + mmsCount
val totalSmsMmsCount = smsDatabase.insecureMessageCount + mmsDatabase.insecureMessageCount
return if (totalSmsMmsCount == 0) {
SmsSettingsState.SmsExportState.NO_SMS_MESSAGES_IN_DATABASE
@@ -32,9 +34,7 @@ class SmsSettingsRepository {
@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
val totalUnexportedCount = smsDatabase.unexportedInsecureMessagesCount + mmsDatabase.unexportedInsecureMessagesCount
return if (totalUnexportedCount > 0) {
SmsSettingsState.SmsExportState.HAS_UNEXPORTED_MESSAGES

View File

@@ -96,7 +96,7 @@ 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 Cursor getUnexportedInsecureMessages(int limit);
public abstract int getInsecureMessageCount();
public abstract void deleteExportedMessages();
@@ -360,11 +360,22 @@ public abstract class MessageDatabase extends Database implements MmsSmsColumns,
}
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) + ")";
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) + ")";
String isNotSecure = "(" + getTypeField() + " <= " + (Types.BASE_TYPE_MASK | Types.MESSAGE_ATTRIBUTE_MASK) + ")";
return String.format(Locale.ENGLISH, "(%s OR %s) AND NOT %s", isSent, isReceived, isSecure);
return String.format(Locale.ENGLISH, "(%s OR %s) AND NOT %s AND %s", isSent, isReceived, isSecure, isNotSecure);
}
public int getUnexportedInsecureMessagesCount() {
try (Cursor cursor = getWritableDatabase().query(getTableName(), SqlUtil.COUNT, getInsecureMessageClause() + " AND NOT " + EXPORTED, null, null, null, null)) {
if (cursor.moveToFirst()) {
return cursor.getInt(0);
}
}
return 0;
}
public void setReactionsSeen(long threadId, long sinceTimestamp) {

View File

@@ -54,7 +54,6 @@ import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.database.model.MessageId;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.database.model.NotificationMmsMessageRecord;
import org.thoughtcrime.securesms.database.model.ParentStoryId;
import org.thoughtcrime.securesms.database.model.Quote;
@@ -2422,13 +2421,13 @@ public class MmsDatabase extends MessageDatabase {
}
@Override
public Cursor getUnexportedInsecureMessages() {
public Cursor getUnexportedInsecureMessages(int limit) {
return rawQuery(
SqlUtil.appendArg(MMS_PROJECTION, EXPORT_STATE),
getInsecureMessageClause() + " AND NOT " + EXPORTED,
null,
false,
0
limit
);
}

View File

@@ -908,13 +908,13 @@ public class SmsDatabase extends MessageDatabase {
}
@Override
public Cursor getUnexportedInsecureMessages() {
public Cursor getUnexportedInsecureMessages(int limit) {
return queryMessages(
SqlUtil.appendArg(MESSAGE_PROJECTION, EXPORT_STATE),
getInsecureMessageClause() + " AND NOT " + EXPORTED,
null,
false,
-1
limit
);
}

View File

@@ -1,136 +1,222 @@
package org.thoughtcrime.securesms.exporter
import android.database.Cursor
import org.signal.core.util.logging.Log
import org.signal.smsexporter.ExportableMessage
import org.signal.smsexporter.SmsExportState
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.database.MessageDatabase
import org.thoughtcrime.securesms.database.MmsDatabase
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.SmsDatabase
import org.thoughtcrime.securesms.database.model.MessageId
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.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.JsonUtils
import java.io.Closeable
import kotlin.time.Duration.Companion.milliseconds
/**
* Reads through the SMS and MMS databases for insecure messages that haven't been exported. Due to cursor size limitations
* we "page" through the unexported messages to reduce chances of exceeding that limit.
*/
class SignalSmsExportReader(
smsCursor: Cursor,
mmsCursor: Cursor
private val smsDatabase: MessageDatabase = SignalDatabase.sms,
private val mmsDatabase: MessageDatabase = SignalDatabase.mms
) : Iterable<ExportableMessage>, Closeable {
private val smsReader = SmsDatabase.readerFor(smsCursor)
private val mmsReader = MmsDatabase.readerFor(mmsCursor)
companion object {
private val TAG = Log.tag(SignalSmsExportReader::class.java)
private const val CURSOR_LIMIT = 1000
}
private var smsReader: SmsDatabase.Reader? = null
private var smsDone: Boolean = false
private var mmsReader: MmsDatabase.Reader? = null
private var mmsDone: Boolean = false
override fun iterator(): Iterator<ExportableMessage> {
return ExportableMessageIterator()
}
fun getCount(): Int {
return smsReader.count + mmsReader.count
return smsDatabase.unexportedInsecureMessagesCount + mmsDatabase.unexportedInsecureMessagesCount
}
override fun close() {
smsReader.close()
mmsReader.close()
smsReader?.close()
mmsReader?.close()
}
private fun refreshReaders() {
if (!smsDone) {
smsReader?.close()
smsReader = null
val refreshedSmsReader = SmsDatabase.readerFor(smsDatabase.getUnexportedInsecureMessages(CURSOR_LIMIT))
if (refreshedSmsReader.count > 0) {
smsReader = refreshedSmsReader
return
} else {
refreshedSmsReader.close()
smsDone = true
}
}
if (!mmsDone) {
mmsReader?.close()
mmsReader = null
val refreshedMmsReader = MmsDatabase.readerFor(mmsDatabase.getUnexportedInsecureMessages(CURSOR_LIMIT))
if (refreshedMmsReader.count > 0) {
mmsReader = refreshedMmsReader
return
} else {
refreshedMmsReader.close()
mmsDone = true
}
}
}
private inner class ExportableMessageIterator : Iterator<ExportableMessage> {
private val smsIterator = smsReader.iterator()
private val mmsIterator = mmsReader.iterator()
private var smsIterator: Iterator<MessageRecord>? = null
private var mmsIterator: Iterator<MessageRecord>? = null
private fun refreshIterators() {
refreshReaders()
smsIterator = smsReader?.iterator()
mmsIterator = mmsReader?.iterator()
}
override fun hasNext(): Boolean {
return smsIterator.hasNext() || mmsIterator.hasNext()
if (smsIterator?.hasNext() == true) {
return true
} else if (!smsDone) {
refreshIterators()
if (smsIterator?.hasNext() == true) {
return true
}
}
if (mmsIterator?.hasNext() == true) {
return true
} else if (!mmsDone) {
refreshIterators()
if (mmsIterator?.hasNext() == true) {
return true
}
}
return false
}
override fun next(): ExportableMessage {
return if (smsIterator.hasNext()) {
readExportableSmsMessageFromRecord(smsIterator.next())
} else if (mmsIterator.hasNext()) {
readExportableMmsMessageFromRecord(mmsIterator.next())
var record: MessageRecord? = null
try {
return if (smsIterator?.hasNext() == true) {
record = smsIterator!!.next()
readExportableSmsMessageFromRecord(record, smsReader!!.messageExportStateForCurrentRecord)
} else if (mmsIterator?.hasNext() == true) {
record = mmsIterator!!.next()
readExportableMmsMessageFromRecord(record, mmsReader!!.messageExportStateForCurrentRecord)
} else {
throw NoSuchElementException()
}
} catch (e: Throwable) {
Log.w(TAG, "Error processing message: isMms: ${record?.isMms} type: ${record?.type}")
throw e
}
}
private fun readExportableMmsMessageFromRecord(record: MessageRecord, exportState: MessageExportState): ExportableMessage {
val threadRecipient: Recipient = SignalDatabase.threads.getRecipientForThreadId(record.threadId)!!
val addresses: Set<String> = if (threadRecipient.isMmsGroup) {
Recipient
.resolvedList(threadRecipient.participantIds)
.map { r -> r.smsExportAddress() }
.toSet()
} else {
throw NoSuchElementException()
setOf(threadRecipient.smsExportAddress())
}
}
}
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 parts: MutableList<ExportableMessage.Mms.Part> = mutableListOf()
if (record.body.isNotBlank()) {
parts.add(ExportableMessage.Mms.Part.Text(record.body))
}
}
val sender = if (record.isOutgoing) Recipient.self().requireSmsAddress() else record.individualRecipient.requireSmsAddress()
if (record is MmsMessageRecord) {
val slideDeck = record.slideDeck
slideDeck
.slides
.filter { it.asAttachment() is DatabaseAttachment }
.forEach {
parts.add(
ExportableMessage.Mms.Part.Stream(
id = JsonUtils.toJson((it.asAttachment() as DatabaseAttachment).attachmentId),
contentType = it.contentType
)
)
}
}
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
)
}
val sender: String = if (record.isOutgoing) Recipient.self().smsExportAddress() else record.individualRecipient.smsExportAddress()
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(),
return ExportableMessage.Mms(
id = MessageId(record.id, record.isMms),
exportState = mapExportState(exportState),
addresses = addresses,
dateReceived = record.dateReceived.milliseconds,
dateSent = record.dateSent.milliseconds,
isRead = true,
isOutgoing = record.isOutgoing,
body = record.body
parts = parts,
sender = sender
)
}
}
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
}
private fun readExportableSmsMessageFromRecord(record: MessageRecord, exportState: MessageExportState): ExportableMessage {
val threadRecipient = SignalDatabase.threads.getRecipientForThreadId(record.threadId)!!
return if (threadRecipient.isMmsGroup) {
readExportableMmsMessageFromRecord(record, exportState)
} else {
ExportableMessage.Sms(
id = MessageId(record.id, record.isMms),
exportState = mapExportState(exportState),
address = record.recipient.smsExportAddress(),
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
}
}
)
}
private fun Recipient.smsExportAddress(): String {
return smsAddress.orElseGet { getDisplayName(ApplicationDependencies.getApplication()) }
}
}
}

View File

@@ -4,6 +4,7 @@ import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import app.cash.exhaustive.Exhaustive
import org.signal.smsexporter.ExportableMessage
import org.signal.smsexporter.SmsExportService
import org.thoughtcrime.securesms.R
@@ -36,7 +37,7 @@ class SignalSmsExportService : SmsExportService() {
return ExportNotification(
NotificationIds.SMS_EXPORT_SERVICE,
NotificationCompat.Builder(this, NotificationChannels.BACKUPS)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setSmallIcon(R.drawable.ic_signal_backup)
.setContentTitle(getString(R.string.SignalSmsExportService__exporting_messages))
.setProgress(total, progress, false)
.build()
@@ -126,18 +127,22 @@ class SignalSmsExportService : SmsExportService() {
}
private fun ExportableMessage.getMessageId(): MessageId {
return when (this) {
is ExportableMessage.Mms -> MessageId(id.toLong(), true)
is ExportableMessage.Sms -> MessageId(id.toLong(), false)
@Exhaustive
val messageId: Any = when (this) {
is ExportableMessage.Mms<*> -> id
is ExportableMessage.Sms<*> -> id
}
if (messageId is MessageId) {
return messageId
} else {
throw AssertionError("Exportable message id must be type MessageId. Type: ${messageId.javaClass}")
}
}
private fun ensureReader() {
if (reader == null) {
reader = SignalSmsExportReader(
smsCursor = SignalDatabase.sms.unexportedInsecureMessages,
mmsCursor = SignalDatabase.mms.unexportedInsecureMessages
)
reader = SignalSmsExportReader()
}
}
}

View File

@@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.exporter.flow
import android.app.Activity
import android.os.Build
import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
@@ -23,6 +24,12 @@ class ChooseANewDefaultSmsAppFragment : Fragment(R.layout.choose_a_new_default_s
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val binding = ChooseANewDefaultSmsAppFragmentBinding.bind(view)
if (Build.VERSION.SDK_INT < 24) {
binding.bullet1Text.setText(R.string.ChooseANewDefaultSmsAppFragment__open_your_phones_settings_app)
binding.bullet2Text.setText(R.string.ChooseANewDefaultSmsAppFragment__navigate_to_apps_default_apps_sms_app)
binding.continueButton.setText(R.string.ChooseANewDefaultSmsAppFragment__done)
}
DefaultSmsHelper.releaseDefaultSms(requireContext()).either(
onSuccess = {
binding.continueButton.setOnClickListener { _ -> startActivity(it) }

View File

@@ -1,10 +1,14 @@
package org.thoughtcrime.securesms.exporter.flow
import android.content.Context
import android.os.Bundle
import android.view.ContextThemeWrapper
import android.view.LayoutInflater
import android.view.View
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.disposables.Disposable
import org.signal.smsexporter.SmsExportProgress
import org.signal.smsexporter.SmsExportService
import org.thoughtcrime.securesms.R
@@ -20,6 +24,30 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate
class ExportingSmsMessagesFragment : Fragment(R.layout.exporting_sms_messages_fragment) {
private val lifecycleDisposable = LifecycleDisposable()
private var navigationDisposable = Disposable.disposed()
override fun onGetLayoutInflater(savedInstanceState: Bundle?): LayoutInflater {
val inflater = super.onGetLayoutInflater(savedInstanceState)
val contextThemeWrapper: Context = ContextThemeWrapper(requireContext(), R.style.Signal_DayNight)
return inflater.cloneInContext(contextThemeWrapper)
}
override fun onResume() {
super.onResume()
navigationDisposable = SmsExportService
.progressState
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
if (it is SmsExportProgress.Done) {
findNavController().safeNavigate(ExportingSmsMessagesFragmentDirections.actionExportingSmsMessagesFragmentToChooseANewDefaultSmsAppFragment())
}
}
}
override fun onPause() {
super.onPause()
navigationDisposable.dispose()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val binding = ExportingSmsMessagesFragmentBinding.bind(view)
@@ -27,9 +55,7 @@ class ExportingSmsMessagesFragment : Fragment(R.layout.exporting_sms_messages_fr
lifecycleDisposable.bindTo(viewLifecycleOwner)
lifecycleDisposable += SmsExportService.progressState.observeOn(AndroidSchedulers.mainThread()).subscribe {
when (it) {
SmsExportProgress.Done -> {
findNavController().safeNavigate(ExportingSmsMessagesFragmentDirections.actionExportingSmsMessagesFragmentToChooseANewDefaultSmsAppFragment())
}
SmsExportProgress.Done -> Unit
is SmsExportProgress.InProgress -> {
binding.progress.isIndeterminate = false
binding.progress.max = it.total

View File

@@ -6,8 +6,15 @@ import androidx.fragment.app.Fragment
import androidx.navigation.fragment.NavHostFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.FragmentWrapperActivity
import org.thoughtcrime.securesms.util.WindowUtil
class SmsExportActivity : FragmentWrapperActivity() {
override fun onResume() {
super.onResume()
WindowUtil.setLightStatusBarFromTheme(this)
}
override fun getFragment(): Fragment {
return NavHostFragment.create(R.navigation.sms_export)
}