322 lines
11 KiB
Swift
322 lines
11 KiB
Swift
//
|
|
// Copyright 2017 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
public import AVFoundation
|
|
import Foundation
|
|
import MobileCoreServices
|
|
import SDWebImage
|
|
public import SignalServiceKit
|
|
|
|
public enum SignalAttachmentError: Error {
|
|
case missingData
|
|
case fileSizeTooLarge
|
|
case invalidData
|
|
case couldNotParseImage
|
|
case couldNotConvertImage
|
|
case couldNotConvertToMpeg4
|
|
case couldNotRemoveMetadata
|
|
case invalidFileFormat
|
|
case couldNotResizeImage
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension SignalAttachmentError: LocalizedError, UserErrorDescriptionProvider {
|
|
public var errorDescription: String? {
|
|
localizedDescription
|
|
}
|
|
|
|
public var localizedDescription: String {
|
|
switch self {
|
|
case .missingData:
|
|
return OWSLocalizedString("ATTACHMENT_ERROR_MISSING_DATA", comment: "Attachment error message for attachments without any data")
|
|
case .fileSizeTooLarge:
|
|
return OWSLocalizedString("ATTACHMENT_ERROR_FILE_SIZE_TOO_LARGE", comment: "Attachment error message for attachments whose data exceed file size limits")
|
|
case .invalidData:
|
|
return OWSLocalizedString("ATTACHMENT_ERROR_INVALID_DATA", comment: "Attachment error message for attachments with invalid data")
|
|
case .couldNotParseImage:
|
|
return OWSLocalizedString("ATTACHMENT_ERROR_COULD_NOT_PARSE_IMAGE", comment: "Attachment error message for image attachments which cannot be parsed")
|
|
case .couldNotConvertImage:
|
|
return OWSLocalizedString("ATTACHMENT_ERROR_COULD_NOT_CONVERT_TO_JPEG", comment: "Attachment error message for image attachments which could not be converted to JPEG")
|
|
case .invalidFileFormat:
|
|
return OWSLocalizedString("ATTACHMENT_ERROR_INVALID_FILE_FORMAT", comment: "Attachment error message for attachments with an invalid file format")
|
|
case .couldNotConvertToMpeg4:
|
|
return OWSLocalizedString("ATTACHMENT_ERROR_COULD_NOT_CONVERT_TO_MP4", comment: "Attachment error message for video attachments which could not be converted to MP4")
|
|
case .couldNotRemoveMetadata:
|
|
return OWSLocalizedString("ATTACHMENT_ERROR_COULD_NOT_REMOVE_METADATA", comment: "Attachment error message for image attachments in which metadata could not be removed")
|
|
case .couldNotResizeImage:
|
|
return OWSLocalizedString("ATTACHMENT_ERROR_COULD_NOT_RESIZE_IMAGE", comment: "Attachment error message for image attachments which could not be resized")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
// Represents a possible attachment to upload.
|
|
//
|
|
// 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.
|
|
//
|
|
// TODO: Perhaps do conversion off the main thread?
|
|
|
|
public class SignalAttachment: CustomDebugStringConvertible {
|
|
|
|
// MARK: Properties
|
|
|
|
public let dataSource: DataSourcePath
|
|
|
|
// Attachment types are identified using UTIs.
|
|
//
|
|
// See: https://developer.apple.com/library/content/documentation/Miscellaneous/Reference/UTIRef/Articles/System-DeclaredUniformTypeIdentifiers.html
|
|
public let dataUTI: String
|
|
|
|
// 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 cachedThumbnail: UIImage?
|
|
private var cachedVideoPreview: UIImage?
|
|
|
|
public var isVoiceMessage = false
|
|
|
|
public static let maxAttachmentsAllowed: Int = 32
|
|
|
|
// MARK: Constructor
|
|
|
|
// This method should not be called directly; use the factory
|
|
// methods instead.
|
|
init(dataSource: DataSourcePath, dataUTI: String) {
|
|
self.dataSource = dataSource
|
|
self.dataUTI = dataUTI
|
|
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(didReceiveMemoryWarningNotification),
|
|
name: UIApplication.didReceiveMemoryWarningNotification,
|
|
object: nil,
|
|
)
|
|
}
|
|
|
|
@objc
|
|
private func didReceiveMemoryWarningNotification() {
|
|
cachedImage = nil
|
|
cachedThumbnail = nil
|
|
cachedVideoPreview = nil
|
|
}
|
|
|
|
// MARK: Methods
|
|
|
|
public var debugDescription: String {
|
|
return "[SignalAttachment mimeType: \(mimeType)]"
|
|
}
|
|
|
|
public func staticThumbnail() -> UIImage? {
|
|
if let cachedThumbnail {
|
|
return cachedThumbnail
|
|
}
|
|
|
|
return autoreleasepool {
|
|
guard
|
|
let image: UIImage = {
|
|
if isImage {
|
|
return image()
|
|
} else if isVideo {
|
|
return videoPreview()
|
|
} else if isAudio {
|
|
return nil
|
|
} else {
|
|
return nil
|
|
}
|
|
}() else { return nil }
|
|
|
|
// We want to limit the *smaller* dimension to 60 points,
|
|
// so figure out what the larger dimension would need to
|
|
// be limited to if we preserved our aspect ratio. This
|
|
// ensures crisp thumbnails when we center crop in a
|
|
// 60x60 or smaller container.
|
|
let pixelSize = image.pixelSize
|
|
let maxDimensionPixels = ((60 * UIScreen.main.scale) / pixelSize.smallerAxis).clamp01() * pixelSize.largerAxis
|
|
|
|
let thumbnail = image.resized(maxDimensionPixels: maxDimensionPixels)
|
|
cachedThumbnail = thumbnail
|
|
return thumbnail
|
|
}
|
|
}
|
|
|
|
public var renderingFlag: AttachmentReference.RenderingFlag {
|
|
if isVoiceMessage {
|
|
return .voiceMessage
|
|
} else if isBorderless {
|
|
return .borderless
|
|
} else if isLoopingVideo || MimeTypeUtil.isSupportedDefinitelyAnimatedMimeType(mimeType) {
|
|
return .shouldLoop
|
|
} else {
|
|
return .default
|
|
}
|
|
}
|
|
|
|
public func image() -> UIImage? {
|
|
if let cachedImage {
|
|
return cachedImage
|
|
}
|
|
guard let imageData = try? dataSource.readData(), let image = UIImage(data: imageData) else {
|
|
return nil
|
|
}
|
|
cachedImage = image
|
|
return image
|
|
}
|
|
|
|
public func videoPreview() -> UIImage? {
|
|
if let cachedVideoPreview {
|
|
return cachedVideoPreview
|
|
}
|
|
|
|
let mediaUrl = dataSource.fileUrl
|
|
|
|
do {
|
|
let filePath = mediaUrl.path
|
|
guard FileManager.default.fileExists(atPath: filePath) else {
|
|
owsFailDebug("asset at \(filePath) doesn't exist")
|
|
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 var isBorderless = false
|
|
public var isLoopingVideo = false
|
|
|
|
// Returns the MIME type for this attachment or nil if no MIME type
|
|
// can be identified.
|
|
public var mimeType: String {
|
|
if isVoiceMessage {
|
|
// Legacy iOS clients don't handle "audio/mp4" files correctly;
|
|
// they are written to disk as .mp4 instead of .m4a which breaks
|
|
// playback. So we send voice messages as "audio/aac" to work
|
|
// around this.
|
|
//
|
|
// TODO: Remove this Nov. 2016 or after.
|
|
return "audio/aac"
|
|
}
|
|
return MimeTypeUtil.mimeTypeForDataSource(dataSource, dataUTI: dataUTI)
|
|
}
|
|
|
|
// Returns the file extension for this attachment or nil if no file extension
|
|
// can be identified.
|
|
public var fileExtension: String? {
|
|
if let filename = dataSource.sourceFilename?.filterFilename() {
|
|
let fileExtension = (filename as NSString).pathExtension
|
|
if !fileExtension.isEmpty {
|
|
return fileExtension.filterFilename()
|
|
}
|
|
}
|
|
guard let fileExtension = MimeTypeUtil.fileExtensionForUtiType(dataUTI) else {
|
|
return nil
|
|
}
|
|
return fileExtension
|
|
}
|
|
|
|
// Returns the set of UTIs that correspond to valid _input_ image formats
|
|
// for Signal attachments.
|
|
//
|
|
// Image attachments may be converted to another image format before
|
|
// being uploaded.
|
|
public static let inputImageUTISet: Set<String> = {
|
|
// We support additional types for input images because we can transcode
|
|
// these to a format that's always supported by the receiver.
|
|
var additionalTypes = [UTType]()
|
|
if #available(iOS 18.2, *) {
|
|
additionalTypes.append(.jpegxl)
|
|
}
|
|
if #available(iOS 18.0, *) {
|
|
additionalTypes.append(.dng)
|
|
} else {
|
|
additionalTypes.append(UTType("com.adobe.raw-image")!)
|
|
}
|
|
if #available(iOS 16.0, *) {
|
|
additionalTypes.append(UTType("public.avif")!)
|
|
}
|
|
additionalTypes.append(.heif)
|
|
additionalTypes.append(.heic)
|
|
additionalTypes.append(.webP)
|
|
|
|
return outputImageUTISet.union(additionalTypes.map(\.identifier))
|
|
}()
|
|
|
|
// Returns the set of UTIs that correspond to valid _output_ image formats
|
|
// for Signal attachments.
|
|
class var outputImageUTISet: Set<String> {
|
|
MimeTypeUtil.supportedImageUtiTypes.union(animatedImageUTISet)
|
|
}
|
|
|
|
private class var outputVideoUTISet: Set<String> {
|
|
[UTType.mpeg4Movie.identifier]
|
|
}
|
|
|
|
// Returns the set of UTIs that correspond to valid animated image formats
|
|
// for Signal attachments.
|
|
private class var animatedImageUTISet: Set<String> {
|
|
MimeTypeUtil.supportedAnimatedImageUtiTypes
|
|
}
|
|
|
|
// Returns the set of UTIs that correspond to valid video formats
|
|
// for Signal attachments.
|
|
public class var videoUTISet: Set<String> {
|
|
MimeTypeUtil.supportedVideoUtiTypes
|
|
}
|
|
|
|
// Returns the set of UTIs that correspond to valid audio formats
|
|
// for Signal attachments.
|
|
public class var audioUTISet: Set<String> {
|
|
MimeTypeUtil.supportedAudioUtiTypes
|
|
}
|
|
|
|
// Returns the set of UTIs that correspond to valid image, video and audio formats
|
|
// for Signal attachments.
|
|
public class var mediaUTISet: Set<String> {
|
|
return audioUTISet.union(videoUTISet).union(animatedImageUTISet).union(inputImageUTISet)
|
|
}
|
|
|
|
public var isImage: Bool {
|
|
return SignalAttachment.outputImageUTISet.contains(dataUTI)
|
|
}
|
|
|
|
/// Only valid when `isImage` is true.
|
|
///
|
|
/// If `isAnimatedImage` is true, then `isImage` must be true. In other
|
|
/// words, all animated images are images (but not all images are animated).
|
|
public var isAnimatedImage = false
|
|
|
|
public var isVideo: Bool {
|
|
return SignalAttachment.videoUTISet.contains(dataUTI)
|
|
}
|
|
|
|
public var isVisualMedia: Bool {
|
|
return self.isImage || self.isVideo
|
|
}
|
|
|
|
public var isAudio: Bool {
|
|
return SignalAttachment.audioUTISet.contains(dataUTI)
|
|
}
|
|
|
|
public var isUrl: Bool {
|
|
UTType(dataUTI)?.conforms(to: .url) ?? false
|
|
}
|
|
}
|