You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
session-ios/SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift

1003 lines
37 KiB
Swift

//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
// stringlint:disable
import UIKit
import Combine
import MobileCoreServices
import AVFoundation
import UniformTypeIdentifiers
import SessionUtilitiesKit
public enum SignalAttachmentError: Error {
case missingData
case fileSizeTooLarge
case invalidData
case couldNotParseImage
case couldNotConvertToJpeg
case couldNotConvertToMpeg4
case couldNotRemoveMetadata
case invalidFileFormat
case couldNotResizeImage
}
extension String {
public var filenameWithoutExtension: String {
return (self as NSString).deletingPathExtension
}
public var fileExtension: String? {
return (self as NSString).pathExtension
}
public func appendingFileExtension(_ fileExtension: String) -> String {
guard let result = (self as NSString).appendingPathExtension(fileExtension) else {
return self
}
return result
}
}
extension SignalAttachmentError: LocalizedError {
public var errorDescription: String? {
switch self {
case .fileSizeTooLarge:
return "attachmentsErrorSize".localized()
case .invalidData, .missingData, .invalidFileFormat:
return "attachmentsErrorNotSupported".localized()
case .couldNotConvertToJpeg, .couldNotParseImage, .couldNotConvertToMpeg4, .couldNotResizeImage:
return "attachmentsErrorOpen".localized()
case .couldNotRemoveMetadata:
return "attachmentsImageErrorMetadata".localized()
}
}
}
@objc
public enum TSImageQualityTier: UInt {
case original
case high
case mediumHigh
case medium
case mediumLow
case low
}
@objc
public enum TSImageQuality: UInt {
case original
case medium
case compact
func imageQualityTier() -> TSImageQualityTier {
switch self {
case .original:
return .original
case .medium:
return .mediumHigh
case .compact:
return .medium
}
}
}
// Represents a possible attachment to upload.
// The attachment may be invalid.
//
// Signal attachments are subject to validation and
// in some cases, file format conversion.
//
// This class gathers that logic. It offers factory methods
// for attachments that do the necessary work.
//
// The return value for the factory methods will be nil if the input is nil.
//
// [SignalAttachment hasError] will be true for non-valid attachments.
//
// TODO: Perhaps do conversion off the main thread?
public class SignalAttachment: Equatable {
// MARK: Properties
public let dataSource: (any DataSource)
public var captionText: String?
public var linkPreviewDraft: LinkPreviewDraft?
public var data: Data { return dataSource.data }
public var dataLength: UInt { return UInt(dataSource.dataLength) }
public var dataUrl: URL? { return dataSource.dataUrl }
public var sourceFilename: String? { return dataSource.sourceFilename?.filteredFilename }
public var isValidImage: Bool { return dataSource.isValidImage }
public var isValidVideo: Bool { return dataSource.isValidVideo }
// This flag should be set for text attachments that can be sent as text messages.
public var isConvertibleToTextMessage = false
// This flag should be set for attachments that can be sent as contact shares.
public var isConvertibleToContactShare = false
// Attachment types are identified using UTType.
public let dataType: UTType
public var error: SignalAttachmentError? {
didSet {
assert(oldValue == nil)
}
}
// To avoid redundant work of repeatedly compressing/uncompressing
// images, we cache the UIImage associated with this attachment if
// possible.
private var cachedImage: UIImage?
private var cachedVideoPreview: UIImage?
private(set) public var isVoiceMessage = false
// MARK:
public static let maxAttachmentsAllowed: Int = 32
// MARK: Constructor
// This method should not be called directly; use the factory
// methods instead.
private init(dataSource: (any DataSource), dataType: UTType) {
self.dataSource = dataSource
self.dataType = dataType
}
// MARK: Methods
public var hasError: Bool { return error != nil }
public var errorName: String? {
guard let error = error else {
// This method should only be called if there is an error.
return nil
}
return "\(error)"
}
public var localizedErrorDescription: String? {
guard let error = self.error else {
// This method should only be called if there is an error.
return nil
}
guard let errorDescription = error.errorDescription else {
return nil
}
return "\(errorDescription)"
}
public class var missingDataErrorMessage: String {
guard let errorDescription = SignalAttachmentError.missingData.errorDescription else {
return ""
}
return errorDescription
}
public func staticThumbnail() -> UIImage? {
if isAnimatedImage {
return image()
}
else if isImage {
return image()
}
else if isVideo {
return videoPreview()
}
else if isAudio {
return nil
}
return nil
}
public func image() -> UIImage? {
if let cachedImage = cachedImage {
return cachedImage
}
guard let image = UIImage(data: dataSource.data) else {
return nil
}
cachedImage = image
return image
}
public func videoPreview() -> UIImage? {
if let cachedVideoPreview = cachedVideoPreview {
return cachedVideoPreview
}
guard let mediaUrl = dataUrl else {
return nil
}
do {
let filePath = mediaUrl.path
guard FileManager.default.fileExists(atPath: filePath) else {
return nil
}
let asset = AVURLAsset(url: mediaUrl)
let generator = AVAssetImageGenerator(asset: asset)
generator.appliesPreferredTrackTransform = true
let cgImage = try generator.copyCGImage(at: CMTimeMake(value: 0, timescale: 1), actualTime: nil)
let image = UIImage(cgImage: cgImage)
cachedVideoPreview = image
return image
} catch {
return nil
}
}
public func text() -> String? {
guard let text = String(data: dataSource.data, encoding: .utf8) else {
return nil
}
return text
}
public func duration() -> TimeInterval? {
switch (isAudio, isVideo) {
case (true, _):
let audioPlayer: AVAudioPlayer? = try? AVAudioPlayer(data: dataSource.data)
return (audioPlayer?.duration).map { $0 > 0 ? $0 : nil }
case (_, true):
return dataUrl.map { url in
let asset: AVURLAsset = AVURLAsset(url: url, options: nil)
// According to the CMTime docs "value/timescale = seconds"
return (TimeInterval(asset.duration.value) / TimeInterval(asset.duration.timescale))
}
default: return nil
}
}
// Returns the MIME type for this attachment or nil if no MIME type
// can be identified.
public var mimeType: String {
guard
let fileExtension: String = sourceFilename.map({ URL(fileURLWithPath: $0) })?.pathExtension,
!fileExtension.isEmpty,
let fileExtensionMimeType: String = UTType(sessionFileExtension: fileExtension)?.preferredMIMEType
else { return (dataType.preferredMIMEType ?? UTType.mimeTypeDefault) }
// UTI types are an imperfect means of representing file type;
// file extensions are also imperfect but far more reliable and
// comprehensive so we always prefer to try to deduce MIME type
// from the file extension.
return fileExtensionMimeType
}
// Use the filename if known. If not, e.g. if the attachment was copy/pasted, we'll generate a filename
// like: "signal-2017-04-24-095918.zip"
public var filenameOrDefault: String {
if let filename = sourceFilename {
return filename.filteredFilename
} else {
let kDefaultAttachmentName = "signal"
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "YYYY-MM-dd-HHmmss"
let dateString = dateFormatter.string(from: Date())
let withoutExtension = "\(kDefaultAttachmentName)-\(dateString)"
if let fileExtension = self.fileExtension {
return "\(withoutExtension).\(fileExtension)"
}
return withoutExtension
}
}
// Returns the file extension for this attachment or nil if no file extension
// can be identified.
public var fileExtension: String? {
guard
let fileExtension: String = sourceFilename.map({ URL(fileURLWithPath: $0) })?.pathExtension,
!fileExtension.isEmpty
else { return dataType.sessionFileExtension(sourceFilename: sourceFilename) }
return fileExtension.filteredFilename
}
public var isImage: Bool { dataType.isImage || dataType.isAnimated }
public var isAnimatedImage: Bool { dataType.isAnimated }
public var isVideo: Bool { dataType.isVideo }
public var isAudio: Bool { dataType.isAudio }
public var isText: Bool {
isConvertibleToTextMessage &&
dataType.conforms(to: .text)
}
public var isUrl: Bool {
dataType.conforms(to: .url)
}
public class func pasteboardHasPossibleAttachment() -> Bool {
return UIPasteboard.general.numberOfItems > 0
}
public class func pasteboardHasText() -> Bool {
guard
UIPasteboard.general.numberOfItems > 0,
let pasteboardUTIdentifiers: [[String]] = UIPasteboard.general.types(forItemSet: IndexSet(integer: 0)),
let pasteboardUTTypes: Set<UTType> = pasteboardUTIdentifiers.first.map({ Set($0.compactMap { UTType($0) }) })
else { return false }
// The pasteboard can be populated with multiple UTI types
// with different payloads. iMessage for example will copy
// an animated GIF to the pasteboard with the following UTI
// types:
//
// * "public.url-name"
// * "public.utf8-plain-text"
// * "com.compuserve.gif"
//
// We want to paste the animated GIF itself, not it's name.
//
// In general, our rule is to prefer non-text pasteboard
// contents, so we return true IFF there is a text UTI type
// and there is no non-text UTI type.
guard !pasteboardUTTypes.contains(where: { !$0.conforms(to: .text) }) else { return false }
return pasteboardUTTypes.contains(where: { $0.conforms(to: .text) || $0.conforms(to: .url) })
}
// Returns an attachment from the pasteboard, or nil if no attachment
// can be found.
//
// NOTE: The attachment returned by this method may not be valid.
// Check the attachment's error property.
public class func attachmentFromPasteboard() -> SignalAttachment? {
guard
UIPasteboard.general.numberOfItems > 0,
let pasteboardUTIdentifiers: [[String]] = UIPasteboard.general.types(forItemSet: IndexSet(integer: 0)),
let pasteboardUTTypes: Set<UTType> = pasteboardUTIdentifiers.first.map({ Set($0.compactMap { UTType($0) }) })
else { return nil }
for type in UTType.supportedInputImageTypes {
if pasteboardUTTypes.contains(type) {
guard let data: Data = dataForFirstPasteboardItem(type: type) else { return nil }
// Pasted images _SHOULD _NOT_ be resized, if possible.
let dataSource = DataSourceValue(data: data, dataType: type)
return attachment(dataSource: dataSource, type: type, imageQuality: .original)
}
}
for type in UTType.supportedVideoTypes {
if pasteboardUTTypes.contains(type) {
guard let data = dataForFirstPasteboardItem(type: type) else { return nil }
let dataSource = DataSourceValue(data: data, dataType: type)
return videoAttachment(dataSource: dataSource, type: type)
}
}
for type in UTType.supportedAudioTypes {
if pasteboardUTTypes.contains(type) {
guard let data = dataForFirstPasteboardItem(type: type) else { return nil }
let dataSource = DataSourceValue(data: data, dataType: type)
return audioAttachment(dataSource: dataSource, type: type)
}
}
let type: UTType = pasteboardUTTypes[pasteboardUTTypes.startIndex]
guard let data = dataForFirstPasteboardItem(type: type) else { return nil }
let dataSource = DataSourceValue(data: data, dataType: type)
return genericAttachment(dataSource: dataSource, type: type)
}
// This method should only be called for dataUTIs that
// are appropriate for the first pasteboard item.
private class func dataForFirstPasteboardItem(type: UTType) -> Data? {
guard
UIPasteboard.general.numberOfItems > 0,
let dataValues: [Data] = UIPasteboard.general.data(
forPasteboardType: type.identifier,
inItemSet: IndexSet(integer: 0)
),
!dataValues.isEmpty
else { return nil }
return dataValues[0]
}
// MARK: Image Attachments
// Factory method for an image attachment.
//
// NOTE: The attachment returned by this method may not be valid.
// Check the attachment's error property.
private class func imageAttachment(dataSource: (any DataSource)?, type: UTType, imageQuality: TSImageQuality) -> SignalAttachment {
assert(dataSource != nil)
guard var dataSource = dataSource else {
let attachment = SignalAttachment(dataSource: DataSourceValue.empty, dataType: type)
attachment.error = .missingData
return attachment
}
let attachment = SignalAttachment(dataSource: dataSource, dataType: type)
guard UTType.supportedInputImageTypes.contains(type) else {
attachment.error = .invalidFileFormat
return attachment
}
guard dataSource.dataLength > 0 else {
attachment.error = .invalidData
return attachment
}
if UTType.supportedAnimatedImageTypes.contains(type) {
guard dataSource.dataLength <= MediaUtils.maxFileSizeAnimatedImage else {
attachment.error = .fileSizeTooLarge
return attachment
}
// Never re-encode animated images (i.e. GIFs) as JPEGs.
return attachment
} else {
guard let image = UIImage(data: dataSource.data) else {
attachment.error = .couldNotParseImage
return attachment
}
attachment.cachedImage = image
let isValidOutput = isValidOutputImage(image: image, dataSource: dataSource, type: type, imageQuality: imageQuality)
if let sourceFilename = dataSource.sourceFilename,
let sourceFileExtension = sourceFilename.fileExtension,
["heic", "heif"].contains(sourceFileExtension.lowercased()) {
// 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.
assert(type == .jpeg || !isValidOutput)
let baseFilename = sourceFilename.filenameWithoutExtension
dataSource.sourceFilename = baseFilename.appendingFileExtension("jpg")
}
if isValidOutput {
return removeImageMetadata(attachment: attachment)
} else {
return compressImageAsJPEG(image: image, attachment: attachment, filename: dataSource.sourceFilename, imageQuality: imageQuality)
}
}
}
// If the proposed attachment already conforms to the
// file size and content size limits, don't recompress it.
private class func isValidOutputImage(image: UIImage?, dataSource: (any DataSource)?, type: UTType, imageQuality: TSImageQuality) -> Bool {
guard
image != nil,
let dataSource = dataSource,
UTType.supportedOutputImageTypes.contains(type)
else { return false }
return (
doesImageHaveAcceptableFileSize(dataSource: dataSource, imageQuality: imageQuality) &&
dataSource.dataLength <= MediaUtils.maxFileSizeImage
)
}
// Factory method for an image attachment.
//
// NOTE: The attachment returned by this method may nil or not be valid.
// Check the attachment's error property.
public class func imageAttachment(image: UIImage?, type: UTType, filename: String?, imageQuality: TSImageQuality) -> SignalAttachment {
guard let image: UIImage = image else {
let dataSource = DataSourceValue.empty
dataSource.sourceFilename = filename
let attachment = SignalAttachment(dataSource: dataSource, dataType: type)
attachment.error = .missingData
return attachment
}
// Make a placeholder attachment on which to hang errors if necessary.
let dataSource = DataSourceValue.empty
dataSource.sourceFilename = filename
let attachment = SignalAttachment(dataSource: dataSource, dataType: type)
attachment.cachedImage = image
return compressImageAsJPEG(image: image, attachment: attachment, filename: filename, imageQuality: imageQuality)
}
private class func compressImageAsJPEG(image: UIImage, attachment: SignalAttachment, filename: String?, imageQuality: TSImageQuality) -> SignalAttachment {
assert(attachment.error == nil)
if imageQuality == .original &&
attachment.dataLength < MediaUtils.maxFileSizeGeneric &&
UTType.supportedOutputImageTypes.contains(attachment.dataType) {
// We should avoid resizing images attached "as documents" if possible.
return attachment
}
var imageUploadQuality = imageQuality.imageQualityTier()
while true {
let maxSize = maxSizeForImage(image: image, imageUploadQuality: imageUploadQuality)
var dstImage: UIImage! = image
if image.size.width > maxSize ||
image.size.height > maxSize {
guard let resizedImage = imageScaled(image, toMaxSize: maxSize) else {
attachment.error = .couldNotResizeImage
return attachment
}
dstImage = resizedImage
}
guard let jpgImageData = dstImage.jpegData(compressionQuality: jpegCompressionQuality(imageUploadQuality: imageUploadQuality)) else {
attachment.error = .couldNotConvertToJpeg
return attachment
}
let dataSource = DataSourceValue(data: jpgImageData, fileExtension: "jpg")
let baseFilename = filename?.filenameWithoutExtension
let jpgFilename = baseFilename?.appendingFileExtension("jpg")
dataSource.sourceFilename = jpgFilename
if doesImageHaveAcceptableFileSize(dataSource: dataSource, imageQuality: imageQuality) &&
dataSource.dataLength <= MediaUtils.maxFileSizeImage {
let recompressedAttachment = SignalAttachment(dataSource: dataSource, dataType: .jpeg)
recompressedAttachment.cachedImage = dstImage
return recompressedAttachment
}
// If the JPEG output is larger than the file size limit,
// continue to try again by progressively reducing the
// image upload quality.
switch imageUploadQuality {
case .original:
imageUploadQuality = .high
case .high:
imageUploadQuality = .mediumHigh
case .mediumHigh:
imageUploadQuality = .medium
case .medium:
imageUploadQuality = .mediumLow
case .mediumLow:
imageUploadQuality = .low
case .low:
attachment.error = .fileSizeTooLarge
return attachment
}
}
}
// NOTE: For unknown reasons, resizing images with UIGraphicsBeginImageContext()
// crashes reliably in the share extension after screen lock's auth UI has been presented.
// Resizing using a CGContext seems to work fine.
private class func imageScaled(_ uiImage: UIImage, toMaxSize maxSize: CGFloat) -> UIImage? {
guard let cgImage = uiImage.cgImage else {
return nil
}
// It's essential that we work consistently in "CG" coordinates (which are
// pixels and don't reflect orientation), not "UI" coordinates (which
// are points and do reflect orientation).
let scrSize = CGSize(width: cgImage.width, height: cgImage.height)
var maxSizeRect = CGRect.zero
maxSizeRect.size = CGSize(width: maxSize, height: maxSize)
let newSize = AVMakeRect(aspectRatio: scrSize, insideRect: maxSizeRect).size
let colorSpace = CGColorSpaceCreateDeviceRGB()
let bitmapInfo: CGBitmapInfo = [
CGBitmapInfo(rawValue: CGImageByteOrderInfo.orderDefault.rawValue),
CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)]
guard let context = CGContext.init(data: nil,
width: Int(newSize.width),
height: Int(newSize.height),
bitsPerComponent: 8,
bytesPerRow: 0,
space: colorSpace,
bitmapInfo: bitmapInfo.rawValue) else {
return nil
}
context.interpolationQuality = .high
var drawRect = CGRect.zero
drawRect.size = newSize
context.draw(cgImage, in: drawRect)
guard let newCGImage = context.makeImage() else {
return nil
}
return UIImage(cgImage: newCGImage,
scale: uiImage.scale,
orientation: uiImage.imageOrientation)
}
private class func doesImageHaveAcceptableFileSize(dataSource: (any DataSource), imageQuality: TSImageQuality) -> Bool {
switch imageQuality {
case .original:
return true
case .medium:
return dataSource.dataLength < UInt(1024 * 1024)
case .compact:
return dataSource.dataLength < UInt(400 * 1024)
}
}
private class func maxSizeForImage(image: UIImage, imageUploadQuality: TSImageQualityTier) -> CGFloat {
switch imageUploadQuality {
case .original:
return max(image.size.width, image.size.height)
case .high:
return 2048
case .mediumHigh:
return 1536
case .medium:
return 1024
case .mediumLow:
return 768
case .low:
return 512
}
}
private class func jpegCompressionQuality(imageUploadQuality: TSImageQualityTier) -> CGFloat {
switch imageUploadQuality {
case .original:
return 1
case .high:
return 0.9
case .mediumHigh:
return 0.8
case .medium:
return 0.7
case .mediumLow:
return 0.6
case .low:
return 0.5
}
}
private class func removeImageMetadata(attachment: SignalAttachment) -> SignalAttachment {
guard let source = CGImageSourceCreateWithData(attachment.data as CFData, nil) else {
let attachment = SignalAttachment(dataSource: DataSourceValue.empty, dataType: attachment.dataType)
attachment.error = .missingData
return attachment
}
guard let type = CGImageSourceGetType(source) else {
let attachment = SignalAttachment(dataSource: DataSourceValue.empty, dataType: attachment.dataType)
attachment.error = .invalidFileFormat
return attachment
}
let count = CGImageSourceGetCount(source)
let mutableData = NSMutableData()
guard let destination = CGImageDestinationCreateWithData(mutableData as CFMutableData, type, count, nil) else {
attachment.error = .couldNotRemoveMetadata
return attachment
}
let removeMetadataProperties: [String: AnyObject] =
[
kCGImagePropertyExifDictionary as String: kCFNull,
kCGImagePropertyExifAuxDictionary as String: kCFNull,
kCGImagePropertyGPSDictionary as String: kCFNull,
kCGImagePropertyTIFFDictionary as String: kCFNull,
kCGImagePropertyJFIFDictionary as String: kCFNull,
kCGImagePropertyPNGDictionary as String: kCFNull,
kCGImagePropertyIPTCDictionary as String: kCFNull,
kCGImagePropertyMakerAppleDictionary as String: kCFNull
]
for index in 0...count-1 {
CGImageDestinationAddImageFromSource(destination, source, index, removeMetadataProperties as CFDictionary)
}
if CGImageDestinationFinalize(destination) {
guard let dataSource = DataSourceValue(data: mutableData as Data, dataType: attachment.dataType) else {
attachment.error = .couldNotRemoveMetadata
return attachment
}
let strippedAttachment = SignalAttachment(dataSource: dataSource, dataType: attachment.dataType)
return strippedAttachment
} else {
attachment.error = .couldNotRemoveMetadata
return attachment
}
}
// MARK: Video Attachments
// Factory method for video attachments.
//
// NOTE: The attachment returned by this method may not be valid.
// Check the attachment's error property.
private class func videoAttachment(dataSource: (any DataSource)?, type: UTType) -> SignalAttachment {
guard let dataSource = dataSource else {
let dataSource = DataSourceValue.empty
let attachment = SignalAttachment(dataSource: dataSource, dataType: type)
attachment.error = .missingData
return attachment
}
return newAttachment(
dataSource: dataSource,
type: type,
validTypes: UTType.supportedVideoTypes,
maxFileSize: MediaUtils.maxFileSizeVideo
)
}
public class func copyToVideoTempDir(url fromUrl: URL) throws -> URL {
let baseDir = SignalAttachment.videoTempPath.appendingPathComponent(UUID().uuidString, isDirectory: true)
try? FileSystem.ensureDirectoryExists(at: baseDir.path)
let toUrl = baseDir.appendingPathComponent(fromUrl.lastPathComponent)
try FileManager.default.copyItem(at: fromUrl, to: toUrl)
return toUrl
}
private class var videoTempPath: URL {
let videoDir = URL(fileURLWithPath: Singleton.appContext.temporaryDirectory).appendingPathComponent("video")
try? FileSystem.ensureDirectoryExists(at: videoDir.path)
return videoDir
}
public class func compressVideoAsMp4(
dataSource: (any DataSource),
type: UTType,
using dependencies: Dependencies
) -> (AnyPublisher<SignalAttachment, Error>, AVAssetExportSession?) {
guard let url = dataSource.dataUrl else {
let attachment = SignalAttachment(dataSource: DataSourceValue.empty, dataType: type)
attachment.error = .missingData
return (
Just(attachment)
.setFailureType(to: Error.self)
.eraseToAnyPublisher(),
nil
)
}
let asset = AVAsset(url: url)
guard let exportSession = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetMediumQuality) else {
let attachment = SignalAttachment(dataSource: DataSourceValue.empty, dataType: type)
attachment.error = .couldNotConvertToMpeg4
return (
Just(attachment)
.setFailureType(to: Error.self)
.eraseToAnyPublisher(),
nil
)
}
exportSession.shouldOptimizeForNetworkUse = true
exportSession.outputFileType = AVFileType.mp4
exportSession.metadataItemFilter = AVMetadataItemFilter.forSharing()
let exportURL = videoTempPath.appendingPathComponent(UUID().uuidString).appendingPathExtension("mp4")
exportSession.outputURL = exportURL
let publisher = Deferred {
Future<SignalAttachment, Error> { resolver in
exportSession.exportAsynchronously {
let baseFilename = dataSource.sourceFilename
let mp4Filename = baseFilename?.filenameWithoutExtension.appendingFileExtension("mp4")
guard let dataSource = DataSourcePath(fileUrl: exportURL, sourceFilename: baseFilename, shouldDeleteOnDeinit: true) else {
let attachment = SignalAttachment(dataSource: DataSourceValue.empty, dataType: type)
attachment.error = .couldNotConvertToMpeg4
resolver(Result.success(attachment))
return
}
dataSource.sourceFilename = mp4Filename
let attachment = SignalAttachment(dataSource: dataSource, dataType: .mpeg4Movie)
resolver(Result.success(attachment))
}
}
}
.eraseToAnyPublisher()
return (publisher, exportSession)
}
public struct VideoCompressionResult {
public let attachmentPublisher: AnyPublisher<SignalAttachment, Error>
public let exportSession: AVAssetExportSession?
fileprivate init(attachmentPublisher: AnyPublisher<SignalAttachment, Error>, exportSession: AVAssetExportSession?) {
self.attachmentPublisher = attachmentPublisher
self.exportSession = exportSession
}
}
public class func compressVideoAsMp4(dataSource: (any DataSource), type: UTType, using dependencies: Dependencies) -> VideoCompressionResult {
let (attachmentPublisher, exportSession) = compressVideoAsMp4(dataSource: dataSource, type: type, using: dependencies)
return VideoCompressionResult(attachmentPublisher: attachmentPublisher, exportSession: exportSession)
}
public class func isInvalidVideo(dataSource: (any DataSource), type: UTType) -> Bool {
guard UTType.supportedVideoTypes.contains(type) else {
// not a video
return false
}
guard isValidOutputVideo(dataSource: dataSource, type: type) else {
// found a video which needs to be converted
return true
}
// It is a video, but it's not invalid
return false
}
private class func isValidOutputVideo(dataSource: (any DataSource)?, type: UTType) -> Bool {
guard
let dataSource = dataSource,
UTType.supportedOutputVideoTypes.contains(type),
dataSource.dataLength <= MediaUtils.maxFileSizeVideo
else { return false }
return false
}
// MARK: Audio Attachments
// Factory method for audio attachments.
//
// NOTE: The attachment returned by this method may not be valid.
// Check the attachment's error property.
private class func audioAttachment(dataSource: (any DataSource)?, type: UTType) -> SignalAttachment {
return newAttachment(
dataSource: dataSource,
type: type,
validTypes: UTType.supportedAudioTypes,
maxFileSize: MediaUtils.maxFileSizeAudio
)
}
// MARK: Generic Attachments
// Factory method for generic attachments.
//
// NOTE: The attachment returned by this method may not be valid.
// Check the attachment's error property.
private class func genericAttachment(dataSource: (any DataSource)?, type: UTType) -> SignalAttachment {
return newAttachment(
dataSource: dataSource,
type: type,
validTypes: nil,
maxFileSize: MediaUtils.maxFileSizeGeneric
)
}
// MARK: Voice Messages
public class func voiceMessageAttachment(dataSource: (any DataSource)?, type: UTType) -> SignalAttachment {
let attachment = audioAttachment(dataSource: dataSource, type: type)
attachment.isVoiceMessage = true
return attachment
}
// MARK: Attachments
// Factory method for non-image Attachments.
//
// NOTE: The attachment returned by this method may not be valid.
// Check the attachment's error property.
public class func attachment(dataSource: (any DataSource)?, type: UTType) -> SignalAttachment {
return attachment(dataSource: dataSource, type: type, imageQuality: .original)
}
// Factory method for attachments of any kind.
//
// NOTE: The attachment returned by this method may not be valid.
// Check the attachment's error property.
public class func attachment(dataSource: (any DataSource)?, type: UTType, imageQuality: TSImageQuality) -> SignalAttachment {
if UTType.supportedInputImageTypes.contains(type) {
return imageAttachment(dataSource: dataSource, type: type, imageQuality: imageQuality)
} else if UTType.supportedVideoTypes.contains(type) {
return videoAttachment(dataSource: dataSource, type: type)
} else if UTType.supportedAudioTypes.contains(type) {
return audioAttachment(dataSource: dataSource, type: type)
}
return genericAttachment(dataSource: dataSource, type: type)
}
public class func empty() -> SignalAttachment {
return SignalAttachment.attachment(
dataSource: DataSourceValue.empty,
type: .content,
imageQuality: .original
)
}
// MARK: Helper Methods
private class func newAttachment(
dataSource: (any DataSource)?,
type: UTType,
validTypes: Set<UTType>?,
maxFileSize: UInt
) -> SignalAttachment {
assert(dataSource != nil)
guard let dataSource = dataSource else {
let attachment = SignalAttachment(dataSource: DataSourceValue.empty, dataType: type)
attachment.error = .missingData
return attachment
}
let attachment = SignalAttachment(dataSource: dataSource, dataType: type)
if let validTypes: Set<UTType> = validTypes {
guard validTypes.contains(type) else {
attachment.error = .invalidFileFormat
return attachment
}
}
guard dataSource.dataLength > 0 else {
assert(dataSource.dataLength > 0)
attachment.error = .invalidData
return attachment
}
guard dataSource.dataLength <= maxFileSize else {
attachment.error = .fileSizeTooLarge
return attachment
}
// Attachment is valid
return attachment
}
// MARK: - Equatable
public static func == (lhs: SignalAttachment, rhs: SignalAttachment) -> Bool {
switch (lhs.dataSource, rhs.dataSource) {
case (let lhsDataSource as DataSourcePath, let rhsDataSource as DataSourcePath):
guard lhsDataSource == rhsDataSource else { return false }
break
case (let lhsDataSource as DataSourceValue, let rhsDataSource as DataSourceValue):
guard lhsDataSource == rhsDataSource else { return false }
break
default: return false
}
return (
lhs.dataType == rhs.dataType &&
lhs.captionText == rhs.captionText &&
lhs.linkPreviewDraft == rhs.linkPreviewDraft &&
lhs.isConvertibleToTextMessage == rhs.isConvertibleToTextMessage &&
lhs.isConvertibleToContactShare == rhs.isConvertibleToContactShare &&
lhs.cachedImage == rhs.cachedImage &&
lhs.cachedVideoPreview == rhs.cachedVideoPreview &&
lhs.isVoiceMessage == rhs.isVoiceMessage
)
}
}