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