Refactored MimeTypeUtil to use UniformTypeIdentifiers

pull/1034/head
Morgan Pretty 7 months ago
parent a1c3d53569
commit 2c9427edcf

@ -639,7 +639,7 @@
FD6A38F72C2A6C0100762359 /* CryptoError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6A38F62C2A6C0100762359 /* CryptoError.swift */; };
FD6A38F92C2A8AF700762359 /* DataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6A38F82C2A8AF700762359 /* DataSource.swift */; };
FD6A38FE2C2A8B7E00762359 /* MediaUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6A38FD2C2A8B7E00762359 /* MediaUtils.swift */; };
FD6A39002C2A8B9100762359 /* MimeTypeUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6A38FF2C2A8B9100762359 /* MimeTypeUtil.swift */; };
FD6A39002C2A8B9100762359 /* UTType+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6A38FF2C2A8B9100762359 /* UTType+Utilities.swift */; };
FD6A39022C2A8BDE00762359 /* UIImage+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6A39012C2A8BDE00762359 /* UIImage+Utilities.swift */; };
FD6A39042C2A8C0300762359 /* CGFloat+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6A39032C2A8C0300762359 /* CGFloat+Utilities.swift */; };
FD6A39062C2A8C1600762359 /* CGPoint+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6A39052C2A8C1600762359 /* CGPoint+Utilities.swift */; };
@ -1804,7 +1804,7 @@
FD6A38F62C2A6C0100762359 /* CryptoError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoError.swift; sourceTree = "<group>"; };
FD6A38F82C2A8AF700762359 /* DataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataSource.swift; sourceTree = "<group>"; };
FD6A38FD2C2A8B7E00762359 /* MediaUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaUtils.swift; sourceTree = "<group>"; };
FD6A38FF2C2A8B9100762359 /* MimeTypeUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MimeTypeUtil.swift; sourceTree = "<group>"; };
FD6A38FF2C2A8B9100762359 /* UTType+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UTType+Utilities.swift"; sourceTree = "<group>"; };
FD6A39012C2A8BDE00762359 /* UIImage+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Utilities.swift"; sourceTree = "<group>"; };
FD6A39032C2A8C0300762359 /* CGFloat+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGFloat+Utilities.swift"; sourceTree = "<group>"; };
FD6A39052C2A8C1600762359 /* CGPoint+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGPoint+Utilities.swift"; sourceTree = "<group>"; };
@ -2673,7 +2673,7 @@
FD6A38F82C2A8AF700762359 /* DataSource.swift */,
FD09797827FAB7E800936362 /* ImageFormat.swift */,
FD6A38FD2C2A8B7E00762359 /* MediaUtils.swift */,
FD6A38FF2C2A8B9100762359 /* MimeTypeUtil.swift */,
FD6A38FF2C2A8B9100762359 /* UTType+Utilities.swift */,
);
path = Media;
sourceTree = "<group>";
@ -5748,7 +5748,7 @@
7B1D74B027C365960030B423 /* Timer+MainThread.swift in Sources */,
FDE049032C76A09700B6F9BB /* UIAlertAction+Utilities.swift in Sources */,
FD428B192B4B576F006D0888 /* AppContext.swift in Sources */,
FD6A39002C2A8B9100762359 /* MimeTypeUtil.swift in Sources */,
FD6A39002C2A8B9100762359 /* UTType+Utilities.swift in Sources */,
FD6A38F92C2A8AF700762359 /* DataSource.swift in Sources */,
FD559DF52A7368CB00C7C62A /* DispatchQueue+Utilities.swift in Sources */,
FDF8488329405A12007DCAE5 /* BatchResponse.swift in Sources */,

@ -287,7 +287,7 @@ extension ConversationVC:
func handleDocumentButtonTapped() {
// UIDocumentPickerModeImport copies to a temp file within our container.
// It uses more memory than "open" but lets us avoid working with security scoped URLs.
let documentPickerVC = UIDocumentPickerViewController(documentTypes: [ kUTTypeItem as String ], in: UIDocumentPickerMode.import)
let documentPickerVC = UIDocumentPickerViewController(forOpeningContentTypes: [.item], asCopy: true)
documentPickerVC.delegate = self
documentPickerVC.modalPresentationStyle = .fullScreen
@ -354,7 +354,7 @@ extension ConversationVC:
return
}
let type = urlResourceValues.typeIdentifier ?? (kUTTypeData as String)
let type: UTType = (urlResourceValues.typeIdentifier.map({ UTType($0) }) ?? .data)
guard urlResourceValues.isDirectory != true else {
DispatchQueue.main.async { [weak self] in
let modal: ConfirmationModal = ConfirmationModal(
@ -382,12 +382,12 @@ extension ConversationVC:
// Although we want to be able to send higher quality attachments through the document picker
// it's more imporant that we ensure the sent format is one all clients can accept (e.g. *not* quicktime .mov)
guard !SignalAttachment.isInvalidVideo(dataSource: dataSource, dataUTI: type) else {
guard !SignalAttachment.isInvalidVideo(dataSource: dataSource, type: type) else {
return showAttachmentApprovalDialogAfterProcessingVideo(at: url, with: fileName)
}
// "Document picker" attachments _SHOULD NOT_ be resized
let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: type, imageQuality: .original)
let attachment = SignalAttachment.attachment(dataSource: dataSource, type: type, imageQuality: .original)
showAttachmentApprovalDialog(for: [ attachment ])
}
@ -412,7 +412,7 @@ extension ConversationVC:
SignalAttachment
.compressVideoAsMp4(
dataSource: dataSource,
dataUTI: kUTTypeMPEG4 as String,
type: .mpeg4Movie,
using: dependencies
)
.attachmentPublisher
@ -700,8 +700,8 @@ extension ConversationVC:
func didPasteImageFromPasteboard(_ image: UIImage) {
guard let imageData = image.jpegData(compressionQuality: 1.0) else { return }
let dataSource = DataSourceValue(data: imageData, utiType: kUTTypeJPEG as String)
let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: kUTTypeJPEG as String, imageQuality: .medium)
let dataSource = DataSourceValue(data: imageData, dataType: .jpeg)
let attachment = SignalAttachment.attachment(dataSource: dataSource, type: .jpeg, imageQuality: .medium)
guard let approvalVC = AttachmentApprovalViewController.wrappedInNavController(
threadId: self.viewModel.threadData.threadId,
@ -1100,7 +1100,7 @@ extension ConversationVC:
if
attachment.isText ||
attachment.isMicrosoftDoc ||
attachment.contentType == MimeTypeUtil.MimeType.applicationPdf
attachment.contentType == UTType.mimeTypePdf
{
// FIXME: If given an invalid text file (eg with binary data) this hangs forever
// Note: I tried dispatching after a short delay, detecting that the new UI is invalid and dismissing it
@ -1921,12 +1921,12 @@ extension ConversationVC:
attachment.state == .downloaded ||
attachment.state == .uploaded
),
let utiType: String = MimeTypeUtil.utiType(for: attachment.contentType),
let type: UTType = UTType(sessionMimeType: attachment.contentType),
let originalFilePath: String = attachment.originalFilePath,
let data: Data = try? Data(contentsOf: URL(fileURLWithPath: originalFilePath))
else { return }
UIPasteboard.general.setData(data, forPasteboardType: utiType)
UIPasteboard.general.setData(data, forPasteboardType: type.identifier)
}
}
@ -2535,7 +2535,7 @@ extension ConversationVC:
let fileName = ("messageVoice".localized() as NSString).appendingPathExtension("m4a")
dataSource.sourceFilename = fileName
let attachment = SignalAttachment.voiceMessageAttachment(dataSource: dataSource, dataUTI: kUTTypeMPEG4Audio as String)
let attachment = SignalAttachment.voiceMessageAttachment(dataSource: dataSource, type: .mpeg4Audio)
guard !attachment.hasError else {
return showErrorAlert(for: attachment)

@ -574,7 +574,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold
let linkPreviewAttachment: Attachment? = linkPreviewDraft.map { draft in
try? LinkPreview.generateAttachmentIfPossible(
imageData: draft.jpegImageData,
mimeType: MimeTypeUtil.MimeType.imageJpeg
type: .jpeg
)
}

@ -100,7 +100,7 @@ final class QuoteView: UIView {
contentView.pin(to: self)
if let attachment: Attachment = attachment {
let isAudio: Bool = MimeTypeUtil.isAudio(attachment.contentType)
let isAudio: Bool = attachment.isAudio
let fallbackImageName: String = (isAudio ? "attachment_audio" : "actionsheet_document_black") // stringlint:disable
let imageView: UIImageView = UIImageView(
image: UIImage(named: fallbackImageName)?

@ -95,7 +95,7 @@ struct QuoteView_SwiftUI: View {
return thumbnail
}
let fallbackImageName: String = (MimeTypeUtil.isAudio(attachment.contentType) ? "attachment_audio" : "actionsheet_document_black")
let fallbackImageName: String = (attachment.isAudio ? "attachment_audio" : "actionsheet_document_black")
return UIImage(named: fallbackImageName)?
.resized(to: CGSize(width: Self.iconSize, height: Self.iconSize))?
.withRenderingMode(.alwaysTemplate)

@ -2,6 +2,7 @@
import UIKit
import QuartzCore
import UniformTypeIdentifiers
import GRDB
import DifferenceKit
import SessionUIKit
@ -338,7 +339,7 @@ public class DocumentTileViewController: UIViewController, UITableViewDelegate,
if
attachment.isText ||
attachment.isMicrosoftDoc ||
attachment.contentType == MimeTypeUtil.MimeType.applicationPdf
attachment.contentType == UTType.mimeTypePdf
{
delegate?.preview(fileUrl: fileUrl)

@ -2,6 +2,7 @@
import Foundation
import Combine
import UniformTypeIdentifiers
import YYImage
import SignalUtilitiesKit
import SessionUtilitiesKit
@ -197,7 +198,7 @@ class GifPickerCell: UICollectionViewCell {
clearViewState()
return
}
guard Data.isValidImage(at: asset.filePath, mimeType: MimeTypeUtil.MimeType.imageGif) else {
guard Data.isValidImage(at: asset.filePath, type: .gif) else {
Log.error("[GitPickerCell] Invalid asset.")
clearViewState()
return

@ -381,7 +381,7 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
}
let dataSource = DataSourcePath(filePath: asset.filePath, shouldDeleteOnDeinit: false)
let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: rendition.utiType, imageQuality: .medium)
let attachment = SignalAttachment.attachment(dataSource: dataSource, type: rendition.type, imageQuality: .medium)
self?.dismiss(animated: true) {
// Delegate presents view controllers, so it's important that *this* controller be dismissed before that occurs.

@ -3,6 +3,7 @@
import Foundation
import Combine
import CoreServices
import UniformTypeIdentifiers
import SignalUtilitiesKit
import SessionUtilitiesKit
@ -56,11 +57,11 @@ class GiphyRendition: ProxiedContentAssetDescription {
}
}
public var utiType: String {
public var type: UTType {
switch format {
case .gif: return kUTTypeGIF as String
case .mp4: return kUTTypeMPEG4 as String
case .jpg: return kUTTypeJPEG as String
case .gif: return .gif
case .mp4: return .mpeg4Movie
case .jpg: return .jpeg
}
}

@ -419,8 +419,8 @@ extension PhotoCapture: CaptureOutputDelegate {
return
}
let dataSource = DataSourceValue(data: photoData, utiType: kUTTypeJPEG as String)
let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: kUTTypeJPEG as String, imageQuality: .medium)
let dataSource = DataSourceValue(data: photoData, dataType: .jpeg)
let attachment = SignalAttachment.attachment(dataSource: dataSource, type: .jpeg, imageQuality: .medium)
delegate?.photoCapture(self, didFinishProcessingAttachment: attachment)
}
@ -442,7 +442,7 @@ extension PhotoCapture: CaptureOutputDelegate {
}
let dataSource = DataSourcePath(fileUrl: outputFileURL, shouldDeleteOnDeinit: true)
let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: kUTTypeMPEG4 as String)
let attachment = SignalAttachment.attachment(dataSource: dataSource, type: .mpeg4Movie)
delegate?.photoCapture(self, didFinishProcessingAttachment: attachment)
}

@ -4,6 +4,7 @@ import Foundation
import Combine
import Photos
import CoreServices
import UniformTypeIdentifiers
import SignalUtilitiesKit
import SessionUtilitiesKit
@ -137,7 +138,7 @@ class PhotoCollectionContents {
_ = imageManager.requestImage(for: asset, targetSize: thumbnailSize, contentMode: .aspectFill, options: nil, resultHandler: resultHandler)
}
private func requestImageDataSource(for asset: PHAsset) -> AnyPublisher<(dataSource: (any DataSource), dataUTI: String), Error> {
private func requestImageDataSource(for asset: PHAsset) -> AnyPublisher<(dataSource: (any DataSource), type: UTType), Error> {
return Deferred {
Future { [weak self] resolver in
@ -151,24 +152,24 @@ class PhotoCollectionContents {
return
}
guard let dataUTI = dataUTI else {
guard let type: UTType = dataUTI.map({ UTType($0) }) else {
resolver(Result.failure(PhotoLibraryError.assertionError(description: "dataUTI was unexpectedly nil")))
return
}
guard let dataSource = DataSourceValue(data: imageData, utiType: dataUTI) else {
guard let dataSource = DataSourceValue(data: imageData, dataType: type) else {
resolver(Result.failure(PhotoLibraryError.assertionError(description: "dataSource was unexpectedly nil")))
return
}
resolver(Result.success((dataSource: dataSource, dataUTI: dataUTI)))
resolver(Result.success((dataSource: dataSource, type: type)))
}
}
}
.eraseToAnyPublisher()
}
private func requestVideoDataSource(for asset: PHAsset) -> AnyPublisher<(dataSource: (any DataSource), dataUTI: String), Error> {
private func requestVideoDataSource(for asset: PHAsset) -> AnyPublisher<(dataSource: (any DataSource), type: UTType), Error> {
return Deferred {
Future { [weak self] resolver in
@ -201,7 +202,7 @@ class PhotoCollectionContents {
return
}
resolver(Result.success((dataSource: dataSource, dataUTI: kUTTypeMPEG4 as String)))
resolver(Result.success((dataSource: dataSource, type: .mpeg4Movie)))
}
}
}
@ -213,17 +214,17 @@ class PhotoCollectionContents {
switch asset.mediaType {
case .image:
return requestImageDataSource(for: asset)
.map { (dataSource: DataSource, dataUTI: String) in
.map { (dataSource: DataSource, type: UTType) in
SignalAttachment
.attachment(dataSource: dataSource, dataUTI: dataUTI, imageQuality: .medium)
.attachment(dataSource: dataSource, type: type, imageQuality: .medium)
}
.eraseToAnyPublisher()
case .video:
return requestVideoDataSource(for: asset)
.map { (dataSource: DataSource, dataUTI: String) in
.map { (dataSource: DataSource, type: UTType) in
SignalAttachment
.attachment(dataSource: dataSource, dataUTI: dataUTI)
.attachment(dataSource: dataSource, type: type)
}
.eraseToAnyPublisher()

File diff suppressed because one or more lines are too long

@ -1,6 +1,7 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import UIKit
import UniformTypeIdentifiers
import SessionUtilitiesKit
class ImagePickerHandler: NSObject, UIImagePickerControllerDelegate & UINavigationControllerDelegate {
@ -38,8 +39,8 @@ class ImagePickerHandler: NSObject, UIImagePickerControllerDelegate & UINavigati
guard
let resourceValues: URLResourceValues = (try? imageUrl.resourceValues(forKeys: [.typeIdentifierKey])),
let type: Any = resourceValues.allValues.first?.value,
let utiTypeString: String = type as? String,
MimeTypeUtil.isAnimated(utiType: utiTypeString)
let typeString: String = type as? String,
UTType.isAnimated(typeString)
else {
let viewController: CropScaleImageViewController = CropScaleImageViewController(
srcImage: rawAvatar,

@ -4,6 +4,7 @@ import Foundation
import AVFAudio
import AVFoundation
import Combine
import UniformTypeIdentifiers
import GRDB
import SessionUtilitiesKit
import SessionSnodeKit
@ -164,7 +165,7 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR
self.width = width
self.height = height
self.duration = duration
self.isVisualMedia = (isVisualMedia ?? MimeTypeUtil.isVisualMedia(contentType))
self.isVisualMedia = (isVisualMedia ?? UTType.isVisualMedia(contentType))
self.isValid = isValid
self.encryptionKey = encryptionKey
self.digest = digest
@ -208,7 +209,7 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR
self.width = imageSize.map { UInt(floor($0.width)) }
self.height = imageSize.map { UInt(floor($0.height)) }
self.duration = duration
self.isVisualMedia = MimeTypeUtil.isVisualMedia(contentType)
self.isVisualMedia = UTType.isVisualMedia(contentType)
self.isValid = isValid
self.encryptionKey = nil
self.digest = nil
@ -259,11 +260,11 @@ extension Attachment: CustomStringConvertible {
// if there were multiple attachments
guard count == 1 else {
return "attachmentsNotification"
.put(key: "emoji", value: emoji(for: MimeTypeUtil.MimeType.imageJpeg))
.put(key: "emoji", value: emoji(for: UTType.mimeTypeJpeg))
.localized()
}
if MimeTypeUtil.isAudio(descriptionInfo.contentType) {
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
@ -283,16 +284,16 @@ extension Attachment: CustomStringConvertible {
}
public static func emoji(for contentType: String) -> String {
if MimeTypeUtil.isAnimated(contentType) {
if UTType.isAnimated(contentType) {
return "🎡" // stringlint:disable
}
else if MimeTypeUtil.isVideo(contentType) {
else if UTType.isVideo(contentType) {
return "🎥" // stringlint:disable
}
else if MimeTypeUtil.isAudio(contentType) {
else if UTType.isAudio(contentType) {
return "🎧" // stringlint:disable
}
else if MimeTypeUtil.isImage(contentType) {
else if UTType.isImage(contentType) {
return "📷" // stringlint:disable
}
@ -342,7 +343,7 @@ extension Attachment {
}()
// 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.isVisualMedia(contentType)
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))
@ -371,7 +372,7 @@ extension Attachment {
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.isVisualMedia(contentType)
UTType.isVisualMedia(contentType)
),
isValid: isValid,
encryptionKey: (encryptionKey ?? self.encryptionKey),
@ -389,9 +390,9 @@ extension Attachment {
guard
let fileName: String = filename,
let fileExtension: String = URL(string: fileName)?.pathExtension
else { return MimeTypeUtil.MimeType.applicationOctetStream }
else { return UTType.mimeTypeDefault }
return (MimeTypeUtil.mimeType(for: fileExtension) ?? MimeTypeUtil.MimeType.applicationOctetStream)
return (UTType.sessionMimeType(for: fileExtension) ?? UTType.mimeTypeDefault)
}
self.id = UUID().uuidString
@ -417,7 +418,7 @@ extension Attachment {
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.isVisualMedia(contentType)
self.isVisualMedia = UTType.isVisualMedia(contentType)
self.isValid = false // Needs to be downloaded to be set
self.encryptionKey = proto.key
self.digest = proto.digest
@ -609,12 +610,57 @@ extension Attachment {
}
public static func originalFilePath(id: String, mimeType: String, sourceFilename: String?) -> String? {
return MimeTypeUtil.filePath(
for: id,
ofMimeType: mimeType,
sourceFilename: sourceFilename,
in: Attachment.attachmentsFolder
)
// 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.appending("/\(id)")
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(".") {
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 ?? UTType.fileExtensionDefault)
}
targetFileExtension = targetFileExtension.lowercased()
if !targetFileExtension.isEmpty {
guard case .success = Result(try FileSystem.ensureDirectoryExists(at: attachmentFolder)) else {
return nil
}
return attachmentFolder.appending("/\(filenameWithoutExtension).\(targetFileExtension)")
}
}
let targetFileExtension: String = (
UTType(sessionMimeType: mimeType)?.sessionFileExtension ??
UTType.fileExtensionDefault
).lowercased()
return attachmentFolder.appending("/\(id).\(targetFileExtension)")
}
public static func localRelativeFilePath(from originalFilePath: String?) -> String? {
@ -625,19 +671,17 @@ extension Attachment {
}
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)
let type: UTType? = UTType(sessionMimeType: contentType)
guard isVideo || isImage || isAnimated else { return nil }
guard type?.isVideo == true || type?.isImage == true || type?.isAnimated == true else { return nil }
if isVideo {
if type?.isVideo == true {
guard MediaUtils.isValidVideo(path: originalFilePath) else { return nil }
return Attachment.videoStillImage(filePath: originalFilePath)?.size
}
return Data.imageSize(for: originalFilePath, mimeType: contentType)
return Data.imageSize(for: originalFilePath, type: type)
}
public static func videoStillImage(filePath: String) -> UIImage? {
@ -662,7 +706,7 @@ extension Attachment {
let targetPath: String = (constructedFilePath ?? originalFilePath)
// Process audio attachments
if MimeTypeUtil.isAudio(contentType) {
if UTType.isAudio(contentType) {
do {
let audioPlayer: AVAudioPlayer = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: targetPath))
@ -680,15 +724,15 @@ extension Attachment {
}
// Process image attachments
if MimeTypeUtil.isImage(contentType) || MimeTypeUtil.isAnimated(contentType) {
if UTType.isImage(contentType) || UTType.isAnimated(contentType) {
return (
Data.isValidImage(at: targetPath, mimeType: contentType),
Data.isValidImage(at: targetPath, type: UTType(sessionMimeType: contentType)),
nil
)
}
// Process video attachments
if MimeTypeUtil.isVideo(contentType) {
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"
@ -771,12 +815,12 @@ extension Attachment {
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 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 }
@ -894,7 +938,7 @@ extension Attachment {
self.isValid,
let thumbnailPath: String = Attachment.originalFilePath(
id: cloneId,
mimeType: MimeTypeUtil.MimeType.imageJpeg,
mimeType: UTType.mimeTypeJpeg,
sourceFilename: thumbnailName
)
else {
@ -939,7 +983,7 @@ extension Attachment {
// Need to retrieve the size of the thumbnail as it maintains it's aspect ratio
let thumbnailSize: CGSize = Attachment
.imageSize(
contentType: MimeTypeUtil.MimeType.imageJpeg,
contentType: UTType.mimeTypeJpeg,
originalFilePath: thumbnailPath
)
.defaulting(
@ -954,7 +998,7 @@ extension Attachment {
id: cloneId,
variant: .standard,
state: .downloaded,
contentType: MimeTypeUtil.MimeType.imageJpeg,
contentType: UTType.mimeTypeJpeg,
byteCount: UInt(thumbnailData.count),
sourceFilename: thumbnailName,
localRelativeFilePath: Attachment.localRelativeFilePath(from: thumbnailPath),

@ -4,6 +4,7 @@
import Foundation
import Combine
import UniformTypeIdentifiers
import GRDB
import SessionUtilitiesKit
import SessionSnodeKit
@ -129,9 +130,10 @@ public extension LinkPreview {
return (floor(sentTimestampMs / 1000 / LinkPreview.timstampResolution) * LinkPreview.timstampResolution)
}
static func generateAttachmentIfPossible(imageData: Data?, mimeType: String) throws -> Attachment? {
static func generateAttachmentIfPossible(imageData: Data?, type: UTType) throws -> Attachment? {
guard let imageData: Data = imageData, !imageData.isEmpty else { return nil }
guard let fileExtension: String = MimeTypeUtil.fileExtension(for: mimeType) else { return nil }
guard let fileExtension: String = type.sessionFileExtension else { return nil }
guard let mimeType: String = type.preferredMIMEType else { return nil }
let filePath = FileSystem.temporaryFilePath(fileExtension: fileExtension)
try imageData.write(to: NSURL.fileURL(withPath: filePath), options: .atomicWrite)
@ -406,7 +408,7 @@ public extension LinkPreview {
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
guard let imageMimeType = mimetype(forImageFileExtension: imageFileExtension) else {
guard let imageMimeType: String = UTType(sessionFileExtension: imageFileExtension)?.preferredMIMEType else {
return Just(LinkPreviewDraft(urlString: linkUrlString, title: title))
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
@ -481,7 +483,8 @@ public extension LinkPreview {
shouldIgnoreSignalProxy: true
)
.tryMap { asset, _ -> Data in
let imageSize = Data.imageSize(for: asset.filePath, mimeType: imageMimeType)
let type: UTType? = UTType(sessionMimeType: imageMimeType)
let imageSize = Data.imageSize(for: asset.filePath, type: type)
guard imageSize.width > 0, imageSize.height > 0 else {
throw LinkPreviewError.invalidContent
@ -494,10 +497,7 @@ public extension LinkPreview {
guard let srcImage = UIImage(data: data) else { throw LinkPreviewError.invalidContent }
// Loki: If it's a GIF then ensure its validity and don't download it as a JPG
if
imageMimeType == MimeTypeUtil.MimeType.imageGif &&
data.isValidImage(mimeType: MimeTypeUtil.MimeType.imageGif)
{
if type == .gif && data.isValidImage(type: .gif) {
return data
}
@ -540,18 +540,12 @@ public extension LinkPreview {
guard imageFileExtension.count > 0 else {
// TODO: For those links don't have a file extension, we should figure out a way to know the image mime type
return "png"
return UTType.fileExtensionDefaultImage
}
return imageFileExtension
}
private static func mimetype(forImageFileExtension imageFileExtension: String) -> String? {
guard imageFileExtension.count > 0 else { return nil }
return MimeTypeUtil.mimeType(for: imageFileExtension)
}
private static func decodeHTMLEntities(inString value: String) -> String? {
guard let data = value.data(using: .utf8) else { return nil }

@ -2,6 +2,7 @@
import Foundation
import CoreGraphics
import UniformTypeIdentifiers
import SessionUtilitiesKit
public extension VisibleMessage {
@ -56,9 +57,9 @@ public extension VisibleMessage {
guard
let fileName: String = proto.fileName,
let fileExtension: String = URL(string: fileName)?.pathExtension
else { return MimeTypeUtil.MimeType.applicationOctetStream }
else { return UTType.mimeTypeDefault }
return (MimeTypeUtil.mimeType(for: fileExtension) ?? MimeTypeUtil.MimeType.applicationOctetStream)
return (UTType.sessionMimeType(for: fileExtension) ?? UTType.mimeTypeDefault)
}
return VMAttachment(

@ -1,11 +1,13 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
// stringlint:disable
import Foundation
import UIKit
import Combine
import MobileCoreServices
import AVFoundation
import UniformTypeIdentifiers
import SessionUtilitiesKit
public enum SignalAttachmentError: Error {
@ -115,10 +117,8 @@ public class SignalAttachment: Equatable {
// This flag should be set for attachments that can be sent as contact shares.
public var isConvertibleToContactShare = false
// Attachment types are identified using UTIs.
//
// See: https://developer.apple.com/library/content/documentation/Miscellaneous/Reference/UTIRef/Articles/System-DeclaredUniformTypeIdentifiers.html
public let dataUTI: String
// Attachment types are identified using UTType.
public let dataType: UTType
public var error: SignalAttachmentError? {
didSet {
@ -142,9 +142,9 @@ public class SignalAttachment: Equatable {
// This method should not be called directly; use the factory
// methods instead.
private init(dataSource: (any DataSource), dataUTI: String) {
private init(dataSource: (any DataSource), dataType: UTType) {
self.dataSource = dataSource
self.dataUTI = dataUTI
self.dataType = dataType
}
// MARK: Methods
@ -268,25 +268,17 @@ public class SignalAttachment: Equatable {
// Returns the MIME type for this attachment or nil if no MIME type
// can be identified.
public var mimeType: String {
if let filename = sourceFilename {
let fileExtension = (filename as NSString).pathExtension
if fileExtension.count > 0 {
if let mimeType = MimeTypeUtil.mimeType(for: fileExtension) {
// UTI types are an imperfect means of representing file type;
// file extensions are also imperfect but far more reliable and
// comprehensive so we always prefer to try to deduce MIME type
// from the file extension.
return mimeType
}
}
}
if dataUTI == MimeTypeUtil.UTI.unknownUTIForTests {
return MimeTypeUtil.MimeType.unknownMimeTypeForTests
}
guard let mimeType = UTTypeCopyPreferredTagWithClass(dataUTI as CFString, kUTTagClassMIMEType) else {
return MimeTypeUtil.MimeType.applicationOctetStream
}
return mimeType.takeRetainedValue() as String
guard
let fileExtension: String = sourceFilename.map({ $0 as NSString })?.pathExtension,
!fileExtension.isEmpty,
let fileExtensionMimeType: String = UTType(sessionFileExtension: fileExtension)?.preferredMIMEType
else { return (dataType.preferredMIMEType ?? UTType.mimeTypeDefault) }
// UTI types are an imperfect means of representing file type;
// file extensions are also imperfect but far more reliable and
// comprehensive so we always prefer to try to deduce MIME type
// from the file extension.
return fileExtensionMimeType
}
// Use the filename if known. If not, e.g. if the attachment was copy/pasted, we'll generate a filename
@ -313,118 +305,38 @@ public class SignalAttachment: Equatable {
// Returns the file extension for this attachment or nil if no file extension
// can be identified.
public var fileExtension: String? {
if let filename = sourceFilename {
let fileExtension = (filename as NSString).pathExtension
if fileExtension.count > 0 {
return fileExtension.filteredFilename
}
}
if dataUTI == MimeTypeUtil.UTI.unknownUTIForTests {
return "unknown".localized()
}
guard let fileExtension = MimeTypeUtil.fileExtension(forUtiType: dataUTI) else {
return nil
}
return fileExtension
}
// Returns the set of UTIs that correspond to valid _input_ image formats
// for Signal attachments.
//
// Image attachments may be converted to another image format before
// being uploaded.
private class var inputImageUTISet: Set<String> {
// HEIC is valid input, but not valid output. Non-iOS11 clients do not support it.
let heicSet: Set<String> = Set(["public.heic", "public.heif"])
return MimeTypeUtil.supportedImageUtiTypes
.union(animatedImageUTISet)
.union(heicSet)
}
// Returns the set of UTIs that correspond to valid _output_ image formats
// for Signal attachments.
private class var outputImageUTISet: Set<String> {
return MimeTypeUtil.supportedImageUtiTypes.union(animatedImageUTISet)
}
private class var outputVideoUTISet: Set<String> {
return Set([kUTTypeMPEG4 as String])
}
// Returns the set of UTIs that correspond to valid animated image formats
// for Signal attachments.
private class var animatedImageUTISet: Set<String> {
return MimeTypeUtil.supportedAnimatedUtiTypes
}
// Returns the set of UTIs that correspond to valid video formats
// for Signal attachments.
private class var videoUTISet: Set<String> {
return MimeTypeUtil.supportedVideoUtiTypes
}
// Returns the set of UTIs that correspond to valid audio formats
// for Signal attachments.
private class var audioUTISet: Set<String> {
return MimeTypeUtil.supportedAudioUtiTypes
}
// Returns the set of UTIs that correspond to valid image, video and audio formats
// for Signal attachments.
private class var mediaUTISet: Set<String> {
return audioUTISet.union(videoUTISet).union(animatedImageUTISet).union(inputImageUTISet)
}
@objc
public var isImage: Bool {
return SignalAttachment.outputImageUTISet.contains(dataUTI)
}
@objc
public var isAnimatedImage: Bool {
return SignalAttachment.animatedImageUTISet.contains(dataUTI)
}
@objc
public var isVideo: Bool {
return SignalAttachment.videoUTISet.contains(dataUTI)
guard
let fileExtension: String = sourceFilename.map({ $0 as NSString })?.pathExtension,
!fileExtension.isEmpty
else { return dataType.sessionFileExtension }
return fileExtension.filteredFilename
}
@objc
public var isAudio: Bool {
return SignalAttachment.audioUTISet.contains(dataUTI)
}
public var isImage: Bool { dataType.isImage || dataType.isAnimated }
public var isAnimatedImage: Bool { dataType.isAnimated }
public var isVideo: Bool { dataType.isVideo }
public var isAudio: Bool { dataType.isAudio }
@objc
public var isText: Bool {
return (
isConvertibleToTextMessage &&
UTTypeConformsTo(dataUTI as CFString, kUTTypeText)
)
isConvertibleToTextMessage &&
dataType.conforms(to: .text)
}
@objc
public var isUrl: Bool {
return UTTypeConformsTo(dataUTI as CFString, kUTTypeURL)
dataType.conforms(to: .url)
}
@objc
public class func pasteboardHasPossibleAttachment() -> Bool {
return UIPasteboard.general.numberOfItems > 0
}
@objc
public class func pasteboardHasText() -> Bool {
if UIPasteboard.general.numberOfItems < 1 {
return false
}
let itemSet = IndexSet(integer: 0)
guard let pasteboardUTITypes = UIPasteboard.general.types(forItemSet: itemSet) else {
return false
}
let pasteboardUTISet = Set<String>(pasteboardUTITypes[0])
guard
UIPasteboard.general.numberOfItems > 0,
let pasteboardUTIdentifiers: [[String]] = UIPasteboard.general.types(forItemSet: IndexSet(integer: 0)),
let pasteboardUTTypes: Set<UTType> = pasteboardUTIdentifiers.first.map({ Set($0.compactMap { UTType($0) }) })
else { return false }
// The pasteboard can be populated with multiple UTI types
// with different payloads. iMessage for example will copy
@ -440,23 +352,9 @@ public class SignalAttachment: Equatable {
// In general, our rule is to prefer non-text pasteboard
// contents, so we return true IFF there is a text UTI type
// and there is no non-text UTI type.
var hasTextUTIType = false
var hasNonTextUTIType = false
for utiType in pasteboardUTISet {
if UTTypeConformsTo(utiType as CFString, kUTTypeText) {
hasTextUTIType = true
} else if mediaUTISet.contains(utiType) {
hasNonTextUTIType = true
}
}
if pasteboardUTISet.contains(kUTTypeURL as String) {
// Treat URL as a textual UTI type.
hasTextUTIType = true
}
if hasNonTextUTIType {
return false
}
return hasTextUTIType
guard !pasteboardUTTypes.contains(where: { !$0.conforms(to: .text) }) else { return false }
return pasteboardUTTypes.contains(where: { $0.conforms(to: .text) || $0.conforms(to: .url) })
}
// Returns an attachment from the pasteboard, or nil if no attachment
@ -465,63 +363,58 @@ public class SignalAttachment: Equatable {
// NOTE: The attachment returned by this method may not be valid.
// Check the attachment's error property.
public class func attachmentFromPasteboard() -> SignalAttachment? {
guard UIPasteboard.general.numberOfItems >= 1 else {
return nil
}
// If pasteboard contains multiple items, use only the first.
let itemSet = IndexSet(integer: 0)
guard let pasteboardUTITypes = UIPasteboard.general.types(forItemSet: itemSet) else {
return nil
}
let pasteboardUTISet = Set<String>(pasteboardUTITypes[0])
for dataUTI in inputImageUTISet {
if pasteboardUTISet.contains(dataUTI) {
guard let data = dataForFirstPasteboardItem(dataUTI: dataUTI) else {
return nil
}
let dataSource = DataSourceValue(data: data, utiType: dataUTI)
guard
UIPasteboard.general.numberOfItems > 0,
let pasteboardUTIdentifiers: [[String]] = UIPasteboard.general.types(forItemSet: IndexSet(integer: 0)),
let pasteboardUTTypes: Set<UTType> = pasteboardUTIdentifiers.first.map({ Set($0.compactMap { UTType($0) }) })
else { return nil }
for type in UTType.supportedInputImageTypes {
if pasteboardUTTypes.contains(type) {
guard let data: Data = dataForFirstPasteboardItem(type: type) else { return nil }
// Pasted images _SHOULD _NOT_ be resized, if possible.
return attachment(dataSource: dataSource, dataUTI: dataUTI, imageQuality: .original)
let dataSource = DataSourceValue(data: data, dataType: type)
return attachment(dataSource: dataSource, type: type, imageQuality: .original)
}
}
for dataUTI in videoUTISet {
if pasteboardUTISet.contains(dataUTI) {
guard let data = dataForFirstPasteboardItem(dataUTI: dataUTI) else {
return nil
}
let dataSource = DataSourceValue(data: data, utiType: dataUTI)
return videoAttachment(dataSource: dataSource, dataUTI: dataUTI)
for type in UTType.supportedVideoTypes {
if pasteboardUTTypes.contains(type) {
guard let data = dataForFirstPasteboardItem(type: type) else { return nil }
let dataSource = DataSourceValue(data: data, dataType: type)
return videoAttachment(dataSource: dataSource, type: type)
}
}
for dataUTI in audioUTISet {
if pasteboardUTISet.contains(dataUTI) {
guard let data = dataForFirstPasteboardItem(dataUTI: dataUTI) else {
return nil
}
let dataSource = DataSourceValue(data: data, utiType: dataUTI)
return audioAttachment(dataSource: dataSource, dataUTI: dataUTI)
for type in UTType.supportedAudioTypes {
if pasteboardUTTypes.contains(type) {
guard let data = dataForFirstPasteboardItem(type: type) else { return nil }
let dataSource = DataSourceValue(data: data, dataType: type)
return audioAttachment(dataSource: dataSource, type: type)
}
}
let dataUTI = pasteboardUTISet[pasteboardUTISet.startIndex]
guard let data = dataForFirstPasteboardItem(dataUTI: dataUTI) else {
return nil
}
let dataSource = DataSourceValue(data: data, utiType: dataUTI)
return genericAttachment(dataSource: dataSource, dataUTI: dataUTI)
let type: UTType = pasteboardUTTypes[pasteboardUTTypes.startIndex]
guard let data = dataForFirstPasteboardItem(type: type) else { return nil }
let dataSource = DataSourceValue(data: data, dataType: type)
return genericAttachment(dataSource: dataSource, type: type)
}
// This method should only be called for dataUTIs that
// are appropriate for the first pasteboard item.
private class func dataForFirstPasteboardItem(dataUTI: String) -> Data? {
let itemSet = IndexSet(integer: 0)
guard let datas = UIPasteboard.general.data(forPasteboardType: dataUTI, inItemSet: itemSet) else {
return nil
}
guard datas.count > 0 else {
return nil
}
return datas[0]
private class func dataForFirstPasteboardItem(type: UTType) -> Data? {
guard
UIPasteboard.general.numberOfItems > 0,
let dataValues: [Data] = UIPasteboard.general.data(
forPasteboardType: type.identifier,
inItemSet: IndexSet(integer: 0)
),
!dataValues.isEmpty
else { return nil }
return dataValues[0]
}
// MARK: Image Attachments
@ -530,18 +423,17 @@ public class SignalAttachment: Equatable {
//
// NOTE: The attachment returned by this method may not be valid.
// Check the attachment's error property.
private class func imageAttachment(dataSource: (any DataSource)?, dataUTI: String, imageQuality: TSImageQuality) -> SignalAttachment {
assert(dataUTI.count > 0)
private class func imageAttachment(dataSource: (any DataSource)?, type: UTType, imageQuality: TSImageQuality) -> SignalAttachment {
assert(dataSource != nil)
guard var dataSource = dataSource else {
let attachment = SignalAttachment(dataSource: DataSourceValue.empty, dataUTI: dataUTI)
let attachment = SignalAttachment(dataSource: DataSourceValue.empty, dataType: type)
attachment.error = .missingData
return attachment
}
let attachment = SignalAttachment(dataSource: dataSource, dataUTI: dataUTI)
let attachment = SignalAttachment(dataSource: dataSource, dataType: type)
guard inputImageUTISet.contains(dataUTI) else {
guard UTType.supportedInputImageTypes.contains(type) else {
attachment.error = .invalidFileFormat
return attachment
}
@ -551,7 +443,7 @@ public class SignalAttachment: Equatable {
return attachment
}
if animatedImageUTISet.contains(dataUTI) {
if UTType.supportedAnimatedImageTypes.contains(type) {
guard dataSource.dataLength <= MediaUtils.maxFileSizeAnimatedImage else {
attachment.error = .fileSizeTooLarge
return attachment
@ -566,7 +458,7 @@ public class SignalAttachment: Equatable {
}
attachment.cachedImage = image
let isValidOutput = isValidOutputImage(image: image, dataSource: dataSource, dataUTI: dataUTI, imageQuality: imageQuality)
let isValidOutput = isValidOutputImage(image: image, dataSource: dataSource, type: type, imageQuality: imageQuality)
if let sourceFilename = dataSource.sourceFilename,
let sourceFileExtension = sourceFilename.fileExtension,
@ -580,7 +472,7 @@ public class SignalAttachment: Equatable {
// updating the extension as well. No problem.
// However the problem comes in when you edit an HEIC image in Photos.app - the image is saved
// in the Photos.app as a JPEG, but retains the (now incongruous) HEIC extension in the filename.
assert(dataUTI == kUTTypeJPEG as String || !isValidOutput)
assert(type == .jpeg || !isValidOutput)
let baseFilename = sourceFilename.filenameWithoutExtension
dataSource.sourceFilename = baseFilename.appendingFileExtension("jpg")
@ -596,34 +488,28 @@ public class SignalAttachment: Equatable {
// If the proposed attachment already conforms to the
// file size and content size limits, don't recompress it.
private class func isValidOutputImage(image: UIImage?, dataSource: (any DataSource)?, dataUTI: String, imageQuality: TSImageQuality) -> Bool {
guard image != nil else {
return false
}
guard let dataSource = dataSource else {
return false
}
guard SignalAttachment.outputImageUTISet.contains(dataUTI) else {
return false
}
if doesImageHaveAcceptableFileSize(dataSource: dataSource, imageQuality: imageQuality) &&
dataSource.dataLength <= MediaUtils.maxFileSizeImage {
return true
}
return false
private class func isValidOutputImage(image: UIImage?, dataSource: (any DataSource)?, type: UTType, imageQuality: TSImageQuality) -> Bool {
guard
image != nil,
let dataSource = dataSource,
UTType.supportedOutputImageTypes.contains(type)
else { return false }
return (
doesImageHaveAcceptableFileSize(dataSource: dataSource, imageQuality: imageQuality) &&
dataSource.dataLength <= MediaUtils.maxFileSizeImage
)
}
// Factory method for an image attachment.
//
// NOTE: The attachment returned by this method may nil or not be valid.
// Check the attachment's error property.
public class func imageAttachment(image: UIImage?, dataUTI: String, filename: String?, imageQuality: TSImageQuality) -> SignalAttachment {
assert(dataUTI.count > 0)
guard let image = image else {
public class func imageAttachment(image: UIImage?, type: UTType, filename: String?, imageQuality: TSImageQuality) -> SignalAttachment {
guard let image: UIImage = image else {
let dataSource = DataSourceValue.empty
dataSource.sourceFilename = filename
let attachment = SignalAttachment(dataSource: dataSource, dataUTI: dataUTI)
let attachment = SignalAttachment(dataSource: dataSource, dataType: type)
attachment.error = .missingData
return attachment
}
@ -631,7 +517,7 @@ public class SignalAttachment: Equatable {
// Make a placeholder attachment on which to hang errors if necessary.
let dataSource = DataSourceValue.empty
dataSource.sourceFilename = filename
let attachment = SignalAttachment(dataSource: dataSource, dataUTI: dataUTI)
let attachment = SignalAttachment(dataSource: dataSource, dataType: type)
attachment.cachedImage = image
return compressImageAsJPEG(image: image, attachment: attachment, filename: filename, imageQuality: imageQuality)
@ -642,7 +528,7 @@ public class SignalAttachment: Equatable {
if imageQuality == .original &&
attachment.dataLength < MediaUtils.maxFileSizeGeneric &&
outputImageUTISet.contains(attachment.dataUTI) {
UTType.supportedOutputImageTypes.contains(attachment.dataType) {
// We should avoid resizing images attached "as documents" if possible.
return attachment
}
@ -672,7 +558,7 @@ public class SignalAttachment: Equatable {
if doesImageHaveAcceptableFileSize(dataSource: dataSource, imageQuality: imageQuality) &&
dataSource.dataLength <= MediaUtils.maxFileSizeImage {
let recompressedAttachment = SignalAttachment(dataSource: dataSource, dataUTI: kUTTypeJPEG as String)
let recompressedAttachment = SignalAttachment(dataSource: dataSource, dataType: .jpeg)
recompressedAttachment.cachedImage = dstImage
return recompressedAttachment
}
@ -787,15 +673,14 @@ public class SignalAttachment: Equatable {
}
private class func removeImageMetadata(attachment: SignalAttachment) -> SignalAttachment {
guard let source = CGImageSourceCreateWithData(attachment.data as CFData, nil) else {
let attachment = SignalAttachment(dataSource: DataSourceValue.empty, dataUTI: attachment.dataUTI)
let attachment = SignalAttachment(dataSource: DataSourceValue.empty, dataType: attachment.dataType)
attachment.error = .missingData
return attachment
}
guard let type = CGImageSourceGetType(source) else {
let attachment = SignalAttachment(dataSource: DataSourceValue.empty, dataUTI: attachment.dataUTI)
let attachment = SignalAttachment(dataSource: DataSourceValue.empty, dataType: attachment.dataType)
attachment.error = .invalidFileFormat
return attachment
}
@ -824,12 +709,12 @@ public class SignalAttachment: Equatable {
}
if CGImageDestinationFinalize(destination) {
guard let dataSource = DataSourceValue(data: mutableData as Data, utiType: attachment.dataUTI) else {
guard let dataSource = DataSourceValue(data: mutableData as Data, dataType: attachment.dataType) else {
attachment.error = .couldNotRemoveMetadata
return attachment
}
let strippedAttachment = SignalAttachment(dataSource: dataSource, dataUTI: attachment.dataUTI)
let strippedAttachment = SignalAttachment(dataSource: dataSource, dataType: attachment.dataType)
return strippedAttachment
} else {
@ -844,18 +729,20 @@ public class SignalAttachment: Equatable {
//
// NOTE: The attachment returned by this method may not be valid.
// Check the attachment's error property.
private class func videoAttachment(dataSource: (any DataSource)?, dataUTI: String) -> SignalAttachment {
private class func videoAttachment(dataSource: (any DataSource)?, type: UTType) -> SignalAttachment {
guard let dataSource = dataSource else {
let dataSource = DataSourceValue.empty
let attachment = SignalAttachment(dataSource: dataSource, dataUTI: dataUTI)
let attachment = SignalAttachment(dataSource: dataSource, dataType: type)
attachment.error = .missingData
return attachment
}
return newAttachment(dataSource: dataSource,
dataUTI: dataUTI,
validUTISet: videoUTISet,
maxFileSize: MediaUtils.maxFileSizeVideo)
return newAttachment(
dataSource: dataSource,
type: type,
validTypes: UTType.supportedVideoTypes,
maxFileSize: MediaUtils.maxFileSizeVideo
)
}
public class func copyToVideoTempDir(url fromUrl: URL) throws -> URL {
@ -876,11 +763,11 @@ public class SignalAttachment: Equatable {
public class func compressVideoAsMp4(
dataSource: (any DataSource),
dataUTI: String,
type: UTType,
using dependencies: Dependencies
) -> (AnyPublisher<SignalAttachment, Error>, AVAssetExportSession?) {
guard let url = dataSource.dataUrl else {
let attachment = SignalAttachment(dataSource: DataSourceValue.empty, dataUTI: dataUTI)
let attachment = SignalAttachment(dataSource: DataSourceValue.empty, dataType: type)
attachment.error = .missingData
return (
Just(attachment)
@ -893,7 +780,7 @@ public class SignalAttachment: Equatable {
let asset = AVAsset(url: url)
guard let exportSession = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetMediumQuality) else {
let attachment = SignalAttachment(dataSource: DataSourceValue.empty, dataUTI: dataUTI)
let attachment = SignalAttachment(dataSource: DataSourceValue.empty, dataType: type)
attachment.error = .couldNotConvertToMpeg4
return (
Just(attachment)
@ -917,7 +804,7 @@ public class SignalAttachment: Equatable {
let mp4Filename = baseFilename?.filenameWithoutExtension.appendingFileExtension("mp4")
guard let dataSource = DataSourcePath(fileUrl: exportURL, shouldDeleteOnDeinit: true) else {
let attachment = SignalAttachment(dataSource: DataSourceValue.empty, dataUTI: dataUTI)
let attachment = SignalAttachment(dataSource: DataSourceValue.empty, dataType: type)
attachment.error = .couldNotConvertToMpeg4
resolver(Result.success(attachment))
return
@ -925,7 +812,7 @@ public class SignalAttachment: Equatable {
dataSource.sourceFilename = mp4Filename
let attachment = SignalAttachment(dataSource: dataSource, dataUTI: kUTTypeMPEG4 as String)
let attachment = SignalAttachment(dataSource: dataSource, dataType: .mpeg4Movie)
resolver(Result.success(attachment))
}
}
@ -945,18 +832,18 @@ public class SignalAttachment: Equatable {
}
}
public class func compressVideoAsMp4(dataSource: (any DataSource), dataUTI: String, using dependencies: Dependencies) -> VideoCompressionResult {
let (attachmentPublisher, exportSession) = compressVideoAsMp4(dataSource: dataSource, dataUTI: dataUTI, using: dependencies)
public class func compressVideoAsMp4(dataSource: (any DataSource), type: UTType, using dependencies: Dependencies) -> VideoCompressionResult {
let (attachmentPublisher, exportSession) = compressVideoAsMp4(dataSource: dataSource, type: type, using: dependencies)
return VideoCompressionResult(attachmentPublisher: attachmentPublisher, exportSession: exportSession)
}
public class func isInvalidVideo(dataSource: (any DataSource), dataUTI: String) -> Bool {
guard videoUTISet.contains(dataUTI) else {
public class func isInvalidVideo(dataSource: (any DataSource), type: UTType) -> Bool {
guard UTType.supportedVideoTypes.contains(type) else {
// not a video
return false
}
guard isValidOutputVideo(dataSource: dataSource, dataUTI: dataUTI) else {
guard isValidOutputVideo(dataSource: dataSource, type: type) else {
// found a video which needs to be converted
return true
}
@ -965,18 +852,13 @@ public class SignalAttachment: Equatable {
return false
}
private class func isValidOutputVideo(dataSource: (any DataSource)?, dataUTI: String) -> Bool {
guard let dataSource = dataSource else {
return false
}
guard SignalAttachment.outputVideoUTISet.contains(dataUTI) else {
return false
}
if dataSource.dataLength <= MediaUtils.maxFileSizeVideo {
return true
}
private class func isValidOutputVideo(dataSource: (any DataSource)?, type: UTType) -> Bool {
guard
let dataSource = dataSource,
UTType.supportedOutputVideoTypes.contains(type),
dataSource.dataLength <= MediaUtils.maxFileSizeVideo
else { return false }
return false
}
@ -986,11 +868,13 @@ public class SignalAttachment: Equatable {
//
// NOTE: The attachment returned by this method may not be valid.
// Check the attachment's error property.
private class func audioAttachment(dataSource: (any DataSource)?, dataUTI: String) -> SignalAttachment {
return newAttachment(dataSource: dataSource,
dataUTI: dataUTI,
validUTISet: audioUTISet,
maxFileSize: MediaUtils.maxFileSizeAudio)
private class func audioAttachment(dataSource: (any DataSource)?, type: UTType) -> SignalAttachment {
return newAttachment(
dataSource: dataSource,
type: type,
validTypes: UTType.supportedAudioTypes,
maxFileSize: MediaUtils.maxFileSizeAudio
)
}
// MARK: Generic Attachments
@ -999,17 +883,19 @@ public class SignalAttachment: Equatable {
//
// NOTE: The attachment returned by this method may not be valid.
// Check the attachment's error property.
private class func genericAttachment(dataSource: (any DataSource)?, dataUTI: String) -> SignalAttachment {
return newAttachment(dataSource: dataSource,
dataUTI: dataUTI,
validUTISet: nil,
maxFileSize: MediaUtils.maxFileSizeGeneric)
private class func genericAttachment(dataSource: (any DataSource)?, type: UTType) -> SignalAttachment {
return newAttachment(
dataSource: dataSource,
type: type,
validTypes: nil,
maxFileSize: MediaUtils.maxFileSizeGeneric
)
}
// MARK: Voice Messages
public class func voiceMessageAttachment(dataSource: (any DataSource)?, dataUTI: String) -> SignalAttachment {
let attachment = audioAttachment(dataSource: dataSource, dataUTI: dataUTI)
public class func voiceMessageAttachment(dataSource: (any DataSource)?, type: UTType) -> SignalAttachment {
let attachment = audioAttachment(dataSource: dataSource, type: type)
attachment.isVoiceMessage = true
return attachment
}
@ -1020,51 +906,53 @@ public class SignalAttachment: Equatable {
//
// NOTE: The attachment returned by this method may not be valid.
// Check the attachment's error property.
public class func attachment(dataSource: (any DataSource)?, dataUTI: String) -> SignalAttachment {
return attachment(dataSource: dataSource, dataUTI: dataUTI, imageQuality: .original)
public class func attachment(dataSource: (any DataSource)?, type: UTType) -> SignalAttachment {
return attachment(dataSource: dataSource, type: type, imageQuality: .original)
}
// Factory method for attachments of any kind.
//
// NOTE: The attachment returned by this method may not be valid.
// Check the attachment's error property.
public class func attachment(dataSource: (any DataSource)?, dataUTI: String, imageQuality: TSImageQuality) -> SignalAttachment {
if inputImageUTISet.contains(dataUTI) {
return imageAttachment(dataSource: dataSource, dataUTI: dataUTI, imageQuality: imageQuality)
} else if videoUTISet.contains(dataUTI) {
return videoAttachment(dataSource: dataSource, dataUTI: dataUTI)
} else if audioUTISet.contains(dataUTI) {
return audioAttachment(dataSource: dataSource, dataUTI: dataUTI)
} else {
return genericAttachment(dataSource: dataSource, dataUTI: dataUTI)
public class func attachment(dataSource: (any DataSource)?, type: UTType, imageQuality: TSImageQuality) -> SignalAttachment {
if UTType.supportedInputImageTypes.contains(type) {
return imageAttachment(dataSource: dataSource, type: type, imageQuality: imageQuality)
} else if UTType.supportedVideoTypes.contains(type) {
return videoAttachment(dataSource: dataSource, type: type)
} else if UTType.supportedAudioTypes.contains(type) {
return audioAttachment(dataSource: dataSource, type: type)
}
return genericAttachment(dataSource: dataSource, type: type)
}
public class func empty() -> SignalAttachment {
return SignalAttachment.attachment(dataSource: DataSourceValue.empty,
dataUTI: kUTTypeContent as String,
imageQuality: .original)
return SignalAttachment.attachment(
dataSource: DataSourceValue.empty,
type: .content,
imageQuality: .original
)
}
// MARK: Helper Methods
private class func newAttachment(dataSource: (any DataSource)?,
dataUTI: String,
validUTISet: Set<String>?,
maxFileSize: UInt) -> SignalAttachment {
assert(dataUTI.count > 0)
private class func newAttachment(
dataSource: (any DataSource)?,
type: UTType,
validTypes: Set<UTType>?,
maxFileSize: UInt
) -> SignalAttachment {
assert(dataSource != nil)
guard let dataSource = dataSource else {
let attachment = SignalAttachment(dataSource: DataSourceValue.empty, dataUTI: dataUTI)
let attachment = SignalAttachment(dataSource: DataSourceValue.empty, dataType: type)
attachment.error = .missingData
return attachment
}
let attachment = SignalAttachment(dataSource: dataSource, dataUTI: dataUTI)
let attachment = SignalAttachment(dataSource: dataSource, dataType: type)
if let validUTISet = validUTISet {
guard validUTISet.contains(dataUTI) else {
if let validTypes: Set<UTType> = validTypes {
guard validTypes.contains(type) else {
attachment.error = .invalidFileFormat
return attachment
}
@ -1101,7 +989,7 @@ public class SignalAttachment: Equatable {
}
return (
lhs.dataUTI == rhs.dataUTI &&
lhs.dataType == rhs.dataType &&
lhs.captionText == rhs.captionText &&
lhs.linkPreviewDraft == rhs.linkPreviewDraft &&
lhs.isConvertibleToTextMessage == rhs.isConvertibleToTextMessage &&

@ -3,6 +3,7 @@
import UIKit
import Combine
import CoreServices
import UniformTypeIdentifiers
import SignalUtilitiesKit
import SessionUIKit
import SessionUtilitiesKit
@ -261,78 +262,28 @@ final class ShareNavController: UINavigationController, ShareViewDelegate {
}
// MARK: Attachment Prep
private class func itemMatchesSpecificUtiType(itemProvider: NSItemProvider, utiType: String) -> Bool {
// URLs, contacts and other special items have to be detected separately.
// Many shares (e.g. pdfs) will register many UTI types and/or conform to kUTTypeData.
guard itemProvider.registeredTypeIdentifiers.count == 1 else {
return false
}
guard let firstUtiType = itemProvider.registeredTypeIdentifiers.first else {
return false
}
return (firstUtiType == utiType)
}
private class func isVisualMediaItem(itemProvider: NSItemProvider) -> Bool {
return (
itemProvider.hasItemConformingToTypeIdentifier(kUTTypeImage as String) ||
itemProvider.hasItemConformingToTypeIdentifier(kUTTypeMovie as String)
)
}
private class func isUrlItem(itemProvider: NSItemProvider) -> Bool {
return itemMatchesSpecificUtiType(
itemProvider: itemProvider,
utiType: kUTTypeURL as String
)
}
private class func isContactItem(itemProvider: NSItemProvider) -> Bool {
return itemMatchesSpecificUtiType(
itemProvider: itemProvider,
utiType: kUTTypeContact as String
)
}
private class func utiType(itemProvider: NSItemProvider) -> String? {
Log.info("utiTypeForItem: \(itemProvider.registeredTypeIdentifiers)")
if isUrlItem(itemProvider: itemProvider) {
return kUTTypeURL as String
}
else if isContactItem(itemProvider: itemProvider) {
return kUTTypeContact as String
}
// Use the first UTI that conforms to "data".
let matchingUtiType = itemProvider.registeredTypeIdentifiers.first { (utiType: String) -> Bool in
UTTypeConformsTo(utiType as CFString, kUTTypeData)
}
return matchingUtiType
}
private class func createDataSource(utiType: String, url: URL, customFileName: String?) -> (any DataSource)? {
if utiType == (kUTTypeURL as String) {
private class func createDataSource(type: UTType, url: URL, customFileName: String?) -> (any DataSource)? {
switch (type, type.conforms(to: .text)) {
// Share URLs as text messages whose text content is the URL
return DataSourceValue(text: url.absoluteString)
}
else if UTTypeConformsTo(utiType as CFString, kUTTypeText) {
case (.url, _): return DataSourceValue(text: url.absoluteString)
// Share text as oversize text messages.
//
// NOTE: SharingThreadPickerViewController will try to unpack them
// and send them as normal text messages if possible.
return DataSourcePath(fileUrl: url, shouldDeleteOnDeinit: false)
}
guard let dataSource = DataSourcePath(fileUrl: url, shouldDeleteOnDeinit: false) else {
return nil
case (_, true): return DataSourcePath(fileUrl: url, shouldDeleteOnDeinit: false)
default:
guard let dataSource = DataSourcePath(fileUrl: url, shouldDeleteOnDeinit: false) else {
return nil
}
// Fallback to the last part of the URL
dataSource.sourceFilename = (customFileName ?? url.lastPathComponent)
return dataSource
}
// Fallback to the last part of the URL
dataSource.sourceFilename = (customFileName ?? url.lastPathComponent)
return dataSource
}
private class func preferredItemProviders(inputItem: NSExtensionItem) -> [NSItemProvider]? {
@ -342,7 +293,7 @@ final class ShareNavController: UINavigationController, ShareViewDelegate {
var hasNonVisualMedia = false
for attachment in attachments {
if isVisualMediaItem(itemProvider: attachment) {
if attachment.isVisualMediaItem {
visualMediaItemProviders.append(attachment)
}
else {
@ -370,7 +321,7 @@ final class ShareNavController: UINavigationController, ShareViewDelegate {
return false
}
return isUrlItem(itemProvider: itemProvider)
return itemProvider.matches(type: .url)
}) {
return [preferredAttachment]
}
@ -412,11 +363,10 @@ final class ShareNavController: UINavigationController, ShareViewDelegate {
// MARK: - LoadedItem
private
struct LoadedItem {
private struct LoadedItem {
let itemProvider: NSItemProvider
let itemUrl: URL
let utiType: String
let type: UTType
var customFileName: String?
var isConvertibleToTextMessage = false
@ -424,13 +374,13 @@ final class ShareNavController: UINavigationController, ShareViewDelegate {
init(itemProvider: NSItemProvider,
itemUrl: URL,
utiType: String,
type: UTType,
customFileName: String? = nil,
isConvertibleToTextMessage: Bool = false,
isConvertibleToContactShare: Bool = false) {
self.itemProvider = itemProvider
self.itemUrl = itemUrl
self.utiType = utiType
self.type = type
self.customFileName = customFileName
self.isConvertibleToTextMessage = isConvertibleToTextMessage
self.isConvertibleToContactShare = isConvertibleToContactShare
@ -449,12 +399,12 @@ final class ShareNavController: UINavigationController, ShareViewDelegate {
// * UTIs aren't very descriptive (there are far more MIME types than UTI types)
// so in the case of file attachments we try to refine the attachment type
// using the file extension.
guard let srcUtiType = ShareNavController.utiType(itemProvider: itemProvider) else {
guard let srcType: UTType = itemProvider.type else {
let error = ShareViewControllerError.unsupportedMedia
return Fail(error: error)
.eraseToAnyPublisher()
}
Log.debug("matched utiType: \(srcUtiType)")
Log.debug("matched UTType: \(srcType.identifier)")
return Deferred {
Future<LoadedItem, Error> { resolver in
@ -477,7 +427,7 @@ final class ShareNavController: UINavigationController, ShareViewDelegate {
switch value {
case let data as Data:
let customFileName = "Contact.vcf" // stringlint:disable
let customFileExtension = MimeTypeUtil.fileExtension(forUtiType: srcUtiType)
let customFileExtension: String? = srcType.sessionFileExtension
guard let tempFilePath = try? FileSystem.write(data: data, toTemporaryFileWithExtension: customFileExtension) else {
resolver(
@ -492,7 +442,7 @@ final class ShareNavController: UINavigationController, ShareViewDelegate {
LoadedItem(
itemProvider: itemProvider,
itemUrl: fileUrl,
utiType: srcUtiType,
type: srcType,
customFileName: customFileName,
isConvertibleToContactShare: false
)
@ -516,15 +466,15 @@ final class ShareNavController: UINavigationController, ShareViewDelegate {
let fileUrl = URL(fileURLWithPath: tempFilePath)
let isConvertibleToTextMessage = !itemProvider.registeredTypeIdentifiers.contains(kUTTypeFileURL as String)
let isConvertibleToTextMessage = !itemProvider.registeredTypeIdentifiers.contains(UTType.fileURL.identifier)
if UTTypeConformsTo(srcUtiType as CFString, kUTTypeText) {
if srcType.conforms(to: .text) {
resolver(
Result.success(
LoadedItem(
itemProvider: itemProvider,
itemUrl: fileUrl,
utiType: srcUtiType,
type: srcType,
isConvertibleToTextMessage: isConvertibleToTextMessage
)
)
@ -536,7 +486,7 @@ final class ShareNavController: UINavigationController, ShareViewDelegate {
LoadedItem(
itemProvider: itemProvider,
itemUrl: fileUrl,
utiType: kUTTypeText as String,
type: .text,
isConvertibleToTextMessage: isConvertibleToTextMessage
)
)
@ -546,8 +496,8 @@ final class ShareNavController: UINavigationController, ShareViewDelegate {
case let url as URL:
// If the share itself is a URL (e.g. a link from Safari), try to send this as a text message.
let isConvertibleToTextMessage = (
itemProvider.registeredTypeIdentifiers.contains(kUTTypeURL as String) &&
!itemProvider.registeredTypeIdentifiers.contains(kUTTypeFileURL as String)
itemProvider.registeredTypeIdentifiers.contains(UTType.url.identifier) &&
!itemProvider.registeredTypeIdentifiers.contains(UTType.fileURL.identifier)
)
if isConvertibleToTextMessage {
@ -556,7 +506,7 @@ final class ShareNavController: UINavigationController, ShareViewDelegate {
LoadedItem(
itemProvider: itemProvider,
itemUrl: url,
utiType: kUTTypeURL as String,
type: .url,
isConvertibleToTextMessage: isConvertibleToTextMessage
)
)
@ -568,7 +518,7 @@ final class ShareNavController: UINavigationController, ShareViewDelegate {
LoadedItem(
itemProvider: itemProvider,
itemUrl: url,
utiType: srcUtiType,
type: srcType,
isConvertibleToTextMessage: isConvertibleToTextMessage
)
)
@ -587,7 +537,7 @@ final class ShareNavController: UINavigationController, ShareViewDelegate {
LoadedItem(
itemProvider: itemProvider,
itemUrl: url,
utiType: srcUtiType
type: srcType
)
)
)
@ -613,7 +563,7 @@ final class ShareNavController: UINavigationController, ShareViewDelegate {
}
}
itemProvider.loadItem(forTypeIdentifier: srcUtiType, options: nil, completionHandler: loadCompletion)
itemProvider.loadItem(forTypeIdentifier: srcType.identifier, options: nil, completionHandler: loadCompletion)
}
}
.eraseToAnyPublisher()
@ -622,7 +572,6 @@ final class ShareNavController: UINavigationController, ShareViewDelegate {
private func buildAttachment(forLoadedItem loadedItem: LoadedItem) -> AnyPublisher<SignalAttachment, Error> {
let itemProvider = loadedItem.itemProvider
let itemUrl = loadedItem.itemUrl
let utiType = loadedItem.utiType
var url = itemUrl
do {
@ -635,35 +584,35 @@ final class ShareNavController: UINavigationController, ShareViewDelegate {
.eraseToAnyPublisher()
}
Log.debug("building DataSource with url: \(url), utiType: \(utiType)")
Log.debug("building DataSource with url: \(url), UTType: \(loadedItem.type)")
guard let dataSource = ShareNavController.createDataSource(utiType: utiType, url: url, customFileName: loadedItem.customFileName) else {
guard let dataSource = ShareNavController.createDataSource(type: loadedItem.type, url: url, customFileName: loadedItem.customFileName) else {
let error = ShareViewControllerError.assertionError(description: "Unable to read attachment data")
return Fail(error: error)
.eraseToAnyPublisher()
}
// start with base utiType, but it might be something generic like "image"
var specificUTIType = utiType
if utiType == (kUTTypeURL as String) {
var specificType: UTType = loadedItem.type
if loadedItem.type == .url {
// Use kUTTypeURL for URLs.
} else if UTTypeConformsTo(utiType as CFString, kUTTypeText) {
} else if loadedItem.type.conforms(to: .text) {
// Use kUTTypeText for text.
} else if url.pathExtension.count > 0 {
// Determine a more specific utiType based on file extension
if let typeExtension = MimeTypeUtil.utiType(forFileExtension: url.pathExtension) {
Log.debug("utiType based on extension: \(typeExtension)")
specificUTIType = typeExtension
if let fileExtensionType: UTType = UTType(sessionFileExtension: url.pathExtension) {
Log.debug("UTType based on extension: \(fileExtensionType.identifier)")
specificType = fileExtensionType
}
}
guard !SignalAttachment.isInvalidVideo(dataSource: dataSource, dataUTI: specificUTIType) else {
guard !SignalAttachment.isInvalidVideo(dataSource: dataSource, type: specificType) else {
// This can happen, e.g. when sharing a quicktime-video from iCloud drive.
let (publisher, _) = SignalAttachment.compressVideoAsMp4(dataSource: dataSource, dataUTI: specificUTIType, using: Dependencies())
let (publisher, _) = SignalAttachment.compressVideoAsMp4(dataSource: dataSource, type: specificType, using: Dependencies())
return publisher
}
let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: specificUTIType, imageQuality: .medium)
let attachment = SignalAttachment.attachment(dataSource: dataSource, type: specificType, imageQuality: .medium)
if loadedItem.isConvertibleToContactShare {
Log.info("isConvertibleToContactShare")
attachment.isConvertibleToContactShare = true
@ -737,12 +686,12 @@ final class ShareNavController: UINavigationController, ShareViewDelegate {
Log.verbose("item URL has no file extension: \(itemUrl).")
return false
}
guard let utiTypeForURL = MimeTypeUtil.utiType(forFileExtension: pathExtension) else {
guard let typeForURL: UTType = UTType(sessionFileExtension: pathExtension) else {
Log.verbose("item has unknown UTI type: \(itemUrl).")
return false
}
Log.verbose("utiTypeForURL: \(utiTypeForURL)")
guard utiTypeForURL == kUTTypeMPEG4 as String else {
Log.verbose("typeForURL: \(typeForURL.identifier)")
guard typeForURL == .mpeg4Movie else {
// Either it's not a video or it was a video which was not auto-converted to mp4.
// Not affected by the issue.
return false
@ -750,6 +699,42 @@ final class ShareNavController: UINavigationController, ShareViewDelegate {
// If video file already existed on disk as an mp4, then the host app didn't need to
// apply any conversion, so no need to relocate the app.
return !itemProvider.registeredTypeIdentifiers.contains(kUTTypeMPEG4 as String)
return !itemProvider.registeredTypeIdentifiers.contains(UTType.mpeg4Movie.identifier)
}
}
// MARK: - NSItemProvider Convenience
private extension NSItemProvider {
var isVisualMediaItem: Bool {
hasItemConformingToTypeIdentifier(UTType.image.identifier) ||
hasItemConformingToTypeIdentifier(UTType.movie.identifier)
}
func matches(type: UTType) -> Bool {
// URLs, contacts and other special items have to be detected separately.
// Many shares (e.g. pdfs) will register many UTI types and/or conform to kUTTypeData.
guard
registeredTypeIdentifiers.count == 1,
let firstTypeIdentifier: String = registeredTypeIdentifiers.first
else { return false }
return (firstTypeIdentifier == type.identifier)
}
var type: UTType? {
Log.info("utiTypeForItem: \(registeredTypeIdentifiers)")
switch (matches(type: .url), matches(type: .contact)) {
case (true, _): return .url
case (_, true): return .contact
// Use the first UTI that conforms to "data".
default:
return registeredTypeIdentifiers
.compactMap { UTType($0) }
.first { $0.conforms(to: .data) }
}
}
}

@ -307,7 +307,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView
attachmentId: LinkPreview
.generateAttachmentIfPossible(
imageData: linkPreviewDraft.jpegImageData,
mimeType: MimeTypeUtil.MimeType.imageJpeg
type: .jpeg
)?
.inserted(db)
.id

@ -2,6 +2,7 @@
import UIKit
import ImageIO
import UniformTypeIdentifiers
import libwebp
public extension Data {
@ -20,7 +21,7 @@ public extension Data {
return (
count < maxFileSize &&
isValidImage(mimeType: nil, format: imageFormat) &&
isValidImage(type: nil, format: imageFormat) &&
hasValidImageDimensions(isAnimated: isAnimated)
)
}
@ -106,18 +107,16 @@ public extension Data {
// MARK: - Initialization
init?(validImageDataAt path: String, mimeType: String? = nil) throws {
init?(validImageDataAt path: String, type: UTType? = nil) throws {
let fileUrl: URL = URL(fileURLWithPath: path)
guard
let mimeType: String = (mimeType ?? MimeTypeUtil.mimeType(for: fileUrl.pathExtension)),
!mimeType.isEmpty,
let fileSize: UInt64 = FileSystem.fileSize(of: path)
let type: UTType = type,
let fileSize: UInt64 = FileSystem.fileSize(of: path),
fileSize <= FileSystem.maxFileSize,
(type.isImage || type.isAnimated)
else { return nil }
guard fileSize <= FileSystem.maxFileSize else { return nil }
guard MimeTypeUtil.isImage(mimeType) || MimeTypeUtil.isAnimated(mimeType) else { return nil }
self = try Data(contentsOf: fileUrl, options: [.dataReadingMapped])
}
@ -132,11 +131,11 @@ public extension Data {
return Data.hasValidImageDimension(source: imageSource, isAnimated: isAnimated)
}
func isValidImage(mimeType: String?) -> Bool {
return isValidImage(mimeType: mimeType, format: self.guessedImageFormat)
func isValidImage(type: UTType?) -> Bool {
return isValidImage(type: type, format: self.guessedImageFormat)
}
func isValidImage(mimeType: String?, format: ImageFormat) -> Bool {
func isValidImage(type: UTType?, format: ImageFormat) -> Bool {
// Don't trust the file extension; iOS (e.g. UIKit, Core Graphics) will happily
// load a .gif with a .png file extension
//
@ -146,37 +145,24 @@ public extension Data {
// deduced image format
switch format {
case .unknown: return false
case .png: return (mimeType == nil || mimeType == MimeTypeUtil.MimeType.imagePng)
case .jpeg: return (mimeType == nil || mimeType == MimeTypeUtil.MimeType.imageJpeg)
case .png: return (type == nil || type == .png)
case .jpeg: return (type == nil || type == .jpeg)
case .gif:
guard hasValidGifSize else { return false }
return (mimeType == nil || mimeType == MimeTypeUtil.MimeType.imageGif)
case .tiff:
return (
mimeType == nil ||
mimeType == MimeTypeUtil.MimeType.imageTiff1 ||
mimeType == MimeTypeUtil.MimeType.imageTiff2
)
case .bmp:
return (
mimeType == nil ||
mimeType == MimeTypeUtil.MimeType.imageBmp1 ||
mimeType == MimeTypeUtil.MimeType.imageBmp2
)
return (type == nil || type == .gif)
case .webp:
return (mimeType == nil || mimeType == MimeTypeUtil.MimeType.imageWebp)
case .tiff: return (type == nil || type == .tiff || type == .xTiff)
case .bmp: return (type == nil || type == .bmp || type == .xWinBpm)
case .webp: return (type == nil || type == .webP)
}
}
static func isValidImage(at path: String, mimeType: String? = nil) -> Bool {
guard let data: Data = try? Data(validImageDataAt: path, mimeType: mimeType) else { return false }
static func isValidImage(at path: String, type: UTType? = nil) -> Bool {
guard let data: Data = try? Data(validImageDataAt: path, type: type) else { return false }
return data.hasValidImageDimensions(isAnimated: (mimeType.map { MimeTypeUtil.isAnimated($0) } ?? false))
return data.hasValidImageDimensions(isAnimated: type?.isAnimated == true)
}
static func hasValidImageDimension(source: CGImageSource, isAnimated: Bool) -> Bool {
@ -234,16 +220,16 @@ public extension Data {
return ImageDimensions(pixelSize: CGSize(width: width, height: height), depthBytes: depthBytes)
}
static func imageSize(for path: String, mimeType: String) -> CGSize {
static func imageSize(for path: String, type: UTType?) -> CGSize {
let fileUrl: URL = URL(fileURLWithPath: path)
let isAnimated: Bool = MimeTypeUtil.isAnimated(mimeType)
let isAnimated: Bool = (type?.isAnimated ?? false)
guard
let data: Data = try? Data(validImageDataAt: path, mimeType: mimeType),
let pixelSize: CGSize = imageSize(at: path, with: data, mimeType: mimeType, isAnimated: isAnimated)
let data: Data = try? Data(validImageDataAt: path, type: type),
let pixelSize: CGSize = imageSize(at: path, with: data, type: type, isAnimated: isAnimated)
else { return .zero }
guard mimeType != MimeTypeUtil.MimeType.imageWebp else { return pixelSize }
guard type != .webP else { return pixelSize }
// With CGImageSource we avoid loading the whole image into memory.
let options: [String: Any] = [kCGImageSourceShouldCache as String: NSNumber(booleanLiteral: false)]
@ -281,11 +267,11 @@ public extension Data {
}
}
private static func imageSize(at path: String, with data: Data?, mimeType: String?, isAnimated: Bool) -> CGSize? {
private static func imageSize(at path: String, with data: Data?, type: UTType?, isAnimated: Bool) -> CGSize? {
let fileUrl: URL = URL(fileURLWithPath: path)
// Need to custom handle WebP images via libwebp
guard mimeType != MimeTypeUtil.MimeType.imageWebp else {
guard type != .webP else {
guard let targetData: Data = (data ?? (try? Data(contentsOf: fileUrl, options: [.dataReadingMapped]))) else {
return nil
}

@ -1,6 +1,7 @@
// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import UniformTypeIdentifiers
// MARK: - DataSource
@ -22,37 +23,18 @@ public protocol DataSource: Equatable {
var mimeType: String? { get }
var shouldDeleteOnDeinit: Bool { get }
var isValidImage: Bool { get }
var isValidVideo: Bool { get }
// MARK: - Functions
func write(to path: String) throws
}
public extension DataSource {
var isValidImage: Bool {
guard let dataPath: String = self.dataPathIfOnDisk else {
return self.data.isValidImage
}
// if ows_isValidImage is given a file path, it will
// avoid loading most of the data into memory, which
// is considerably more performant, so try to do that.
return Data.isValidImage(at: dataPath, mimeType: mimeType)
}
var isValidVideo: Bool {
guard let dataUrl: URL = self.dataUrl else { return false }
return MediaUtils.isValidVideo(path: dataUrl.path)
}
}
// MARK: - DataSourceValue
public class DataSourceValue: DataSource {
public static let empty: DataSourceValue = DataSourceValue(
data: Data(),
fileExtension: MimeTypeUtil.FileExtension.syncMessage
)
public static let empty: DataSourceValue = DataSourceValue(data: Data(), fileExtension: UTType.fileExtensionText)
public var data: Data
public var sourceFilename: String?
@ -63,7 +45,7 @@ public class DataSourceValue: DataSource {
public var dataUrl: URL? { dataPath.map { URL(fileURLWithPath: $0) } }
public var dataPathIfOnDisk: String? { cachedFilePath }
public var dataLength: Int { data.count }
public var mimeType: String? { MimeTypeUtil.mimeType(for: fileExtension) }
public var mimeType: String? { UTType.sessionMimeType(for: fileExtension) }
var dataPath: String? {
let fileExtension: String = self.fileExtension
@ -83,6 +65,23 @@ public class DataSourceValue: DataSource {
}
}
public var isValidImage: Bool {
guard let dataPath: String = self.dataPathIfOnDisk else {
return self.data.isValidImage
}
// if ows_isValidImage is given a file path, it will
// avoid loading most of the data into memory, which
// is considerably more performant, so try to do that.
return Data.isValidImage(at: dataPath, type: UTType(sessionFileExtension: fileExtension))
}
public var isValidVideo: Bool {
guard let dataUrl: URL = self.dataUrl else { return false }
return MediaUtils.isValidVideo(path: dataUrl.path)
}
// MARK: - Initialization
public init(data: Data, fileExtension: String) {
@ -97,8 +96,8 @@ public class DataSourceValue: DataSource {
self.init(data: data, fileExtension: fileExtension)
}
public convenience init?(data: Data?, utiType: String) {
guard let fileExtension: String = MimeTypeUtil.fileExtension(forUtiType: utiType) else { return nil }
public convenience init?(data: Data?, dataType: UTType) {
guard let fileExtension: String = dataType.sessionFileExtension else { return nil }
self.init(data: data, fileExtension: fileExtension)
}
@ -109,11 +108,7 @@ public class DataSourceValue: DataSource {
let data: Data = text.filteredForDisplay.data(using: .utf8)
else { return nil }
self.init(data: data, fileExtension: MimeTypeUtil.FileExtension.text)
}
convenience init(syncMessageData: Data) {
self.init(data: syncMessageData, fileExtension: MimeTypeUtil.FileExtension.syncMessage)
self.init(data: data, fileExtension: UTType.fileExtensionText)
}
deinit {
@ -191,7 +186,27 @@ public class DataSourcePath: DataSource {
}
}
public var mimeType: String? { MimeTypeUtil.mimeType(for: URL(fileURLWithPath: filePath).pathExtension) }
public var mimeType: String? { UTType.sessionMimeType(for: URL(fileURLWithPath: filePath).pathExtension) }
public var isValidImage: Bool {
guard let dataPath: String = self.dataPathIfOnDisk else {
return self.data.isValidImage
}
// if ows_isValidImage is given a file path, it will
// avoid loading most of the data into memory, which
// is considerably more performant, so try to do that.
return Data.isValidImage(
at: dataPath,
type: UTType(sessionFileExtension: URL(fileURLWithPath: filePath).pathExtension)
)
}
public var isValidVideo: Bool {
guard let dataUrl: URL = self.dataUrl else { return false }
return MediaUtils.isValidVideo(path: dataUrl.path)
}
// MARK: - Initialization

@ -76,11 +76,11 @@ public enum MediaUtils {
return false
}
let fileExtension = URL(fileURLWithPath: path).pathExtension
guard let contentType = MimeTypeUtil.mimeType(for: fileExtension) else {
guard let contentType: String = UTType.sessionMimeType(for: fileExtension) else {
SNLog("Media file has unknown content type.")
return false
}
guard MimeTypeUtil.isVideo(contentType) else {
guard UTType.isVideo(contentType) else {
SNLog("Media file has invalid content type.")
return false
}

@ -4,386 +4,199 @@
import UIKit
import MobileCoreServices
import UniformTypeIdentifiers
public extension MimeTypeUtil.MimeType {
static let imageGif: MimeTypeUtil.MimeType = "image/gif"
static let imagePng: MimeTypeUtil.MimeType = "image/png"
static let imageJpeg: MimeTypeUtil.MimeType = "image/jpeg"
static let imageWebp: MimeTypeUtil.MimeType = "image/webp"
static let imageTiff1: MimeTypeUtil.MimeType = "image/tiff"
static let imageTiff2: MimeTypeUtil.MimeType = "image/x-tiff"
static let imageBmp1: MimeTypeUtil.MimeType = "image/bmp"
static let imageBmp2: MimeTypeUtil.MimeType = "image/x-windows-bmp"
public extension UTType {
/// This is an invalid type used to improve DSL for UTType usage
static let invalid: UTType = UTType(exportedAs: "invalid")
static let fileExtensionText: String = "txt"
static let fileExtensionDefault: String = "bin"
static let fileExtensionDefaultImage: String = "png"
static let mimeTypeDefault: String = "application/octet-stream"
static let mimeTypeJpeg: String = "image/jpeg"
static let mimeTypePdf: String = "application/pdf"
static let applicationPdf: MimeTypeUtil.MimeType = "application/pdf"
static let applicationOctetStream: MimeTypeUtil.MimeType = "application/octet-stream"
static let unknownMimeTypeForTests: MimeTypeUtil.MimeType = "unknown/mimetype"
}
public extension MimeTypeUtil.FileExtension {
static let text: MimeTypeUtil.FileExtension = "txt"
static let syncMessage: MimeTypeUtil.FileExtension = "bin"
static let unknownExtensionForTests: MimeTypeUtil.FileExtension = "unknown"
}
public extension MimeTypeUtil.UTI {
static let unknownUTIForTests: MimeTypeUtil.UTI = "org.whispersystems.unknown"
}
public enum MimeTypeUtil {
public typealias MimeType = String
public typealias FileExtension = String
public typealias UTI = String
public static let supportedAudioUtiTypes: Set<String> = {
supportedAudioMimeTypesToExtensionTypes
.keys
.compactMap { utiType(for: $0) }
.asSet()
}()
public static let supportedImageUtiTypes: Set<String> = {
supportedImageMimeTypesToExtensionTypes
.keys
.compactMap { utiType(for: $0) }
.asSet()
}()
public static let supportedAnimatedUtiTypes: Set<String> = {
supportedAnimatedMimeTypesToExtensionTypes
.keys
.compactMap { utiType(for: $0) }
.asSet()
}()
public static let supportedVideoUtiTypes: Set<String> = {
supportedVideoMimeTypesToExtensionTypes
.keys
.compactMap { utiType(for: $0) }
.asSet()
}()
static let xTiff: UTType = UTType(mimeType: "image/x-tiff")!
static let xWinBpm: UTType = UTType(mimeType: "image/x-windows-bmp")!
static let supportedAnimatedImageTypes: Set<UTType> = [
.gif, .webP
]
public static func isAnimated(_ mimeType: String) -> Bool {
return (supportedAnimatedMimeTypesToExtensionTypes[mimeType] != nil)
}
static let supportedAudioTypes: Set<UTType> = [
UTType(mimeType: "audio/aac"),
UTType(mimeType: "audio/x-m4p"),
UTType(mimeType: "audio/x-m4b"),
UTType(mimeType: "audio/x-m4a"),
.wav,
.mp3,
.aiff,
.mpeg4Audio,
UTType(mimeType: "audio/3gpp2"),
UTType(mimeType: "audio/3gpp")
].compactMap { $0 }.asSet()
public static func isAnimated(utiType: String) -> Bool {
return supportedAnimatedMimeTypesToExtensionTypes.keys.contains { mimeType in
return (self.utiType(for: mimeType) == utiType)
}
}
static let supportedImageTypes: Set<UTType> = [
.jpeg, .png, .tiff, .bmp, .gif, .ico, .webP
]
public static func isBinaryData(_ mimeType: String) -> Bool {
return (supportedBinaryDataMimeTypesToExtensionTypes[mimeType] != nil)
}
/// HEIC is valid input, but not valid output. Non-iOS11 clients do not support it.
static let supportedInputImageTypes: Set<UTType> = supportedImageTypes
.union(supportedAnimatedImageTypes)
.union([.heic, .heif])
public static func isImage(_ mimeType: String) -> Bool {
return (supportedImageMimeTypesToExtensionTypes[mimeType] != nil)
}
static let supportedOutputImageTypes: Set<UTType> = supportedImageTypes
.union(supportedAnimatedImageTypes)
public static func isVideo(_ mimeType: String) -> Bool {
return (supportedVideoMimeTypesToExtensionTypes[mimeType] != nil)
}
static let supportedVideoTypes: Set<UTType> = [
UTType(mimeType: "video/3gpp"),
UTType(mimeType: "video/3gpp2"),
.mpeg4Movie,
.appleProtectedMPEG4Video,
.quickTimeMovie,
UTType(mimeType: "video/x-m4v"),
UTType(mimeType: "video/mpeg")
].compactMap { $0 }.asSet()
public static func isAudio(_ mimeType: String) -> Bool {
return (supportedAudioMimeTypesToExtensionTypes[mimeType] != nil)
}
static let supportedOutputVideoTypes: Set<UTType> = [.mpeg4Movie]
public static func isText(_ mimeType: String) -> Bool {
return supportedTextMimeTypes.contains(mimeType)
}
static let supportedVisualMediaTypes: Set<UTType> = supportedImageTypes
.union(supportedAnimatedImageTypes)
.union(supportedVideoTypes)
public static func isMicrosoftDoc(_ mimeType: String) -> Bool {
return supportedMicrosoftDocMimeTypes.contains(mimeType)
}
static let supportedTextTypes: Set<UTType> = [
.text, .plainText, .commaSeparatedText, .tabSeparatedText
]
public static func isVisualMedia(_ mimeType: String) -> Bool {
guard !isImage(mimeType) else { return true }
guard !isVideo(mimeType) else { return true }
guard !isAnimated(mimeType) else { return true }
static let supportedMicrosoftDocTypes: Set<UTType> = [
// Word files
UTType(mimeType: "application/msword"),
return false
}
public static func mimeType(for fileExtension: String) -> String? {
return genericExtensionTypesToMimeTypes[fileExtension]
}
public static func filePath(
for attachmentId: String,
ofMimeType mimeType: String,
sourceFilename: String?,
in folder: String
) -> String? {
let defaultFileExtension: String = "bin"
UTType(mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
UTType(mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.template"),
UTType(mimeType: "application/vnd.ms-word.document.macroEnabled.12"),
UTType(mimeType: "application/vnd.ms-word.template.macroEnabled.12"),
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(".") {
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 = (fileExtension(for: mimeType) ?? defaultFileExtension)
}
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 attachmentFolderPath: String = folder.appending("/\(attachmentId)")
guard case .success = Result(try FileSystem.ensureDirectoryExists(at: attachmentFolderPath)) else {
return nil
}
return attachmentFolderPath.appending("/\(filenameWithoutExtension).\(targetFileExtension)")
}
}
// Excel files
UTType(mimeType: "application/vnd.ms-excel"),
let maybeExtension: String? = {
guard !isVideo(mimeType) else { return supportedVideoMimeTypesToExtensionTypes[mimeType] }
guard !isAudio(mimeType) else { return supportedAudioMimeTypesToExtensionTypes[mimeType] }
guard !isImage(mimeType) else { return supportedImageMimeTypesToExtensionTypes[mimeType] }
guard !isAnimated(mimeType) else { return supportedAnimatedMimeTypesToExtensionTypes[mimeType] }
guard !isBinaryData(mimeType) else { return supportedBinaryDataMimeTypesToExtensionTypes[mimeType] }
guard mimeType != MimeType.unknownMimeTypeForTests else {
// This file extension is arbitrary - it should never be exposed to the user or
// be used outside the app.
return FileExtension.unknownExtensionForTests
}
return fileExtension(for: mimeType)
}()
UTType(mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
UTType(mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.template"),
UTType(mimeType: "application/vnd.ms-excel.sheet.macroEnabled.12"),
UTType(mimeType: "application/vnd.ms-excel.template.macroEnabled.12"),
UTType(mimeType: "application/vnd.ms-excel.addin.macroEnabled.12"),
UTType(mimeType: "application/vnd.ms-excel.sheet.binary.macroEnabled.12"),
let targetFileExtension: String = (maybeExtension ?? defaultFileExtension).lowercased()
// Powerpoint files
UTType(mimeType: "application/vnd.ms-powerpoint"),
return folder.appending("/\(attachmentId).\(targetFileExtension)")
}
UTType(mimeType: "application/vnd.openxmlformats-officedocument.presentationml.presentation"),
UTType(mimeType: "application/vnd.openxmlformats-officedocument.presentationml.template"),
UTType(mimeType: "application/vnd.openxmlformats-officedocument.presentationml.slideshow"),
UTType(mimeType: "application/vnd.ms-powerpoint.addin.macroEnabled.12"),
UTType(mimeType: "application/vnd.ms-powerpoint.presentation.macroEnabled.12"),
UTType(mimeType: "application/vnd.ms-powerpoint.template.macroEnabled.12"),
UTType(mimeType: "application/vnd.ms-powerpoint.slideshow.macroEnabled.12")
].compactMap { $0 }.asSet()
// MARK: - Deduction Logic
var isAnimated: Bool { UTType.supportedAnimatedImageTypes.contains(self) }
var isImage: Bool { UTType.supportedImageTypes.contains(self) }
var isVideo: Bool { UTType.supportedVideoTypes.contains(self) }
var isAudio: Bool { UTType.supportedAudioTypes.contains(self) }
var isText: Bool { UTType.supportedTextTypes.contains(self) }
var isMicrosoftDoc: Bool { UTType.supportedMicrosoftDocTypes.contains(self) }
var isVisualMedia: Bool { isImage || isVideo || isAnimated }
public static func fileExtension(forUtiType utiType: String?) -> String? {
guard let utiType: String = utiType else { return nil }
var sessionFileExtension: String? {
// Special-case the "aac" filetype we use for voice messages (for legacy reasons)
// to use a .m4a file extension, not .aac, since AVAudioPlayer can't handle .aac
// properly. Doesn't affect file contents.
guard utiType != "public.aac-audio" else { return "m4a" }
let maybeFileExtension: Unmanaged<CFString>? = UTTypeCopyPreferredTagWithClass(utiType as CFString, kUTTagClassFilenameExtension)
guard let fileExtension: CFString = maybeFileExtension?.takeRetainedValue() else { return nil }
guard identifier != "public.aac-audio" else { return "m4a" }
return fileExtension as String
}
public static func fileExtension(for mimeType: String) -> String? {
// Try to deduce the file extension by using a lookup table.
//
// This should be more accurate than deducing the file extension by
// converting to a UTI type. For example, .m4a files will have a
// UTI type of kUTTypeMPEG4Audio which incorrectly yields the file
// extension .mp4 instead of .m4a.
guard let fileExtension: String = genericExtensionTypesToMimeTypes[mimeType] else {
// Try to deduce the file extension by converting to a UTI type
return fileExtension(forUtiType: utiType(for: mimeType))
}
guard
let mimeType: String = preferredMIMEType,
let fileExtension: String = UTType.genericExtensionTypesToMimeTypes
.first(where: { _, value in value == mimeType })?
.value
else { return preferredFilenameExtension }
return fileExtension
}
public static func utiType(forFileExtension fileExtension: String) -> String? {
let maybeUtiType: Unmanaged<CFString>? = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, fileExtension as CFString, nil)
guard let utiType: CFString = maybeUtiType?.takeRetainedValue() else { return nil }
// MARK: - Initialization
init?(sessionFileExtension: String) {
guard
let mimeType: String = UTType.sessionMimeType(for: sessionFileExtension),
let result: UTType = UTType(sessionMimeType: mimeType)
else { return nil }
return utiType as String
self = result
}
public static func utiType(for mimeType: String) -> String? {
let maybeUtiType: Unmanaged<CFString>? = UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType, mimeType as CFString, nil)
guard let utiType: CFString = maybeUtiType?.takeRetainedValue() else {
switch mimeType as String {
case "audio/amr": return "org.3gpp.adaptive-multi-rate-audio"
case "audio/mp3", "audio/x-mpeg", "audio/mpeg", "audio/mpeg3", "audio/x-mp3", "audio/x-mpeg3":
return kUTTypeMP3 as String
case "audio/aac", "audio/x-m4a": return kUTTypeMPEG4Audio as String
case "audio/aiff", "audio/x-aiff": return kUTTypeAudioInterchangeFileFormat as String
init?(sessionMimeType: String) {
guard let result: UTType = UTType(mimeType: sessionMimeType) else {
switch sessionMimeType {
case "audio/amr":
guard let result: UTType = UTType("org.3gpp.adaptive-multi-rate-audio") else { return nil }
self = result
case "audio/mp3", "audio/x-mpeg", "audio/mpeg", "audio/mpeg3", "audio/x-mp3", "audio/x-mpeg3": self = .mp3
case "audio/aac", "audio/x-m4a": self = .mpeg4Audio
case "audio/aiff", "audio/x-aiff": self = .aiff
default: return nil
}
return
}
return utiType as String
self = result
}
}
// MARK: - Definitions
fileprivate extension MimeTypeUtil {
static let supportedVideoMimeTypesToExtensionTypes: [String: String] = [
"video/3gpp": "3gp",
"video/3gpp2": "3g2",
"video/mp4": "mp4",
"video/quicktime": "mov",
"video/x-m4v": "m4v",
"video/mpeg": "mpg"
]
static let supportedAudioMimeTypesToExtensionTypes: [String: String] = [
"audio/aac": "m4a",
"audio/x-m4p": "m4p",
"audio/x-m4b": "m4b",
"audio/x-m4a": "m4a",
"audio/wav": "wav",
"audio/x-wav": "wav",
"audio/x-mpeg": "mp3",
"audio/mpeg": "mp3",
"audio/mp4": "mp4",
"audio/mp3": "mp3",
"audio/mpeg3": "mp3",
"audio/x-mp3": "mp3",
"audio/x-mpeg3": "mp3",
"audio/aiff": "aiff",
"audio/x-aiff": "aiff",
"audio/3gpp2": "3g2",
"audio/3gpp": "3gp"
]
// MARK: - Convenience
static let supportedImageMimeTypesToExtensionTypes: [String: String] = [
"image/jpeg": "jpeg",
"image/pjpeg": "jpeg",
"image/png": "png",
"image/tiff": "tif",
"image/x-tiff": "tif",
"image/bmp": "bmp",
"image/x-windows-bmp": "bmp",
"image/gif": "gif",
"image/x-icon": "ico",
"image/webp": "webp"
]
static func isAnimated(_ mimeType: String) -> Bool {
return (UTType(sessionMimeType: mimeType) ?? .invalid).isAnimated
}
static let supportedAnimatedMimeTypesToExtensionTypes: [String: String] = [
"image/gif": "gif",
"image/webp": "image/webp"
]
static func isImage(_ mimeType: String) -> Bool {
return (UTType(sessionMimeType: mimeType) ?? .invalid).isImage
}
static let supportedTextMimeTypes: Set<String> = [
"text/plain", "text/csv", "text/tab-separated-values"
]
static func isVideo(_ mimeType: String) -> Bool {
return (UTType(sessionMimeType: mimeType) ?? .invalid).isVideo
}
static let supportedMicrosoftDocMimeTypes: Set<String> = [
// Word files
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.wordprocessingml.template",
"application/vnd.ms-word.document.macroEnabled.12",
"application/vnd.ms-word.template.macroEnabled.12",
// Excel files
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.spreadsheetml.template",
"application/vnd.ms-excel.sheet.macroEnabled.12",
"application/vnd.ms-excel.template.macroEnabled.12",
"application/vnd.ms-excel.addin.macroEnabled.12",
"application/vnd.ms-excel.sheet.binary.macroEnabled.12",
// Powerpoint files
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.openxmlformats-officedocument.presentationml.template",
"application/vnd.openxmlformats-officedocument.presentationml.slideshow",
"application/vnd.ms-powerpoint.addin.macroEnabled.12",
"application/vnd.ms-powerpoint.presentation.macroEnabled.12",
"application/vnd.ms-powerpoint.template.macroEnabled.12",
"application/vnd.ms-powerpoint.slideshow.macroEnabled.12"
]
static func isAudio(_ mimeType: String) -> Bool {
return (UTType(sessionMimeType: mimeType) ?? .invalid).isAudio
}
static let supportedBinaryDataMimeTypesToExtensionTypes: [String: String] = [
"application/octet-stream": "dat"
]
static func isText(_ mimeType: String) -> Bool {
return (UTType(sessionMimeType: mimeType) ?? .invalid).isText
}
static let supportedVideoExtensionTypesToMimeTypes: [String: String] = [
"3gp": "video/3gpp",
"3gpp": "video/3gpp",
"3gp2": "video/3gpp2",
"3gpp2": "video/3gpp2",
"mp4": "video/mp4",
"mov": "video/quicktime",
"mqv": "video/quicktime",
"m4v": "video/x-m4v",
"mpg": "video/mpeg",
"mpeg": "video/mpeg"
]
static func isMicrosoftDoc(_ mimeType: String) -> Bool {
return (UTType(sessionMimeType: mimeType) ?? .invalid).isMicrosoftDoc
}
static let supportedAudioExtensionTypesToMimeTypes: [String: String] = [
"3gp": "audio/3gpp",
"3gpp": "@audio/3gpp",
"3g2": "audio/3gpp2",
"3gp2": "audio/3gpp2",
"aiff": "audio/aiff",
"aif": "audio/aiff",
"aifc": "audio/aiff",
"cdda": "audio/aiff",
"mp3": "audio/mp3",
"swa": "audio/mp3",
"mp4": "audio/mp4",
"wav": "audio/wav",
"bwf": "audio/wav",
"m4a": "audio/x-m4a",
"m4b": "audio/x-m4b",
"m4p": "audio/x-m4p"
]
static func isVisualMedia(_ mimeType: String) -> Bool {
return (UTType(sessionMimeType: mimeType) ?? .invalid).isVisualMedia
}
static let supportedImageExtensionTypesToMimeTypes: [String: String] = [
"png": "image/png",
"x-png": "image/png",
"jfif": "image/jpeg",
"jfif-tbnl": "image/jpeg",
"jpe": "image/jpeg",
"jpeg": "image/jpeg",
"jpg": "image/jpeg",
"tif": "image/tiff",
"tiff": "image/tiff",
"webp": "image/webp"
]
// MARK: - Lookup Table
static let supportedAnimatedExtensionTypesToMimeTypes: [String: String] = [
"gif": "image/gif",
"image/webp": "image/webp"
]
static func sessionMimeType(for fileExtension: String) -> String? {
return UTType.genericExtensionTypesToMimeTypes[fileExtension]
}
static let genericExtensionTypesToMimeTypes = [
private static let genericExtensionTypesToMimeTypes: [String: String] = [
"123": "application/vnd.lotus-1-2-3",
"3dml": "text/vnd.in3d.3dml",
"3ds": "image/x-3ds",
@ -1369,24 +1182,4 @@ fileprivate extension MimeTypeUtil {
"zirz": "application/vnd.zul",
"zmm": "application/vnd.handheld-entertainment+xml",
]
static func isSupportedVideo(mimeType: String) -> Bool {
return (supportedVideoMimeTypesToExtensionTypes[mimeType] != nil)
}
static func isSupportedAudio(mimeType: String) -> Bool {
return (supportedAudioMimeTypesToExtensionTypes[mimeType] != nil)
}
static func isSupportedImage(mimeType: String) -> Bool {
return (supportedImageMimeTypesToExtensionTypes[mimeType] != nil)
}
static func isSupportedAnimated(mimeType: String) -> Bool {
return (supportedAnimatedMimeTypesToExtensionTypes[mimeType] != nil)
}
static func isSupportedBinaryData(mimeType: String) -> Bool {
return (supportedBinaryDataMimeTypesToExtensionTypes[mimeType] != nil)
}
}

@ -505,18 +505,18 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC
Log.error("[AttachmentApprovalViewController] Could not render for output.")
return attachmentItem.attachment
}
var dataUTI = kUTTypeImage as String
var dataType: UTType = .image
let maybeDstData: Data? = {
let isLossy: Bool = (
attachmentItem.attachment.mimeType.caseInsensitiveCompare(MimeTypeUtil.MimeType.imageJpeg) == .orderedSame
attachmentItem.attachment.mimeType.caseInsensitiveCompare(UTType.mimeTypeJpeg) == .orderedSame
)
if isLossy {
dataUTI = kUTTypeJPEG as String
dataType = .jpeg
return dstImage.jpegData(compressionQuality: 0.9)
}
else {
dataUTI = kUTTypePNG as String
dataType = .png
return dstImage.pngData()
}
}()
@ -525,7 +525,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC
Log.error("[AttachmentApprovalViewController] Could not export for output.")
return attachmentItem.attachment
}
guard let dataSource = DataSourceValue(data: dstData, utiType: dataUTI) else {
guard let dataSource = DataSourceValue(data: dstData, dataType: dataType) else {
Log.error("[AttachmentApprovalViewController] Could not prepare data source for output.")
return attachmentItem.attachment
}
@ -533,13 +533,13 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC
// Rewrite the filename's extension to reflect the output file format.
var filename: String? = attachmentItem.attachment.sourceFilename
if let sourceFilename = attachmentItem.attachment.sourceFilename {
if let fileExtension: String = MimeTypeUtil.fileExtension(forUtiType: dataUTI) {
if let fileExtension: String = dataType.sessionFileExtension {
filename = (sourceFilename as NSString).deletingPathExtension.appendingFileExtension(fileExtension)
}
}
dataSource.sourceFilename = filename
let dstAttachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: dataUTI, imageQuality: .medium)
let dstAttachment = SignalAttachment.attachment(dataSource: dataSource, type: dataType, imageQuality: .medium)
if let attachmentError = dstAttachment.error {
Log.error("[AttachmentApprovalViewController] Could not prepare attachment for output: \(attachmentError).")
return attachmentItem.attachment

@ -1,6 +1,7 @@
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
import UIKit
import UniformTypeIdentifiers
import SessionUtilitiesKit
// Used to represent undo/redo operations.
@ -57,16 +58,16 @@ public class ImageEditorModel {
let srcFileName = (srcImagePath as NSString).lastPathComponent
let srcFileExtension = (srcFileName as NSString).pathExtension
guard let mimeType = MimeTypeUtil.mimeType(for: srcFileExtension) else {
guard let type: UTType = UTType(sessionFileExtension: srcFileExtension) else {
Log.error("[ImageEditorModel] Couldn't determine MIME type for file.")
throw ImageEditorError.invalidInput
}
guard MimeTypeUtil.isImage(mimeType), !MimeTypeUtil.isAnimated(mimeType) else {
Log.error("[ImageEditorModel] Invalid MIME type: \(mimeType).")
guard type.isImage && !type.isAnimated else {
Log.error("[ImageEditorModel] Invalid MIME type: \(type.preferredMIMEType ?? "unknown").")
throw ImageEditorError.invalidInput
}
let srcImageSizePixels = Data.imageSize(for: srcImagePath, mimeType: mimeType)
let srcImageSizePixels = Data.imageSize(for: srcImagePath, type: type)
guard srcImageSizePixels.width > 0, srcImageSizePixels.height > 0 else {
Log.error("[ImageEditorModel] Couldn't determine image size.")
throw ImageEditorError.invalidInput

@ -171,7 +171,7 @@ fileprivate struct CallInfo {
}
}
fileprivate func generateCallInfo<M, T, R>(_ actualExpression: Expression<M>, _ functionBlock: @escaping (inout T) throws -> R) -> CallInfo where M: Mock<T> {
fileprivate func generateCallInfo<M, T, R>(_ actualExpression: Nimble.Expression<M>, _ functionBlock: @escaping (inout T) throws -> R) -> CallInfo where M: Mock<T> {
var maybeFunction: MockFunction?
var allFunctionsCalled: [FunctionConsumer.Key] = []
var desiredFunctionCalls: [String] = []

Loading…
Cancel
Save