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

550 lines
18 KiB
Swift

// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import SessionUtilitiesKit
public struct Attachment: Codable, Identifiable, 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 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 = UUID().uuidString
/// 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 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(
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,
encryptionKey: Data? = nil,
digest: Data? = nil,
caption: String? = nil
) {
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.encryptionKey = encryptionKey
self.digest = digest
self.caption = caption
}
public init?(
variant: Variant = .standard,
contentType: String,
dataSource: DataSource
) {
guard
let originalFilePath: String = Attachment.originalFilePath(id: self.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
)
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.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 {
return Attachment(
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,
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.serverId = nil
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.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 {
static func fetchAllPendingAttachments(_ db: Database, for threadId: String) throws -> [Attachment] {
return try Attachment
.select(Attachment.Columns.allCases + [Interaction.Columns.id])
.filter(Columns.variant == Variant.standard)
.filter(Columns.state == State.pending)
.joining(
optional: Attachment.interactionAttachments
.filter(Interaction.Columns.threadId == threadId)
)
.joining(
optional: Attachment.quote
.joining(
required: Quote.interaction
.filter(Interaction.Columns.threadId == threadId)
)
)//tmp.authorId
.joining(
optional: Attachment.linkPreview
.joining(
required: LinkPreview.interactions
.filter(Interaction.Columns.threadId == threadId)
)
)
.order(Interaction.Columns.id.desc) // Newest attachments first
.fetchAll(db)
}
}
// 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 thumbnailDimensionsLarge: CGFloat = {
let screenSizePoints: CGSize = UIScreen.main.bounds.size
let minZoomFactor: CGFloat = 2 // TODO: Should this be screen scale?
return (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
)
}
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: Attachment.thumbnailDimensionsLarge
)
}
}
// MARK: - Convenience
extension Attachment {
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 NSData.ows_isValidImage(atPath: originalFilePath, mimeType: contentType) else {
return nil
}
return UIImage(contentsOfFile: originalFilePath)
}
var isImage: Bool { MIMETypeUtil.isImage(contentType) }
var isVideo: Bool { MIMETypeUtil.isVideo(contentType) }
var isAnimated: Bool { MIMETypeUtil.isAnimated(contentType) }
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() }
)
}
func thumbnailImageSmallSync() -> UIImage? {
guard isVideo || isImage || isAnimated else { return nil }
let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
var image: UIImage?
loadThumbnail(
with: Attachment.thumbnailDimensionSmall,
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
}
}