diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/data/DataAndStorageSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/data/DataAndStorageSettingsFragment.kt index bbfa876541..0160d557ea 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/data/DataAndStorageSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/data/DataAndStorageSettingsFragment.kt @@ -27,6 +27,7 @@ import org.signal.core.util.bytes import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier import org.thoughtcrime.securesms.mms.SentMediaQuality +import org.thoughtcrime.securesms.util.AttachmentUtil import org.thoughtcrime.securesms.util.navigation.safeNavigate import org.thoughtcrime.securesms.webrtc.CallDataMode import kotlin.math.abs @@ -169,6 +170,21 @@ private fun DataAndStorageSettingsScreen( ) } + item { + Rows.TextRow( + text = { + Text( + text = stringResource( + R.string.DataAndStorageSettingsFragment__voice_messages_and_stickers_under_size_are_always_auto_downloaded, + AttachmentUtil.SMALL_ATTACHMENT_SIZE.toUnitString() + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + ) + } + item { Dividers.Default() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentDownloadJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentDownloadJob.kt index b41704c2a0..48559806f8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentDownloadJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentDownloadJob.kt @@ -38,6 +38,7 @@ import org.thoughtcrime.securesms.transport.RetryLaterException import org.thoughtcrime.securesms.util.AttachmentUtil import org.thoughtcrime.securesms.util.RemoteConfig import org.whispersystems.signalservice.api.crypto.AttachmentCipherInputStream.IntegrityCheck +import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil import org.whispersystems.signalservice.api.messages.AttachmentTransferProgress import org.whispersystems.signalservice.api.messages.SignalServiceAttachment import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer @@ -46,6 +47,7 @@ import org.whispersystems.signalservice.api.push.exceptions.MissingConfiguration import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException import org.whispersystems.signalservice.api.push.exceptions.RangeException +import org.whispersystems.signalservice.internal.crypto.PaddingInputStream import java.io.File import java.io.IOException import java.util.Optional @@ -140,14 +142,16 @@ class AttachmentDownloadJob private constructor( } } - constructor(messageId: Long, attachmentId: AttachmentId, forceDownload: Boolean) : this( + constructor(messageId: Long, attachmentId: AttachmentId, forceDownload: Boolean) : this(messageId, attachmentId, forceDownload, forceDownload, forceDownload) + + constructor(messageId: Long, attachmentId: AttachmentId, forceDownload: Boolean, skipInCallConstraint: Boolean, isHighPriority: Boolean) : this( Parameters.Builder() .setQueue(constructQueueString(attachmentId)) .addConstraint(NetworkConstraint.KEY) - .maybeApplyNotInCallConstraint(forceDownload) + .maybeApplyNotInCallConstraint(skipInCallConstraint) .setLifespan(TimeUnit.DAYS.toMillis(1)) .setMaxAttempts(Parameters.UNLIMITED) - .setQueuePriority(if (forceDownload) Parameters.PRIORITY_HIGH else Parameters.PRIORITY_DEFAULT) + .setQueuePriority(if (isHighPriority) Parameters.PRIORITY_HIGH else Parameters.PRIORITY_DEFAULT) .build(), messageId, attachmentId, @@ -314,12 +318,20 @@ class AttachmentDownloadJob private constructor( throw InvalidAttachmentException("Attachment has no integrity check!") } + if (attachment.size <= 0) { + Log.w(TAG, "[$attachmentId] Attachment has no declared size!") + throw InvalidAttachmentException("Attachment has no declared size!") + } + + val expectedCiphertextSize = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(attachment.size)) + val downloadLimit: Long = minOf(expectedCiphertextSize, maxReceiveSize) + val decryptingStream = AppDependencies .signalServiceMessageReceiver .retrieveAttachment( pointer, attachmentFile, - maxReceiveSize, + downloadLimit, IntegrityCheck.forEncryptedDigestAndPlaintextHash(attachment.remoteDigest, attachment.dataHash), progressListener ) @@ -470,8 +482,8 @@ class AttachmentDownloadJob private constructor( } } -private fun Parameters.Builder.maybeApplyNotInCallConstraint(forceDownload: Boolean): Parameters.Builder { - if (forceDownload) { +private fun Parameters.Builder.maybeApplyNotInCallConstraint(skipConstraint: Boolean): Parameters.Builder { + if (skipConstraint) { return this } return this.addConstraint(NotInCallConstraint.KEY) diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/DataMessageProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/messages/DataMessageProcessor.kt index d70581f3a3..c914981fcc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/DataMessageProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/DataMessageProcessor.kt @@ -973,14 +973,16 @@ object DataMessageProcessor { SignalDatabase.runPostSuccessfulTransaction { if (insertResult.insertedAttachments != null) { val downloadJobs: List = insertResult.insertedAttachments.mapNotNull { (attachment, attachmentId) -> - if (attachment.isSticker) { - if (attachment.transferState != AttachmentTable.TRANSFER_PROGRESS_DONE) { - AttachmentDownloadJob(messageId = insertResult.messageId, attachmentId = attachmentId, forceDownload = true) - } else { - null - } + if (attachment.transferState != AttachmentTable.TRANSFER_PROGRESS_DONE) { + AttachmentDownloadJob( + messageId = insertResult.messageId, + attachmentId = attachmentId, + forceDownload = false, + skipInCallConstraint = attachment.isSticker, + isHighPriority = attachment.isSticker + ) } else { - AttachmentDownloadJob(messageId = insertResult.messageId, attachmentId = attachmentId, forceDownload = false) + null } } AppDependencies.jobManager.addAll(downloadJobs) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AttachmentUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/AttachmentUtil.java deleted file mode 100644 index 6d1d3afa64..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/AttachmentUtil.java +++ /dev/null @@ -1,153 +0,0 @@ -package org.thoughtcrime.securesms.util; - - -import android.content.Context; -import android.text.TextUtils; - -import androidx.annotation.MainThread; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.WorkerThread; - -import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.attachments.Attachment; -import org.thoughtcrime.securesms.attachments.AttachmentId; -import org.thoughtcrime.securesms.attachments.DatabaseAttachment; -import org.thoughtcrime.securesms.database.NoSuchMessageException; -import org.thoughtcrime.securesms.database.SignalDatabase; -import org.thoughtcrime.securesms.database.model.MessageRecord; -import org.thoughtcrime.securesms.jobmanager.impl.NotInCallConstraint; -import org.thoughtcrime.securesms.jobs.MultiDeviceDeleteSyncJob; -import org.thoughtcrime.securesms.recipients.Recipient; - -import java.util.Collections; -import java.util.Set; - -public class AttachmentUtil { - - private static final String TAG = Log.tag(AttachmentUtil.class); - - @MainThread - public static boolean isRestoreOnOpenPermitted(@NonNull Context context, @Nullable Attachment attachment) { - if (attachment == null) { - Log.w(TAG, "attachment was null, returning vacuous true"); - return true; - } - Set allowedTypes = getAllowedAutoDownloadTypes(context); - String contentType = attachment.contentType; - - if (MediaUtil.isImageType(contentType)) { - return NotInCallConstraint.isNotInConnectedCall() && allowedTypes.contains(MediaUtil.getDiscreteMimeType(contentType)); - } - return false; - } - - @WorkerThread - public static boolean isAutoDownloadPermitted(@NonNull Context context, @Nullable DatabaseAttachment attachment) { - if (attachment == null) { - Log.w(TAG, "attachment was null, returning vacuous true"); - return true; - } - - if (!isFromTrustedConversation(context, attachment)) { - Log.w(TAG, "Not allowing download due to untrusted conversation"); - return false; - } - - Set allowedTypes = getAllowedAutoDownloadTypes(context); - String contentType = attachment.contentType; - - if (attachment.voiceNote || - (MediaUtil.isAudio(attachment) && TextUtils.isEmpty(attachment.fileName)) || - MediaUtil.isLongTextType(attachment.contentType) || - attachment.isSticker()) - { - return true; - } else if (attachment.videoGif) { - boolean allowed = NotInCallConstraint.isNotInConnectedCall() && allowedTypes.contains("image"); - if (!allowed) { - Log.w(TAG, "Not auto downloading. inCall: " + !NotInCallConstraint.isNotInConnectedCall() + " allowedType: " + allowedTypes.contains("image")); - } - return allowed; - } else if (isNonDocumentType(contentType)) { - boolean allowed = NotInCallConstraint.isNotInConnectedCall() && allowedTypes.contains(MediaUtil.getDiscreteMimeType(contentType)); - if (!allowed) { - Log.w(TAG, "Not auto downloading. inCall: " + !NotInCallConstraint.isNotInConnectedCall() + " allowedType: " + allowedTypes.contains(MediaUtil.getDiscreteMimeType(contentType))); - } - return allowed; - } else { - boolean allowed = NotInCallConstraint.isNotInConnectedCall() && allowedTypes.contains("documents"); - if (!allowed) { - Log.w(TAG, "Not auto downloading. inCall: " + !NotInCallConstraint.isNotInConnectedCall() + " allowedType: " + allowedTypes.contains("documents")); - } - return allowed; - } - } - - /** - * Deletes the specified attachment. If its the only attachment for its linked message, the entire - * message is deleted. - * - * @return message record of deleted message if a message is deleted - */ - @WorkerThread - public static @Nullable MessageRecord deleteAttachment(@NonNull DatabaseAttachment attachment) { - AttachmentId attachmentId = attachment.attachmentId; - long mmsId = attachment.mmsId; - int attachmentCount = SignalDatabase.attachments() - .getAttachmentsForMessage(mmsId) - .size(); - - MessageRecord deletedMessageRecord = null; - if (attachmentCount <= 1) { - deletedMessageRecord = SignalDatabase.messages().getMessageRecordOrNull(mmsId); - SignalDatabase.messages().deleteMessage(mmsId); - } else { - SignalDatabase.attachments().deleteAttachment(attachmentId); - MultiDeviceDeleteSyncJob.enqueueAttachmentDelete(SignalDatabase.messages().getMessageRecordOrNull(mmsId), attachment); - } - - return deletedMessageRecord; - } - - private static boolean isNonDocumentType(String contentType) { - return - MediaUtil.isImageType(contentType) || - MediaUtil.isVideoType(contentType) || - MediaUtil.isAudioType(contentType); - } - - private static @NonNull Set getAllowedAutoDownloadTypes(@NonNull Context context) { - if (NetworkUtil.isConnectedWifi(context)) return TextSecurePreferences.getWifiMediaDownloadAllowed(context); - else if (NetworkUtil.isConnectedRoaming(context)) return TextSecurePreferences.getRoamingMediaDownloadAllowed(context); - else if (NetworkUtil.isConnectedMobile(context)) return TextSecurePreferences.getMobileMediaDownloadAllowed(context); - else return Collections.emptySet(); - } - - @WorkerThread - private static boolean isFromTrustedConversation(@NonNull Context context, @NonNull DatabaseAttachment attachment) { - try { - MessageRecord message = SignalDatabase.messages().getMessageRecord(attachment.mmsId); - - Recipient fromRecipient = message.getFromRecipient(); - Recipient toRecipient = SignalDatabase.threads().getRecipientForThreadId(message.getThreadId()); - - if (toRecipient != null && toRecipient.isGroup()) { - return toRecipient.isProfileSharing() || isTrustedIndividual(fromRecipient, message); - } else { - return isTrustedIndividual(fromRecipient, message); - } - } catch (NoSuchMessageException e) { - Log.w(TAG, "Message could not be found! Assuming not a trusted contact."); - return false; - } - } - - private static boolean isTrustedIndividual(@NonNull Recipient recipient, @NonNull MessageRecord message) { - return recipient.isSystemContact() || - recipient.isProfileSharing() || - message.isOutgoing() || - recipient.isSelf() || - recipient.isReleaseNotes(); - } - } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AttachmentUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/util/AttachmentUtil.kt new file mode 100644 index 0000000000..925029c5a5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/AttachmentUtil.kt @@ -0,0 +1,158 @@ +package org.thoughtcrime.securesms.util + +import android.content.Context +import androidx.annotation.MainThread +import androidx.annotation.WorkerThread +import org.signal.core.util.ByteSize +import org.signal.core.util.bytes +import org.signal.core.util.kibiBytes +import org.signal.core.util.logging.Log +import org.signal.core.util.mebiBytes +import org.thoughtcrime.securesms.attachments.Attachment +import org.thoughtcrime.securesms.attachments.Cdn +import org.thoughtcrime.securesms.attachments.DatabaseAttachment +import org.thoughtcrime.securesms.database.NoSuchMessageException +import org.thoughtcrime.securesms.database.SignalDatabase.Companion.attachments +import org.thoughtcrime.securesms.database.SignalDatabase.Companion.messages +import org.thoughtcrime.securesms.database.SignalDatabase.Companion.threads +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.jobmanager.impl.NotInCallConstraint +import org.thoughtcrime.securesms.jobs.MultiDeviceDeleteSyncJob.Companion.enqueueAttachmentDelete +import org.thoughtcrime.securesms.recipients.Recipient +import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil +import org.whispersystems.signalservice.internal.crypto.PaddingInputStream + +object AttachmentUtil { + private val TAG = Log.tag(AttachmentUtil::class.java) + + private val MAX_AUTO_DOWNLOAD_SIZE: ByteSize = 200.mebiBytes + val SMALL_ATTACHMENT_SIZE: ByteSize = 100.kibiBytes + + @JvmStatic + @MainThread + fun isRestoreOnOpenPermitted(context: Context, attachment: Attachment?): Boolean { + if (attachment == null) { + return true + } + + val contentType = attachment.contentType ?: return false + if (!MediaUtil.isImageType(contentType)) { + return false + } + + val allowedTypes = getAllowedAutoDownloadTypes(context) + return NotInCallConstraint.isNotInConnectedCall() && allowedTypes.contains(MediaUtil.getDiscreteMimeType(contentType)) + } + + @JvmStatic + @WorkerThread + fun isAutoDownloadPermitted(context: Context, attachment: DatabaseAttachment?): Boolean { + if (attachment == null) { + return true + } + + if (!isFromTrustedConversation(context, attachment)) { + Log.w(TAG, "Not allowing download due to untrusted conversation") + return false + } + + if (attachment.size <= 0 && attachment.cdn != Cdn.S3) { + Log.w(TAG, "Not auto downloading. Attachment has no declared size.") + return false + } + + val ciphertextSize: ByteSize = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(attachment.size)).bytes + + if (ciphertextSize > MAX_AUTO_DOWNLOAD_SIZE) { + Log.w(TAG, "Not auto downloading. Attachment ciphertext size $ciphertextSize exceeds max auto download size $MAX_AUTO_DOWNLOAD_SIZE") + return false + } + + val allowedTypes = getAllowedAutoDownloadTypes(context) + val contentType = attachment.contentType + + return when { + MediaUtil.isLongTextType(contentType) -> true + attachment.isSticker -> ciphertextSize <= SMALL_ATTACHMENT_SIZE || allowedForType(allowedTypes, "image", "sticker") + attachment.voiceNote -> ciphertextSize <= SMALL_ATTACHMENT_SIZE || allowedForType(allowedTypes, "audio", "voice message") + attachment.videoGif -> allowedForType(allowedTypes, "image", "video gif") + contentType != null && isNonDocumentType(contentType) -> allowedForType(allowedTypes, MediaUtil.getDiscreteMimeType(contentType), contentType) + else -> allowedForType(allowedTypes, "documents", "document") + } + } + + /** + * Deletes the specified attachment. If its the only attachment for its linked message, the entire + * message is deleted. + * + * @return message record of deleted message if a message is deleted + */ + @JvmStatic + @WorkerThread + fun deleteAttachment(attachment: DatabaseAttachment): MessageRecord? { + val attachmentId = attachment.attachmentId + val mmsId = attachment.mmsId + val attachmentCount = attachments.getAttachmentsForMessage(mmsId).size + + if (attachmentCount <= 1) { + val deletedMessageRecord = messages.getMessageRecordOrNull(mmsId) + messages.deleteMessage(mmsId) + return deletedMessageRecord + } + + attachments.deleteAttachment(attachmentId) + enqueueAttachmentDelete(messages.getMessageRecordOrNull(mmsId), attachment) + return null + } + + private fun allowedForType(allowedTypes: Set, typeKey: String?, label: String): Boolean { + val notInCall = NotInCallConstraint.isNotInConnectedCall() + val typeAllowed = typeKey != null && allowedTypes.contains(typeKey) + val allowed = notInCall && typeAllowed + if (!allowed) { + Log.w(TAG, "Not auto downloading $label. inCall: ${!notInCall} allowedType: $typeAllowed") + } + return allowed + } + + private fun isNonDocumentType(contentType: String): Boolean { + return MediaUtil.isImageType(contentType) || + MediaUtil.isVideoType(contentType) || + MediaUtil.isAudioType(contentType) + } + + private fun getAllowedAutoDownloadTypes(context: Context): Set { + return when { + NetworkUtil.isConnectedWifi(context) -> TextSecurePreferences.getWifiMediaDownloadAllowed(context) + NetworkUtil.isConnectedRoaming(context) -> TextSecurePreferences.getRoamingMediaDownloadAllowed(context) + NetworkUtil.isConnectedMobile(context) -> TextSecurePreferences.getMobileMediaDownloadAllowed(context) + else -> emptySet() + } + } + + @WorkerThread + private fun isFromTrustedConversation(context: Context, attachment: DatabaseAttachment): Boolean { + return try { + val message = messages.getMessageRecord(attachment.mmsId) + val fromRecipient = message.fromRecipient + val toRecipient = threads.getRecipientForThreadId(message.threadId) + + if (toRecipient != null && toRecipient.isGroup) { + toRecipient.isProfileSharing || isTrustedIndividual(fromRecipient, message) + } else { + isTrustedIndividual(fromRecipient, message) + } + } catch (e: NoSuchMessageException) { + Log.w(TAG, "Message could not be found! Assuming not a trusted contact.") + false + } + } + + private fun isTrustedIndividual(recipient: Recipient, message: MessageRecord): Boolean { + return recipient.isSystemContact || + recipient.isProfileSharing || + message.isOutgoing || + recipient.isSelf || + recipient.isReleaseNotes + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b101043a06..416a53e7da 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6025,6 +6025,8 @@ Sent media quality Sending high quality media will use more data. + + Voice messages and stickers (under %1$s) are always auto-downloaded. High diff --git a/app/src/test/java/org/thoughtcrime/securesms/util/AttachmentUtilTest.kt b/app/src/test/java/org/thoughtcrime/securesms/util/AttachmentUtilTest.kt new file mode 100644 index 0000000000..141133fd8e --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/util/AttachmentUtilTest.kt @@ -0,0 +1,493 @@ +package org.thoughtcrime.securesms.util + +import android.content.Context +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.thoughtcrime.securesms.attachments.AttachmentId +import org.thoughtcrime.securesms.attachments.Cdn +import org.thoughtcrime.securesms.attachments.DatabaseAttachment +import org.thoughtcrime.securesms.database.AttachmentTable +import org.thoughtcrime.securesms.database.MessageTable +import org.thoughtcrime.securesms.database.NoSuchMessageException +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.ThreadTable +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.jobmanager.impl.NotInCallConstraint +import org.thoughtcrime.securesms.jobs.MultiDeviceDeleteSyncJob +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.stickers.StickerLocator + +class AttachmentUtilTest { + + private val context = mockk(relaxed = true) + private val messageTable = mockk(relaxed = true) + private val threadTable = mockk(relaxed = true) + private val attachmentTable = mockk(relaxed = true) + private val fromRecipient = mockk(relaxed = true) + private val toRecipient = mockk(relaxed = true) + private val messageRecord = mockk(relaxed = true) + + @Before + fun setUp() { + mockkObject(SignalDatabase.Companion) + every { SignalDatabase.instance } returns mockk { + every { messageTable } returns this@AttachmentUtilTest.messageTable + every { threadTable } returns this@AttachmentUtilTest.threadTable + every { attachmentTable } returns this@AttachmentUtilTest.attachmentTable + } + + mockkStatic(NotInCallConstraint::class) + mockkStatic(NetworkUtil::class) + mockkStatic(TextSecurePreferences::class) + mockkObject(MultiDeviceDeleteSyncJob.Companion) + every { MultiDeviceDeleteSyncJob.enqueueAttachmentDelete(any(), any()) } just Runs + + every { NotInCallConstraint.isNotInConnectedCall() } returns true + every { NetworkUtil.isConnectedWifi(context) } returns true + every { NetworkUtil.isConnectedRoaming(context) } returns false + every { NetworkUtil.isConnectedMobile(context) } returns false + every { TextSecurePreferences.getWifiMediaDownloadAllowed(context) } returns emptySet() + every { TextSecurePreferences.getRoamingMediaDownloadAllowed(context) } returns emptySet() + every { TextSecurePreferences.getMobileMediaDownloadAllowed(context) } returns emptySet() + + every { messageTable.getMessageRecord(any()) } returns messageRecord + every { messageRecord.fromRecipient } returns fromRecipient + every { messageRecord.threadId } returns 1L + every { messageRecord.isOutgoing } returns false + every { threadTable.getRecipientForThreadId(any()) } returns toRecipient + every { toRecipient.isGroup } returns false + every { fromRecipient.isSystemContact } returns true + } + + @After + fun tearDown() { + unmockkAll() + } + + // --- isAutoDownloadPermitted --- + + @Test + fun `null attachment returns true`() { + assertTrue(AttachmentUtil.isAutoDownloadPermitted(context, null)) + } + + @Test + fun `untrusted conversation returns false`() { + every { fromRecipient.isSystemContact } returns false + every { fromRecipient.isProfileSharing } returns false + every { fromRecipient.isSelf } returns false + every { fromRecipient.isReleaseNotes } returns false + every { messageRecord.isOutgoing } returns false + + assertFalse(AttachmentUtil.isAutoDownloadPermitted(context, attachment())) + } + + @Test + fun `NoSuchMessageException treats as untrusted`() { + every { messageTable.getMessageRecord(any()) } throws NoSuchMessageException("") + assertFalse(AttachmentUtil.isAutoDownloadPermitted(context, attachment())) + } + + @Test + fun `group with profile sharing is trusted`() { + every { toRecipient.isGroup } returns true + every { toRecipient.isProfileSharing } returns true + every { fromRecipient.isSystemContact } returns false + every { TextSecurePreferences.getWifiMediaDownloadAllowed(context) } returns setOf("image") + + assertTrue(AttachmentUtil.isAutoDownloadPermitted(context, attachment())) + } + + @Test + fun `group without profile sharing and untrusted individual returns false`() { + every { toRecipient.isGroup } returns true + every { toRecipient.isProfileSharing } returns false + every { fromRecipient.isSystemContact } returns false + every { fromRecipient.isProfileSharing } returns false + every { fromRecipient.isSelf } returns false + every { fromRecipient.isReleaseNotes } returns false + every { messageRecord.isOutgoing } returns false + + assertFalse(AttachmentUtil.isAutoDownloadPermitted(context, attachment())) + } + + @Test + fun `null toRecipient falls to individual trust`() { + every { threadTable.getRecipientForThreadId(any()) } returns null + every { TextSecurePreferences.getWifiMediaDownloadAllowed(context) } returns setOf("image") + + assertTrue(AttachmentUtil.isAutoDownloadPermitted(context, attachment())) + } + + @Test + fun `profile sharing from recipient is trusted`() { + every { fromRecipient.isSystemContact } returns false + every { fromRecipient.isProfileSharing } returns true + every { TextSecurePreferences.getWifiMediaDownloadAllowed(context) } returns setOf("image") + + assertTrue(AttachmentUtil.isAutoDownloadPermitted(context, attachment())) + } + + @Test + fun `video gif blocked when in call`() { + val attachment = attachment(videoGif = true, contentType = "video/mp4") + every { TextSecurePreferences.getWifiMediaDownloadAllowed(context) } returns setOf("image") + every { NotInCallConstraint.isNotInConnectedCall() } returns false + + assertFalse(AttachmentUtil.isAutoDownloadPermitted(context, attachment)) + } + + @Test + fun `document blocked when in call`() { + val attachment = attachment(contentType = "application/pdf", fileName = "doc.pdf") + every { TextSecurePreferences.getWifiMediaDownloadAllowed(context) } returns setOf("documents") + every { NotInCallConstraint.isNotInConnectedCall() } returns false + assertFalse(AttachmentUtil.isAutoDownloadPermitted(context, attachment)) + } + + @Test + fun `group without profile sharing falls to individual trust`() { + every { toRecipient.isGroup } returns true + every { toRecipient.isProfileSharing } returns false + every { fromRecipient.isSystemContact } returns false + every { fromRecipient.isProfileSharing } returns false + every { messageRecord.isOutgoing } returns true + every { TextSecurePreferences.getWifiMediaDownloadAllowed(context) } returns setOf("image") + + assertTrue(AttachmentUtil.isAutoDownloadPermitted(context, attachment())) + } + + @Test + fun `outgoing message is trusted`() { + every { fromRecipient.isSystemContact } returns false + every { messageRecord.isOutgoing } returns true + every { TextSecurePreferences.getWifiMediaDownloadAllowed(context) } returns setOf("image") + + assertTrue(AttachmentUtil.isAutoDownloadPermitted(context, attachment())) + } + + @Test + fun `self recipient is trusted`() { + every { fromRecipient.isSystemContact } returns false + every { fromRecipient.isSelf } returns true + every { TextSecurePreferences.getWifiMediaDownloadAllowed(context) } returns setOf("image") + + assertTrue(AttachmentUtil.isAutoDownloadPermitted(context, attachment())) + } + + @Test + fun `release notes recipient is trusted`() { + every { fromRecipient.isSystemContact } returns false + every { fromRecipient.isReleaseNotes } returns true + every { TextSecurePreferences.getWifiMediaDownloadAllowed(context) } returns setOf("image") + + assertTrue(AttachmentUtil.isAutoDownloadPermitted(context, attachment())) + } + + @Test + fun `zero size rejects`() { + every { TextSecurePreferences.getWifiMediaDownloadAllowed(context) } returns setOf("image") + assertFalse(AttachmentUtil.isAutoDownloadPermitted(context, attachment(size = 0L, contentType = "image/jpeg"))) + } + + @Test + fun `ciphertext over 200MB rejects`() { + val huge = attachment(size = 250L * 1024 * 1024, contentType = "image/jpeg") + every { TextSecurePreferences.getWifiMediaDownloadAllowed(context) } returns setOf("image") + + assertFalse(AttachmentUtil.isAutoDownloadPermitted(context, huge)) + } + + @Test + fun `long text always permitted`() { + val attachment = attachment(contentType = "text/x-signal-plain") + assertTrue(AttachmentUtil.isAutoDownloadPermitted(context, attachment)) + } + + @Test + fun `small sticker always permitted regardless of allowed types or in-call`() { + val attachment = attachment(size = 50L * 1024, isSticker = true, contentType = "image/webp") + every { NotInCallConstraint.isNotInConnectedCall() } returns false + + assertTrue(AttachmentUtil.isAutoDownloadPermitted(context, attachment)) + } + + @Test + fun `large sticker requires image allowed type`() { + val attachment = attachment(size = 500L * 1024, isSticker = true, contentType = "image/webp") + every { TextSecurePreferences.getWifiMediaDownloadAllowed(context) } returns setOf("image") + + assertTrue(AttachmentUtil.isAutoDownloadPermitted(context, attachment)) + } + + @Test + fun `large sticker blocked when in call`() { + val attachment = attachment(size = 500L * 1024, isSticker = true, contentType = "image/webp") + every { TextSecurePreferences.getWifiMediaDownloadAllowed(context) } returns setOf("image") + every { NotInCallConstraint.isNotInConnectedCall() } returns false + + assertFalse(AttachmentUtil.isAutoDownloadPermitted(context, attachment)) + } + + @Test + fun `large sticker blocked when image type not allowed`() { + val attachment = attachment(size = 500L * 1024, isSticker = true, contentType = "image/webp") + every { TextSecurePreferences.getWifiMediaDownloadAllowed(context) } returns setOf("audio") + + assertFalse(AttachmentUtil.isAutoDownloadPermitted(context, attachment)) + } + + @Test + fun `small voice note always permitted`() { + val attachment = attachment(size = 10L * 1024, voiceNote = true, contentType = "audio/aac", fileName = null) + assertTrue(AttachmentUtil.isAutoDownloadPermitted(context, attachment)) + } + + @Test + fun `unnamed audio without voice note flag uses audio setting`() { + val attachment = attachment(size = 10L * 1024, voiceNote = false, contentType = "audio/aac", fileName = null) + every { TextSecurePreferences.getWifiMediaDownloadAllowed(context) } returns setOf("audio") + assertTrue(AttachmentUtil.isAutoDownloadPermitted(context, attachment)) + + every { TextSecurePreferences.getWifiMediaDownloadAllowed(context) } returns emptySet() + assertFalse(AttachmentUtil.isAutoDownloadPermitted(context, attachment)) + } + + @Test + fun `named audio uses audio setting`() { + val attachment = attachment(size = 10L * 1024, voiceNote = false, contentType = "audio/mpeg", fileName = "song.mp3") + every { TextSecurePreferences.getWifiMediaDownloadAllowed(context) } returns setOf("audio") + assertTrue(AttachmentUtil.isAutoDownloadPermitted(context, attachment)) + + every { TextSecurePreferences.getWifiMediaDownloadAllowed(context) } returns emptySet() + assertFalse(AttachmentUtil.isAutoDownloadPermitted(context, attachment)) + } + + @Test + fun `large voice note requires audio allowed type`() { + val attachment = attachment(size = 500L * 1024, voiceNote = true, contentType = "audio/aac", fileName = null) + every { TextSecurePreferences.getWifiMediaDownloadAllowed(context) } returns setOf("audio") + + assertTrue(AttachmentUtil.isAutoDownloadPermitted(context, attachment)) + } + + @Test + fun `large voice note blocked when audio not allowed`() { + val attachment = attachment(size = 500L * 1024, voiceNote = true, contentType = "audio/aac", fileName = null) + every { TextSecurePreferences.getWifiMediaDownloadAllowed(context) } returns setOf("image") + + assertFalse(AttachmentUtil.isAutoDownloadPermitted(context, attachment)) + } + + @Test + fun `large voice note blocked when in call`() { + val attachment = attachment(size = 500L * 1024, voiceNote = true, contentType = "audio/aac", fileName = null) + every { TextSecurePreferences.getWifiMediaDownloadAllowed(context) } returns setOf("audio") + every { NotInCallConstraint.isNotInConnectedCall() } returns false + + assertFalse(AttachmentUtil.isAutoDownloadPermitted(context, attachment)) + } + + @Test + fun `video gif requires image allowed type`() { + val attachment = attachment(videoGif = true, contentType = "video/mp4") + every { TextSecurePreferences.getWifiMediaDownloadAllowed(context) } returns setOf("image") + assertTrue(AttachmentUtil.isAutoDownloadPermitted(context, attachment)) + + every { TextSecurePreferences.getWifiMediaDownloadAllowed(context) } returns emptySet() + assertFalse(AttachmentUtil.isAutoDownloadPermitted(context, attachment)) + } + + @Test + fun `image content type uses image setting`() { + val attachment = attachment(contentType = "image/jpeg") + every { TextSecurePreferences.getWifiMediaDownloadAllowed(context) } returns setOf("image") + assertTrue(AttachmentUtil.isAutoDownloadPermitted(context, attachment)) + + every { TextSecurePreferences.getWifiMediaDownloadAllowed(context) } returns emptySet() + assertFalse(AttachmentUtil.isAutoDownloadPermitted(context, attachment)) + } + + @Test + fun `video content type uses video setting`() { + val attachment = attachment(contentType = "video/mp4") + every { TextSecurePreferences.getWifiMediaDownloadAllowed(context) } returns setOf("video") + assertTrue(AttachmentUtil.isAutoDownloadPermitted(context, attachment)) + } + + @Test + fun `document content type uses documents setting`() { + val attachment = attachment(contentType = "application/pdf", fileName = "doc.pdf") + every { TextSecurePreferences.getWifiMediaDownloadAllowed(context) } returns setOf("documents") + assertTrue(AttachmentUtil.isAutoDownloadPermitted(context, attachment)) + + every { TextSecurePreferences.getWifiMediaDownloadAllowed(context) } returns emptySet() + assertFalse(AttachmentUtil.isAutoDownloadPermitted(context, attachment)) + } + + @Test + fun `null content type falls to documents setting`() { + val attachment = attachment(contentType = null, fileName = "file") + every { TextSecurePreferences.getWifiMediaDownloadAllowed(context) } returns setOf("documents") + assertTrue(AttachmentUtil.isAutoDownloadPermitted(context, attachment)) + } + + @Test + fun `in-call blocks non-sticker paths`() { + every { NotInCallConstraint.isNotInConnectedCall() } returns false + every { TextSecurePreferences.getWifiMediaDownloadAllowed(context) } returns setOf("image", "video", "audio", "documents") + + assertFalse(AttachmentUtil.isAutoDownloadPermitted(context, attachment(contentType = "image/jpeg"))) + assertFalse(AttachmentUtil.isAutoDownloadPermitted(context, attachment(contentType = "application/pdf", fileName = "a.pdf"))) + } + + @Test + fun `roaming network uses roaming allowed set`() { + every { NetworkUtil.isConnectedWifi(context) } returns false + every { NetworkUtil.isConnectedRoaming(context) } returns true + every { TextSecurePreferences.getRoamingMediaDownloadAllowed(context) } returns setOf("image") + + assertTrue(AttachmentUtil.isAutoDownloadPermitted(context, attachment(contentType = "image/jpeg"))) + } + + @Test + fun `mobile network uses mobile allowed set`() { + every { NetworkUtil.isConnectedWifi(context) } returns false + every { NetworkUtil.isConnectedRoaming(context) } returns false + every { NetworkUtil.isConnectedMobile(context) } returns true + every { TextSecurePreferences.getMobileMediaDownloadAllowed(context) } returns setOf("image") + + assertTrue(AttachmentUtil.isAutoDownloadPermitted(context, attachment(contentType = "image/jpeg"))) + } + + @Test + fun `no network yields empty allowed set`() { + every { NetworkUtil.isConnectedWifi(context) } returns false + every { NetworkUtil.isConnectedRoaming(context) } returns false + every { NetworkUtil.isConnectedMobile(context) } returns false + + assertFalse(AttachmentUtil.isAutoDownloadPermitted(context, attachment(contentType = "image/jpeg"))) + } + + // --- deleteAttachment --- + + @Test + fun `deleteAttachment with single attachment deletes message and returns its record`() { + val attachment = attachment() + every { attachmentTable.getAttachmentsForMessage(42L) } returns listOf(attachment) + every { messageTable.getMessageRecordOrNull(42L) } returns messageRecord + + val result = AttachmentUtil.deleteAttachment(attachment) + + assertTrue(result === messageRecord) + verify { messageTable.deleteMessage(42L) } + verify(exactly = 0) { attachmentTable.deleteAttachment(any()) } + } + + @Test + fun `deleteAttachment with multiple attachments deletes only the attachment and returns null`() { + val attachment = attachment() + every { attachmentTable.getAttachmentsForMessage(42L) } returns listOf(attachment, attachment) + every { messageTable.getMessageRecordOrNull(42L) } returns messageRecord + + val result = AttachmentUtil.deleteAttachment(attachment) + + assertTrue(result == null) + verify { attachmentTable.deleteAttachment(AttachmentId(1L)) } + verify(exactly = 0) { messageTable.deleteMessage(any()) } + } + + // --- isRestoreOnOpenPermitted --- + + @Test + fun `restore null attachment returns true`() { + assertTrue(AttachmentUtil.isRestoreOnOpenPermitted(context, null)) + } + + @Test + fun `restore null contentType returns false`() { + assertFalse(AttachmentUtil.isRestoreOnOpenPermitted(context, attachment(contentType = null, fileName = null))) + } + + @Test + fun `restore non-image returns false`() { + assertFalse(AttachmentUtil.isRestoreOnOpenPermitted(context, attachment(contentType = "video/mp4"))) + } + + @Test + fun `restore image permitted when allowed and not in call`() { + every { TextSecurePreferences.getWifiMediaDownloadAllowed(context) } returns setOf("image") + assertTrue(AttachmentUtil.isRestoreOnOpenPermitted(context, attachment(contentType = "image/jpeg"))) + } + + @Test + fun `restore image blocked when in call`() { + every { TextSecurePreferences.getWifiMediaDownloadAllowed(context) } returns setOf("image") + every { NotInCallConstraint.isNotInConnectedCall() } returns false + assertFalse(AttachmentUtil.isRestoreOnOpenPermitted(context, attachment(contentType = "image/jpeg"))) + } + + @Test + fun `restore image blocked when type not allowed`() { + every { TextSecurePreferences.getWifiMediaDownloadAllowed(context) } returns emptySet() + assertFalse(AttachmentUtil.isRestoreOnOpenPermitted(context, attachment(contentType = "image/jpeg"))) + } + + private fun attachment( + size: Long = 1_000L, + contentType: String? = "image/jpeg", + isSticker: Boolean = false, + voiceNote: Boolean = false, + videoGif: Boolean = false, + fileName: String? = "photo.jpg" + ): DatabaseAttachment { + return DatabaseAttachment( + attachmentId = AttachmentId(1L), + mmsId = 42L, + hasData = false, + hasThumbnail = false, + contentType = contentType, + transferProgress = AttachmentTable.TRANSFER_PROGRESS_PENDING, + size = size, + fileName = fileName, + cdn = Cdn.CDN_3, + location = null, + key = null, + digest = null, + incrementalDigest = null, + incrementalMacChunkSize = 0, + fastPreflightId = null, + voiceNote = voiceNote, + borderless = false, + videoGif = videoGif, + width = 0, + height = 0, + quote = false, + caption = null, + stickerLocator = if (isSticker) StickerLocator("pack", "key", 0, null) else null, + blurHash = null, + audioHash = null, + transformProperties = null, + displayOrder = 0, + uploadTimestamp = 0, + dataHash = null, + archiveCdn = null, + thumbnailRestoreState = AttachmentTable.ThumbnailRestoreState.NONE, + archiveTransferState = AttachmentTable.ArchiveTransferState.NONE, + uuid = null, + quoteTargetContentType = null, + metadata = null + ) + } +}