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/Database/Models/Attachment.swift

1311 lines
52 KiB
Swift

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import AVFAudio
import AVFoundation
import Combine
import UniformTypeIdentifiers
import GRDB
import SessionUtilitiesKit
import SessionSnodeKit
import SessionUIKit
public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
public static var databaseTableName: String { "attachment" }
internal static let quoteForeignKey = ForeignKey([Columns.id], to: [Quote.Columns.attachmentId])
internal static let linkPreviewForeignKey = ForeignKey([Columns.id], to: [LinkPreview.Columns.attachmentId])
public static let interactionAttachments = hasOne(InteractionAttachment.self)
public static let interaction = hasOne(
Interaction.self,
through: interactionAttachments,
using: InteractionAttachment.interaction
)
fileprivate static let quote = belongsTo(Quote.self, using: quoteForeignKey)
fileprivate static let linkPreview = belongsTo(LinkPreview.self, using: linkPreviewForeignKey)
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 isVisualMedia
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 failedDownload
case pendingDownload
case downloading
case downloaded
case failedUpload
case uploading
case uploaded
case invalid = 100
}
/// 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 havent 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 havent 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 dont 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 is visual media
public let isVisualMedia: Bool
/// 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 = .pendingDownload,
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,
isVisualMedia: Bool? = 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.isVisualMedia = (isVisualMedia ?? UTType.isVisualMedia(contentType))
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: any DataSource,
sourceFilename: String? = nil,
caption: String? = nil,
using dependencies: Dependencies
) {
guard let originalFilePath: String = Attachment.originalFilePath(id: id, mimeType: contentType, sourceFilename: sourceFilename, using: dependencies) else {
return nil
}
guard case .success = Result(try dataSource.write(to: originalFilePath)) else { return nil }
let imageSize: CGSize? = Attachment.imageSize(
contentType: contentType,
originalFilePath: originalFilePath,
using: dependencies
)
let (isValid, duration): (Bool, TimeInterval?) = Attachment.determineValidityAndDuration(
contentType: contentType,
localRelativeFilePath: nil,
originalFilePath: originalFilePath,
using: dependencies
)
self.id = id
self.serverId = nil
self.variant = variant
self.state = .uploading
self.contentType = contentType
self.byteCount = UInt(dataSource.dataLength)
self.creationTimestamp = nil
self.sourceFilename = sourceFilename
self.downloadUrl = nil
self.localRelativeFilePath = Attachment.localRelativeFilePath(from: originalFilePath, using: dependencies)
self.width = imageSize.map { UInt(floor($0.width)) }
self.height = imageSize.map { UInt(floor($0.height)) }
self.duration = duration
self.isVisualMedia = UTType.isVisualMedia(contentType)
self.isValid = isValid
self.encryptionKey = nil
self.digest = nil
self.caption = caption
}
}
// MARK: - CustomStringConvertible
extension Attachment: CustomStringConvertible {
public struct DescriptionInfo: FetchableRecord, Decodable, Equatable, Hashable, ColumnExpressible {
public typealias Columns = CodingKeys
public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable {
case id
case variant
case contentType
case sourceFilename
}
let id: String
let variant: Attachment.Variant
let contentType: String
let sourceFilename: String?
public init(
id: String,
variant: Attachment.Variant,
contentType: String,
sourceFilename: String?
) {
self.id = id
self.variant = variant
self.contentType = contentType
self.sourceFilename = sourceFilename
}
}
public static func description(for descriptionInfo: DescriptionInfo?, count: Int?) -> String? {
guard let descriptionInfo: DescriptionInfo = descriptionInfo else {
return nil
}
return description(for: descriptionInfo, count: (count ?? 1))
}
public static func description(for descriptionInfo: DescriptionInfo, count: Int) -> String {
// We only support multi-attachment sending of images so we can just default to the image attachment
// if there were multiple attachments
guard count == 1 else {
return "attachmentsNotification"
.put(key: "emoji", value: emoji(for: UTType.mimeTypeJpeg))
.localized()
}
if UTType.isAudio(descriptionInfo.contentType) {
// a missing filename is the legacy way to determine if an audio attachment is
// a voice note vs. other arbitrary audio attachments.
if
descriptionInfo.variant == .voiceMessage ||
descriptionInfo.sourceFilename == nil ||
(descriptionInfo.sourceFilename?.count ?? 0) == 0
{
return "messageVoiceSnippet"
.put(key: "emoji", value: "🎙️")
.localized()
}
}
return "attachmentsNotification"
.put(key: "emoji", value: emoji(for: descriptionInfo.contentType))
.localized()
}
// stringlint:ignore_contents
public static func emoji(for contentType: String) -> String {
if UTType.isAnimated(contentType) {
return "🎡"
}
else if UTType.isVideo(contentType) {
return "🎥"
}
else if UTType.isAudio(contentType) {
return "🎧"
}
else if UTType.isImage(contentType) {
return "📷"
}
return "📎"
}
public var description: String {
return Attachment.description(
for: DescriptionInfo(
id: id,
variant: variant,
contentType: contentType,
sourceFilename: sourceFilename
),
count: 1
)
}
}
// MARK: - Mutation
extension Attachment {
public func with(
serverId: String? = nil,
state: State? = nil,
creationTimestamp: TimeInterval? = nil,
downloadUrl: String? = nil,
localRelativeFilePath: String? = nil,
encryptionKey: Data? = nil,
digest: Data? = nil,
using dependencies: Dependencies
) -> Attachment {
let (isValid, duration): (Bool, TimeInterval?) = {
switch (self.state, state) {
case (_, .downloaded):
return Attachment.determineValidityAndDuration(
contentType: contentType,
localRelativeFilePath: localRelativeFilePath,
originalFilePath: originalFilePath(using: dependencies),
using: dependencies
)
// Assume the data is already correct for "uploading" attachments (and don't override it)
case (.uploading, _), (.uploaded, _), (.failedUpload, _): return (self.isValid, self.duration)
case (_, .failedDownload): return (false, nil)
default: return (self.isValid, self.duration)
}
}()
// Regenerate this just in case we added support since the attachment was inserted into
// the database (eg. manually downloaded in a later update)
let isVisualMedia: Bool = UTType.isVisualMedia(contentType)
let attachmentResolution: CGSize? = {
if let width: UInt = self.width, let height: UInt = self.height, width > 0, height > 0 {
return CGSize(width: Int(width), height: Int(height))
}
guard isVisualMedia else { return nil }
guard state == .downloaded else { return nil }
guard let originalFilePath: String = originalFilePath(using: dependencies) else { return nil }
return Attachment.imageSize(contentType: contentType, originalFilePath: originalFilePath, using: dependencies)
}()
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: attachmentResolution.map { UInt($0.width) },
height: attachmentResolution.map { UInt($0.height) },
duration: duration,
isVisualMedia: (
// Regenerate this just in case we added support since the attachment was inserted into
// the database (eg. manually downloaded in a later update)
UTType.isVisualMedia(contentType)
),
isValid: isValid,
encryptionKey: (encryptionKey ?? self.encryptionKey),
digest: (digest ?? self.digest),
caption: self.caption
)
}
}
// MARK: - Protobuf
extension Attachment {
public init(proto: SNProtoAttachmentPointer) {
func inferContentType(from filename: String?) -> String {
guard
let fileName: String = filename,
let fileExtension: String = URL(string: fileName)?.pathExtension
else { return UTType.mimeTypeDefault }
return (UTType.sessionMimeType(for: fileExtension) ?? UTType.mimeTypeDefault)
}
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 = .pendingDownload
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.isVisualMedia = UTType.isVisualMedia(contentType)
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)
}
public 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
extension Attachment {
public struct StateInfo: FetchableRecord, Decodable {
public let attachmentId: String
public let interactionId: Int64
public let state: Attachment.State
public let downloadUrl: String?
public let albumIndex: Int
}
public static func stateInfo(authorId: String, state: State? = nil) -> SQLRequest<Attachment.StateInfo> {
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let quote: TypedTableAlias<Quote> = TypedTableAlias()
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
let linkPreview: TypedTableAlias<LinkPreview> = 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,
\(attachment[.state]) AS state,
\(attachment[.downloadUrl]) AS downloadUrl,
IFNULL(\(interactionAttachment[.albumIndex]), 0) AS albumIndex
FROM \(Attachment.self)
JOIN \(Interaction.self) ON
\(SQL("\(interaction[.authorId]) = \(authorId)")) AND (
\(interaction[.id]) = \(quote[.interactionId]) OR
\(interaction[.id]) = \(interactionAttachment[.interactionId]) OR
(
\(interaction[.linkPreviewUrl]) = \(linkPreview[.url]) AND
\(Interaction.linkPreviewFilterLiteral())
)
)
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
\(SQL("\(linkPreview[.variant]) = \(LinkPreview.Variant.standard)"))
WHERE
(
\(SQL("\(state) IS NULL")) OR
\(SQL("\(attachment[.state]) = \(state)"))
)
ORDER BY interactionId DESC
"""
}
public static func stateInfo(interactionId: Int64, state: State? = nil) -> SQLRequest<Attachment.StateInfo> {
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let quote: TypedTableAlias<Quote> = TypedTableAlias()
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
let linkPreview: TypedTableAlias<LinkPreview> = 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,
\(attachment[.state]) AS state,
\(attachment[.downloadUrl]) AS downloadUrl,
IFNULL(\(interactionAttachment[.albumIndex]), 0) AS albumIndex
FROM \(Attachment.self)
JOIN \(Interaction.self) ON
\(SQL("\(interaction[.id]) = \(interactionId)")) AND (
\(interaction[.id]) = \(quote[.interactionId]) OR
\(interaction[.id]) = \(interactionAttachment[.interactionId]) OR
(
\(interaction[.linkPreviewUrl]) = \(linkPreview[.url]) AND
\(Interaction.linkPreviewFilterLiteral())
)
)
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
\(SQL("\(linkPreview[.variant]) = \(LinkPreview.Variant.standard)"))
WHERE
(
\(SQL("\(state) IS NULL")) OR
\(SQL("\(attachment[.state]) = \(state)"))
)
"""
}
}
// MARK: - Convenience - Static
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 = {
URL(fileURLWithPath: SessionFileManager.nonInjectedAppSharedDataDirectoryPath)
.appendingPathComponent("Attachments") // stringlint:ignore
.path
}()
internal static func attachmentsFolder(using dependencies: Dependencies) -> String {
let attachmentsFolder: String = sharedDataAttachmentsDirPath
try? dependencies[singleton: .fileManager].ensureDirectoryExists(at: attachmentsFolder)
return attachmentsFolder
}
public static func resetAttachmentStorage(using dependencies: Dependencies) {
try? dependencies[singleton: .fileManager].removeItem(atPath: Attachment.sharedDataAttachmentsDirPath)
}
public static func originalFilePath(id: String, mimeType: String, sourceFilename: String?, using dependencies: Dependencies) -> String? {
if let sourceFilename: String = sourceFilename, !sourceFilename.isEmpty {
// Ensure that the filename is a valid filesystem name,
// replacing invalid characters with an underscore.
var normalizedFileName: String = sourceFilename
.trimmingCharacters(in: .whitespacesAndNewlines)
.components(separatedBy: .whitespacesAndNewlines)
.joined(separator: "_")
.components(separatedBy: .illegalCharacters)
.joined(separator: "_")
.components(separatedBy: .controlCharacters)
.joined(separator: "_")
.components(separatedBy: CharacterSet(charactersIn: "<>|\\:()&;?*/~"))
.joined(separator: "_")
while normalizedFileName.hasPrefix(".") { // stringlint:ignore
normalizedFileName = String(normalizedFileName.substring(from: 1))
}
var targetFileExtension: String = URL(fileURLWithPath: normalizedFileName).pathExtension
let filenameWithoutExtension: String = URL(fileURLWithPath: normalizedFileName)
.deletingPathExtension()
.lastPathComponent
.trimmingCharacters(in: .whitespacesAndNewlines)
// If the filename has not file extension, deduce one
// from the MIME type.
if targetFileExtension.isEmpty {
targetFileExtension = (
UTType(sessionMimeType: mimeType)?.sessionFileExtension(sourceFilename: sourceFilename) ??
UTType.fileExtensionDefault
)
}
targetFileExtension = targetFileExtension.lowercased()
if !targetFileExtension.isEmpty {
// Store the file in a subdirectory whose name is the uniqueId of this attachment,
// to avoid collisions between multiple attachments with the same name
let attachmentFolder: String = Attachment
.attachmentsFolder(using: dependencies)
.appending("/\(id)") // stringlint:ignore
guard case .success = Result(try dependencies[singleton: .fileManager].ensureDirectoryExists(at: attachmentFolder)) else {
return nil
}
return attachmentFolder
.appending("/\(filenameWithoutExtension).\(targetFileExtension)") // stringlint:ignore
}
}
let targetFileExtension: String = (
UTType(sessionMimeType: mimeType)?.sessionFileExtension(sourceFilename: sourceFilename) ??
UTType.fileExtensionDefault
).lowercased()
return Attachment
.attachmentsFolder(using: dependencies)
.appending("/\(id).\(targetFileExtension)") // stringlint:ignore
}
public static func localRelativeFilePath(from originalFilePath: String?, using dependencies: Dependencies) -> String? {
guard let originalFilePath: String = originalFilePath else { return nil }
return originalFilePath
.substring(from: (Attachment.attachmentsFolder(using: dependencies).count + 1)) // Leading forward slash
}
internal static func imageSize(contentType: String, originalFilePath: String, using dependencies: Dependencies) -> CGSize? {
let type: UTType? = UTType(sessionMimeType: contentType)
guard type?.isVideo == true || type?.isImage == true || type?.isAnimated == true else { return nil }
if type?.isVideo == true {
guard MediaUtils.isValidVideo(path: originalFilePath, using: dependencies) else { return nil }
return Attachment.videoStillImage(filePath: originalFilePath, using: dependencies)?.size
}
return Data.imageSize(for: originalFilePath, type: type, using: dependencies)
}
public static func videoStillImage(filePath: String, using dependencies: Dependencies) -> UIImage? {
return try? MediaUtils.thumbnail(
forVideoAtPath: filePath,
maxDimension: CGFloat(Attachment.thumbnailDimensionLarge),
using: dependencies
)
}
internal static func determineValidityAndDuration(
contentType: String,
localRelativeFilePath: String?,
originalFilePath: String?,
using dependencies: Dependencies
) -> (isValid: Bool, duration: TimeInterval?) {
guard let originalFilePath: String = originalFilePath else { return (false, nil) }
let constructedFilePath: String? = localRelativeFilePath.map {
URL(fileURLWithPath: Attachment.attachmentsFolder(using: dependencies))
.appendingPathComponent($0)
.path
}
let targetPath: String = (constructedFilePath ?? originalFilePath)
// Process audio attachments
if UTType.isAudio(contentType) {
do {
let audioPlayer: AVAudioPlayer = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: targetPath))
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)
default: return (false, nil)
}
}
}
// Process image attachments
if UTType.isImage(contentType) || UTType.isAnimated(contentType) {
return (
Data.isValidImage(at: targetPath, type: UTType(sessionMimeType: contentType), using: dependencies),
nil
)
}
// Process video attachments
if UTType.isVideo(contentType) {
let asset: AVURLAsset = AVURLAsset(url: URL(fileURLWithPath: targetPath), options: nil)
let durationSeconds: TimeInterval = (
// According to the CMTime docs "value/timescale = seconds"
TimeInterval(asset.duration.value) / TimeInterval(asset.duration.timescale)
)
return (
MediaUtils.isValidVideo(path: targetPath, using: dependencies),
durationSeconds
)
}
// Any other attachment types are valid and have no duration
return (true, nil)
}
}
// MARK: - Convenience
extension Attachment {
public static let nonMediaQuoteFileId: String = "NON_MEDIA_QUOTE_FILE_ID" // stringlint:ignore
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 func originalFilePath(using dependencies: Dependencies) -> String? {
if let localRelativeFilePath: String = self.localRelativeFilePath {
return URL(fileURLWithPath: Attachment.attachmentsFolder(using: dependencies))
.appendingPathComponent(localRelativeFilePath)
.path
}
return Attachment.originalFilePath(
id: self.id,
mimeType: self.contentType,
sourceFilename: self.sourceFilename,
using: dependencies
)
}
var thumbnailsDirPath: String {
// Thumbnails are written to the caches directory, so that iOS can
// remove them if necessary
return "\(SessionFileManager.cachesDirectoryPath)/\(id)-thumbnails" // stringlint:ignore
}
func legacyThumbnailPath(using dependencies: Dependencies) -> String? {
guard
let originalFilePath: String = originalFilePath(using: dependencies),
(isImage || isVideo || isAnimated)
else { return nil }
let fileUrl: URL = URL(fileURLWithPath: originalFilePath)
let filename: String = fileUrl.lastPathComponent.filenameWithoutExtension
let containingDir: String = fileUrl.deletingLastPathComponent().path
return "\(containingDir)/\(filename)-signal-ios-thumbnail.jpg" // stringlint:ignore
}
func originalImage(using dependencies: Dependencies) -> UIImage? {
guard let originalFilePath: String = originalFilePath(using: dependencies) else { return nil }
if isVideo {
return Attachment.videoStillImage(filePath: originalFilePath, using: dependencies)
}
guard isImage || isAnimated else { return nil }
guard isValid else { return nil }
return UIImage(contentsOfFile: originalFilePath)
}
public var isImage: Bool { UTType.isImage(contentType) }
public var isVideo: Bool { UTType.isVideo(contentType) }
public var isAnimated: Bool { UTType.isAnimated(contentType) }
public var isAudio: Bool { UTType.isAudio(contentType) }
public var isText: Bool { UTType.isText(contentType) }
public var isMicrosoftDoc: Bool { UTType.isMicrosoftDoc(contentType) }
public var documentFileName: String {
if let sourceFilename: String = sourceFilename { return sourceFilename }
return shortDescription
}
public var shortDescription: String {
if isImage { return "image".localized() }
if isAudio { return "audio".localized() }
if isVideo { return "video".localized() }
return "document".localized()
}
public var documentFileInfo: String {
switch duration {
case .some(let duration) where duration > 0:
return "\(Format.fileSize(byteCount)), \(Format.duration(duration))"
default: return Format.fileSize(byteCount)
}
}
public func readDataFromFile(using dependencies: Dependencies) throws -> Data? {
guard let filePath: String = self.originalFilePath(using: dependencies) else {
return nil
}
return try Data(contentsOf: URL(fileURLWithPath: filePath))
}
public func thumbnailPath(for dimensions: UInt) -> String {
return "\(thumbnailsDirPath)/thumbnail-\(dimensions).jpg" // stringlint:ignore
}
private func loadThumbnail(with dimensions: UInt, using dependencies: Dependencies, success: @escaping (UIImage, () throws -> Data) -> (), 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(using: dependencies) else {
failure()
return
}
success(
image,
{
guard let originalFilePath: String = originalFilePath(using: dependencies) else {
throw AttachmentError.invalidData
}
return try Data(contentsOf: URL(fileURLWithPath: originalFilePath))
}
)
return
}
let thumbnailPath = thumbnailPath(for: dimensions)
if dependencies[singleton: .fileManager].fileExists(atPath: thumbnailPath) {
guard
let data: Data = try? Data(contentsOf: URL(fileURLWithPath: thumbnailPath)),
let image: UIImage = UIImage(data: data)
else {
failure()
return
}
success(image, { data })
return
}
dependencies[singleton: .thumbnailService].ensureThumbnail(
for: self,
dimensions: dimensions,
success: { loadedThumbnail in success(loadedThumbnail.image, loadedThumbnail.dataSourceBlock) },
failure: { _ in failure() }
)
}
public func thumbnail(size: ThumbnailSize, using dependencies: Dependencies, success: @escaping (UIImage, () throws -> Data) -> (), failure: @escaping () -> ()) {
loadThumbnail(with: size.dimension, using: dependencies, success: success, failure: failure)
}
public func existingThumbnail(size: ThumbnailSize, using dependencies: Dependencies) -> UIImage? {
var existingImage: UIImage?
let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
loadThumbnail(
with: size.dimension,
using: dependencies,
success: { image, _ in
existingImage = image
semaphore.signal()
},
failure: { semaphore.signal() }
)
// We don't really want to wait at all so having a tiny timeout here will give the
// 'loadThumbnail' call the change to return a result for an existing thumbnail but
// not a new one
_ = semaphore.wait(timeout: .now() + .milliseconds(10))
return existingImage
}
public func cloneAsQuoteThumbnail(using dependencies: Dependencies) -> Attachment? {
let cloneId: String = UUID().uuidString
let thumbnailName: String = "quoted-thumbnail-\(sourceFilename ?? "null")" // stringlint:ignore
guard self.isVisualMedia else { return nil }
guard
self.isValid,
let thumbnailPath: String = Attachment.originalFilePath(
id: cloneId,
mimeType: UTType.mimeTypeJpeg,
sourceFilename: thumbnailName,
using: dependencies
)
else {
// Non-media files cannot have thumbnails but may be sent as quotes, in these cases we want
// to create an attachment in an 'uploaded' state with a hard-coded file id so the messageSend
// job doesn't try to upload the attachment (we include the original `serverId` as it's
// required for generating the protobuf)
return Attachment(
id: cloneId,
serverId: self.serverId,
variant: self.variant,
state: .uploaded,
contentType: self.contentType,
byteCount: 0,
downloadUrl: Attachment.nonMediaQuoteFileId,
isValid: self.isValid
)
}
// Try generate the thumbnail
var thumbnailData: Data?
let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
self.thumbnail(
size: .small,
using: dependencies,
success: { _, dataSourceBlock in
thumbnailData = try? dataSourceBlock()
semaphore.signal()
},
failure: { semaphore.signal() }
)
// Wait up to 0.5 seconds
_ = semaphore.wait(timeout: .now() + .milliseconds(500))
guard let thumbnailData: Data = thumbnailData else { return nil }
// Write the quoted thumbnail to disk
do { try thumbnailData.write(to: URL(fileURLWithPath: thumbnailPath)) }
catch { return nil }
// Need to retrieve the size of the thumbnail as it maintains it's aspect ratio
let thumbnailSize: CGSize = Attachment
.imageSize(
contentType: UTType.mimeTypeJpeg,
originalFilePath: thumbnailPath,
using: dependencies
)
.defaulting(
to: CGSize(
width: Int(ThumbnailSize.small.dimension),
height: Int(ThumbnailSize.small.dimension)
)
)
// Copy the thumbnail to a new attachment
return Attachment(
id: cloneId,
variant: .standard,
state: .downloaded,
contentType: UTType.mimeTypeJpeg,
byteCount: UInt(thumbnailData.count),
sourceFilename: thumbnailName,
localRelativeFilePath: Attachment.localRelativeFilePath(from: thumbnailPath, using: dependencies),
width: UInt(thumbnailSize.width),
height: UInt(thumbnailSize.height),
isValid: true
)
}
public func write(data: Data, using dependencies: Dependencies) throws -> Bool {
guard let originalFilePath: String = originalFilePath(using: dependencies) else { return false }
try data.write(to: URL(fileURLWithPath: originalFilePath))
return true
}
public static func fileId(for downloadUrl: String?) -> String? {
return downloadUrl
.map { urlString -> String? in
urlString
.split(separator: "/") // stringlint:ignore
.last
.map { String($0) }
}
}
}
// MARK: - Upload
extension Attachment {
private enum Destination {
case fileServer
case community(OpenGroup)
var shouldEncrypt: Bool {
switch self {
case .fileServer: return true
case .community: return false
}
}
}
public static func prepare(attachments: [SignalAttachment], using dependencies: Dependencies) -> [Attachment] {
return attachments.compactMap { signalAttachment in
Attachment(
variant: (signalAttachment.isVoiceMessage ?
.voiceMessage :
.standard
),
contentType: signalAttachment.mimeType,
dataSource: signalAttachment.dataSource,
sourceFilename: signalAttachment.sourceFilename,
caption: signalAttachment.captionText,
using: dependencies
)
}
}
public static func process(
_ db: Database,
attachments: [Attachment]?,
for interactionId: Int64?
) throws {
guard
let attachments: [Attachment] = attachments,
let interactionId: Int64 = interactionId
else { return }
try attachments
.enumerated()
.forEach { index, attachment in
let interactionAttachment: InteractionAttachment = InteractionAttachment(
albumIndex: index,
interactionId: interactionId,
attachmentId: attachment.id
)
try attachment.insert(db)
try interactionAttachment.insert(db)
}
}
public func preparedUpload(
_ db: Database,
threadId: String,
logCategory cat: Log.Category,
using dependencies: Dependencies
) throws -> Network.PreparedRequest<String> {
typealias UploadInfo = (
attachment: Attachment,
preparedRequest: Network.PreparedRequest<FileUploadResponse>,
encryptionKey: Data?,
digest: Data?
)
// Retrieve the correct destination for the given thread
let destination: Destination = (try? OpenGroup.fetchOne(db, id: threadId))
.map { .community($0) }
.defaulting(to: .fileServer)
let uploadInfo: UploadInfo = try {
let endpoint: (any EndpointType) = {
switch destination {
case .fileServer: return Network.FileServer.Endpoint.file
case .community(let openGroup): return OpenGroupAPI.Endpoint.roomFile(openGroup.roomToken)
}
}()
// This can occur if an AttachmentUploadJob was explicitly created for a message
// dependant on the attachment being uploaded (in this case the attachment has
// already been uploaded so just succeed)
if state == .uploaded, let fileId: String = Attachment.fileId(for: downloadUrl) {
return (
self,
try Network.PreparedRequest<FileUploadResponse>.cached(
FileUploadResponse(id: fileId),
endpoint: endpoint,
using: dependencies
),
self.encryptionKey,
self.digest
)
}
// If the attachment is a downloaded attachment, check if it came from
// the server and if so just succeed immediately (no use re-uploading
// an attachment that is already present on the server) - or if we want
// it to be encrypted and it's not then encrypt it
//
// Note: The most common cases for this will be for LinkPreviews or Quotes
if
state == .downloaded,
serverId != nil,
let fileId: String = Attachment.fileId(for: downloadUrl),
(
!destination.shouldEncrypt || (
encryptionKey != nil &&
digest != nil
)
)
{
return (
self,
try Network.PreparedRequest.cached(
FileUploadResponse(id: fileId),
endpoint: endpoint,
using: dependencies
),
self.encryptionKey,
self.digest
)
}
// Get the raw attachment data
guard let rawData: Data = try? readDataFromFile(using: dependencies) else {
Log.error(cat, "Couldn't read attachment from disk.")
throw AttachmentError.noAttachment
}
// Encrypt the attachment if needed
var finalData: Data = rawData
var encryptionKey: Data?
var digest: Data?
typealias EncryptionData = (ciphertext: Data, encryptionKey: Data, digest: Data)
if destination.shouldEncrypt {
guard
let result: EncryptionData = dependencies[singleton: .crypto].generate(
.encryptAttachment(plaintext: rawData, using: dependencies)
)
else {
Log.error(cat, "Couldn't encrypt attachment.")
throw AttachmentError.encryptionFailed
}
finalData = result.ciphertext
encryptionKey = result.encryptionKey
digest = result.digest
}
// Ensure the file size is smaller than our upload limit
Log.info(cat, "File size: \(finalData.count) bytes.")
guard finalData.count <= Network.maxFileSize else { throw NetworkError.maxFileSizeExceeded }
// Generate the request
switch destination {
case .fileServer:
return (
self,
try Network.preparedUpload(data: finalData, using: dependencies),
encryptionKey,
digest
)
case .community(let openGroup):
return (
self,
try OpenGroupAPI.preparedUpload(
db,
data: finalData,
to: openGroup.roomToken,
on: openGroup.server,
using: dependencies
),
encryptionKey,
digest
)
}
}()
return uploadInfo.preparedRequest
.handleEvents(
receiveSubscription: {
// If we have a `cachedResponse` (ie. already uploaded) then don't change
// the attachment state to uploading as it's already been done
guard uploadInfo.preparedRequest.cachedResponse == nil else { return }
// Update the attachment to the 'uploading' state
dependencies[singleton: .storage].write { db in
_ = try? Attachment
.filter(id: uploadInfo.attachment.id)
.updateAll(db, Attachment.Columns.state.set(to: Attachment.State.uploading))
}
},
receiveOutput: { _, response in
/// Save the final upload info
///
/// **Note:** We **MUST** use the `.with` function here to ensure the `isValid` flag is
/// updated correctly
let updatedAttachment: Attachment = uploadInfo.attachment
.with(
serverId: response.id,
state: .uploaded,
creationTimestamp: (
uploadInfo.attachment.creationTimestamp ??
(dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000)
),
downloadUrl: {
switch (uploadInfo.attachment.downloadUrl, destination) {
case (.some(let downloadUrl), _): return downloadUrl
case (.none, .fileServer):
return Network.FileServer.downloadUrlString(for: response.id)
case (.none, .community(let openGroup)):
return OpenGroupAPI.downloadUrlString(
for: response.id,
server: openGroup.server,
roomToken: openGroup.roomToken
)
}
}(),
encryptionKey: uploadInfo.encryptionKey,
digest: uploadInfo.digest,
using: dependencies
)
// Ensure there were changes before triggering a db write to avoid unneeded
// write queue use and UI updates
guard updatedAttachment != uploadInfo.attachment else { return }
dependencies[singleton: .storage].write { db in
try updatedAttachment.upserted(db)
}
},
receiveCompletion: { result in
switch result {
case .finished: break
case .failure:
dependencies[singleton: .storage].write { db in
try Attachment
.filter(id: uploadInfo.attachment.id)
.updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedUpload))
}
}
},
receiveCancel: {
dependencies[singleton: .storage].write { db in
try Attachment
.filter(id: uploadInfo.attachment.id)
.updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedUpload))
}
}
)
.map { _, response in response.id }
}
}