Path: blob/main/SignalUI/Attachments/PreviewableAttachment.swift
1 views
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
public import AVFoundation
public import SignalServiceKit
/// Represents an attachment the user *might* choose to send.
///
/// See also ``SendableAttachment``.
///
/// These are attachments that are valid enough that we believe we can make
/// them fully valid if the user chooses to send them.
///
/// For example, if the user selects an image we can't decode, we can't make
/// a `PreviewableAttachment`. However, if the user selects an image whose
/// file size is too large, we *can* make a `PreviewableAttachment`. If the
/// user chooses to send it, we can re-encode the image with lower quality
/// and/or dimensions to fit within the limit.
///
/// On the other hand, if the user selects a PDF document that's too large,
/// we can't make it valid (e.g., we don't delete or re-encode pages), so we
/// can't make a `PreviewableAttachment` for it.
public struct PreviewableAttachment {
public let rawValue: SignalAttachment
public let attachmentType: AttachmentType
public enum AttachmentType {
case image(NormalizedImage)
case animatedImage
case other
}
public var dataSource: DataSourcePath { self.rawValue.dataSource }
public var dataUTI: String { self.rawValue.dataUTI }
public var mimeType: String { self.rawValue.mimeType }
public var renderingFlag: AttachmentReference.RenderingFlag { self.rawValue.renderingFlag }
public var isImage: Bool { self.rawValue.isImage }
public var isAnimatedImage: Bool { self.rawValue.isAnimatedImage }
public var isVideo: Bool { self.rawValue.isVideo }
public var isVisualMedia: Bool { self.rawValue.isVisualMedia }
public var isAudio: Bool { self.rawValue.isAudio }
// Factory method for an image attachment.
public static func imageAttachment(dataSource: DataSourcePath, dataUTI: String, canBeBorderless: Bool = false) throws -> Self {
assert(!dataUTI.isEmpty)
guard SignalAttachment.inputImageUTISet.contains(dataUTI) else {
throw SignalAttachmentError.invalidFileFormat
}
// [15M] TODO: Allow sending empty attachments?
guard let fileSize = try? dataSource.readLength(), fileSize > 0 else {
owsFailDebug("imageData was empty")
throw SignalAttachmentError.invalidData
}
let imageMetadata = try? dataSource.imageSource().imageMetadata()
let isBorderless = canBeBorderless && (imageMetadata?.hasStickerLikeProperties ?? false)
let isAnimated = imageMetadata?.isAnimated ?? false
// Never re-encode animated images (i.e. GIFs) as JPEGs.
if isAnimated {
guard fileSize <= OWSMediaUtils.kMaxFileSizeAnimatedImage else {
throw SignalAttachmentError.fileSizeTooLarge
}
let rawValue = SignalAttachment(dataSource: dataSource, dataUTI: dataUTI)
rawValue.isBorderless = isBorderless
rawValue.isAnimatedImage = true
return Self(
rawValue: rawValue,
attachmentType: .animatedImage,
)
} else {
if
let sourceFilename = dataSource.sourceFilename,
["heic", "heif"].contains((sourceFilename as NSString).pathExtension.lowercased()),
dataUTI == UTType.jpeg.identifier as String
{
// If a .heic file actually contains jpeg data, update the extension to
// match.
//
// Here's how that can happen:
//
// In iOS11, the Photos.app records photos with HEIC UTIType, with the
// .HEIC extension. Since HEIC isn't a valid output format for Signal,
// we'll detect that and convert to JPEG, updating the extension as well.
// No problem. However the problem comes in when you edit an HEIC image in
// Photos.app - the image is saved in the Photos.app as a JPEG, but retains
// the (now incongruous) HEIC extension in the filename.
let baseFilename = (sourceFilename as NSString).deletingPathExtension
dataSource.sourceFilename = (baseFilename as NSString).appendingPathExtension("jpg") ?? baseFilename
}
let normalizedImage = try NormalizedImage.forDataSource(dataSource, dataUTI: dataUTI)
return imageAttachmentForNormalizedImage(normalizedImage, isBorderless: isBorderless)
}
}
public static func imageAttachmentForNormalizedImage(_ normalizedImage: NormalizedImage, isBorderless: Bool = false) -> Self {
let rawValue = SignalAttachment(dataSource: normalizedImage.dataSource, dataUTI: normalizedImage.dataUTI)
rawValue.isBorderless = isBorderless
return Self(
rawValue: rawValue,
attachmentType: .image(normalizedImage),
)
}
// Factory method for video attachments.
public static func videoAttachment(
dataSource: DataSourcePath,
dataUTI: String,
attachmentLimits: OutgoingAttachmentLimits,
) throws -> Self {
try OWSMediaUtils.validateVideoExtension(ofPath: dataSource.fileUrl.path)
try OWSMediaUtils.validateVideoAsset(atPath: dataSource.fileUrl.path)
return try newAttachment(
dataSource: dataSource,
dataUTI: dataUTI,
validUTISet: SignalAttachment.videoUTISet,
maxFileSize: attachmentLimits.maxPlaintextVideoBytes,
)
}
@MainActor
public static func compressVideoAsMp4(
dataSource: DataSourcePath,
attachmentLimits: OutgoingAttachmentLimits,
sessionCallback: (@MainActor (AVAssetExportSession) -> Void)? = nil,
) async throws -> Self {
return try await compressVideoAsMp4(
asset: AVAsset(url: dataSource.fileUrl),
baseFilename: dataSource.sourceFilename,
attachmentLimits: attachmentLimits,
sessionCallback: sessionCallback,
)
}
@MainActor
public static func compressVideoAsMp4(
asset: AVAsset,
baseFilename: String?,
attachmentLimits: OutgoingAttachmentLimits,
sessionCallback: (@MainActor (AVAssetExportSession) -> Void)? = nil,
) async throws -> Self {
let startTime = MonotonicDate()
guard let exportSession = AVAssetExportSession(asset: asset, presetName: AVAssetExportPreset640x480) else {
throw SignalAttachmentError.couldNotConvertToMpeg4
}
exportSession.shouldOptimizeForNetworkUse = true
exportSession.metadataItemFilter = AVMetadataItemFilter.forSharing()
if let sessionCallback {
sessionCallback(exportSession)
}
let exportURL = OWSFileSystem.temporaryFileUrl(fileExtension: "mp4", isAvailableWhileDeviceLocked: false)
try await exportSession.exportAsync(to: exportURL, as: .mp4)
switch exportSession.status {
case .unknown:
throw OWSAssertionError("Unknown export status.")
case .waiting:
throw OWSAssertionError("Export status: .waiting.")
case .exporting:
throw OWSAssertionError("Export status: .exporting.")
case .completed:
break
case .failed:
if let error = exportSession.error {
owsFailDebug("Error: \(error)")
throw error
} else {
throw OWSAssertionError("Export failed without error.")
}
case .cancelled:
throw CancellationError()
@unknown default:
throw OWSAssertionError("Unknown export status: \(exportSession.status.rawValue)")
}
let mp4Filename: String?
if let baseFilename {
let baseFilenameWithoutExtension = (baseFilename as NSString).deletingPathExtension
mp4Filename = (baseFilenameWithoutExtension as NSString).appendingPathExtension("mp4") ?? baseFilenameWithoutExtension
} else {
mp4Filename = nil
}
let dataSource = DataSourcePath(fileUrl: exportURL, ownership: .owned)
dataSource.sourceFilename = mp4Filename
let endTime = MonotonicDate()
let formattedDuration = OWSOperation.formattedNs((endTime - startTime).nanoseconds)
Logger.info("transcoded video in \(formattedDuration)s")
return try videoAttachment(dataSource: dataSource, dataUTI: UTType.mpeg4Movie.identifier, attachmentLimits: attachmentLimits)
}
// MARK: Audio Attachments
// Factory method for audio attachments.
public static func audioAttachment(
dataSource: DataSourcePath,
dataUTI: String,
attachmentLimits: OutgoingAttachmentLimits,
) throws(SignalAttachmentError) -> Self {
return try newAttachment(
dataSource: dataSource,
dataUTI: dataUTI,
validUTISet: SignalAttachment.audioUTISet,
maxFileSize: attachmentLimits.maxPlaintextAudioBytes,
)
}
// MARK: Generic Attachments
// Factory method for generic attachments.
public static func genericAttachment(
dataSource: DataSourcePath,
dataUTI: String,
attachmentLimits: OutgoingAttachmentLimits,
) throws(SignalAttachmentError) -> Self {
// [15M] TODO: Enforce this at compile-time rather than runtime.
owsPrecondition(!SignalAttachment.videoUTISet.contains(dataUTI))
owsPrecondition(!SignalAttachment.inputImageUTISet.contains(dataUTI))
return try newAttachment(
dataSource: dataSource,
dataUTI: dataUTI,
validUTISet: nil,
maxFileSize: attachmentLimits.maxPlaintextBytes,
)
}
// MARK: Voice Messages
public static func voiceMessageAttachment(
dataSource: DataSourcePath,
dataUTI: String,
attachmentLimits: OutgoingAttachmentLimits,
) throws(SignalAttachmentError) -> Self {
let attachment = try audioAttachment(dataSource: dataSource, dataUTI: dataUTI, attachmentLimits: attachmentLimits)
attachment.rawValue.isVoiceMessage = true
return attachment
}
// MARK: Attachments
public struct AttachmentTypes: OptionSet {
public let rawValue: Int
public init(rawValue: Int) {
self.rawValue = rawValue
}
public static let image = Self(rawValue: 1 << 0)
public static let video = Self(rawValue: 1 << 1)
public static let audio = Self(rawValue: 1 << 2)
public static let other = Self(rawValue: 1 << 3)
public static let all: Self = [.image, .video, .audio, .other]
}
public struct BuildOptions: OptionSet {
public let rawValue: Int
public init(rawValue: Int) {
self.rawValue = rawValue
}
}
public static func buildAttachment(
ofTypes types: AttachmentTypes = .all,
dataSource: DataSourcePath,
dataUTI: String,
attachmentLimits: OutgoingAttachmentLimits,
options: BuildOptions = [],
) throws -> Self {
if SignalAttachment.inputImageUTISet.contains(dataUTI) {
guard types.contains(.image) else {
throw SignalAttachmentError.invalidFileFormat
}
return try imageAttachment(dataSource: dataSource, dataUTI: dataUTI)
}
if SignalAttachment.videoUTISet.contains(dataUTI) {
guard types.contains(.video) else {
throw SignalAttachmentError.invalidFileFormat
}
return try videoAttachment(dataSource: dataSource, dataUTI: dataUTI, attachmentLimits: attachmentLimits)
}
if SignalAttachment.audioUTISet.contains(dataUTI) {
guard types.contains(.audio) else {
throw SignalAttachmentError.invalidFileFormat
}
return try audioAttachment(dataSource: dataSource, dataUTI: dataUTI, attachmentLimits: attachmentLimits)
}
guard types.contains(.other) else {
throw SignalAttachmentError.invalidFileFormat
}
return try genericAttachment(dataSource: dataSource, dataUTI: dataUTI, attachmentLimits: attachmentLimits)
}
// MARK: Helper Methods
private static func newAttachment(
dataSource: DataSourcePath,
dataUTI: String,
validUTISet: Set<String>?,
maxFileSize: UInt64,
) throws(SignalAttachmentError) -> Self {
assert(!dataUTI.isEmpty)
let attachment = SignalAttachment(dataSource: dataSource, dataUTI: dataUTI)
if let validUTISet {
guard validUTISet.contains(dataUTI) else {
throw .invalidFileFormat
}
}
// [15M] TODO: Allow sending empty attachments?
guard let fileSize = try? dataSource.readLength(), fileSize > 0 else {
owsFailDebug("Empty attachment")
throw .invalidData
}
guard fileSize <= maxFileSize else {
throw .fileSizeTooLarge
}
// Attachment is valid
return Self(rawValue: attachment, attachmentType: .other)
}
}