Migrate quotes to have a separate quoteTargetContentType.

This commit is contained in:
Greyson Parrelli
2025-08-29 15:39:51 -04:00
parent 631b51baf2
commit 662404d335
45 changed files with 435 additions and 132 deletions

View File

@@ -45,11 +45,13 @@ class ArchivedAttachment : Attachment {
stickerLocator: StickerLocator?,
gif: Boolean,
quote: Boolean,
quoteTargetContentType: String?,
uuid: UUID?,
fileName: String?
) : super(
contentType = contentType ?: "",
quote = quote,
quoteTargetContentType = quoteTargetContentType,
transferState = AttachmentTable.TRANSFER_NEEDS_RESTORE,
size = size,
fileName = fileName,

View File

@@ -59,6 +59,8 @@ abstract class Attachment(
@JvmField
val quote: Boolean,
@JvmField
val quoteTargetContentType: String?,
@JvmField
val uploadTimestamp: Long,
@JvmField
val caption: String?,
@@ -98,6 +100,7 @@ abstract class Attachment(
height = parcel.readInt(),
incrementalMacChunkSize = parcel.readInt(),
quote = ParcelUtil.readBoolean(parcel),
quoteTargetContentType = parcel.readString(),
uploadTimestamp = parcel.readLong(),
caption = parcel.readString(),
stickerLocator = ParcelCompat.readParcelable(parcel, StickerLocator::class.java.classLoader, StickerLocator::class.java),
@@ -126,6 +129,7 @@ abstract class Attachment(
dest.writeInt(height)
dest.writeInt(incrementalMacChunkSize)
ParcelUtil.writeBoolean(dest, quote)
dest.writeString(quoteTargetContentType)
dest.writeLong(uploadTimestamp)
dest.writeString(caption)
dest.writeParcelable(stickerLocator, 0)

View File

@@ -75,7 +75,8 @@ class DatabaseAttachment : Attachment {
archiveCdn: Int?,
thumbnailRestoreState: AttachmentTable.ThumbnailRestoreState,
archiveTransferState: AttachmentTable.ArchiveTransferState,
uuid: UUID?
uuid: UUID?,
quoteTargetContentType: String?
) : super(
contentType = contentType,
transferState = transferProgress,
@@ -93,6 +94,7 @@ class DatabaseAttachment : Attachment {
height = height,
incrementalMacChunkSize = incrementalMacChunkSize,
quote = quote,
quoteTargetContentType = quoteTargetContentType,
uploadTimestamp = uploadTimestamp,
caption = caption,
stickerLocator = stickerLocator,

View File

@@ -40,6 +40,7 @@ class LocalStickerAttachment : Attachment {
height = StickerSlide.HEIGHT,
incrementalMacChunkSize = 0,
quote = false,
quoteTargetContentType = null,
uploadTimestamp = 0,
caption = null,
stickerLocator = stickerLocator,

View File

@@ -38,7 +38,9 @@ class PointerAttachment : Attachment {
caption: String?,
stickerLocator: StickerLocator?,
blurHash: BlurHash?,
uuid: UUID?
uuid: UUID?,
quote: Boolean,
quoteTargetContentType: String? = null
) : super(
contentType = contentType,
transferState = transferState,
@@ -56,7 +58,8 @@ class PointerAttachment : Attachment {
width = width,
height = height,
incrementalMacChunkSize = incrementalMacChunkSize,
quote = false,
quote = quote,
quoteTargetContentType = quoteTargetContentType,
uploadTimestamp = uploadTimestamp,
caption = caption,
stickerLocator = stickerLocator,
@@ -91,7 +94,9 @@ class PointerAttachment : Attachment {
pointer: Optional<SignalServiceAttachment>,
stickerLocator: StickerLocator? = null,
fastPreflightId: String? = null,
transferState: Int = AttachmentTable.TRANSFER_PROGRESS_PENDING
transferState: Int = AttachmentTable.TRANSFER_PROGRESS_PENDING,
quote: Boolean = false,
quoteTargetContentType: String? = null
): Optional<Attachment> {
if (!pointer.isPresent || !pointer.get().isPointer()) {
return Optional.empty()
@@ -122,7 +127,9 @@ class PointerAttachment : Attachment {
caption = pointer.get().asPointer().caption.orElse(null),
stickerLocator = stickerLocator,
blurHash = BlurHash.parseOrNull(pointer.get().asPointer().blurHash.orElse(null)),
uuid = pointer.get().asPointer().uuid
uuid = pointer.get().asPointer().uuid,
quote = quote,
quoteTargetContentType = quoteTargetContentType
)
)
}
@@ -140,7 +147,9 @@ class PointerAttachment : Attachment {
return Optional.of(
PointerAttachment(
contentType = quotedAttachment.contentType!!,
quote = true,
contentType = quotedAttachment.thumbnail?.contentType,
quoteTargetContentType = quotedAttachment.contentType!!,
transferState = AttachmentTable.TRANSFER_PROGRESS_PENDING,
size = (if (thumbnail != null) thumbnail.asPointer().size.orElse(0) else 0).toLong(),
fileName = quotedAttachment.fileName,

View File

@@ -5,6 +5,7 @@ import android.os.Parcel
import org.thoughtcrime.securesms.blurhash.BlurHash
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.stickers.StickerLocator
import org.thoughtcrime.securesms.util.MediaUtil
import java.util.UUID
/**
@@ -14,9 +15,21 @@ import java.util.UUID
* quote them and know their contentType even though the media has been deleted.
*/
class TombstoneAttachment : Attachment {
constructor(contentType: String?, quote: Boolean) : super(
companion object {
fun forQuote(): TombstoneAttachment {
return TombstoneAttachment(contentType = null, quote = true, quoteTargetContentType = MediaUtil.VIEW_ONCE)
}
fun forNonQuote(contentType: String?): TombstoneAttachment {
return TombstoneAttachment(contentType = contentType, quote = false, quoteTargetContentType = null)
}
}
constructor(contentType: String?, quote: Boolean, quoteTargetContentType: String?) : super(
contentType = contentType,
quote = quote,
quoteTargetContentType = quoteTargetContentType,
transferState = AttachmentTable.TRANSFER_PROGRESS_DONE,
size = 0,
fileName = null,
@@ -55,10 +68,12 @@ class TombstoneAttachment : Attachment {
gif: Boolean = false,
stickerLocator: StickerLocator? = null,
quote: Boolean,
quoteTargetContentType: String?,
uuid: UUID?
) : super(
contentType = contentType ?: "",
quote = quote,
quoteTargetContentType = quoteTargetContentType,
transferState = AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE,
size = 0,
fileName = fileName,

View File

@@ -22,6 +22,7 @@ class UriAttachment : Attachment {
borderless: Boolean,
videoGif: Boolean,
quote: Boolean,
quoteTargetContentType: String?,
caption: String?,
stickerLocator: StickerLocator?,
blurHash: BlurHash?,
@@ -40,6 +41,7 @@ class UriAttachment : Attachment {
borderless = borderless,
videoGif = videoGif,
quote = quote,
quoteTargetContentType = quoteTargetContentType,
caption = caption,
stickerLocator = stickerLocator,
blurHash = blurHash,
@@ -61,6 +63,7 @@ class UriAttachment : Attachment {
borderless: Boolean,
videoGif: Boolean,
quote: Boolean,
quoteTargetContentType: String?,
caption: String?,
stickerLocator: StickerLocator?,
blurHash: BlurHash?,
@@ -85,6 +88,7 @@ class UriAttachment : Attachment {
height = height,
incrementalMacChunkSize = 0,
quote = quote,
quoteTargetContentType = quoteTargetContentType,
uploadTimestamp = 0,
caption = caption,
stickerLocator = stickerLocator,

View File

@@ -30,6 +30,7 @@ class WallpaperAttachment() : Attachment(
height = 0,
incrementalMacChunkSize = 0,
quote = false,
quoteTargetContentType = null,
uploadTimestamp = 0,
caption = null,
stickerLocator = null,

View File

@@ -1071,7 +1071,7 @@ private fun BackupMessageRecord.toRemoteQuote(exportState: ExportState, attachme
val localType = QuoteModel.Type.fromCode(this.quoteType)
val remoteType = when (localType) {
QuoteModel.Type.NORMAL -> {
if (attachments?.any { it.contentType == MediaUtil.VIEW_ONCE } == true) {
if (attachments?.any { it.quoteTargetContentType == MediaUtil.VIEW_ONCE } == true) {
Quote.Type.VIEW_ONCE
} else {
Quote.Type.NORMAL
@@ -1158,11 +1158,11 @@ private fun DatabaseAttachment.toRemoteStickerMessage(sentTimestamp: Long, react
private fun List<DatabaseAttachment>.toRemoteQuoteAttachments(): List<Quote.QuotedAttachment> {
return this.map { attachment ->
Quote.QuotedAttachment(
contentType = attachment.contentType,
contentType = attachment.quoteTargetContentType,
fileName = attachment.fileName,
thumbnail = attachment.toRemoteMessageAttachment(
flagOverride = MessageAttachment.Flag.NONE,
contentTypeOverride = "image/jpeg"
contentTypeOverride = attachment.contentType
)
)
}

View File

@@ -76,7 +76,6 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stickers.StickerLocator
import org.thoughtcrime.securesms.util.JsonUtils
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.MessageUtil
import org.whispersystems.signalservice.api.payments.Money
import org.whispersystems.signalservice.api.push.ServiceId
@@ -1059,8 +1058,8 @@ class ChatItemArchiveImporter(
else -> null
}
},
start = bodyRange.start ?: 0,
length = bodyRange.length ?: 0
start = bodyRange.start,
length = bodyRange.length
)
}
)
@@ -1090,11 +1089,11 @@ class ChatItemArchiveImporter(
private fun Quote.toLocalAttachments(): List<Attachment> {
if (this.type == Quote.Type.VIEW_ONCE) {
return listOf(TombstoneAttachment(contentType = MediaUtil.VIEW_ONCE, quote = true))
return listOf(TombstoneAttachment.forQuote())
}
return attachments.mapNotNull { attachment ->
val thumbnail = attachment.thumbnail?.toLocalAttachment(quote = true)
return this.attachments.mapNotNull { attachment ->
val thumbnail = attachment.thumbnail?.toLocalAttachment(quote = true, quoteTargetContentType = attachment.contentType)
if (thumbnail != null) {
return@mapNotNull thumbnail
@@ -1141,7 +1140,7 @@ class ChatItemArchiveImporter(
)
}
private fun MessageAttachment.toLocalAttachment(quote: Boolean = false, contentType: String? = pointer?.contentType): Attachment? {
private fun MessageAttachment.toLocalAttachment(quote: Boolean = false, quoteTargetContentType: String? = null, contentType: String? = pointer?.contentType): Attachment? {
return pointer?.toLocalAttachment(
voiceNote = flag == MessageAttachment.Flag.VOICE_MESSAGE,
borderless = flag == MessageAttachment.Flag.BORDERLESS,
@@ -1150,7 +1149,8 @@ class ChatItemArchiveImporter(
contentType = contentType,
fileName = pointer.fileName,
uuid = clientUuid,
quote = quote
quote = quote,
quoteTargetContentType = quoteTargetContentType
)
}

View File

@@ -39,7 +39,8 @@ fun FilePointer?.toLocalAttachment(
contentType: String? = this?.contentType,
fileName: String? = this?.fileName,
uuid: ByteString? = null,
quote: Boolean = false
quote: Boolean = false,
quoteTargetContentType: String? = null
): Attachment? {
if (this == null || this.locatorInfo == null) return null
@@ -71,6 +72,7 @@ fun FilePointer?.toLocalAttachment(
stickerLocator = stickerLocator,
gif = gif,
quote = quote,
quoteTargetContentType = quoteTargetContentType,
uuid = UuidUtil.fromByteStringOrNull(uuid),
fileName = fileName
)
@@ -100,7 +102,9 @@ fun FilePointer?.toLocalAttachment(
PointerAttachment.forPointer(
pointer = Optional.of(signalAttachmentPointer),
stickerLocator = stickerLocator,
transferState = if (wasDownloaded) AttachmentTable.TRANSFER_NEEDS_RESTORE else AttachmentTable.TRANSFER_PROGRESS_PENDING
transferState = if (wasDownloaded) AttachmentTable.TRANSFER_NEEDS_RESTORE else AttachmentTable.TRANSFER_PROGRESS_PENDING,
quote = quote,
quoteTargetContentType = quoteTargetContentType
).orNull()
}
AttachmentType.INVALID -> {
@@ -117,6 +121,7 @@ fun FilePointer?.toLocalAttachment(
borderless = borderless,
gif = gif,
quote = quote,
quoteTargetContentType = quoteTargetContentType,
stickerLocator = stickerLocator,
uuid = UuidUtil.fromByteStringOrNull(uuid)
)

View File

@@ -219,7 +219,7 @@ public class InputPanel extends ConstraintLayout
@NonNull SlideDeck attachments,
@NonNull QuoteModel.Type quoteType)
{
this.quoteView.setQuote(requestManager, id, author, body, false, attachments, null, quoteType);
this.quoteView.setQuote(requestManager, id, author, body, false, attachments, null, quoteType, true);
if (listener != null) {
this.quoteView.setOnClickListener(v -> listener.onQuoteClicked(id, author.getId()));
}

View File

@@ -47,6 +47,7 @@ import org.thoughtcrime.securesms.util.views.Stub;
import java.io.IOException;
import java.util.List;
import java.util.Optional;
public class QuoteView extends ConstraintLayout implements RecipientForeverObserver {
@@ -200,7 +201,8 @@ public class QuoteView extends ConstraintLayout implements RecipientForeverObser
boolean originalMissing,
@NonNull SlideDeck attachments,
@Nullable String storyReaction,
@NonNull QuoteModel.Type quoteType)
@NonNull QuoteModel.Type quoteType,
boolean composeMode)
{
if (this.author != null) this.author.removeForeverObserver(this);
@@ -211,9 +213,19 @@ public class QuoteView extends ConstraintLayout implements RecipientForeverObser
this.quoteType = quoteType;
this.author.observeForever(this);
Slide slide = attachments.getFirstSlide();
String quoteTargetContentType;
if (composeMode) {
quoteTargetContentType = Optional.ofNullable(slide).map(Slide::getContentType).orElse(null);
} else {
quoteTargetContentType = Optional.ofNullable(slide).map(Slide::getQuoteTargetContentType).orElse(null);
}
setQuoteAuthor(author);
setQuoteText(resolveBody(body, quoteType), attachments, originalMissing, storyReaction);
setQuoteAttachment(requestManager, body, attachments, originalMissing);
setQuoteText(resolveBody(body, quoteType), slide, originalMissing, storyReaction, quoteTargetContentType);
setQuoteAttachment(requestManager, body, slide, originalMissing, quoteTargetContentType);
setQuoteMissingFooter(originalMissing);
applyColorTheme();
}
@@ -267,9 +279,10 @@ public class QuoteView extends ConstraintLayout implements RecipientForeverObser
}
private void setQuoteText(@Nullable CharSequence body,
@NonNull SlideDeck attachments,
@Nullable Slide slide,
boolean originalMissing,
@Nullable String storyReaction)
@Nullable String storyReaction,
@Nullable String quoteTargetContentType)
{
if (originalMissing && isStoryReply()) {
bodyView.setVisibility(GONE);
@@ -316,40 +329,37 @@ public class QuoteView extends ConstraintLayout implements RecipientForeverObser
bodyView.setVisibility(GONE);
mediaDescriptionText.setVisibility(VISIBLE);
Slide audioSlide = attachments.getSlides().stream().filter(Slide::hasAudio).findFirst().orElse(null);
Slide documentSlide = attachments.getSlides().stream().filter(Slide::hasDocument).findFirst().orElse(null);
Slide imageSlide = attachments.getSlides().stream().filter(Slide::hasImage).findFirst().orElse(null);
Slide videoSlide = attachments.getSlides().stream().filter(Slide::hasVideo).findFirst().orElse(null);
Slide stickerSlide = attachments.getSlides().stream().filter(Slide::hasSticker).findFirst().orElse(null);
Slide viewOnceSlide = attachments.getSlides().stream().filter(Slide::hasViewOnce).findFirst().orElse(null);
// Given that most types have images, we specifically check images last
if (viewOnceSlide != null) {
if (MediaUtil.isViewOnceType(quoteTargetContentType)) {
mediaDescriptionText.setPadding(0, mediaDescriptionText.getPaddingTop(), 0, (int) DimensionUnit.DP.toPixels(8));
mediaDescriptionText.setText(R.string.QuoteView_view_once_media);
} else if (audioSlide != null) {
} else if (MediaUtil.isAudioType(quoteTargetContentType)) {
mediaDescriptionText.setPadding(0, mediaDescriptionText.getPaddingTop(), 0, (int) DimensionUnit.DP.toPixels(8));
mediaDescriptionText.setText(R.string.QuoteView_audio);
} else if (documentSlide != null) {
mediaDescriptionText.setVisibility(GONE);
} else if (videoSlide != null) {
if (videoSlide.isVideoGif()) {
} else if (MediaUtil.isVideoType(quoteTargetContentType)) {
if (slide != null && slide.isVideoGif()) {
mediaDescriptionText.setText(R.string.QuoteView_gif);
} else {
mediaDescriptionText.setText(R.string.QuoteView_video);
}
} else if (stickerSlide != null) {
} else if (slide != null && slide.hasSticker()) {
mediaDescriptionText.setText(R.string.QuoteView_sticker);
} else if (imageSlide != null) {
if (MediaUtil.isGif(imageSlide.getContentType())) {
} else if (MediaUtil.isImageType(quoteTargetContentType)) {
if (MediaUtil.isGif(quoteTargetContentType)) {
mediaDescriptionText.setText(R.string.QuoteView_gif);
} else {
mediaDescriptionText.setText(R.string.QuoteView_photo);
}
} else {
mediaDescriptionText.setVisibility(GONE);
}
}
private void setQuoteAttachment(@NonNull RequestManager requestManager, @NonNull CharSequence body, @NonNull SlideDeck slideDeck, boolean originalMissing) {
private void setQuoteAttachment(@NonNull RequestManager requestManager,
@NonNull CharSequence body,
@NonNull Slide slide,
boolean originalMissing,
@Nullable String quoteTargetContentType)
{
boolean outgoing = messageType != MessageType.INCOMING && messageType != MessageType.STORY_REPLY_INCOMING;
boolean preview = messageType == MessageType.PREVIEW || messageType == MessageType.STORY_REPLY_PREVIEW;
@@ -394,41 +404,49 @@ public class QuoteView extends ConstraintLayout implements RecipientForeverObser
return;
}
Slide imageVideoSlide = slideDeck.getSlides().stream().filter(s -> s.hasImage() || s.hasVideo() || s.hasSticker()).findFirst().orElse(null);
Slide documentSlide = slideDeck.getSlides().stream().filter(Slide::hasDocument).findFirst().orElse(null);
Slide viewOnceSlide = slideDeck.getSlides().stream().filter(Slide::hasViewOnce).findFirst().orElse(null);
attachmentVideoOVerlayStub.setVisibility(GONE);
if (viewOnceSlide != null) {
thumbnailView.setVisibility(GONE);
attachmentNameViewStub.setVisibility(GONE);
} else if (imageVideoSlide != null && imageVideoSlide.getUri() != null) {
thumbnailView.setVisibility(VISIBLE);
attachmentNameViewStub.setVisibility(GONE);
if (dismissStub.resolved()) {
dismissStub.get().setBackgroundResource(R.drawable.dismiss_background);
}
if (imageVideoSlide.hasVideo() && !imageVideoSlide.isVideoGif()) {
attachmentVideoOVerlayStub.setVisibility(VISIBLE);
}
requestManager.load(new DecryptableUri(imageVideoSlide.getUri()))
.centerCrop()
.override(thumbWidth, thumbHeight)
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.into(thumbnailView);
} else if (documentSlide != null){
thumbnailView.setVisibility(GONE);
attachmentNameViewStub.setVisibility(VISIBLE);
attachmentNameViewStub.get().setText(documentSlide.getFileName().orElse(""));
} else {
if (TextUtils.isEmpty(quoteTargetContentType) || slide == null || slide.getUri() == null) {
thumbnailView.setVisibility(GONE);
attachmentNameViewStub.setVisibility(GONE);
if (dismissStub.resolved()) {
dismissStub.get().setBackground(null);
}
return;
}
attachmentVideoOVerlayStub.setVisibility(GONE);
if (MediaUtil.isViewOnceType(quoteTargetContentType)) {
thumbnailView.setVisibility(GONE);
attachmentNameViewStub.setVisibility(GONE);
} else if (MediaUtil.isImageOrVideoType(quoteTargetContentType)) {
thumbnailView.setVisibility(VISIBLE);
attachmentNameViewStub.setVisibility(GONE);
if (dismissStub.resolved()) {
dismissStub.get().setBackgroundResource(R.drawable.dismiss_background);
}
if (MediaUtil.isVideoType(quoteTargetContentType) && !slide.isVideoGif()) {
attachmentVideoOVerlayStub.setVisibility(VISIBLE);
}
requestManager.load(new DecryptableUri(slide.getUri()))
.centerCrop()
.override(thumbWidth, thumbHeight)
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.into(thumbnailView);
} else if (MediaUtil.isAudioType(quoteTargetContentType)) {
thumbnailView.setVisibility(GONE);
attachmentNameViewStub.setVisibility(GONE);
if (dismissStub.resolved()) {
dismissStub.get().setBackground(null);
}
} else {
thumbnailView.setVisibility(GONE);
attachmentNameViewStub.setVisibility(VISIBLE);
attachmentNameViewStub.get().setText(slide.getFileName().orElse(""));
}
}

View File

@@ -98,6 +98,7 @@ class InternalConversationSettingsFragment : ComposeFragment(), InternalConversa
borderless = false,
videoGif = false,
quote = false,
quoteTargetContentType = null,
caption = null,
stickerLocator = null,
blurHash = null,

View File

@@ -650,7 +650,7 @@ public class Contact implements Parcelable {
private static Attachment attachmentFromUri(@Nullable Uri uri) {
if (uri == null) return null;
return new UriAttachment(uri, MediaUtil.IMAGE_JPEG, AttachmentTable.TRANSFER_PROGRESS_DONE, 0, null, false, false, false, false, null, null, null, null, null);
return new UriAttachment(uri, MediaUtil.IMAGE_JPEG, AttachmentTable.TRANSFER_PROGRESS_DONE, 0, null, false, false, false, false, null, null, null, null, null, null);
}
@Override

View File

@@ -1649,7 +1649,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
quote.isOriginalMissing(),
quote.getAttachment(),
isStoryReaction(current) ? current.getBody() : null,
quote.getQuoteType());
quote.getQuoteType(),
false);
quoteView.setWallpaperEnabled(hasWallpaper);
quoteView.setVisibility(View.VISIBLE);

View File

@@ -505,7 +505,7 @@ class ConversationRepository(
}
if (messageRecord.isViewOnceMessage()) {
val attachment = TombstoneAttachment(MediaUtil.VIEW_ONCE, true)
val attachment = TombstoneAttachment.forQuote()
slideDeck = SlideDeck()
slideDeck.addSlide(MediaUtil.getSlideForAttachment(attachment))
}

View File

@@ -88,7 +88,8 @@ class V2ConversationItemMediaViewHolder<Model : MappingModel<Model>>(
quote.isOriginalMissing,
quote.attachment,
if (conversationMessage.messageRecord.isStoryReaction()) conversationMessage.messageRecord.body else null,
quote.quoteType
quote.quoteType,
false
)
quoteView.setMessageType(

View File

@@ -180,6 +180,7 @@ class AttachmentTable(
const val THUMBNAIL_RESTORE_STATE = "thumbnail_restore_state"
const val ATTACHMENT_UUID = "attachment_uuid"
const val OFFLOAD_RESTORED_AT = "offload_restored_at"
const val QUOTE_TARGET_CONTENT_TYPE = "quote_target_content_type"
const val ATTACHMENT_JSON_ALIAS = "attachment_json"
@@ -217,6 +218,7 @@ class AttachmentTable(
BORDERLESS,
VIDEO_GIF,
QUOTE,
QUOTE_TARGET_CONTENT_TYPE,
WIDTH,
HEIGHT,
CAPTION,
@@ -279,7 +281,8 @@ class AttachmentTable(
$THUMBNAIL_RANDOM BLOB DEFAULT NULL,
$THUMBNAIL_RESTORE_STATE INTEGER DEFAULT ${ThumbnailRestoreState.NONE.value},
$ATTACHMENT_UUID TEXT DEFAULT NULL,
$OFFLOAD_RESTORED_AT INTEGER DEFAULT 0
$OFFLOAD_RESTORED_AT INTEGER DEFAULT 0,
$QUOTE_TARGET_CONTENT_TYPE TEXT DEFAULT NULL
)
"""
@@ -423,6 +426,13 @@ class AttachmentTable(
.run()
}
fun hasData(attachmentId: AttachmentId): Boolean {
return readableDatabase
.exists(TABLE_NAME)
.where("$ID = ? AND $DATA_FILE NOT NULL", attachmentId)
.run()
}
fun getAttachment(attachmentId: AttachmentId): DatabaseAttachment? {
return readableDatabase
.select(*PROJECTION)
@@ -1790,9 +1800,9 @@ class AttachmentTable(
for (attachment in attachments) {
val attachmentId = when {
attachment is LocalStickerAttachment -> insertLocalStickerAttachment(mmsId, attachment)
attachment.uri != null -> insertAttachmentWithData(mmsId, attachment, attachment.quote)
attachment is ArchivedAttachment -> insertArchivedAttachment(mmsId, attachment, attachment.quote)
else -> insertUndownloadedAttachment(mmsId, attachment, attachment.quote)
attachment.uri != null -> insertAttachmentWithData(mmsId, attachment)
attachment is ArchivedAttachment -> insertArchivedAttachment(mmsId, attachment, quote = false, quoteTargetContentType = null)
else -> insertUndownloadedAttachment(mmsId, attachment, quote = false)
}
insertedAttachments[attachment] = attachmentId
@@ -1803,8 +1813,8 @@ class AttachmentTable(
for (attachment in quoteAttachment) {
val attachmentId = when {
attachment.uri != null -> insertQuoteAttachment(mmsId, attachment)
attachment is ArchivedAttachment -> insertArchivedAttachment(mmsId, attachment, true)
else -> insertUndownloadedAttachment(mmsId, attachment, true)
attachment is ArchivedAttachment -> insertArchivedAttachment(mmsId, attachment, quote = true, quoteTargetContentType = attachment.quoteTargetContentType)
else -> insertUndownloadedAttachment(mmsId, attachment, quote = true)
}
insertedAttachments[attachment] = attachmentId
@@ -2040,6 +2050,7 @@ class AttachmentTable(
width = jsonObject.getInt(WIDTH),
height = jsonObject.getInt(HEIGHT),
quote = jsonObject.getInt(QUOTE) != 0,
quoteTargetContentType = if (!jsonObject.isNull(QUOTE_TARGET_CONTENT_TYPE)) jsonObject.getString(QUOTE_TARGET_CONTENT_TYPE) else null,
caption = jsonObject.getString(CAPTION),
stickerLocator = if (jsonObject.getInt(STICKER_ID) >= 0) {
StickerLocator(
@@ -2394,6 +2405,7 @@ class AttachmentTable(
put(WIDTH, attachment.width)
put(HEIGHT, attachment.height)
put(QUOTE, quote.toInt())
put(QUOTE_TARGET_CONTENT_TYPE, attachment.quoteTargetContentType)
put(CAPTION, attachment.caption)
put(UPLOAD_TIMESTAMP, attachment.uploadTimestamp)
put(BLUR_HASH, attachment.blurHash?.hash)
@@ -2425,6 +2437,8 @@ class AttachmentTable(
/**
* When inserting a quote attachment, it looks a lot like a normal attachment insert, but rather than insert the actual data pointed at by the attachment's
* URI, we instead want to generate a thumbnail of that attachment and use that instead.
*
* It's important to note that it's assumed that [attachment] is the attachment that you're *quoting*. We'll use it's contentType as the quoteTargetContentType.
*/
@Throws(MmsException::class)
private fun insertQuoteAttachment(messageId: Long, attachment: Attachment): AttachmentId {
@@ -2438,7 +2452,8 @@ class AttachmentTable(
messageId = messageId,
dataStream = thumbnail.data.inputStream(),
attachment = attachment,
quote = true
quote = true,
quoteTargetContentType = attachment.contentType
)
}
@@ -2446,7 +2461,7 @@ class AttachmentTable(
val attachmentId: AttachmentId = writableDatabase.withinTransaction { db ->
val contentValues = ContentValues().apply {
put(MESSAGE_ID, messageId)
put(CONTENT_TYPE, attachment.contentType)
putNull(CONTENT_TYPE)
put(VOICE_NOTE, attachment.voiceNote.toInt())
put(BORDERLESS, attachment.borderless.toInt())
put(VIDEO_GIF, attachment.videoGif.toInt())
@@ -2455,6 +2470,7 @@ class AttachmentTable(
put(WIDTH, attachment.width)
put(HEIGHT, attachment.height)
put(QUOTE, 1)
put(QUOTE_TARGET_CONTENT_TYPE, attachment.contentType)
put(BLUR_HASH, attachment.blurHash?.hash)
put(FILE_NAME, attachment.fileName)
@@ -2529,7 +2545,7 @@ class AttachmentTable(
* Callers are expected to later call [finalizeAttachmentAfterDownload] once they have downloaded the data for this attachment.
*/
@Throws(MmsException::class)
private fun insertArchivedAttachment(messageId: Long, attachment: ArchivedAttachment, quote: Boolean): AttachmentId {
private fun insertArchivedAttachment(messageId: Long, attachment: ArchivedAttachment, quote: Boolean, quoteTargetContentType: String?): AttachmentId {
Log.d(TAG, "[insertArchivedAttachment] Inserting attachment for messageId $messageId.")
val attachmentId: AttachmentId = writableDatabase.withinTransaction { db ->
@@ -2552,6 +2568,7 @@ class AttachmentTable(
put(WIDTH, attachment.width)
put(HEIGHT, attachment.height)
put(QUOTE, quote.toInt())
put(QUOTE_TARGET_CONTENT_TYPE, quoteTargetContentType)
put(CAPTION, attachment.caption)
put(UPLOAD_TIMESTAMP, attachment.uploadTimestamp)
put(ARCHIVE_CDN, attachment.archiveCdn)
@@ -2681,14 +2698,14 @@ class AttachmentTable(
attachmentId = AttachmentId(rowId)
}
return attachmentId
return attachmentId as AttachmentId
}
/**
* Inserts an attachment with existing data. This is likely an outgoing attachment that we're in the process of sending.
*/
@Throws(MmsException::class)
private fun insertAttachmentWithData(messageId: Long, attachment: Attachment, quote: Boolean): AttachmentId {
private fun insertAttachmentWithData(messageId: Long, attachment: Attachment): AttachmentId {
requireNotNull(attachment.uri) { "Attachment must have a uri!" }
Log.d(TAG, "[insertAttachmentWithData] Inserting attachment for messageId $messageId. (MessageId: $messageId, ${attachment.uri})")
@@ -2699,7 +2716,7 @@ class AttachmentTable(
throw MmsException(e)
}
return insertAttachmentWithData(messageId, dataStream, attachment, quote)
return insertAttachmentWithData(messageId, dataStream, attachment, quote = false, quoteTargetContentType = null)
}
/**
@@ -2708,7 +2725,7 @@ class AttachmentTable(
* @param dataStream The stream to read the data from. This stream will be closed by this method.
*/
@Throws(MmsException::class)
private fun insertAttachmentWithData(messageId: Long, dataStream: InputStream, attachment: Attachment, quote: Boolean): AttachmentId {
private fun insertAttachmentWithData(messageId: Long, dataStream: InputStream, attachment: Attachment, quote: Boolean, quoteTargetContentType: String?): AttachmentId {
// To avoid performing long-running operations in a transaction, we write the data to an independent file first in a way that doesn't rely on db state.
val fileWriteResult: DataFileWriteResult = writeToDataFile(newDataFile(context), dataStream, attachment.transformProperties ?: TransformProperties.empty())
Log.d(TAG, "[insertAttachmentWithData] Wrote data to file: ${fileWriteResult.file.absolutePath} (MessageId: $messageId, ${attachment.uri})")
@@ -2808,6 +2825,7 @@ class AttachmentTable(
contentValues.put(WIDTH, uploadTemplate?.width ?: attachment.width)
contentValues.put(HEIGHT, uploadTemplate?.height ?: attachment.height)
contentValues.put(QUOTE, quote.toInt())
contentValues.put(QUOTE_TARGET_CONTENT_TYPE, quoteTargetContentType)
contentValues.put(CAPTION, attachment.caption)
contentValues.put(UPLOAD_TIMESTAMP, uploadTemplate?.uploadTimestamp ?: 0)
contentValues.put(TRANSFORM_PROPERTIES, transformProperties.serialize())
@@ -2849,7 +2867,7 @@ class AttachmentTable(
}
fun insertWallpaper(dataStream: InputStream): AttachmentId {
return insertAttachmentWithData(WALLPAPER_MESSAGE_ID, dataStream, WallpaperAttachment(), quote = false).also { id ->
return insertAttachmentWithData(WALLPAPER_MESSAGE_ID, dataStream, WallpaperAttachment(), quote = false, quoteTargetContentType = null).also { id ->
createRemoteKeyIfNecessary(id)
}
}
@@ -2964,6 +2982,7 @@ class AttachmentTable(
width = cursor.requireInt(WIDTH),
height = cursor.requireInt(HEIGHT),
quote = cursor.requireBoolean(QUOTE),
quoteTargetContentType = cursor.requireString(QUOTE_TARGET_CONTENT_TYPE),
caption = cursor.requireString(CAPTION),
stickerLocator = cursor.readStickerLocator(),
blurHash = if (MediaUtil.isAudioType(contentType)) null else BlurHash.parseOrNull(cursor.requireString(BLUR_HASH)),
@@ -3148,7 +3167,7 @@ class AttachmentTable(
* disk savings.
*/
@Throws(Exception::class)
fun migrationFinalizeQuoteWithData(previousDataFile: String, thumbnail: ImageCompressionUtil.Result): String {
fun migrationFinalizeQuoteWithData(previousDataFile: String, thumbnail: ImageCompressionUtil.Result, quoteTargetContentType: String?): String {
val newDataFileInfo = writeToDataFile(newDataFile(context), thumbnail.data.inputStream(), TransformProperties.empty())
writableDatabase
@@ -3160,6 +3179,7 @@ class AttachmentTable(
DATA_HASH_START to newDataFileInfo.hash,
DATA_HASH_END to newDataFileInfo.hash,
CONTENT_TYPE to thumbnail.mimeType,
QUOTE_TARGET_CONTENT_TYPE to quoteTargetContentType,
WIDTH to thumbnail.width,
HEIGHT to thumbnail.height,
QUOTE to 1

View File

@@ -396,7 +396,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
'${AttachmentTable.ARCHIVE_CDN}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.ARCHIVE_CDN},
'${AttachmentTable.THUMBNAIL_RESTORE_STATE}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.THUMBNAIL_RESTORE_STATE},
'${AttachmentTable.ARCHIVE_TRANSFER_STATE}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.ARCHIVE_TRANSFER_STATE},
'${AttachmentTable.ATTACHMENT_UUID}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.ATTACHMENT_UUID}
'${AttachmentTable.ATTACHMENT_UUID}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.ATTACHMENT_UUID},
'${AttachmentTable.QUOTE_TARGET_CONTENT_TYPE}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.QUOTE_TARGET_CONTENT_TYPE}
)
) AS ${AttachmentTable.ATTACHMENT_JSON_ALIAS}
""".toSingleLine()

View File

@@ -143,6 +143,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V285_AddEpochToCall
import org.thoughtcrime.securesms.database.helpers.migration.V286_FixRemoteKeyEncoding
import org.thoughtcrime.securesms.database.helpers.migration.V287_FixInvalidArchiveState
import org.thoughtcrime.securesms.database.helpers.migration.V288_CopyStickerDataHashStartToEnd
import org.thoughtcrime.securesms.database.helpers.migration.V289_AddQuoteTargetContentTypeColumn
import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSqliteDatabase
/**
@@ -291,48 +292,49 @@ object SignalDatabaseMigrations {
285 to V285_AddEpochToCallLinksTable,
286 to V286_FixRemoteKeyEncoding,
287 to V287_FixInvalidArchiveState,
288 to V288_CopyStickerDataHashStartToEnd
288 to V288_CopyStickerDataHashStartToEnd,
289 to V289_AddQuoteTargetContentTypeColumn
)
const val DATABASE_VERSION = 288
const val DATABASE_VERSION = 289
@JvmStatic
fun migrate(context: Application, db: SignalSqliteDatabase, oldVersion: Int, newVersion: Int) {
val initialForeignKeyState = db.areForeignKeyConstraintsEnabled()
for (migrationData in migrations) {
val eligibleMigrations = migrations.filter { (version, _) -> version > oldVersion && version <= newVersion }
for (migrationData in eligibleMigrations) {
val (version, migration) = migrationData
if (oldVersion < version) {
Log.i(TAG, "Running migration for version $version: ${migration.javaClass.simpleName}. Foreign keys: ${migration.enableForeignKeys}")
val startTime = System.currentTimeMillis()
Log.i(TAG, "Running migration for version $version: ${migration.javaClass.simpleName}. Foreign keys: ${migration.enableForeignKeys}")
val startTime = System.currentTimeMillis()
var ftsException: SQLiteException? = null
var ftsException: SQLiteException? = null
db.setForeignKeyConstraintsEnabled(migration.enableForeignKeys)
db.beginTransaction()
try {
migration.migrate(context, db, oldVersion, newVersion)
db.version = version
db.setTransactionSuccessful()
} catch (e: SQLiteException) {
if (e.message?.contains("invalid fts5 file format") == true || e.message?.contains("vtable constructor failed") == true) {
ftsException = e
} else {
throw e
}
} finally {
db.endTransaction()
db.setForeignKeyConstraintsEnabled(migration.enableForeignKeys)
db.beginTransaction()
try {
migration.migrate(context, db, oldVersion, newVersion)
db.version = version
db.setTransactionSuccessful()
} catch (e: SQLiteException) {
if (e.message?.contains("invalid fts5 file format") == true || e.message?.contains("vtable constructor failed") == true) {
ftsException = e
} else {
throw e
}
if (ftsException != null) {
Log.w(TAG, "Encountered FTS format issue! Attempting to repair.", ftsException)
SignalDatabase.messageSearch.fullyResetTables(db)
throw ftsException
}
Log.i(TAG, "Successfully completed migration for version $version in ${System.currentTimeMillis() - startTime} ms")
} finally {
db.endTransaction()
}
if (ftsException != null) {
Log.w(TAG, "Encountered FTS format issue! Attempting to repair.", ftsException)
SignalDatabase.messageSearch.fullyResetTables(db)
throw ftsException
}
Log.i(TAG, "Successfully completed migration for version $version in ${System.currentTimeMillis() - startTime} ms")
}
db.setForeignKeyConstraintsEnabled(initialForeignKeyState)

View File

@@ -0,0 +1,22 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.database.helpers.migration
import android.app.Application
import org.thoughtcrime.securesms.database.SQLiteDatabase
/**
* Adds the quote_target_content_type column to attachments and migrates existing quote attachments
* to populate this field with their current content_type.
*/
@Suppress("ClassName")
object V289_AddQuoteTargetContentTypeColumn : SignalDatabaseMigration {
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
db.execSQL("ALTER TABLE attachment ADD COLUMN quote_target_content_type TEXT DEFAULT NULL;")
db.execSQL("UPDATE attachment SET quote_target_content_type = content_type WHERE quote != 0;")
}
}

View File

@@ -374,7 +374,7 @@ public abstract class PushSendJob extends SendJob {
Attachment attachment = localQuoteAttachment.get();
SignalServiceAttachment quoteAttachmentPointer = getAttachmentPointerFor(localQuoteAttachment.get());
quoteAttachments.add(new SignalServiceDataMessage.Quote.QuotedAttachment(attachment.videoGif ? MediaUtil.IMAGE_GIF : attachment.contentType,
quoteAttachments.add(new SignalServiceDataMessage.Quote.QuotedAttachment(attachment.quoteTargetContentType != null ? attachment.quoteTargetContentType : MediaUtil.IMAGE_JPEG,
attachment.fileName,
quoteAttachmentPointer));
}

View File

@@ -83,7 +83,7 @@ class QuoteThumbnailBackfillJob private constructor(parameters: Parameters) : Jo
val thumbnail = SignalDatabase.attachments.generateQuoteThumbnail(DecryptableUri(attachment.uri), attachment.contentType, quiet = true)
if (thumbnail != null) {
SignalDatabase.attachments.migrationFinalizeQuoteWithData(attachment.dataFile, thumbnail)
SignalDatabase.attachments.migrationFinalizeQuoteWithData(attachment.dataFile, thumbnail, attachment.contentType)
} else {
Log.w(TAG, "Failed to generate thumbnail for attachment: ${attachment.id}. Clearing data.")
SignalDatabase.attachments.finalizeQuoteWithNoData(attachment.dataFile)

View File

@@ -488,6 +488,7 @@ public class LinkPreviewRepository {
null,
null,
null,
null,
null);
}

View File

@@ -1110,7 +1110,7 @@ object DataMessageProcessor {
.firstOrNull { it.hasData }
if (quotedMessage.isViewOnce) {
thumbnailAttachment = TombstoneAttachment(MediaUtil.VIEW_ONCE, true)
thumbnailAttachment = TombstoneAttachment.forQuote()
} else if (thumbnailAttachment == null) {
thumbnailAttachment = quotedMessage
.linkPreviews

View File

@@ -829,7 +829,7 @@ object SyncMessageProcessor {
val giftBadge: GiftBadge? = if (dataMessage.giftBadge?.receiptCredentialPresentation != null) GiftBadge.Builder().redemptionToken(dataMessage.giftBadge!!.receiptCredentialPresentation!!).build() else null
val viewOnce: Boolean = dataMessage.isViewOnce == true
val bodyRanges: BodyRangeList? = dataMessage.bodyRanges.toBodyRangeList()
val syncAttachments: List<Attachment> = listOfNotNull(sticker) + if (viewOnce) listOf<Attachment>(TombstoneAttachment(MediaUtil.VIEW_ONCE, false)) else dataMessage.attachments.toPointersWithinLimit()
val syncAttachments: List<Attachment> = listOfNotNull(sticker) + if (viewOnce) listOf<Attachment>(TombstoneAttachment.forNonQuote(MediaUtil.VIEW_ONCE)) else dataMessage.attachments.toPointersWithinLimit()
val mediaMessage = OutgoingMessage(
recipient = recipient,

View File

@@ -53,6 +53,7 @@ public class AudioSlide extends Slide {
null,
null,
null,
null,
null));
}
@@ -61,7 +62,7 @@ public class AudioSlide extends Slide {
}
public AudioSlide(Uri uri, long dataSize, String contentType, boolean voiceNote) {
super(new UriAttachment(uri, contentType, AttachmentTable.TRANSFER_PROGRESS_STARTED, dataSize, 0, 0, null, null, voiceNote, false, false, false, null, null, null, null, null));
super(new UriAttachment(uri, contentType, AttachmentTable.TRANSFER_PROGRESS_STARTED, dataSize, 0, 0, null, null, voiceNote, false, false, false, null, null, null, null, null, null));
}
public AudioSlide(Attachment attachment) {

View File

@@ -92,6 +92,11 @@ public abstract class Slide {
return attachment.fastPreflightId;
}
@Nullable
public String getQuoteTargetContentType() {
return attachment.quoteTargetContentType;
}
public long getFileSize() {
return attachment.size;
}
@@ -222,6 +227,7 @@ public abstract class Slide {
borderless,
gif,
quote,
null,
caption,
stickerLocator,
blurHash,

View File

@@ -562,7 +562,7 @@ public class MessageSender {
{
Set<String> finalUploadJobIds = new HashSet<>(uploadJobIds);
if (quoteAttachmentId != null) {
if (quoteAttachmentId != null && SignalDatabase.attachments().hasData(quoteAttachmentId)) {
Job uploadJob = new AttachmentUploadJob(quoteAttachmentId);
AppDependencies.getJobManager().add(uploadJob);
finalUploadJobIds.add(uploadJob.getId());