Add initial sms exporter integration behind a feature flag.

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

View File

@@ -1,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>,

View File

@@ -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.

View File

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

View File

@@ -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 {

View File

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

View File

@@ -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
}

View File

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

View File

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

View File

@@ -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() {

View File

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

View File

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