mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-24 13:08:46 +00:00
Add initial sms exporter integration behind a feature flag.
This commit is contained in:
committed by
Greyson Parrelli
parent
1cc39fb89b
commit
936212e684
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user