mirror of
https://github.com/oxen-io/session-ios.git
synced 2023-12-13 21:30:14 +01:00
f8b2f73f7b
Fixed an issue where quotes containing images wouldn't send Fixed an issue where a MessageSend job could get stuck in an infinite retry loop if it had an attachment in an invalid state Fixed an issue where quotes containing non-media files wouldn't contain the correct data Fixed an issue where the quote thumbnail was getting the wrong content mode set Fixed an issue where the local disappearing messages config wasn't getting generated correctly Fixed an issue where the format parameters for the disappearing message info message were the wrong way around in one case Updated the AttachmentUploadJob to try to support images which haven't completed downloading (untested as it's not supported via the UI)
1142 lines
43 KiB
Swift
1142 lines
43 KiB
Swift
// 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, 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 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 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 ?? (
|
||
MIMETypeUtil.isImage(contentType) ||
|
||
MIMETypeUtil.isVideo(contentType) ||
|
||
MIMETypeUtil.isAnimated(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: DataSource,
|
||
sourceFilename: String? = nil,
|
||
caption: String? = nil
|
||
) {
|
||
guard let originalFilePath: String = Attachment.originalFilePath(id: id, mimeType: contentType, sourceFilename: sourceFilename) 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,
|
||
localRelativeFilePath: nil,
|
||
originalFilePath: originalFilePath
|
||
)
|
||
|
||
self.id = id
|
||
self.serverId = nil
|
||
self.variant = variant
|
||
self.state = .uploading
|
||
self.contentType = contentType
|
||
self.byteCount = dataSource.dataLength()
|
||
self.creationTimestamp = nil
|
||
self.sourceFilename = sourceFilename
|
||
self.downloadUrl = nil
|
||
self.localRelativeFilePath = Attachment.localRelativeFilePath(from: originalFilePath)
|
||
self.width = imageSize.map { UInt(floor($0.width)) }
|
||
self.height = imageSize.map { UInt(floor($0.height)) }
|
||
self.duration = duration
|
||
self.isVisualMedia = (
|
||
MIMETypeUtil.isImage(contentType) ||
|
||
MIMETypeUtil.isVideo(contentType) ||
|
||
MIMETypeUtil.isAnimated(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 "\(emoji(for: OWSMimeTypeImageJpeg)) \("ATTACHMENT".localized())" }
|
||
|
||
if MIMETypeUtil.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 "🎙️ \("ATTACHMENT_TYPE_VOICE_MESSAGE".localized())"
|
||
}
|
||
}
|
||
|
||
return "\(emoji(for: descriptionInfo.contentType)) \("ATTACHMENT".localized())"
|
||
}
|
||
|
||
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: 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
|
||
) -> Attachment {
|
||
let (isValid, duration): (Bool, TimeInterval?) = {
|
||
switch (self.state, state) {
|
||
case (_, .downloaded):
|
||
return Attachment.determineValidityAndDuration(
|
||
contentType: contentType,
|
||
localRelativeFilePath: localRelativeFilePath,
|
||
originalFilePath: originalFilePath
|
||
)
|
||
|
||
// 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 = (
|
||
MIMETypeUtil.isImage(contentType) ||
|
||
MIMETypeUtil.isVideo(contentType) ||
|
||
MIMETypeUtil.isAnimated(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 else { return nil }
|
||
|
||
return Attachment.imageSize(contentType: contentType, originalFilePath: originalFilePath)
|
||
}()
|
||
|
||
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)
|
||
MIMETypeUtil.isImage(contentType) ||
|
||
MIMETypeUtil.isVideo(contentType) ||
|
||
MIMETypeUtil.isAnimated(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 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 = .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 = (
|
||
MIMETypeUtil.isImage(contentType) ||
|
||
MIMETypeUtil.isVideo(contentType) ||
|
||
MIMETypeUtil.isAnimated(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 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
|
||
|
||
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
|
||
/* Note: This equation MUST match the `linkPreviewFilterLiteral` logic in Interaction.swift */
|
||
(ROUND((\(interaction[.timestampMs]) / 1000 / 100000) - 0.5) * 100000) = \(linkPreview[.timestamp])
|
||
)
|
||
)
|
||
|
||
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
|
||
|
||
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
|
||
/* Note: This equation MUST match the `linkPreviewFilterLiteral` logic in Interaction.swift */
|
||
(ROUND((\(interaction[.timestampMs]) / 1000 / 100000) - 0.5) * 100000) = \(linkPreview[.timestamp])
|
||
)
|
||
)
|
||
|
||
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: OWSFileSystem.appSharedDataDirectoryPath())
|
||
.appendingPathComponent("Attachments")
|
||
.path
|
||
}()
|
||
|
||
internal static var attachmentsFolder: String = {
|
||
let attachmentsFolder: String = sharedDataAttachmentsDirPath
|
||
OWSFileSystem.ensureDirectoryExists(attachmentsFolder)
|
||
|
||
return attachmentsFolder
|
||
}()
|
||
|
||
public static func resetAttachmentStorage() {
|
||
try? FileManager.default.removeItem(atPath: Attachment.sharedDataAttachmentsDirPath)
|
||
}
|
||
|
||
public static func originalFilePath(id: String, mimeType: String, sourceFilename: String?) -> String? {
|
||
return MIMETypeUtil.filePath(
|
||
forAttachment: id,
|
||
ofMIMEType: mimeType,
|
||
sourceFilename: sourceFilename,
|
||
inFolder: Attachment.attachmentsFolder
|
||
)
|
||
}
|
||
|
||
public static func localRelativeFilePath(from originalFilePath: String?) -> String? {
|
||
guard let originalFilePath: String = originalFilePath else { return nil }
|
||
|
||
return originalFilePath
|
||
.substring(from: (Attachment.attachmentsFolder.count + 1)) // Leading forward slash
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
public static func videoStillImage(filePath: String) -> UIImage? {
|
||
return try? OWSMediaUtils.thumbnail(
|
||
forVideoAtPath: filePath,
|
||
maxDimension: CGFloat(Attachment.thumbnailDimensionLarge)
|
||
)
|
||
}
|
||
|
||
internal static func determineValidityAndDuration(
|
||
contentType: String,
|
||
localRelativeFilePath: String?,
|
||
originalFilePath: String?
|
||
) -> (isValid: Bool, duration: TimeInterval?) {
|
||
guard let originalFilePath: String = originalFilePath else { return (false, nil) }
|
||
|
||
let constructedFilePath: String? = localRelativeFilePath.map {
|
||
URL(fileURLWithPath: Attachment.attachmentsFolder)
|
||
.appendingPathComponent($0)
|
||
.path
|
||
}
|
||
let targetPath: String = (constructedFilePath ?? originalFilePath)
|
||
|
||
// Process audio attachments
|
||
if MIMETypeUtil.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 MIMETypeUtil.isImage(contentType) || MIMETypeUtil.isAnimated(contentType) {
|
||
return (
|
||
NSData.ows_isValidImage(atPath: targetPath, mimeType: contentType),
|
||
nil
|
||
)
|
||
}
|
||
|
||
// Process video attachments
|
||
if MIMETypeUtil.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 (
|
||
OWSMediaUtils.isValidVideo(path: targetPath),
|
||
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"
|
||
|
||
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? {
|
||
if let localRelativeFilePath: String = self.localRelativeFilePath {
|
||
return URL(fileURLWithPath: Attachment.attachmentsFolder)
|
||
.appendingPathComponent(localRelativeFilePath)
|
||
.path
|
||
}
|
||
|
||
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 legacyThumbnailPath: String? {
|
||
guard
|
||
let originalFilePath: String = originalFilePath,
|
||
(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"
|
||
}
|
||
|
||
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 isText: Bool { MIMETypeUtil.isText(contentType) }
|
||
public var isMicrosoftDoc: Bool { MIMETypeUtil.isMicrosoftDoc(contentType) }
|
||
|
||
public func readDataFromFile() throws -> Data? {
|
||
guard let filePath: String = self.originalFilePath 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, () 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 else {
|
||
failure()
|
||
return
|
||
}
|
||
|
||
success(
|
||
image,
|
||
{
|
||
guard let originalFilePath: String = originalFilePath else { throw AttachmentError.invalidData }
|
||
|
||
return try Data(contentsOf: URL(fileURLWithPath: originalFilePath))
|
||
}
|
||
)
|
||
return
|
||
}
|
||
|
||
let thumbnailPath = thumbnailPath(for: dimensions)
|
||
|
||
if FileManager.default.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
|
||
}
|
||
|
||
ThumbnailService.shared.ensureThumbnail(
|
||
for: self,
|
||
dimensions: dimensions,
|
||
success: { loadedThumbnail in success(loadedThumbnail.image, loadedThumbnail.dataSourceBlock) },
|
||
failure: { _ in failure() }
|
||
)
|
||
}
|
||
|
||
public func thumbnail(size: ThumbnailSize, success: @escaping (UIImage, () throws -> Data) -> (), failure: @escaping () -> ()) {
|
||
loadThumbnail(with: size.dimension, success: success, failure: failure)
|
||
}
|
||
|
||
public func existingThumbnail(size: ThumbnailSize) -> UIImage? {
|
||
var existingImage: UIImage?
|
||
|
||
let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
|
||
loadThumbnail(
|
||
with: size.dimension,
|
||
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() -> Attachment? {
|
||
let cloneId: String = UUID().uuidString
|
||
let thumbnailName: String = "quoted-thumbnail-\(sourceFilename ?? "null")"
|
||
|
||
guard
|
||
self.isValid,
|
||
self.isVisualMedia,
|
||
let thumbnailPath: String = Attachment.originalFilePath(
|
||
id: cloneId,
|
||
mimeType: OWSMimeTypeImageJpeg,
|
||
sourceFilename: thumbnailName
|
||
)
|
||
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,
|
||
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: OWSMimeTypeImageJpeg,
|
||
originalFilePath: thumbnailPath
|
||
)
|
||
.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: OWSMimeTypeImageJpeg,
|
||
byteCount: UInt(thumbnailData.count),
|
||
sourceFilename: thumbnailName,
|
||
localRelativeFilePath: Attachment.localRelativeFilePath(from: thumbnailPath),
|
||
width: UInt(thumbnailSize.width),
|
||
height: UInt(thumbnailSize.height),
|
||
isValid: true
|
||
)
|
||
}
|
||
|
||
public func write(data: Data) throws -> Bool {
|
||
guard let originalFilePath: String = originalFilePath 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: "/")
|
||
.last
|
||
.map { String($0) }
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Upload
|
||
|
||
extension Attachment {
|
||
internal func upload(
|
||
_ db: Database? = nil,
|
||
queue: DispatchQueue,
|
||
using upload: (Database, Data) -> Promise<String>,
|
||
encrypt: Bool,
|
||
success: ((String?) -> Void)?,
|
||
failure: ((Error) -> Void)?
|
||
) {
|
||
// This can occur if an AttachmnetUploadJob was explicitly created for a message
|
||
// dependant on the attachment being uploaded (in this case the attachment has
|
||
// already been uploaded so just succeed)
|
||
guard state != .uploaded else {
|
||
success?(Attachment.fileId(for: self.downloadUrl))
|
||
return
|
||
}
|
||
|
||
// Get the attachment
|
||
guard var data = try? readDataFromFile() else {
|
||
SNLog("Couldn't read attachment from disk.")
|
||
failure?(AttachmentError.noAttachment)
|
||
return
|
||
}
|
||
|
||
let attachmentId: String = self.id
|
||
|
||
// 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
|
||
guard
|
||
state != .downloaded ||
|
||
serverId == nil ||
|
||
downloadUrl == nil ||
|
||
!encrypt ||
|
||
encryptionKey == nil ||
|
||
digest == nil
|
||
else {
|
||
// Save the final upload info
|
||
let uploadedAttachment: Attachment? = {
|
||
guard let db: Database = db else {
|
||
Storage.shared.write { db in
|
||
try? Attachment
|
||
.filter(id: attachmentId)
|
||
.updateAll(db, Attachment.Columns.state.set(to: Attachment.State.uploaded))
|
||
}
|
||
|
||
return self.with(state: .uploaded)
|
||
}
|
||
|
||
_ = try? Attachment
|
||
.filter(id: attachmentId)
|
||
.updateAll(db, Attachment.Columns.state.set(to: Attachment.State.uploaded))
|
||
|
||
return self.with(state: .uploaded)
|
||
}()
|
||
|
||
guard uploadedAttachment != nil else {
|
||
SNLog("Couldn't update attachmentUpload job.")
|
||
failure?(StorageError.failedToSave)
|
||
return
|
||
}
|
||
|
||
success?(Attachment.fileId(for: self.downloadUrl))
|
||
return
|
||
}
|
||
|
||
var processedAttachment: Attachment = self
|
||
|
||
// Encrypt the attachment if needed
|
||
if encrypt {
|
||
var encryptionKey: NSData = NSData()
|
||
var digest: NSData = NSData()
|
||
|
||
guard let ciphertext = Cryptography.encryptAttachmentData(data, shouldPad: true, outKey: &encryptionKey, outDigest: &digest) else {
|
||
SNLog("Couldn't encrypt attachment.")
|
||
failure?(AttachmentError.encryptionFailed)
|
||
return
|
||
}
|
||
|
||
processedAttachment = processedAttachment.with(
|
||
encryptionKey: encryptionKey as Data,
|
||
digest: digest as Data
|
||
)
|
||
data = ciphertext
|
||
}
|
||
|
||
// Check the file size
|
||
SNLog("File size: \(data.count) bytes.")
|
||
if Double(data.count) > Double(FileServerAPI.maxFileSize) / FileServerAPI.fileSizeORMultiplier {
|
||
failure?(HTTP.Error.maxFileSizeExceeded)
|
||
return
|
||
}
|
||
|
||
// Update the attachment to the 'uploading' state
|
||
let updatedAttachment: Attachment? = {
|
||
guard let db: Database = db else {
|
||
Storage.shared.write { db in
|
||
try? Attachment
|
||
.filter(id: attachmentId)
|
||
.updateAll(db, Attachment.Columns.state.set(to: Attachment.State.uploading))
|
||
}
|
||
|
||
return processedAttachment.with(state: .uploading)
|
||
}
|
||
|
||
_ = try? Attachment
|
||
.filter(id: attachmentId)
|
||
.updateAll(db, Attachment.Columns.state.set(to: Attachment.State.uploading))
|
||
|
||
return processedAttachment.with(state: .uploading)
|
||
}()
|
||
|
||
guard updatedAttachment != nil else {
|
||
SNLog("Couldn't update attachmentUpload job.")
|
||
failure?(StorageError.failedToSave)
|
||
return
|
||
}
|
||
|
||
// Perform the upload
|
||
let uploadPromise: Promise<String> = {
|
||
guard let db: Database = db else {
|
||
return Storage.shared.read { db in upload(db, data) }
|
||
}
|
||
|
||
return upload(db, data)
|
||
}()
|
||
|
||
uploadPromise
|
||
.done(on: queue) { fileId in
|
||
/// Save the final upload info
|
||
///
|
||
/// **Note:** We **MUST** use the `.with` function here to ensure the `isValid` flag is
|
||
/// updated correctly
|
||
let uploadedAttachment: Attachment? = Storage.shared.write { db in
|
||
try updatedAttachment?
|
||
.with(
|
||
serverId: "\(fileId)",
|
||
state: .uploaded,
|
||
creationTimestamp: (
|
||
updatedAttachment?.creationTimestamp ??
|
||
Date().timeIntervalSince1970
|
||
),
|
||
downloadUrl: "\(FileServerAPI.server)/files/\(fileId)"
|
||
)
|
||
.saved(db)
|
||
}
|
||
|
||
guard uploadedAttachment != nil else {
|
||
SNLog("Couldn't update attachmentUpload job.")
|
||
failure?(StorageError.failedToSave)
|
||
return
|
||
}
|
||
|
||
success?(fileId)
|
||
}
|
||
.catch(on: queue) { error in
|
||
Storage.shared.write { db in
|
||
try Attachment
|
||
.filter(id: attachmentId)
|
||
.updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedUpload))
|
||
}
|
||
|
||
failure?(error)
|
||
}
|
||
}
|
||
}
|