// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation import GRDB import PromiseKit import SignalCoreKit import SessionUtilitiesKit import AVFAudio import AVFoundation public struct Attachment: Codable, Identifiable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "attachment" } internal static let interactionAttachments = belongsTo(InteractionAttachment.self) fileprivate static let quote = belongsTo(Quote.self) fileprivate static let linkPreview = belongsTo(LinkPreview.self) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { case id case serverId case variant case state case contentType case byteCount case creationTimestamp case sourceFilename case downloadUrl case localRelativeFilePath case width case height case duration case isValid case encryptionKey case digest case caption } public enum Variant: Int, Codable, DatabaseValueConvertible { case standard case voiceMessage } public enum State: Int, Codable, DatabaseValueConvertible { case pending case downloading case downloaded case uploading case uploaded case failed } /// A unique identifier for the attachment public let id: String /// The id for the attachment returned by the server /// /// This will be null for attachments which haven’t completed uploading /// /// **Note:** This value is not unique as multiple SOGS could end up having the same file id public let serverId: String? /// The type of this attachment, used to distinguish logic handling public let variant: Variant /// The current state of the attachment public let state: State /// The MIMEType for the attachment public let contentType: String /// The size of the attachment in bytes /// /// **Note:** This may be `0` for some legacy attachments public let byteCount: UInt /// Timestamp in seconds since epoch for when this attachment was created /// /// **Uploaded:** This will be the timestamp the file finished uploading /// **Downloaded:** This will be the timestamp the file finished downloading /// **Other:** This will be null public let creationTimestamp: TimeInterval? /// Represents the "source" filename sent or received in the protos, not the filename on disk public let sourceFilename: String? /// The url the attachment can be downloaded from, this will be `null` for attachments which haven’t yet been uploaded /// /// **Note:** The url is a fully constructed url but the clients just extract the id from the end of the url to perform the actual download public let downloadUrl: String? /// The file path for the attachment relative to the attachments folder /// /// **Note:** We store this path so that file path generation changes don’t break existing attachments public let localRelativeFilePath: String? /// The width of the attachment, this will be `null` for non-visual attachment types public let width: UInt? /// The height of the attachment, this will be `null` for non-visual attachment types public let height: UInt? /// The number of seconds the attachment plays for (this will only be set for video and audio attachment types) public let duration: TimeInterval? /// A flag indicating whether the attachment data downloaded is valid for it's content type public let isValid: Bool /// The key used to decrypt the attachment public let encryptionKey: Data? /// The computed digest for the attachment (generated from `iv || encrypted data || hmac`) public let digest: Data? /// Caption for the attachment public let caption: String? // MARK: - Initialization public init( id: String = UUID().uuidString, serverId: String? = nil, variant: Variant, state: State = .pending, contentType: String, byteCount: UInt, creationTimestamp: TimeInterval? = nil, sourceFilename: String? = nil, downloadUrl: String? = nil, localRelativeFilePath: String? = nil, width: UInt? = nil, height: UInt? = nil, duration: TimeInterval? = nil, isValid: Bool = false, encryptionKey: Data? = nil, digest: Data? = nil, caption: String? = nil ) { self.id = id self.serverId = serverId self.variant = variant self.state = state self.contentType = contentType self.byteCount = byteCount self.creationTimestamp = creationTimestamp self.sourceFilename = sourceFilename self.downloadUrl = downloadUrl self.localRelativeFilePath = localRelativeFilePath self.width = width self.height = height self.duration = duration self.isValid = isValid self.encryptionKey = encryptionKey self.digest = digest self.caption = caption } /// This initializer should only be used when converting from either a LinkPreview or a SignalAttachment to an Attachment (prior to upload) public init?( id: String = UUID().uuidString, variant: Variant = .standard, contentType: String, dataSource: DataSource ) { guard let originalFilePath: String = Attachment.originalFilePath(id: id, mimeType: contentType, sourceFilename: nil) else { return nil } guard dataSource.write(toPath: originalFilePath) else { return nil } let imageSize: CGSize? = Attachment.imageSize( contentType: contentType, originalFilePath: originalFilePath ) let (isValid, duration): (Bool, TimeInterval?) = Attachment.determineValidityAndDuration( contentType: contentType, originalFilePath: originalFilePath ) self.id = id self.serverId = nil self.variant = variant self.state = .pending self.contentType = contentType self.byteCount = dataSource.dataLength() self.creationTimestamp = nil self.sourceFilename = nil self.downloadUrl = nil self.localRelativeFilePath = nil self.width = imageSize.map { UInt(floor($0.width)) } self.height = imageSize.map { UInt(floor($0.height)) } self.duration = duration self.isValid = isValid self.encryptionKey = nil self.digest = nil self.caption = nil } } // MARK: - CustomStringConvertible extension Attachment: CustomStringConvertible { public static func description(for variant: Variant, contentType: String, sourceFilename: String?) -> String { if MIMETypeUtil.isAudio(contentType) { // a missing filename is the legacy way to determine if an audio attachment is // a voice note vs. other arbitrary audio attachments. if variant == .voiceMessage || sourceFilename == nil || (sourceFilename?.count ?? 0) == 0 { return "🎙️ \("ATTACHMENT_TYPE_VOICE_MESSAGE".localized())" } } return "\("ATTACHMENT".localized()) \(emoji(for: contentType))" } public static func emoji(for contentType: String) -> String { if MIMETypeUtil.isImage(contentType) { return "📷" } else if MIMETypeUtil.isVideo(contentType) { return "🎥" } else if MIMETypeUtil.isAudio(contentType) { return "🎧" } else if MIMETypeUtil.isAnimated(contentType) { return "🎡" } return "📎" } public var description: String { return Attachment.description( for: variant, contentType: contentType, sourceFilename: sourceFilename ) } } // MARK: - Mutation public extension Attachment { func with( serverId: String? = nil, state: State? = nil, creationTimestamp: TimeInterval? = nil, downloadUrl: String? = nil, localRelativeFilePath: String? = nil, encryptionKey: Data? = nil, digest: Data? = nil ) -> Attachment { let (isValid, duration): (Bool, TimeInterval?) = { switch (self.state, state) { case (_, .downloaded): return Attachment.determineValidityAndDuration( contentType: contentType, originalFilePath: originalFilePath ) // Assume the data is already correct for "uploading" attachments (and don't override it) case (.uploading, .failed), (.uploaded, .failed): return (self.isValid, self.duration) case (_, .failed): return (false, nil) default: return (self.isValid, self.duration) } }() return Attachment( id: self.id, serverId: (serverId ?? self.serverId), variant: variant, state: (state ?? self.state), contentType: contentType, byteCount: byteCount, creationTimestamp: (creationTimestamp ?? self.creationTimestamp), sourceFilename: sourceFilename, downloadUrl: (downloadUrl ?? self.downloadUrl), localRelativeFilePath: (localRelativeFilePath ?? self.localRelativeFilePath), width: width, height: height, duration: duration, isValid: isValid, encryptionKey: (encryptionKey ?? self.encryptionKey), digest: (digest ?? self.digest), caption: self.caption ) } } // MARK: - Protobuf public extension Attachment { init(proto: SNProtoAttachmentPointer) { func inferContentType(from filename: String?) -> String { guard let fileName: String = filename, let fileExtension: String = URL(string: fileName)?.pathExtension else { return OWSMimeTypeApplicationOctetStream } return (MIMETypeUtil.mimeType(forFileExtension: fileExtension) ?? OWSMimeTypeApplicationOctetStream) } self.id = UUID().uuidString self.serverId = "\(proto.id)" self.variant = { let voiceMessageFlag: Int32 = SNProtoAttachmentPointer.SNProtoAttachmentPointerFlags .voiceMessage .rawValue guard proto.hasFlags && ((proto.flags & UInt32(voiceMessageFlag)) > 0) else { return .standard } return .voiceMessage }() self.state = .pending self.contentType = (proto.contentType ?? inferContentType(from: proto.fileName)) self.byteCount = UInt(proto.size) self.creationTimestamp = nil self.sourceFilename = proto.fileName self.downloadUrl = proto.url self.localRelativeFilePath = nil self.width = (proto.hasWidth && proto.width > 0 ? UInt(proto.width) : nil) self.height = (proto.hasHeight && proto.height > 0 ? UInt(proto.height) : nil) self.duration = nil // Needs to be downloaded to be set self.isValid = false // Needs to be downloaded to be set self.encryptionKey = proto.key self.digest = proto.digest self.caption = (proto.hasCaption ? proto.caption : nil) } func buildProto() -> SNProtoAttachmentPointer? { guard let serverId: UInt64 = UInt64(self.serverId ?? "") else { return nil } let builder = SNProtoAttachmentPointer.builder(id: serverId) builder.setContentType(contentType) if let sourceFilename: String = sourceFilename, !sourceFilename.isEmpty { builder.setFileName(sourceFilename) } if let caption: String = self.caption, !caption.isEmpty { builder.setCaption(caption) } builder.setSize(UInt32(byteCount)) builder.setFlags(variant == .voiceMessage ? UInt32(SNProtoAttachmentPointer.SNProtoAttachmentPointerFlags.voiceMessage.rawValue) : 0 ) if let encryptionKey: Data = encryptionKey, let digest: Data = digest { builder.setKey(encryptionKey) builder.setDigest(digest) } if let width: UInt = self.width, let height: UInt = self.height, width > 0, width < Int.max, height > 0, height < Int.max { builder.setWidth(UInt32(width)) builder.setHeight(UInt32(height)) } if let downloadUrl: String = self.downloadUrl { builder.setUrl(downloadUrl) } do { return try builder.build() } catch { SNLog("Couldn't construct attachment proto from: \(self).") return nil } } } // MARK: - GRDB Interactions public extension Attachment { struct DownloadInfo: FetchableRecord, Decodable { public let attachmentId: String public let interactionId: Int64 } static func pendingAttachmentDownloadInfo(for authorId: String) -> SQLRequest { let attachment: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() let quote: TypedTableAlias = TypedTableAlias() let interactionAttachment: TypedTableAlias = TypedTableAlias() let linkPreview: TypedTableAlias = TypedTableAlias() // Note: In GRDB all joins need to run via their "association" system which doesn't support the type // of query we have below (a required join based on one of 3 optional joins) so we have to construct // the query manually return """ SELECT DISTINCT \(attachment[.id]) AS attachmentId, \(interaction[.id]) AS interactionId FROM \(Attachment.self) JOIN \(Interaction.self) ON \(interaction[.authorId]) = \(SQL(sql: ":authorId", arguments: StatementArguments(["authorId": authorId]))) AND ( \(interaction[.id]) = \(quote[.interactionId]) OR \(interaction[.id]) = \(interactionAttachment[.interactionId]) OR \(interaction[.linkPreviewUrl]) = \(linkPreview[.url]) ) LEFT JOIN \(Quote.self) ON \(quote[.attachmentId]) = \(attachment[.id]) LEFT JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id]) LEFT JOIN \(LinkPreview.self) ON \(linkPreview[.attachmentId]) = \(attachment[.id]) AND \(linkPreview[.variant]) = \(SQL( sql: ":variant", arguments: StatementArguments(["variant": LinkPreview.Variant.standard]) )) WHERE \(attachment[.variant]) = \(SQL( sql: ":attachmentVariant", arguments: StatementArguments(["attachmentVariant": Attachment.Variant.standard]) )) AND \(attachment[.state]) = \(SQL( sql: ":state", arguments: StatementArguments(["state": Attachment.State.pending]) )) ORDER BY interactionId DESC """ } } // MARK: - Convenience - Static public extension Attachment { private static let thumbnailDimensionSmall: UInt = 200 private static let thumbnailDimensionMedium: UInt = 450 /// This size is large enough to render full screen private static var thumbnailDimensionLarge: UInt = { let screenSizePoints: CGSize = UIScreen.main.bounds.size let minZoomFactor: CGFloat = UIScreen.main.scale return UInt(floor(max(screenSizePoints.width, screenSizePoints.height) * minZoomFactor)) }() private static var sharedDataAttachmentsDirPath: String = { OWSFileSystem.appSharedDataDirectoryPath().appending("/Attachments") }() internal static var attachmentsFolder: String = { let attachmentsFolder: String = sharedDataAttachmentsDirPath OWSFileSystem.ensureDirectoryExists(attachmentsFolder) return attachmentsFolder }() private static var thumbnailsFolder: String = { let attachmentsFolder: String = sharedDataAttachmentsDirPath OWSFileSystem.ensureDirectoryExists(attachmentsFolder) return attachmentsFolder }() internal static func originalFilePath(id: String, mimeType: String, sourceFilename: String?) -> String? { return MIMETypeUtil.filePath( forAttachment: id, ofMIMEType: mimeType, sourceFilename: sourceFilename, inFolder: Attachment.attachmentsFolder ) } internal static func imageSize(contentType: String, originalFilePath: String) -> CGSize? { let isVideo: Bool = MIMETypeUtil.isVideo(contentType) let isImage: Bool = MIMETypeUtil.isImage(contentType) let isAnimated: Bool = MIMETypeUtil.isAnimated(contentType) guard isVideo || isImage || isAnimated else { return nil } if isVideo { guard OWSMediaUtils.isValidVideo(path: originalFilePath) else { return nil } return Attachment.videoStillImage(filePath: originalFilePath)?.size } return NSData.imageSize(forFilePath: originalFilePath, mimeType: contentType) } static func videoStillImage(filePath: String) -> UIImage? { return try? OWSMediaUtils.thumbnail( forVideoAtPath: filePath, maxDimension: CGFloat(Attachment.thumbnailDimensionLarge) ) } internal static func determineValidityAndDuration(contentType: String, originalFilePath: String?) -> (isValid: Bool, duration: TimeInterval?) { guard let originalFilePath: String = originalFilePath else { return (false, nil) } // Process audio attachments if MIMETypeUtil.isAudio(contentType) { do { let audioPlayer: AVAudioPlayer = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: originalFilePath)) return ((audioPlayer.duration > 0), audioPlayer.duration) } catch { switch (error as NSError).code { case Int(kAudioFileInvalidFileError), Int(kAudioFileStreamError_InvalidFile): // Ignore "invalid audio file" errors return (false, nil) // TODO: Confirm this behaviour (previously returned 0) default: return (false, nil) } } } // Process image attachments if MIMETypeUtil.isImage(contentType) { return ( NSData.ows_isValidImage(atPath: originalFilePath, mimeType: contentType), nil ) } // Process video attachments if MIMETypeUtil.isVideo(contentType) { let videoPlayer: AVPlayer = AVPlayer(url: URL(fileURLWithPath: originalFilePath)) let durationSeconds: TimeInterval? = videoPlayer.currentItem .map { item -> TimeInterval in // Accorting to the CMTime docs "value/timescale = seconds" (TimeInterval(item.duration.value) / TimeInterval(item.duration.timescale)) } return ( OWSMediaUtils.isValidVideo(path: originalFilePath), durationSeconds ) } // Any other attachment types are valid and have no duration return (true, nil) } } // MARK: - Convenience extension Attachment { public enum ThumbnailSize { case small case medium case large var dimension: UInt { switch self { case .small: return Attachment.thumbnailDimensionSmall case .medium: return Attachment.thumbnailDimensionMedium case .large: return Attachment.thumbnailDimensionLarge } } } public var originalFilePath: String? { return Attachment.originalFilePath( id: self.id, mimeType: self.contentType, sourceFilename: self.sourceFilename ) } var thumbnailsDirPath: String { // Thumbnails are written to the caches directory, so that iOS can // remove them if necessary return "\(OWSFileSystem.cachesDirectoryPath())/\(id)-thumbnails" } var originalImage: UIImage? { guard let originalFilePath: String = originalFilePath else { return nil } if isVideo { return Attachment.videoStillImage(filePath: originalFilePath) } guard isImage || isAnimated else { return nil } guard isValid else { return nil } return UIImage(contentsOfFile: originalFilePath) } public var isImage: Bool { MIMETypeUtil.isImage(contentType) } public var isVideo: Bool { MIMETypeUtil.isVideo(contentType) } public var isAnimated: Bool { MIMETypeUtil.isAnimated(contentType) } public var isAudio: Bool { MIMETypeUtil.isAudio(contentType) } public var isVisualMedia: Bool { isImage || isVideo || isAnimated } public func readDataFromFile() throws -> Data? { guard let filePath: String = Attachment.originalFilePath(id: self.id, mimeType: self.contentType, sourceFilename: self.sourceFilename) else { return nil } return try Data(contentsOf: URL(fileURLWithPath: filePath)) } public func thumbnailPath(for dimensions: UInt) -> String { return "\(thumbnailsDirPath)/thumbnail-\(dimensions).jpg" } private func loadThumbnail(with dimensions: UInt, success: @escaping (UIImage) -> (), failure: @escaping () -> ()) { guard let width: UInt = self.width, let height: UInt = self.height, width > 1, height > 1 else { failure() return } // There's no point in generating a thumbnail if the original is smaller than the // thumbnail size if width < dimensions || height < dimensions { guard let image: UIImage = originalImage else { failure() return } success(image) return } let thumbnailPath = thumbnailPath(for: dimensions) if FileManager.default.fileExists(atPath: thumbnailPath) { guard let image: UIImage = UIImage(contentsOfFile: thumbnailPath) else { failure() return } success(image) return } OWSThumbnailService.shared.ensureThumbnail( for: self, dimensions: dimensions, success: { loadedThumbnail in success(loadedThumbnail.image) }, failure: { _ in failure() } ) } public func thumbnail(size: ThumbnailSize, success: @escaping (UIImage) -> (), failure: @escaping () -> ()) { loadThumbnail(with: size.dimension, success: success, failure: failure) } func thumbnailSync(size: ThumbnailSize) -> UIImage? { guard isVideo || isImage || isAnimated else { return nil } let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) var image: UIImage? thumbnail( size: size, success: { loadedImage in image = loadedImage semaphore.signal() }, failure: { semaphore.signal() } ) // Wait up to 5 seconds for the thumbnail to be loaded _ = semaphore.wait(timeout: .now() + .seconds(5)) return image } public func cloneAsThumbnail() -> Attachment { fatalError("TODO: Add this back") } public func write(data: Data) throws -> Bool { guard let originalFilePath: String = originalFilePath else { return false } try data.write(to: URL(fileURLWithPath: originalFilePath)) return true } }