Adjust auto-download checks.

This commit is contained in:
Cody Henthorne
2026-04-27 10:21:03 -04:00
committed by Greyson Parrelli
parent fdcd21132c
commit 561186df90
7 changed files with 696 additions and 166 deletions
@@ -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()
}
@@ -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)
@@ -973,14 +973,16 @@ object DataMessageProcessor {
SignalDatabase.runPostSuccessfulTransaction {
if (insertResult.insertedAttachments != null) {
val downloadJobs: List<AttachmentDownloadJob> = 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)
@@ -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<String> 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<String> 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<String> 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();
}
}
@@ -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<String>, 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<String> {
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
}
}
+2
View File
@@ -6025,6 +6025,8 @@
<!-- Category title for the quality of the media to be sent -->
<string name="DataAndStorageSettingsFragment__sent_media_quality">Sent media quality</string>
<string name="DataAndStorageSettingsFragment__sending_high_quality_media_will_use_more_data">Sending high quality media will use more data.</string>
<!-- Disclaimer shown under the media auto-download settings. Placeholder is a formatted size like "100 KB". -->
<string name="DataAndStorageSettingsFragment__voice_messages_and_stickers_under_size_are_always_auto_downloaded">Voice messages and stickers (under %1$s) are always auto-downloaded.</string>
<!-- Setting option that can be selected to default media to be sent as high quality by default -->
<string name="DataAndStorageSettingsFragment__high">High</string>
<!-- Setting option that can be selected to default media to be sent as standard quality by default -->
@@ -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<Context>(relaxed = true)
private val messageTable = mockk<MessageTable>(relaxed = true)
private val threadTable = mockk<ThreadTable>(relaxed = true)
private val attachmentTable = mockk<AttachmentTable>(relaxed = true)
private val fromRecipient = mockk<Recipient>(relaxed = true)
private val toRecipient = mockk<Recipient>(relaxed = true)
private val messageRecord = mockk<MessageRecord>(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
)
}
}