diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupExporter.java b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupExporter.java index b958e73cf3..98f09db573 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupExporter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupExporter.java @@ -19,8 +19,8 @@ import org.greenrobot.eventbus.EventBus; import org.signal.core.util.Conversions; import org.signal.core.util.CursorUtil; import org.signal.core.util.SetUtil; +import org.signal.core.util.Stopwatch; import org.signal.core.util.logging.Log; -import org.signal.libsignal.protocol.kdf.HKDF; import org.signal.libsignal.protocol.kdf.HKDFv3; import org.signal.libsignal.protocol.util.ByteUtil; import org.thoughtcrime.securesms.attachments.AttachmentId; @@ -50,7 +50,6 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.keyvalue.KeyValueDataSet; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.profiles.AvatarHelper; -import org.thoughtcrime.securesms.util.Stopwatch; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; @@ -79,22 +78,22 @@ public class FullBackupExporter extends FullBackupBase { private static final String TAG = Log.tag(FullBackupExporter.class); - private static final long DATABASE_VERSION_RECORD_COUNT = 1L; - private static final long TABLE_RECORD_COUNT_MULTIPLIER = 3L; + private static final long DATABASE_VERSION_RECORD_COUNT = 1L; + private static final long TABLE_RECORD_COUNT_MULTIPLIER = 3L; private static final long IDENTITY_KEY_BACKUP_RECORD_COUNT = 2L; - private static final long FINAL_MESSAGE_COUNT = 1L; + private static final long FINAL_MESSAGE_COUNT = 1L; private static final Set BLACKLISTED_TABLES = SetUtil.newHashSet( - SignedPreKeyDatabase.TABLE_NAME, - OneTimePreKeyDatabase.TABLE_NAME, - SessionDatabase.TABLE_NAME, - SearchDatabase.SMS_FTS_TABLE_NAME, - SearchDatabase.MMS_FTS_TABLE_NAME, - EmojiSearchDatabase.TABLE_NAME, - SenderKeyDatabase.TABLE_NAME, - SenderKeySharedDatabase.TABLE_NAME, - PendingRetryReceiptDatabase.TABLE_NAME, - AvatarPickerDatabase.TABLE_NAME + SignedPreKeyDatabase.TABLE_NAME, + OneTimePreKeyDatabase.TABLE_NAME, + SessionDatabase.TABLE_NAME, + SearchDatabase.SMS_FTS_TABLE_NAME, + SearchDatabase.MMS_FTS_TABLE_NAME, + EmojiSearchDatabase.TABLE_NAME, + SenderKeyDatabase.TABLE_NAME, + SenderKeySharedDatabase.TABLE_NAME, + PendingRetryReceiptDatabase.TABLE_NAME, + AvatarPickerDatabase.TABLE_NAME ); public static void export(@NonNull Context context, @@ -315,7 +314,7 @@ public class FullBackupExporter extends FullBackupBase { statement.append('('); - for (int i=0;i { + + data class Failure(val failure: F) : Result() + data class Success(val success: S) : Result() + + companion object { + fun success(value: S) = Success(value) + fun failure(value: F) = Failure(value) + } + + /** + * Maps an Result to an Result. Failure values will pass through, while + * right values will be operated on by the parameter. + */ + fun map(onSuccess: (S) -> T): Result { + return when (this) { + is Failure -> this + is Success -> success(onSuccess(success)) + } + } + + /** + * Allows the caller to operate on the Result such that the correct function is applied + * to the value it contains. + */ + fun either( + onSuccess: (S) -> T, + onFailure: (F) -> T + ): T { + return when (this) { + is Success -> onSuccess(success) + is Failure -> onFailure(failure) + } + } +} + +/** + * Maps an Result to an Result. Failure values will pass through, while + * right values will be operated on by the parameter. + * + * Note this is an extension method in order to make the generics happy. + */ +fun Result.flatMap(onSuccess: (S) -> Result): Result { + return when (this) { + is Result.Success -> onSuccess(success) + is Result.Failure -> this + } +} + +/** + * Try is a specialization of Result where the Failure is fixed to Throwable. + */ +typealias Try = Result diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Stopwatch.java b/core-util/src/main/java/org/signal/core/util/Stopwatch.java similarity index 97% rename from app/src/main/java/org/thoughtcrime/securesms/util/Stopwatch.java rename to core-util/src/main/java/org/signal/core/util/Stopwatch.java index 22ee9d67cd..23f700c9a3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/Stopwatch.java +++ b/core-util/src/main/java/org/signal/core/util/Stopwatch.java @@ -1,4 +1,4 @@ -package org.thoughtcrime.securesms.util; +package org.signal.core.util; import androidx.annotation.NonNull; diff --git a/dependencies.gradle b/dependencies.gradle index a86b9b81c6..251910caf1 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -48,6 +48,7 @@ dependencyResolutionManagement { alias('androidx-biometric').to('androidx.biometric:biometric:1.1.0') alias('androidx-sharetarget').to('androidx.sharetarget:sharetarget:1.1.0') alias('androidx-sqlite').to('androidx.sqlite:sqlite:2.1.0') + alias('androidx-core-role').to('androidx.core:core-role:1.0.0') // Material alias('material-material').to('com.google.android.material:material:1.5.0') diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 897d604540..8dddcca499 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -336,6 +336,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -2850,6 +2855,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -2858,6 +2871,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -2866,6 +2887,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -2874,6 +2903,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -2882,6 +2919,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -2890,6 +2935,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -2898,6 +2951,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -2906,6 +2967,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -2914,6 +2983,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -2922,6 +2999,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -2930,6 +3015,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -3095,6 +3188,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -3103,6 +3201,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -3134,6 +3237,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -4602,6 +4710,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -4634,6 +4747,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -4738,6 +4856,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -4754,6 +4877,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -4859,6 +4987,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -4920,6 +5053,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -4960,6 +5098,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -5000,6 +5143,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + diff --git a/settings.gradle b/settings.gradle index 78f4e11b59..e6ad5e1c3d 100644 --- a/settings.gradle +++ b/settings.gradle @@ -12,6 +12,8 @@ include ':device-transfer' include ':device-transfer-app' include ':image-editor' include ':image-editor-app' +include ':sms-exporter' +include ':sms-exporter-app' include ':donations' include ':donations-app' include ':spinner' @@ -33,6 +35,9 @@ project(':libsignal-service').projectDir = file('libsignal/service') project(':image-editor').projectDir = file('image-editor/lib') project(':image-editor-app').projectDir = file('image-editor/app') +project(':sms-exporter').projectDir = file('sms-exporter/lib') +project(':sms-exporter-app').projectDir = file('sms-exporter/app') + project(':donations').projectDir = file('donations/lib') project(':donations-app').projectDir = file('donations/app') diff --git a/sms-exporter/app/build.gradle b/sms-exporter/app/build.gradle new file mode 100644 index 0000000000..aff7f90af3 --- /dev/null +++ b/sms-exporter/app/build.gradle @@ -0,0 +1,19 @@ +apply from: "$rootProject.projectDir/signalModuleApp.gradle" + +android { + defaultConfig { + applicationId "org.signal.smsexporter.app" + } +} + +dependencies { + implementation libs.androidx.core.ktx + implementation libs.androidx.appcompat + implementation libs.androidx.core.role + implementation libs.material.material + implementation libs.rxjava3.rxjava + implementation libs.rxjava3.rxandroid + implementation libs.rxjava3.rxkotlin + implementation project(':core-util') + implementation project(':sms-exporter') +} diff --git a/sms-exporter/app/proguard-rules.pro b/sms-exporter/app/proguard-rules.pro new file mode 100644 index 0000000000..481bb43481 --- /dev/null +++ b/sms-exporter/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/sms-exporter/app/src/main/AndroidManifest.xml b/sms-exporter/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..e3bb0ff3e2 --- /dev/null +++ b/sms-exporter/app/src/main/AndroidManifest.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sms-exporter/app/src/main/java/org/signal/smsexporter/app/BitmapGenerator.kt b/sms-exporter/app/src/main/java/org/signal/smsexporter/app/BitmapGenerator.kt new file mode 100644 index 0000000000..51a60835f5 --- /dev/null +++ b/sms-exporter/app/src/main/java/org/signal/smsexporter/app/BitmapGenerator.kt @@ -0,0 +1,35 @@ +package org.signal.smsexporter.app + +import android.graphics.Bitmap +import android.graphics.Color +import androidx.core.graphics.applyCanvas +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.util.Random + +object BitmapGenerator { + + private val colors = listOf( + Color.BLACK, + Color.BLUE, + Color.GRAY, + Color.GREEN, + Color.RED, + Color.CYAN + ) + + fun getStream(): InputStream { + val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) + bitmap.applyCanvas { + val random = Random() + drawColor(colors[random.nextInt(colors.size - 1)]) + } + + val out = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.JPEG, 80, out) + val data = out.toByteArray() + + return ByteArrayInputStream(data) + } +} diff --git a/sms-exporter/app/src/main/java/org/signal/smsexporter/app/BroadcastSmsReceiver.kt b/sms-exporter/app/src/main/java/org/signal/smsexporter/app/BroadcastSmsReceiver.kt new file mode 100644 index 0000000000..79cadd9826 --- /dev/null +++ b/sms-exporter/app/src/main/java/org/signal/smsexporter/app/BroadcastSmsReceiver.kt @@ -0,0 +1,10 @@ +package org.signal.smsexporter.app + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent + +class BroadcastSmsReceiver : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + } +} diff --git a/sms-exporter/app/src/main/java/org/signal/smsexporter/app/BroadcastWapPushReceiver.kt b/sms-exporter/app/src/main/java/org/signal/smsexporter/app/BroadcastWapPushReceiver.kt new file mode 100644 index 0000000000..04625a965f --- /dev/null +++ b/sms-exporter/app/src/main/java/org/signal/smsexporter/app/BroadcastWapPushReceiver.kt @@ -0,0 +1,10 @@ +package org.signal.smsexporter.app + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent + +class BroadcastWapPushReceiver : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + } +} diff --git a/sms-exporter/app/src/main/java/org/signal/smsexporter/app/MainActivity.kt b/sms-exporter/app/src/main/java/org/signal/smsexporter/app/MainActivity.kt new file mode 100644 index 0000000000..93e9c46a58 --- /dev/null +++ b/sms-exporter/app/src/main/java/org/signal/smsexporter/app/MainActivity.kt @@ -0,0 +1,140 @@ +package org.signal.smsexporter.app + +import android.content.Intent +import android.os.Bundle +import android.widget.TextView +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import com.google.android.material.button.MaterialButton +import com.google.android.material.progressindicator.LinearProgressIndicator +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import io.reactivex.rxjava3.schedulers.Schedulers +import org.signal.smsexporter.DefaultSmsHelper +import org.signal.smsexporter.ReleaseSmsAppFailure +import org.signal.smsexporter.SmsExportProgress +import org.signal.smsexporter.SmsExportService + +class MainActivity : AppCompatActivity(R.layout.main_activity) { + + private lateinit var exportSmsButton: MaterialButton + private lateinit var setAsDefaultSmsButton: MaterialButton + private lateinit var clearDefaultSmsButton: MaterialButton + private lateinit var exportStatus: TextView + private lateinit var exportProgress: LinearProgressIndicator + private val disposables = CompositeDisposable() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + exportSmsButton = findViewById(R.id.export_sms) + setAsDefaultSmsButton = findViewById(R.id.set_as_default_sms) + clearDefaultSmsButton = findViewById(R.id.clear_default_sms) + exportStatus = findViewById(R.id.export_status) + exportProgress = findViewById(R.id.export_progress) + + disposables += SmsExportService.progressState.onBackpressureLatest().subscribeOn(Schedulers.computation()).observeOn(AndroidSchedulers.mainThread()).subscribe { + when (it) { + SmsExportProgress.Done -> { + exportStatus.text = "Done" + exportProgress.isVisible = true + } + is SmsExportProgress.InProgress -> { + exportStatus.text = "$it" + exportProgress.isVisible = true + exportProgress.progress = it.progress + exportProgress.max = it.total + } + SmsExportProgress.Init -> { + exportStatus.text = "Init" + exportProgress.isVisible = false + } + SmsExportProgress.Starting -> { + exportStatus.text = "Starting" + exportProgress.isVisible = true + } + } + } + + setAsDefaultSmsButton.setOnClickListener { + DefaultSmsHelper.becomeDefaultSms(this).either( + onFailure = { onAppIsIneligableForDefaultSmsSelection() }, + onSuccess = this::onStartActivityForDefaultSmsSelection + ) + } + + clearDefaultSmsButton.setOnClickListener { + DefaultSmsHelper.releaseDefaultSms(this).either( + onFailure = { + when (it) { + ReleaseSmsAppFailure.APP_IS_INELIGABLE_TO_RELEASE_SMS_SELECTION -> onAppIsIneligableForReleaseSmsSelection() + ReleaseSmsAppFailure.NO_METHOD_TO_RELEASE_SMS_AVIALABLE -> onNoMethodToReleaseSmsAvailable() + } + }, + onSuccess = this::onStartActivityForReleaseSmsSelection + ) + } + + exportSmsButton.setOnClickListener { + exportSmsButton.isEnabled = false + ContextCompat.startForegroundService(this, Intent(this, TestSmsExportService::class.java)) + } + + presentButtonState() + } + + override fun onResume() { + super.onResume() + presentButtonState() + } + + override fun onDestroy() { + super.onDestroy() + disposables.clear() + } + + private fun presentButtonState() { + setAsDefaultSmsButton.isVisible = !DefaultSmsHelper.isDefaultSms(this) + clearDefaultSmsButton.isVisible = DefaultSmsHelper.isDefaultSms(this) + exportSmsButton.isVisible = DefaultSmsHelper.isDefaultSms(this) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + when (requestCode) { + 1 -> presentButtonState() + 2 -> presentButtonState() + else -> super.onActivityResult(requestCode, resultCode, data) + } + } + + private fun onStartActivityForDefaultSmsSelection(intent: Intent) { + startActivityForResult(intent, 1) + } + + private fun onAppIsIneligableForDefaultSmsSelection() { + if (DefaultSmsHelper.isDefaultSms(this)) { + Toast.makeText(this, "Already the SMS manager.", Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(this, "Cannot be SMS manager.", Toast.LENGTH_SHORT).show() + } + } + + private fun onStartActivityForReleaseSmsSelection(intent: Intent) { + startActivityForResult(intent, 2) + } + + private fun onAppIsIneligableForReleaseSmsSelection() { + if (!DefaultSmsHelper.isDefaultSms(this)) { + Toast.makeText(this, "Already not the SMS manager.", Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(this, "Cannot be SMS manager.", Toast.LENGTH_SHORT).show() + } + } + + private fun onNoMethodToReleaseSmsAvailable() { + Toast.makeText(this, "Cannot automatically release sms. Display manual instructions.", Toast.LENGTH_SHORT).show() + } +} diff --git a/sms-exporter/app/src/main/java/org/signal/smsexporter/app/SendResponseViaMessageService.kt b/sms-exporter/app/src/main/java/org/signal/smsexporter/app/SendResponseViaMessageService.kt new file mode 100644 index 0000000000..5857bab86c --- /dev/null +++ b/sms-exporter/app/src/main/java/org/signal/smsexporter/app/SendResponseViaMessageService.kt @@ -0,0 +1,11 @@ +package org.signal.smsexporter.app + +import android.app.Service +import android.content.Intent +import android.os.IBinder + +class SendResponseViaMessageService : Service() { + override fun onBind(intent: Intent?): IBinder? { + return null + } +} diff --git a/sms-exporter/app/src/main/java/org/signal/smsexporter/app/TestSmsExportService.kt b/sms-exporter/app/src/main/java/org/signal/smsexporter/app/TestSmsExportService.kt new file mode 100644 index 0000000000..7aecd9d530 --- /dev/null +++ b/sms-exporter/app/src/main/java/org/signal/smsexporter/app/TestSmsExportService.kt @@ -0,0 +1,165 @@ +package org.signal.smsexporter.app + +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import org.signal.core.util.logging.Log +import org.signal.smsexporter.ExportableMessage +import org.signal.smsexporter.SmsExportService +import org.signal.smsexporter.SmsExportState +import java.io.InputStream + +class TestSmsExportService : SmsExportService() { + + companion object { + private val TAG = Log.tag(TestSmsExportService::class.java) + + private const val NOTIFICATION_ID = 1234 + private const val NOTIFICATION_CHANNEL_ID = "sms_export" + + private const val startTime = 1659377120L + } + + override fun getNotification(progress: Int, total: Int): ExportNotification { + ensureNotificationChannel() + return ExportNotification( + id = NOTIFICATION_ID, + NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle("Test Exporter") + .setProgress(total, progress, false) + .build() + ) + } + + override fun getExportState(exportableMessage: ExportableMessage): SmsExportState { + return SmsExportState() + } + + override fun getUnexportedMessageCount(): Int { + return 50 + } + + override fun getUnexportedMessages(): Iterable { + return object : Iterable { + override fun iterator(): Iterator { + return ExportableMessageIterator(getUnexportedMessageCount()) + } + } + } + + override fun onMessageExportStarted(exportableMessage: ExportableMessage) { + Log.d(TAG, "onMessageExportStarted() called with: exportableMessage = $exportableMessage") + } + + override fun onMessageExportSucceeded(exportableMessage: ExportableMessage) { + Log.d(TAG, "onMessageExportSucceeded() called with: exportableMessage = $exportableMessage") + } + + override fun onMessageExportFailed(exportableMessage: ExportableMessage) { + Log.d(TAG, "onMessageExportFailed() called with: exportableMessage = $exportableMessage") + } + + override fun onMessageIdCreated(exportableMessage: ExportableMessage, messageId: Long) { + Log.d(TAG, "onMessageIdCreated() called with: exportableMessage = $exportableMessage, messageId = $messageId") + } + + override fun onAttachmentPartExportStarted(exportableMessage: ExportableMessage, part: ExportableMessage.Mms.Part) { + Log.d(TAG, "onAttachmentPartExportStarted() called with: exportableMessage = $exportableMessage, attachment = $part") + } + + override fun onAttachmentPartExportSucceeded(exportableMessage: ExportableMessage, part: ExportableMessage.Mms.Part) { + Log.d(TAG, "onAttachmentPartExportSucceeded() called with: exportableMessage = $exportableMessage, attachment = $part") + } + + override fun onAttachmentPartExportFailed(exportableMessage: ExportableMessage, part: ExportableMessage.Mms.Part) { + Log.d(TAG, "onAttachmentPartExportFailed() called with: exportableMessage = $exportableMessage, attachment = $part") + } + + override fun onRecipientExportStarted(exportableMessage: ExportableMessage, recipient: String) { + Log.d(TAG, "onRecipientExportStarted() called with: exportableMessage = $exportableMessage, recipient = $recipient") + } + + override fun onRecipientExportSucceeded(exportableMessage: ExportableMessage, recipient: String) { + Log.d(TAG, "onRecipientExportSucceeded() called with: exportableMessage = $exportableMessage, recipient = $recipient") + } + + override fun onRecipientExportFailed(exportableMessage: ExportableMessage, recipient: String) { + Log.d(TAG, "onRecipientExportFailed() called with: exportableMessage = $exportableMessage, recipient = $recipient") + } + + override fun getInputStream(part: ExportableMessage.Mms.Part): InputStream { + 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") + } + + private fun ensureNotificationChannel() { + val notificationManager = NotificationManagerCompat.from(this) + val channel = notificationManager.getNotificationChannel(NOTIFICATION_CHANNEL_ID) + if (channel == null) { + val newChannel = NotificationChannelCompat + .Builder(NOTIFICATION_CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT) + .setName("misc") + .build() + + notificationManager.createNotificationChannel(newChannel) + } + } + + private class ExportableMessageIterator(private val size: Int) : Iterator { + private var emitted: Int = 0 + + override fun hasNext(): Boolean { + return emitted < size + } + + override fun next(): ExportableMessage { + val message = if (emitted % 2 == 0) { + getSmsMessage(emitted) + } else { + getMmsMessage(emitted) + } + + emitted++ + return message + } + + private fun getMmsMessage(it: Int): ExportableMessage.Mms { + val me = "+15065550101" + val addresses = setOf(me, "+15065550102", "+15065550121") + val address = addresses.random() + return ExportableMessage.Mms( + id = "$it", + addresses = addresses, + dateSent = startTime + it - 1, + dateReceived = startTime + it, + isRead = true, + isOutgoing = address == me, + sender = address, + parts = listOf( + ExportableMessage.Mms.Part.Text("Hello, $it from $address"), + ExportableMessage.Mms.Part.Stream("$it", "image/jpeg") + ) + ) + } + + private fun getSmsMessage(it: Int): ExportableMessage.Sms { + return ExportableMessage.Sms( + id = it.toString(), + address = "+15065550102", + body = "Hello, World! $it", + dateSent = startTime + it - 1, + dateReceived = startTime + it, + isRead = true, + isOutgoing = it % 4 == 0 + ) + } + } +} diff --git a/sms-exporter/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/sms-exporter/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000000..2b068d1146 --- /dev/null +++ b/sms-exporter/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/sms-exporter/app/src/main/res/drawable/ic_launcher_background.xml b/sms-exporter/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000000..07d5da9cbf --- /dev/null +++ b/sms-exporter/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sms-exporter/app/src/main/res/layout/main_activity.xml b/sms-exporter/app/src/main/res/layout/main_activity.xml new file mode 100644 index 0000000000..888c01c7d0 --- /dev/null +++ b/sms-exporter/app/src/main/res/layout/main_activity.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sms-exporter/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/sms-exporter/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000000..eca70cfe52 --- /dev/null +++ b/sms-exporter/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/sms-exporter/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/sms-exporter/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000000..eca70cfe52 --- /dev/null +++ b/sms-exporter/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/sms-exporter/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/sms-exporter/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000..c209e78ecd Binary files /dev/null and b/sms-exporter/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/sms-exporter/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/sms-exporter/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..b2dfe3d1ba Binary files /dev/null and b/sms-exporter/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/sms-exporter/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/sms-exporter/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000000..4f0f1d64e5 Binary files /dev/null and b/sms-exporter/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/sms-exporter/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/sms-exporter/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..62b611da08 Binary files /dev/null and b/sms-exporter/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/sms-exporter/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/sms-exporter/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000000..948a3070fe Binary files /dev/null and b/sms-exporter/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/sms-exporter/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/sms-exporter/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..1b9a6956b3 Binary files /dev/null and b/sms-exporter/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/sms-exporter/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/sms-exporter/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000000..28d4b77f9f Binary files /dev/null and b/sms-exporter/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/sms-exporter/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/sms-exporter/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..9287f50836 Binary files /dev/null and b/sms-exporter/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/sms-exporter/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/sms-exporter/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000000..aa7d6427e6 Binary files /dev/null and b/sms-exporter/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/sms-exporter/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/sms-exporter/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..9126ae37cb Binary files /dev/null and b/sms-exporter/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/sms-exporter/app/src/main/res/values/colors.xml b/sms-exporter/app/src/main/res/values/colors.xml new file mode 100644 index 0000000000..d2cd14a9ff --- /dev/null +++ b/sms-exporter/app/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + + #008577 + #00574B + #D81B60 + \ No newline at end of file diff --git a/sms-exporter/app/src/main/res/values/strings.xml b/sms-exporter/app/src/main/res/values/strings.xml new file mode 100644 index 0000000000..8e85f3485c --- /dev/null +++ b/sms-exporter/app/src/main/res/values/strings.xml @@ -0,0 +1,6 @@ + + Sms Exporter Test App + Set as default SMS app + Clear default SMS app + Export SMS + diff --git a/sms-exporter/app/src/main/res/values/themes.xml b/sms-exporter/app/src/main/res/values/themes.xml new file mode 100644 index 0000000000..ab7e2b3f8f --- /dev/null +++ b/sms-exporter/app/src/main/res/values/themes.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/sms-exporter/lib/build.gradle b/sms-exporter/lib/build.gradle new file mode 100644 index 0000000000..cea7936468 --- /dev/null +++ b/sms-exporter/lib/build.gradle @@ -0,0 +1,18 @@ +apply from: "$rootProject.projectDir/signalModule.gradle" + +dependencies { + lintChecks project(':lintchecks') + + implementation project(':core-util') + + coreLibraryDesugaring libs.android.tools.desugar + + implementation libs.androidx.core.ktx + implementation libs.androidx.annotation + implementation libs.androidx.appcompat + implementation libs.androidx.core.role + implementation libs.android.smsmms + implementation libs.rxjava3.rxjava + implementation libs.rxjava3.rxandroid + implementation libs.rxjava3.rxkotlin +} \ No newline at end of file diff --git a/sms-exporter/lib/consumer-rules.pro b/sms-exporter/lib/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sms-exporter/lib/proguard-rules.pro b/sms-exporter/lib/proguard-rules.pro new file mode 100644 index 0000000000..481bb43481 --- /dev/null +++ b/sms-exporter/lib/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/sms-exporter/lib/src/main/AndroidManifest.xml b/sms-exporter/lib/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..d6da870f81 --- /dev/null +++ b/sms-exporter/lib/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + diff --git a/sms-exporter/lib/src/main/java/org/signal/smsexporter/BecomeSmsAppFailure.kt b/sms-exporter/lib/src/main/java/org/signal/smsexporter/BecomeSmsAppFailure.kt new file mode 100644 index 0000000000..9d6ecd0a64 --- /dev/null +++ b/sms-exporter/lib/src/main/java/org/signal/smsexporter/BecomeSmsAppFailure.kt @@ -0,0 +1,13 @@ +package org.signal.smsexporter + +enum class BecomeSmsAppFailure { + /** + * Already the default sms app + */ + ALREADY_DEFAULT_SMS, + + /** + * The system doesn't think we are allowed to become the sms app + */ + ROLE_IS_NOT_AVAILABLE +} diff --git a/sms-exporter/lib/src/main/java/org/signal/smsexporter/DefaultSmsHelper.kt b/sms-exporter/lib/src/main/java/org/signal/smsexporter/DefaultSmsHelper.kt new file mode 100644 index 0000000000..d6413aae0d --- /dev/null +++ b/sms-exporter/lib/src/main/java/org/signal/smsexporter/DefaultSmsHelper.kt @@ -0,0 +1,26 @@ +package org.signal.smsexporter + +import android.content.Context +import org.signal.smsexporter.internal.BecomeDefaultSmsUseCase +import org.signal.smsexporter.internal.IsDefaultSms +import org.signal.smsexporter.internal.ReleaseDefaultSmsUseCase + +/** + * Basic API for checking / becoming / releasing default SMS + */ +object DefaultSmsHelper { + /** + * Checks whether this app is currently the default SMS app + */ + fun isDefaultSms(context: Context) = IsDefaultSms.checkIsDefaultSms(context) + + /** + * Attempts to get an Intent which can be launched to become the default SMS app + */ + fun becomeDefaultSms(context: Context) = BecomeDefaultSmsUseCase.execute(context) + + /** + * Attempts to get an Intent which can be launched to relinquish the role of default SMS app + */ + fun releaseDefaultSms(context: Context) = ReleaseDefaultSmsUseCase.execute(context) +} diff --git a/sms-exporter/lib/src/main/java/org/signal/smsexporter/ExportableMessage.kt b/sms-exporter/lib/src/main/java/org/signal/smsexporter/ExportableMessage.kt new file mode 100644 index 0000000000..2e763178d7 --- /dev/null +++ b/sms-exporter/lib/src/main/java/org/signal/smsexporter/ExportableMessage.kt @@ -0,0 +1,54 @@ +package org.signal.smsexporter + +/** + * Represents an exportable MMS or SMS message + */ +sealed interface ExportableMessage { + + /** + * An exportable SMS message + */ + data class Sms( + val id: String, + val address: String, + val dateReceived: Long, + val dateSent: Long, + val isRead: Boolean, + val isOutgoing: Boolean, + val body: String + ) : ExportableMessage + + /** + * An exportable MMS message + */ + data class Mms( + val id: String, + val addresses: Set, + val dateReceived: Long, + val dateSent: Long, + val isRead: Boolean, + val isOutgoing: Boolean, + val parts: List, + val sender: CharSequence + ) : ExportableMessage { + /** + * An attachment, attached to an MMS message + */ + sealed interface Part { + + val contentType: String + val contentId: String + + data class Text(val text: String) : Part { + override val contentType: String = "text/plain" + override val contentId: String = "text" + } + data class Stream( + val id: String, + override val contentType: String + ) : Part { + override val contentId: String = id + } + } + } +} diff --git a/sms-exporter/lib/src/main/java/org/signal/smsexporter/ReleaseSmsAppFailure.kt b/sms-exporter/lib/src/main/java/org/signal/smsexporter/ReleaseSmsAppFailure.kt new file mode 100644 index 0000000000..fa3f52ffa2 --- /dev/null +++ b/sms-exporter/lib/src/main/java/org/signal/smsexporter/ReleaseSmsAppFailure.kt @@ -0,0 +1,13 @@ +package org.signal.smsexporter + +enum class ReleaseSmsAppFailure { + /** + * Occurs when we are not the default sms app + */ + APP_IS_INELIGABLE_TO_RELEASE_SMS_SELECTION, + + /** + * No good way to release sms. Have to instruct user manually. + */ + NO_METHOD_TO_RELEASE_SMS_AVIALABLE +} diff --git a/sms-exporter/lib/src/main/java/org/signal/smsexporter/SmsExportProgress.kt b/sms-exporter/lib/src/main/java/org/signal/smsexporter/SmsExportProgress.kt new file mode 100644 index 0000000000..e2d3ea0042 --- /dev/null +++ b/sms-exporter/lib/src/main/java/org/signal/smsexporter/SmsExportProgress.kt @@ -0,0 +1,29 @@ +package org.signal.smsexporter + +/** + * Expresses the current progress of SMS exporting. + */ +sealed class SmsExportProgress { + /** + * Have not started yet. + */ + object Init : SmsExportProgress() + + /** + * Starting up and about to start processing messages + */ + object Starting : SmsExportProgress() + + /** + * Processing messages + */ + data class InProgress( + val progress: Int, + val total: Int + ) : SmsExportProgress() + + /** + * All done. + */ + object Done : SmsExportProgress() +} diff --git a/sms-exporter/lib/src/main/java/org/signal/smsexporter/SmsExportService.kt b/sms-exporter/lib/src/main/java/org/signal/smsexporter/SmsExportService.kt new file mode 100644 index 0000000000..b5cf31b67e --- /dev/null +++ b/sms-exporter/lib/src/main/java/org/signal/smsexporter/SmsExportService.kt @@ -0,0 +1,312 @@ +package org.signal.smsexporter + +import android.app.Notification +import android.app.Service +import android.content.Intent +import android.os.IBinder +import io.reactivex.rxjava3.processors.BehaviorProcessor +import org.signal.core.util.Result +import org.signal.core.util.Try +import org.signal.core.util.logging.Log +import org.signal.smsexporter.internal.mms.ExportMmsMessagesUseCase +import org.signal.smsexporter.internal.mms.ExportMmsPartsUseCase +import org.signal.smsexporter.internal.mms.ExportMmsRecipientsUseCase +import org.signal.smsexporter.internal.mms.GetOrCreateMmsThreadIdsUseCase +import org.signal.smsexporter.internal.sms.ExportSmsMessagesUseCase +import java.io.InputStream +import java.util.concurrent.Executor +import java.util.concurrent.Executors + +/** + * Exports SMS and MMS messages to the system database. + */ +abstract class SmsExportService : Service() { + + companion object { + private val TAG = Log.tag(SmsExportService::class.java) + + /** + * Progress state which can be listened to by interested components, such as fragments. + */ + val progressState: BehaviorProcessor = BehaviorProcessor.createDefault(SmsExportProgress.Init) + } + + override fun onBind(intent: Intent?): IBinder? { + return null + } + + private val threadCache: MutableMap, Long> = mutableMapOf() + private var isStarted = false + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Log.d(TAG, "Got start command in SMS Export Service") + + startExport() + + return START_NOT_STICKY + } + + private fun startExport() { + if (isStarted) { + return + } + + Log.d(TAG, "Running export...") + + isStarted = true + updateNotification(-1, -1) + progressState.onNext(SmsExportProgress.Starting) + + var progress = 0 + executor.execute { + val totalCount = getUnexportedMessageCount() + getUnexportedMessages().forEach { message -> + val exportState = getExportState(message) + if (exportState.progress != SmsExportState.Progress.COMPLETED) { + when (message) { + is ExportableMessage.Sms -> exportSms(exportState, message) + is ExportableMessage.Mms -> exportMms(exportState, message) + } + + progress++ + updateNotification(progress, totalCount) + progressState.onNext(SmsExportProgress.InProgress(progress, totalCount)) + } + } + + onExportPassCompleted() + progressState.onNext(SmsExportProgress.Done) + stopForeground(true) + isStarted = false + } + } + + /** + * The executor that this service should do its work on. + */ + protected open val executor: Executor = Executors.newSingleThreadExecutor() + + /** + * Produces the notification and notification id to display for this foreground service. + * The progress and total represent how many messages we've processed, and how many total + * we have to process. Failures and successes are both aggregated in this progress. You can + * query for "failure" state *after* we signal completion of a run. + */ + 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. + */ + protected abstract fun getUnexportedMessageCount(): Int + + /** + * Gets an iterable of exportable messages. + */ + protected abstract fun getUnexportedMessages(): Iterable + + /** + * We've started the export process for a given MMS / SMS message + */ + protected abstract fun onMessageExportStarted(exportableMessage: ExportableMessage) + + /** + * We've completely succeeded exporting a given MMS / SMS message + */ + protected abstract fun onMessageExportSucceeded(exportableMessage: ExportableMessage) + + /** + * We've failed to completely export a given MMS / SMS message + */ + protected abstract fun onMessageExportFailed(exportableMessage: ExportableMessage) + + /** + * We've written the message contents to the system database and were handed back an id. + */ + protected abstract fun onMessageIdCreated(exportableMessage: ExportableMessage, messageId: Long) + + /** + * We've begun trying to export a part row for an attachment for the given message + */ + protected abstract fun onAttachmentPartExportStarted(exportableMessage: ExportableMessage, part: ExportableMessage.Mms.Part) + + /** + * We've successfully exported the attachment part for a given message + */ + protected abstract fun onAttachmentPartExportSucceeded(exportableMessage: ExportableMessage, part: ExportableMessage.Mms.Part) + + /** + * We failed to export the attachment part for a given message. + */ + protected abstract fun onAttachmentPartExportFailed(exportableMessage: ExportableMessage, part: ExportableMessage.Mms.Part) + + /** + * We've begun trying to export a recipient addr for a given message + */ + protected abstract fun onRecipientExportStarted(exportableMessage: ExportableMessage, recipient: String) + + /** + * We've successfully exported a recipient addr for a given message + */ + protected abstract fun onRecipientExportSucceeded(exportableMessage: ExportableMessage, recipient: String) + + /** + * We've failed to export a recipient addr for a given message + */ + protected abstract fun onRecipientExportFailed(exportableMessage: ExportableMessage, recipient: String) + + /** + * Gets the input stream for the given attachment, so that it might be written out to disk. + */ + 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. + */ + protected abstract fun onExportPassCompleted() + + private fun updateNotification(progress: Int, total: Int) { + val exportNotification = getNotification(progress, total) + startForeground(exportNotification.id, exportNotification.notification) + } + + private fun exportSms(smsExportState: SmsExportState, sms: ExportableMessage.Sms) { + onMessageExportStarted(sms) + val mayAlreadyExist = smsExportState.progress == SmsExportState.Progress.STARTED + ExportSmsMessagesUseCase.execute(this, sms, mayAlreadyExist).either(onSuccess = { + onMessageExportSucceeded(sms) + }, onFailure = { + onMessageExportFailed(sms) + }) + } + + private fun exportMms(smsExportState: SmsExportState, mms: ExportableMessage.Mms) { + onMessageExportStarted(mms) + val threadIdOutput: GetOrCreateMmsThreadIdsUseCase.Output? = getThreadId(mms) + val exportMmsOutput: ExportMmsMessagesUseCase.Output? = threadIdOutput?.let { exportMms(smsExportState, it) } + val exportMmsPartsOutput: List? = exportMmsOutput?.let { exportMmsParts(smsExportState, it) } + val writeMmsPartsOutput: List>? = exportMmsPartsOutput?.filterNotNull()?.map { writeAttachmentToDisk(smsExportState, it) } + val exportMmsRecipients: List? = exportMmsOutput?.let { exportMmsRecipients(smsExportState, it) } + + if (threadIdOutput != null && + exportMmsOutput != null && + exportMmsPartsOutput != null && !exportMmsPartsOutput.contains(null) && + writeMmsPartsOutput != null && writeMmsPartsOutput.all { it is Result.Success } && + exportMmsRecipients != null && !exportMmsRecipients.contains(null) + ) { + onMessageExportSucceeded(mms) + } else { + onMessageExportFailed(mms) + } + } + + private fun getThreadId(mms: ExportableMessage.Mms): GetOrCreateMmsThreadIdsUseCase.Output? { + return GetOrCreateMmsThreadIdsUseCase.execute(this, mms, threadCache).either( + onSuccess = { output -> + output + }, + onFailure = { + Log.w(TAG, "Failed to get thread id for export", it) + null + } + ) + } + + private fun exportMms(smsExportState: SmsExportState, threadIdOutput: GetOrCreateMmsThreadIdsUseCase.Output): ExportMmsMessagesUseCase.Output? { + return ExportMmsMessagesUseCase.execute(this, threadIdOutput, smsExportState.progress == SmsExportState.Progress.STARTED).either( + onSuccess = { + onMessageIdCreated(it.mms, it.messageId) + it + }, + onFailure = { + Log.w(TAG, "Failed to export MMS into system database", it) + null + } + ) + } + + private fun exportMmsParts(smsExportState: SmsExportState, exportMmsOutput: ExportMmsMessagesUseCase.Output): List { + val attachments = exportMmsOutput.mms.parts + return if (attachments.isEmpty()) { + emptyList() + } else { + attachments.filterNot { it.contentId in smsExportState.completedAttachments }.map { attachment -> + onAttachmentPartExportStarted(exportMmsOutput.mms, attachment) + ExportMmsPartsUseCase.execute(this, attachment, exportMmsOutput, smsExportState.startedAttachments.contains(attachment.contentId)).either( + onSuccess = { + onAttachmentPartExportSucceeded(exportMmsOutput.mms, attachment) + it + }, + onFailure = { + onAttachmentPartExportFailed(exportMmsOutput.mms, attachment) + Log.d(TAG, "Could not export MMS Part", it) + null + } + ) + } + } + } + + private fun exportMmsRecipients(smsExportState: SmsExportState, exportMmsOutput: ExportMmsMessagesUseCase.Output): List { + val recipients = exportMmsOutput.mms.addresses.map { it.toString() }.toSet() + return if (recipients.isEmpty()) { + emptyList() + } else { + recipients.filterNot { it in smsExportState.completedRecipients }.map { recipient -> + onRecipientExportStarted(exportMmsOutput.mms, recipient) + ExportMmsRecipientsUseCase.execute(this, exportMmsOutput.messageId, recipient, exportMmsOutput.mms.sender.toString(), smsExportState.startedRecipients.contains(recipient)).either( + onSuccess = { + onRecipientExportSucceeded(exportMmsOutput.mms, recipient) + }, + onFailure = { + onRecipientExportFailed(exportMmsOutput.mms, recipient) + Log.w(TAG, "Failed to export MMS Recipient", it) + null + } + ) + } + } + } + + private fun writeAttachmentToDisk(smsExportState: SmsExportState, output: ExportMmsPartsUseCase.Output): Try { + if (output.part.contentId in smsExportState.completedAttachments) { + return Try.success(Unit) + } + + if (output.part is ExportableMessage.Mms.Part.Text) { + onAttachmentWrittenToDisk(output.message, output.part) + Try.success(Unit) + } + + return try { + contentResolver.openOutputStream(output.uri)!!.use { out -> + getInputStream(output.part).use { + it.copyTo(out) + } + } + onAttachmentWrittenToDisk(output.message, output.part) + Try.success(Unit) + } catch (e: Exception) { + Log.d(TAG, "Failed to write attachment to disk.", e) + Try.failure(e) + } + } + + data class ExportNotification( + val id: Int, + val notification: Notification + ) +} diff --git a/sms-exporter/lib/src/main/java/org/signal/smsexporter/SmsExportState.kt b/sms-exporter/lib/src/main/java/org/signal/smsexporter/SmsExportState.kt new file mode 100644 index 0000000000..efca3194d3 --- /dev/null +++ b/sms-exporter/lib/src/main/java/org/signal/smsexporter/SmsExportState.kt @@ -0,0 +1,21 @@ +package org.signal.smsexporter + +/** + * Describes the current "Export State" of a given message. This should be updated + * by and persisted by the application whenever a state change occurs. + */ +data class SmsExportState( + val messageId: Long = -1L, + val startedRecipients: Set = emptySet(), + val completedRecipients: Set = emptySet(), + val startedAttachments: Set = emptySet(), + val completedAttachments: Set = emptySet(), + val copiedAttachments: Set = emptySet(), + val progress: Progress = Progress.INIT +) { + enum class Progress { + INIT, + STARTED, + COMPLETED + } +} diff --git a/sms-exporter/lib/src/main/java/org/signal/smsexporter/internal/BecomeDefaultSmsUseCase.kt b/sms-exporter/lib/src/main/java/org/signal/smsexporter/internal/BecomeDefaultSmsUseCase.kt new file mode 100644 index 0000000000..b6a55968f1 --- /dev/null +++ b/sms-exporter/lib/src/main/java/org/signal/smsexporter/internal/BecomeDefaultSmsUseCase.kt @@ -0,0 +1,36 @@ +package org.signal.smsexporter.internal + +import android.app.role.RoleManager +import android.content.Context +import android.content.Intent +import android.os.Build +import android.provider.Telephony +import androidx.core.role.RoleManagerCompat +import org.signal.core.util.Result +import org.signal.smsexporter.BecomeSmsAppFailure + +/** + * Requests that this app becomes the default SMS app. The exact UX here is + * API dependant. + * + * Returns an intent to fire for a result, or a Failure. + */ +internal object BecomeDefaultSmsUseCase { + fun execute(context: Context): Result { + return if (IsDefaultSms.checkIsDefaultSms(context)) { + Result.failure(BecomeSmsAppFailure.ALREADY_DEFAULT_SMS) + } else if (Build.VERSION.SDK_INT >= 29) { + val roleManager = context.getSystemService(RoleManager::class.java) + if (roleManager.isRoleAvailable(RoleManagerCompat.ROLE_SMS)) { + Result.success(roleManager.createRequestRoleIntent(RoleManagerCompat.ROLE_SMS)) + } else { + Result.failure(BecomeSmsAppFailure.ROLE_IS_NOT_AVAILABLE) + } + } else { + Result.success( + Intent(Telephony.Sms.Intents.ACTION_CHANGE_DEFAULT) + .putExtra(Telephony.Sms.Intents.EXTRA_PACKAGE_NAME, context.packageName) + ) + } + } +} diff --git a/sms-exporter/lib/src/main/java/org/signal/smsexporter/internal/IsDefaultSms.kt b/sms-exporter/lib/src/main/java/org/signal/smsexporter/internal/IsDefaultSms.kt new file mode 100644 index 0000000000..1622f36c8e --- /dev/null +++ b/sms-exporter/lib/src/main/java/org/signal/smsexporter/internal/IsDefaultSms.kt @@ -0,0 +1,20 @@ +package org.signal.smsexporter.internal + +import android.app.role.RoleManager +import android.content.Context +import android.os.Build +import android.provider.Telephony +import androidx.core.role.RoleManagerCompat + +/** + * Uses the appropriate service to check if we are the default sms + */ +internal object IsDefaultSms { + fun checkIsDefaultSms(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= 29) { + context.getSystemService(RoleManager::class.java).isRoleHeld(RoleManagerCompat.ROLE_SMS) + } else { + context.packageName == Telephony.Sms.getDefaultSmsPackage(context) + } + } +} diff --git a/sms-exporter/lib/src/main/java/org/signal/smsexporter/internal/ReleaseDefaultSmsUseCase.kt b/sms-exporter/lib/src/main/java/org/signal/smsexporter/internal/ReleaseDefaultSmsUseCase.kt new file mode 100644 index 0000000000..0d58cf6533 --- /dev/null +++ b/sms-exporter/lib/src/main/java/org/signal/smsexporter/internal/ReleaseDefaultSmsUseCase.kt @@ -0,0 +1,31 @@ +package org.signal.smsexporter.internal + +import android.content.Context +import android.content.Intent +import android.os.Build +import android.provider.Settings +import org.signal.core.util.Result +import org.signal.smsexporter.ReleaseSmsAppFailure + +/** + * Request to no longer be the default SMS app. This has a pretty bad UX, we need + * to get the user to manually do it in settings. On API 24+ we can launch the default + * app settings screen, whereas on 19 to 23, we can't. In this situation, we should + * display some UX (perhaps based off API level) explaining to the user exactly what to + * do. + * + * Returns the Intent to fire off, or a Failure. + */ +internal object ReleaseDefaultSmsUseCase { + fun execute(context: Context): Result { + return if (!IsDefaultSms.checkIsDefaultSms(context)) { + Result.failure(ReleaseSmsAppFailure.APP_IS_INELIGABLE_TO_RELEASE_SMS_SELECTION) + } else if (Build.VERSION.SDK_INT >= 24) { + Result.success( + Intent(Settings.ACTION_MANAGE_DEFAULT_APPS_SETTINGS) + ) + } else { + Result.failure(ReleaseSmsAppFailure.NO_METHOD_TO_RELEASE_SMS_AVIALABLE) + } + } +} diff --git a/sms-exporter/lib/src/main/java/org/signal/smsexporter/internal/mms/ExportMmsMessagesUseCase.kt b/sms-exporter/lib/src/main/java/org/signal/smsexporter/internal/mms/ExportMmsMessagesUseCase.kt new file mode 100644 index 0000000000..007011ca8b --- /dev/null +++ b/sms-exporter/lib/src/main/java/org/signal/smsexporter/internal/mms/ExportMmsMessagesUseCase.kt @@ -0,0 +1,79 @@ +package org.signal.smsexporter.internal.mms + +import android.content.ContentUris +import android.content.Context +import android.provider.Telephony +import androidx.core.content.contentValuesOf +import com.google.android.mms.pdu_alt.PduHeaders +import org.signal.core.util.Try +import org.signal.core.util.logging.Log +import org.signal.smsexporter.ExportableMessage + +/** + * Takes a list of messages and inserts them as a single batch. This includes + * thread id get/create if necessary. The output is a list of (mms, message_id) + */ +internal object ExportMmsMessagesUseCase { + + private val TAG = Log.tag(ExportMmsMessagesUseCase::class.java) + + fun execute( + context: Context, + getOrCreateThreadOutput: GetOrCreateMmsThreadIdsUseCase.Output, + checkForExistence: Boolean + ): Try { + try { + val (mms, threadId) = getOrCreateThreadOutput + val transactionId = "signal:T${mms.id}" + + if (checkForExistence) { + Log.d(TAG, "Checking if the message is already in the database.") + val messageId = isMessageAlreadyInDatabase(context, transactionId) + if (messageId != -1L) { + Log.d(TAG, "Message exists in database. Returning its id.") + return Try.success(Output(mms, messageId)) + } + } + + val mmsContentValues = contentValuesOf( + Telephony.Mms.THREAD_ID to threadId, + Telephony.Mms.DATE to mms.dateReceived, + Telephony.Mms.DATE_SENT to mms.dateSent, + 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", + Telephony.Mms.MESSAGE_TYPE to PduHeaders.MESSAGE_TYPE_SEND_REQ, + Telephony.Mms.MMS_VERSION to PduHeaders.MMS_VERSION_1_3, + Telephony.Mms.MESSAGE_CLASS to "personal", + Telephony.Mms.PRIORITY to PduHeaders.PRIORITY_NORMAL, + Telephony.Mms.TRANSACTION_ID to transactionId, + Telephony.Mms.RESPONSE_STATUS to PduHeaders.RESPONSE_STATUS_OK + ) + + val uri = context.contentResolver.insert(Telephony.Mms.CONTENT_URI, mmsContentValues) + val newMessageId = ContentUris.parseId(uri!!) + + return Try.success(Output(getOrCreateThreadOutput.mms, newMessageId)) + } catch (e: Exception) { + return Try.failure(e) + } + } + + private fun isMessageAlreadyInDatabase(context: Context, transactionId: String): Long { + return context.contentResolver.query( + Telephony.Mms.CONTENT_URI, + arrayOf("_id"), + "${Telephony.Mms.TRANSACTION_ID} == ?", + arrayOf(transactionId), + null + )?.use { + it.moveToFirst() + it.getLong(0) + } ?: -1L + } + + data class Output( + val mms: ExportableMessage.Mms, + val messageId: Long + ) +} diff --git a/sms-exporter/lib/src/main/java/org/signal/smsexporter/internal/mms/ExportMmsPartsUseCase.kt b/sms-exporter/lib/src/main/java/org/signal/smsexporter/internal/mms/ExportMmsPartsUseCase.kt new file mode 100644 index 0000000000..4c61a420ec --- /dev/null +++ b/sms-exporter/lib/src/main/java/org/signal/smsexporter/internal/mms/ExportMmsPartsUseCase.kt @@ -0,0 +1,63 @@ +package org.signal.smsexporter.internal.mms + +import android.content.ContentUris +import android.content.Context +import android.net.Uri +import android.provider.Telephony +import androidx.core.content.contentValuesOf +import org.signal.core.util.Try +import org.signal.core.util.logging.Log +import org.signal.smsexporter.ExportableMessage + +/** + * Inserts the part objects for the given list of mms message insertion outputs. Returns a list + * of attachments that can be enqueued for a disk write. + */ +internal object ExportMmsPartsUseCase { + + private val TAG = Log.tag(ExportMmsPartsUseCase::class.java) + + fun execute(context: Context, part: ExportableMessage.Mms.Part, output: ExportMmsMessagesUseCase.Output, checkForExistence: Boolean): Try { + try { + val (message, messageId) = output + val contentId = "" + val mmsPartUri = Telephony.Mms.CONTENT_URI.buildUpon().appendPath(messageId.toString()).appendPath("part").build() + + if (checkForExistence) { + Log.d(TAG, "Checking attachment that may already be present...") + val partId: Long? = context.contentResolver.query(mmsPartUri, arrayOf(Telephony.Mms.Part._ID), "${Telephony.Mms.Part.CONTENT_ID} = ?", arrayOf(contentId), null)?.use { + if (it.moveToFirst()) { + it.getLong(0) + } else { + null + } + } + + if (partId != null) { + Log.d(TAG, "Found attachment part that already exists.") + return Try.success( + Output( + uri = ContentUris.withAppendedId(mmsPartUri, partId), + part = part, + message = message + ) + ) + } + } + + val mmsPartContentValues = contentValuesOf( + Telephony.Mms.Part.MSG_ID to messageId, + Telephony.Mms.Part.CONTENT_TYPE to part.contentType, + Telephony.Mms.Part.CONTENT_ID to contentId, + Telephony.Mms.Part.TEXT to if (part is ExportableMessage.Mms.Part.Text) part.text else null + ) + + val attachmentUri = context.contentResolver.insert(mmsPartUri, mmsPartContentValues)!! + return Try.success(Output(attachmentUri, part, message)) + } catch (e: Exception) { + return Try.failure(e) + } + } + + data class Output(val uri: Uri, val part: ExportableMessage.Mms.Part, val message: ExportableMessage) +} diff --git a/sms-exporter/lib/src/main/java/org/signal/smsexporter/internal/mms/ExportMmsRecipientsUseCase.kt b/sms-exporter/lib/src/main/java/org/signal/smsexporter/internal/mms/ExportMmsRecipientsUseCase.kt new file mode 100644 index 0000000000..8da7244ed6 --- /dev/null +++ b/sms-exporter/lib/src/main/java/org/signal/smsexporter/internal/mms/ExportMmsRecipientsUseCase.kt @@ -0,0 +1,48 @@ +package org.signal.smsexporter.internal.mms + +import android.content.Context +import android.provider.Telephony +import androidx.core.content.contentValuesOf +import com.google.android.mms.pdu_alt.CharacterSets +import com.google.android.mms.pdu_alt.PduHeaders +import org.signal.core.util.Try +import org.signal.core.util.logging.Log +import org.signal.smsexporter.internal.sms.ExportSmsMessagesUseCase + +/** + * Inserts the recipients for each individual message in the insert mms output. Returns nothing. + */ +object ExportMmsRecipientsUseCase { + + private val TAG = Log.tag(ExportSmsMessagesUseCase::class.java) + + fun execute(context: Context, messageId: Long, recipient: String, sender: String, checkForExistence: Boolean): Try { + try { + val addrUri = Telephony.Mms.CONTENT_URI.buildUpon().appendPath(messageId.toString()).appendPath("addr").build() + + if (checkForExistence) { + Log.d(TAG, "Checking for recipient that may have already been inserted...") + val exists = context.contentResolver.query(addrUri, arrayOf("_id"), "${Telephony.Mms.Addr.ADDRESS} == ?", arrayOf(recipient), null)?.use { + it.moveToFirst() + } ?: false + + if (exists) { + Log.d(TAG, "Recipient was already inserted. Skipping.") + return Try.success(Unit) + } + } + + val addrValues = contentValuesOf( + Telephony.Mms.Addr.ADDRESS to recipient, + Telephony.Mms.Addr.CHARSET to CharacterSets.DEFAULT_CHARSET, + Telephony.Mms.Addr.TYPE to if (recipient == sender) PduHeaders.FROM else PduHeaders.TO, + ) + + context.contentResolver.insert(addrUri, addrValues) + + return Try.success(Unit) + } catch (e: Exception) { + return Try.failure(e) + } + } +} diff --git a/sms-exporter/lib/src/main/java/org/signal/smsexporter/internal/mms/GetOrCreateMmsThreadIdsUseCase.kt b/sms-exporter/lib/src/main/java/org/signal/smsexporter/internal/mms/GetOrCreateMmsThreadIdsUseCase.kt new file mode 100644 index 0000000000..a27c2123f6 --- /dev/null +++ b/sms-exporter/lib/src/main/java/org/signal/smsexporter/internal/mms/GetOrCreateMmsThreadIdsUseCase.kt @@ -0,0 +1,50 @@ +package org.signal.smsexporter.internal.mms + +import android.content.Context +import com.klinker.android.send_message.Utils +import org.signal.core.util.Try +import org.signal.smsexporter.ExportableMessage + +/** + * Given a list of messages, gets or creates the threadIds for each different recipient set. + * Returns a list of outputs that tie a given message to a thread id. + * + * This method will also filter out messages that do not have addresses. + */ +internal object GetOrCreateMmsThreadIdsUseCase { + fun execute( + context: Context, + mms: ExportableMessage.Mms, + threadCache: MutableMap, Long> + ): Try { + return try { + val recipients = getRecipientSet(mms) + val threadId = getOrCreateThreadId(context, recipients, threadCache) + + Try.success(Output(mms, threadId)) + } catch (e: Exception) { + Try.failure(e) + } + } + + private fun getOrCreateThreadId(context: Context, recipients: Set, cache: MutableMap, Long>): Long { + return if (cache.containsKey(recipients)) { + cache[recipients]!! + } else { + val threadId = Utils.getOrCreateThreadId(context, recipients) + cache[recipients] = threadId + threadId + } + } + + private fun getRecipientSet(mms: ExportableMessage.Mms): Set { + val recipients = mms.addresses + if (recipients.isEmpty()) { + error("Expected non-empty recipient count.") + } + + return HashSet(recipients.map { it.toString() }) + } + + data class Output(val mms: ExportableMessage.Mms, val threadId: Long) +} diff --git a/sms-exporter/lib/src/main/java/org/signal/smsexporter/internal/sms/ExportSmsMessagesUseCase.kt b/sms-exporter/lib/src/main/java/org/signal/smsexporter/internal/sms/ExportSmsMessagesUseCase.kt new file mode 100644 index 0000000000..d16051c244 --- /dev/null +++ b/sms-exporter/lib/src/main/java/org/signal/smsexporter/internal/sms/ExportSmsMessagesUseCase.kt @@ -0,0 +1,49 @@ +package org.signal.smsexporter.internal.sms + +import android.content.Context +import android.provider.Telephony +import androidx.core.content.contentValuesOf +import org.signal.core.util.Try +import org.signal.smsexporter.ExportableMessage +import java.lang.Exception + +/** + * Given a list of Sms messages, export each one to the system SMS database + * Returns nothing. + */ +internal object ExportSmsMessagesUseCase { + fun execute(context: Context, sms: ExportableMessage.Sms, checkForExistence: Boolean): Try { + try { + if (checkForExistence) { + val exists = context.contentResolver.query( + Telephony.Sms.CONTENT_URI, + arrayOf("_id"), + "${Telephony.Sms.ADDRESS} = ? AND ${Telephony.Sms.DATE_SENT} = ?", + arrayOf(sms.address, sms.dateSent.toString()), + null + )?.use { + it.count > 0 + } ?: false + + if (exists) { + return Try.success(Unit) + } + } + + 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.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 + ) + + context.contentResolver.insert(Telephony.Sms.CONTENT_URI, contentValues) + + return Try.success(Unit) + } catch (e: Exception) { + return Try.failure(e) + } + } +}