ChatSecure-iOS/ChatSecure/Classes/Controllers/FileTransferManager.swift

976 lines
40 KiB
Swift

//
// FileTransferManager.swift
// ChatSecure
//
// Created by Chris Ballinger on 3/28/17.
// Copyright © 2017 Chris Ballinger. All rights reserved.
//
import Foundation
import XMPPFramework
import CocoaLumberjack
import OTRKit
import Alamofire
import OTRAssets
extension UIImage {
enum DataSize {
case unlimited
case maxBytes(UInt)
var numBytes: UInt {
switch self {
case .unlimited:
return UInt.max
case .maxBytes(let limit):
return limit
}
}
}
struct Quality {
static let low = Quality(initial: 0.4, decrementFactor: 0.65)
static let medium = Quality(initial: 0.65, decrementFactor: 0.65)
static let high = Quality(initial: 0.75, decrementFactor: 0.75)
/// This value cannot be > 1 or bad things will happen
let initial: CGFloat
/// Multiplied to reduce the initial value. This value cannot be > 1 or bad things will happen
let decrementFactor: CGFloat
}
func jpegData(dataSize: DataSize,
resize: Quality = Quality.medium,
jpeg: Quality = Quality.medium,
maxTries: UInt = 10) -> Data? {
let image = self
var sizeInBytes: UInt = 0
var scaleFactor: CGFloat = resize.initial
var jpegQuality: CGFloat = jpeg.initial
let qualityDecrement: CGFloat = jpeg.decrementFactor
let scaleDecrement: CGFloat = resize.decrementFactor
var scaledImageData: Data? = nil
var newSize = CGSize.zero
let maxTries = 10
var numTries = 0
let maxSize = dataSize.numBytes
while (sizeInBytes == 0 || sizeInBytes > maxSize) &&
numTries < maxTries {
numTries = numTries + 1
newSize = CGSize(width: image.size.width * scaleFactor, height: image.size.height * scaleFactor)
let scaledImage = UIImage.otr_image(with: image, scaledTo: newSize)
scaledImageData = UIImageJPEGRepresentation(scaledImage, jpegQuality)
if let imageData = scaledImageData {
sizeInBytes = UInt(imageData.count)
scaleFactor = scaleFactor * scaleDecrement
jpegQuality = jpegQuality * qualityDecrement
} else {
DDLogError("Could not make JPEG out of image!")
return nil
}
}
return scaledImageData
}
}
public enum FileTransferError: LocalizedError, CustomNSError {
case unknown
case noServers
case serverError
case exceedsMaxSize
case urlFormatting
case fileNotFound
case keyGenerationError
case cryptoError
case automaticDownloadsDisabled
case userCanceled
public var errorUserInfo: [String : Any] {
if let errorDescription = self.errorDescription {
return [NSLocalizedDescriptionKey: errorDescription];
}
return [:]
}
// localizedDescription
public var errorDescription: String? {
switch self {
case .unknown:
return UNKNOWN_ERROR_STRING()
case .noServers:
return NO_HTTP_UPLOAD_SERVERS_STRING() + " " + PLEASE_CONTACT_SERVER_OP_STRING()
case .serverError:
return UNKNOWN_ERROR_STRING() + " " + PLEASE_CONTACT_SERVER_OP_STRING()
case .exceedsMaxSize:
return FILE_EXCEEDS_MAX_SIZE_STRING()
case .urlFormatting:
return COULD_NOT_PARSE_URL_STRING()
case .fileNotFound:
return FILE_NOT_FOUND_STRING()
case .cryptoError, .keyGenerationError:
return errSSLCryptoString()
case .automaticDownloadsDisabled:
return AUTOMATIC_DOWNLOADS_DISABLED_STRING()
case .userCanceled:
return USER_CANCELED_STRING()
}
}
}
public class FileTransferManager: NSObject, OTRServerCapabilitiesDelegate {
let httpFileUpload: XMPPHTTPFileUpload
let serverCapabilities: OTRServerCapabilities
let connection: YapDatabaseConnection
let internalQueue = DispatchQueue(label: "FileTransferManager Queue")
let callbackQueue = DispatchQueue.main
let sessionManager: SessionManager
private var servers: [HTTPServer] = []
@objc public var canUploadFiles: Bool {
return self.servers.first != nil
}
deinit {
httpFileUpload.removeDelegate(self)
serverCapabilities.removeDelegate(self)
}
@objc public init(connection: YapDatabaseConnection,
serverCapabilities: OTRServerCapabilities,
sessionConfiguration: URLSessionConfiguration) {
self.serverCapabilities = serverCapabilities
self.httpFileUpload = XMPPHTTPFileUpload()
self.connection = connection
self.sessionManager = Alamofire.SessionManager(configuration: sessionConfiguration)
super.init()
if let stream = serverCapabilities.xmppStream {
httpFileUpload.activate(stream)
}
httpFileUpload.addDelegate(self, delegateQueue: DispatchQueue.main)
serverCapabilities.addDelegate(self, delegateQueue: DispatchQueue.main)
self.refreshCapabilities()
}
// MARK: - Public Methods
// Resume downloads, i.e. look for media items that are partially downloaded and retry getting them. TODO - use ranges
@objc public func resumeDownloads() {
connection.asyncRead { (transaction) in
transaction.enumerateUnfinishedDownloads({ (mediaItem, stop) in
if let downloadMessage = mediaItem.parentObject(with: transaction) as? OTRDownloadMessage, downloadMessage.messageError == nil {
self.internalQueue.async {
self.downloadMedia(downloadMessage)
}
}
})
}
}
/// This will fetch capabilities and setup XMPP transfer module if needed
@objc public func refreshCapabilities() {
guard let allCapabilities = serverCapabilities.allCapabilities else {
serverCapabilities.fetchAllCapabilities()
return
}
servers = serversFromCapabilities(capabilities: allCapabilities)
serverCapabilities.fetchAllCapabilities()
}
private func upload(mediaItem: OTRMediaItem,
shouldEncrypt: Bool,
prefetchedData: Data?,
completion: @escaping (_ url: URL?, _ error: Error?) -> ()) {
internalQueue.async {
if let data = prefetchedData {
self.upload(media: mediaItem, data: data, shouldEncrypt: shouldEncrypt, filename: mediaItem.filename, contentType: mediaItem.mimeType, completion: completion)
} else {
var url: URL? = nil
self.connection.read({ (transaction) in
url = mediaItem.mediaServerURL(with: transaction)
})
if let url = url {
self.upload(media: mediaItem, file: url, shouldEncrypt: shouldEncrypt, completion: completion)
} else {
let error = FileTransferError.fileNotFound
DDLogError("Upload filed: File not found \(error)")
self.callbackQueue.async {
completion(nil, error)
}
}
}
}
}
/// Currently just a wrapper around sendData
private func upload(media: OTRMediaItem,
file: URL,
shouldEncrypt: Bool,
completion: @escaping (_ url: URL?, _ error: Error?) -> ()) {
internalQueue.async {
do {
let data = try Data(contentsOf: file)
let mimeType = OTRKitGetMimeTypeForExtension(file.pathExtension)
self.upload(media: media, data: data, shouldEncrypt: shouldEncrypt, filename: file.lastPathComponent, contentType: mimeType, completion: completion)
} catch let error {
DDLogError("Error sending file URL \(file): \(error)")
}
}
}
private func upload(media: OTRMediaItem,
data inData: Data,
shouldEncrypt: Bool,
filename: String,
contentType: String,
completion: @escaping (_ url: URL?, _ error: Error?) -> ()) {
internalQueue.async {
guard let service = self.servers.first else {
DDLogWarn("No HTTP upload servers available")
self.callbackQueue.async {
completion(nil, FileTransferError.noServers)
}
return
}
var data = inData
// When resending images, sometimes we need to recompress them
// to fit the max upload limit
if UInt(data.count) > service.maxSize,
let _ = media as? OTRImageItem,
let image = UIImage(data: inData),
let imageData = image.jpegData(dataSize: .maxBytes(service.maxSize), resize: UIImage.Quality.medium, jpeg: UIImage.Quality.medium, maxTries: 10)
{
data = imageData
}
if UInt(data.count) > service.maxSize {
DDLogError("HTTP Upload exceeds max size \(data.count) > \(service.maxSize)")
self.callbackQueue.async {
completion(nil, FileTransferError.exceedsMaxSize)
}
return
}
// TODO: Refactor to use streaming encryption
var outData = data
var outKeyIv: Data? = nil
if shouldEncrypt {
guard let key = OTRPasswordGenerator.randomData(withLength: 32), let iv = OTRPasswordGenerator.randomData(withLength: 16) else {
DDLogError("Could not generate key/iv")
self.callbackQueue.async {
completion(nil, FileTransferError.keyGenerationError)
}
return
}
outKeyIv = iv + key
do {
let crypted = try OTRCryptoUtility.encryptAESGCMData(data, key: key, iv: iv)
outData = crypted.data + crypted.authTag
} catch let error {
outData = Data()
DDLogError("Could not encrypt data for file transfer \(error)")
self.callbackQueue.async {
completion(nil, error)
}
return
}
}
self.httpFileUpload.requestSlot(fromService: service.jid, filename: filename, size: UInt(outData.count), contentType: contentType, completion: { (slot: XMPPSlot?, iq: XMPPIQ?, error: Error?) in
guard let slot = slot else {
let outError = error ?? FileTransferError.serverError
DDLogError("\(service) failed to assign upload slot: \(outError)")
self.callbackQueue.async {
completion(nil, outError)
}
return
}
self.sessionManager.upload(outData, to: slot.putURL, method: .put)
.validate()
.responseData(queue: self.callbackQueue) { response in
switch response.result {
case .success:
if let outKeyIv = outKeyIv {
// If there's a AES-GCM key, we gotta put it in the url
// and change the scheme to `aesgcm`
if var components = URLComponents(url: slot.getURL, resolvingAgainstBaseURL: true) {
components.scheme = URLScheme.aesgcm.rawValue
components.fragment = outKeyIv.hexString()
if let outURL = components.url {
completion(outURL, nil)
} else {
completion(nil, FileTransferError.urlFormatting)
}
} else {
completion(nil, FileTransferError.urlFormatting)
}
} else {
// The plaintext case
completion(slot.getURL, nil)
}
case .failure(let error):
completion(nil, error)
DDLogError("Upload error: \(error)")
}
}.uploadProgress(queue: self.internalQueue) { progress in
//DDLogVerbose("Upload progress \(progress.fractionCompleted)")
self.connection.asyncReadWrite { transaction in
if let media = media.refetch(with: transaction) {
media.transferProgress = Float(progress.fractionCompleted)
media.save(with: transaction)
media.touchParentMessage(with: transaction)
}
}
}
})
}
}
@objc public func send(videoURL url: URL, thread: OTRThreadOwner) {
internalQueue.async {
self.send(url: url, thread: thread, type: .video)
}
}
private enum MediaURLType {
case audio
case video
//case image
}
private func send(url: URL, thread: OTRThreadOwner, type: MediaURLType) {
internalQueue.async {
var item: OTRMediaItem? = nil
switch type {
case .audio:
item = OTRAudioItem(audioURL: url, isIncoming: false)
case .video:
item = OTRVideoItem(videoURL: url, isIncoming: false)
}
guard let mediaItem = item else {
DDLogError("No media item to share for URL: \(url)")
return
}
guard let message = self.newOutgoingMessage(to: thread, mediaItem: mediaItem) else {
DDLogError("No message could be created for \(thread) \(mediaItem)")
return
}
mediaItem.parentObjectKey = message.messageKey
mediaItem.parentObjectCollection = message.messageCollection
let newPath = OTRMediaFileManager.path(for: mediaItem, buddyUniqueId: thread.threadIdentifier)
self.connection.readWrite { transaction in
message.save(with: transaction)
mediaItem.save(with: transaction)
}
OTRMediaFileManager.shared.copyData(fromFilePath: url.path, toEncryptedPath: newPath, completion: { (result, copyError: Error?) in
var prefetchedData: Data? = nil
if FileManager.default.fileExists(atPath: url.path) {
do {
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
if let size = attributes[FileAttributeKey.size] as? NSNumber, size.uint64Value < 1024 * 1024 * 1 {
prefetchedData = try Data(contentsOf: url)
}
} catch let error {
DDLogError("Error prefetching data: \(error)")
}
do {
try FileManager.default.removeItem(atPath: url.path)
} catch let error {
DDLogError("Error removing video: \(error)")
}
}
message.messageError = copyError
self.connection.readWrite({ (transaction) in
mediaItem.save(with: transaction)
message.save(with: transaction)
})
self.send(mediaItem: mediaItem, prefetchedData: prefetchedData, message: message)
}, completionQueue: self.internalQueue)
}
}
@objc public func send(audioURL url: URL, thread: OTRThreadOwner) {
internalQueue.async {
self.send(url: url, thread: thread, type: .audio)
}
}
@objc public func send(image: UIImage, thread: OTRThreadOwner) {
internalQueue.async {
guard let service = self.servers.first, service.maxSize > 0 else {
DDLogError("No HTTP upload service available!")
return
}
let filename = "\(UUID().uuidString).jpg"
let imageItem = OTRImageItem(filename: filename, size: image.size, mimeType: "image/jpeg", isIncoming: false)
guard let message = self.newOutgoingMessage(to: thread, mediaItem: imageItem) else {
DDLogError("No message could be created")
return
}
imageItem.parentObjectKey = message.messageKey
imageItem.parentObjectCollection = message.messageCollection
self.connection.readWrite { transaction in
message.save(with: transaction)
imageItem.save(with: transaction)
}
guard let ourImageData = image.jpegData(dataSize: .unlimited, resize: UIImage.Quality.high, jpeg: UIImage.Quality.high, maxTries: 10) else {
DDLogError("Could not make JPEG out of image!")
return
}
OTRMediaFileManager.shared.setData(ourImageData, for: imageItem, buddyUniqueId: thread.threadIdentifier, completion: { (bytesWritten: Int, error: Error?) in
self.connection.readWrite({ (transaction) in
imageItem.touchParentMessage(with: transaction)
if let error = error {
message.messageError = error
message.save(with: transaction)
}
})
if let imageData = image.jpegData(dataSize: .maxBytes(service.maxSize), resize: UIImage.Quality.medium, jpeg: UIImage.Quality.medium, maxTries: 10) {
self.send(mediaItem: imageItem, prefetchedData: imageData, message: message)
} else {
DDLogError("Could not make JPEG out of image! Bad size")
message.messageError = FileTransferError.exceedsMaxSize
self.connection.readWrite { transaction in
message.save(with: transaction)
}
}
}, completionQueue: self.internalQueue)
}
}
private func newOutgoingMessage(to thread: OTRThreadOwner, mediaItem: OTRMediaItem) -> OTRMessageProtocol? {
if let buddy = thread as? OTRBuddy {
let message = OTROutgoingMessage()!
var security: OTRMessageTransportSecurity = .invalid
self.connection.read({ (transaction) in
security = buddy.preferredTransportSecurity(with: transaction)
})
message.buddyUniqueId = buddy.uniqueId
message.mediaItemUniqueId = mediaItem.uniqueId
message.messageSecurityInfo = OTRMessageEncryptionInfo(messageSecurity: security)
return message
} else if let room = thread as? OTRXMPPRoom {
var message:OTRXMPPRoomMessage? = nil
self.connection.read({ (transaction) in
message = room.outgoingMessage(withText: "", transaction: transaction) as? OTRXMPPRoomMessage
})
if let message = message {
message.messageText = nil
message.mediaItemId = mediaItem.uniqueId
}
return message
}
return nil
}
@objc public func send(mediaItem: OTRMediaItem, prefetchedData: Data?, message: OTRMessageProtocol) {
var shouldEncrypt = false
switch message.messageSecurity {
case .OMEMO, .OTR:
shouldEncrypt = true
case .invalid, .plaintext, .plaintextWithOTR:
shouldEncrypt = false
}
self.upload(mediaItem: mediaItem, shouldEncrypt: shouldEncrypt, prefetchedData: prefetchedData, completion: { (_url: URL?, error: Error?) in
guard let url = _url else {
if let error = error {
DDLogError("Error uploading: \(error)")
}
self.connection.readWrite({ (transaction) in
message.messageError = error
message.save(with: transaction)
})
return
}
self.connection.readWrite({ (transaction) in
mediaItem.transferProgress = 1.0
message.messageText = url.absoluteString
mediaItem.save(with: transaction)
message.save(with: transaction)
})
self.queueOutgoingMessage(message: message)
})
}
private func queueOutgoingMessage(message: OTRMessageProtocol) {
let sendAction = OTRYapMessageSendAction(forMessage: message, date: nil)
self.connection.readWrite { transaction in
message.save(with: transaction)
sendAction.save(with: transaction)
if let thread = message.threadOwner(with: transaction) {
if let buddy = thread as? OTRBuddy {
buddy.lastMessageId = message.uniqueId
buddy.save(with: transaction)
}
}
}
}
// MARK: - Private Methods
private func serversFromCapabilities(capabilities: [XMPPJID : XMLElement]) -> [HTTPServer] {
var servers: [HTTPServer] = []
for (jid, element) in capabilities {
let supported = element.supportsHTTPUpload()
let maxSize = element.maxHTTPUploadSize()
if supported && maxSize > 0 {
let server = HTTPServer(jid: jid, maxSize: maxSize)
servers.append(server)
}
}
return servers
}
// MARK: - OTRServerCapabilitiesDelegate
public func serverCapabilities(_ sender: OTRServerCapabilities, didDiscoverCapabilities capabilities: [XMPPJID : XMLElement]) {
servers = serversFromCapabilities(capabilities: capabilities)
}
}
// MARK: - Scanning and downloading incoming media
extension FileTransferManager {
/** creates downloadmessages and then downloads if needed. parent message should already be saved! @warn Do not call from within an existing db transaction! */
@objc public func createAndDownloadItemsIfNeeded(message: OTRMessageProtocol, force: Bool, transaction: YapDatabaseReadWriteTransaction) {
if message.messageMediaItemKey != nil || message.messageText?.count == 0 || message.downloadableURLs.count == 0 {
//DDLogVerbose("Download of message not needed \(message.messageKey)")
return
}
var downloads: [OTRDownloadMessage] = []
var disableAutomaticURLFetching = false
if !force {
downloads = message.existingDownloads(with: transaction)
if let thread = message.threadOwner(with: transaction), let account = OTRAccount.fetchObject(withUniqueID: thread.threadAccountIdentifier, transaction: transaction) {
disableAutomaticURLFetching = account.disableAutomaticURLFetching
if !disableAutomaticURLFetching, let message = message as? OTRXMPPRoomMessage {
// For room messages, default to safe mode
disableAutomaticURLFetching = true
if let senderBuddy = message.buddyUniqueId, let buddy = OTRXMPPBuddy.fetchObject(withUniqueID: senderBuddy, transaction: transaction), buddy.trustLevel == .roster {
// We have a buddy, i.e. we are friends with the sender.
disableAutomaticURLFetching = false
}
}
}
}
if downloads.count == 0 {
downloads = message.downloads()
if downloads.count == 0 {
return
}
for download in downloads {
if disableAutomaticURLFetching,
let filename = download.downloadableURL?.absoluteString {
let media = OTRMediaItem.incomingItem(withFilename: filename, mimeType: nil)
media.parentObjectKey = download.uniqueId
media.parentObjectCollection = download.messageCollection
media.save(with: transaction)
download.messageMediaItemKey = media.uniqueId
download.messageError = FileTransferError.automaticDownloadsDisabled
}
download.save(with: transaction)
}
message.touch(with: transaction)
}
if disableAutomaticURLFetching {
DDLogVerbose("Automatic URL fetching disabled \(message.messageKey)")
return
}
for download in downloads {
self.downloadMediaIfNeeded(download)
}
}
/** Downloads media for a single downloadmessage */
public func downloadMediaIfNeeded(_ downloadMessage: OTRDownloadMessage) {
// Bail out if we've already downloaded the media
if downloadMessage.messageMediaItemKey != nil &&
downloadMessage.messageError == nil {
// DDLogWarn("Already downloaded media for this item")
return
}
downloadMedia(downloadMessage)
}
private func downloadMedia(_ downloadMessage: OTRDownloadMessage) {
guard let url = downloadMessage.downloadableURL else {
DDLogWarn("Attempted to download message but couldn't parse a URL \(downloadMessage)")
return
}
self.sessionManager.session.getTasksWithCompletionHandler { (tasks, _, _) in
// Bail out if we've already got a task for this
for task in tasks where task.originalRequest?.url == url {
DDLogWarn("Already have outstanding task: \(task)")
return
}
self.sessionManager.request(url, method: .head)
.validate()
.responseData(queue: self.internalQueue) { response in
switch response.result {
case .success:
DDLogInfo("HEAD response: \(String(describing: response.response?.allHeaderFields))")
if let headers = response.response?.allHeaderFields {
let contentType = headers["Content-Type"] as? String
let contentLength = headers["Content-Length"] as? UInt ?? 0
self.continueDownloading(downloadMessage: downloadMessage, url: url, contentType: contentType, contentLength: contentLength)
}
case .failure(let error):
self.setError(error, onMessage: downloadMessage)
DDLogError("HEAD error: \(error)")
}
}
}
}
private func setError(_ error: Error, onMessage downloadMessage: OTRDownloadMessage) {
self.connection.readWrite { transaction in
if let message = downloadMessage.refetch(with: transaction) {
// If we have no media item, add one so we can retry
if message.messageMediaItemKey == nil, let filename = message.downloadableURL?.absoluteString {
let media = OTRMediaItem.incomingItem(withFilename: filename, mimeType: nil)
media.parentObjectKey = message.uniqueId
media.parentObjectCollection = message.messageCollection
media.save(with: transaction)
message.messageMediaItemKey = media.uniqueId
}
// If user canceled, keep that error
if let error = (message.messageError as NSError?), error.isUserCanceledError {
return
}
message.messageError = error
message.save(with: transaction)
}
}
}
private func continueDownloading(downloadMessage: OTRDownloadMessage, url: URL, contentType: String?, contentLength: UInt) {
var mediaItem: OTRMediaItem? = nil
self.connection.readWrite { transaction in
// Remove placeholder media item
mediaItem = OTRMediaItem(forMessage: downloadMessage, transaction: transaction)
mediaItem?.remove(with: transaction)
mediaItem = OTRMediaItem.incomingItem(withFilename: url.lastPathComponent, mimeType: contentType)
mediaItem?.parentObjectKey = downloadMessage.uniqueId
mediaItem?.parentObjectCollection = downloadMessage.messageCollection
mediaItem?.save(with: transaction)
downloadMessage.messageMediaItemKey = mediaItem?.uniqueId
downloadMessage.messageError = nil
downloadMessage.save(with: transaction)
}
guard let media = mediaItem else {
DDLogError("Could not unwrap media item")
self.setError(FileTransferError.fileNotFound, onMessage: downloadMessage)
return
}
DDLogVerbose("Downloading media item at URL: \(url)")
self.sessionManager.request(url)
.validate()
.responseData(queue: self.internalQueue) { response in
self.finishDownload(downloadMessage: downloadMessage, mediaItem: media, inData: response.data, urlResponse: response.response, error: response.error)
switch response.result {
case .success:
DDLogVerbose("Download Successful")
case .failure(let error):
self.setError(error, onMessage: downloadMessage)
DDLogError("Download Error \(error)")
}
}.downloadProgress(queue: self.internalQueue) { progress in
//DDLogVerbose("Download progress \(progress.fractionCompleted)")
self.connection.asyncReadWrite { transaction in
if let media = media.refetch(with: transaction) {
media.transferProgress = Float(progress.fractionCompleted)
media.save(with: transaction)
media.touchParentMessage(with: transaction)
}
}
}
}
private func finishDownload(downloadMessage: OTRDownloadMessage, mediaItem: OTRMediaItem, inData: Data?, urlResponse: URLResponse?, error: Error?) {
if let error = error {
self.setError(error, onMessage: downloadMessage)
DDLogError("Error downloading file \(error)")
return
}
guard var data = inData, let response = urlResponse, let url = response.url else {
let error = FileTransferError.fileNotFound
self.setError(error, onMessage: downloadMessage)
DDLogError("No data or response \(error)")
return
}
//DDLogVerbose("Received response \(response)")
let authTagSize = 16 // i'm not sure if this can be assumed, but how else would we know the size?
if let (key, iv) = url.aesGcmKey, data.count > authTagSize {
//DDLogVerbose("Received encrypted response, attempting decryption...")
let cryptedData = data.subdata(in: 0..<data.count - authTagSize)
let authTag = data.subdata(in: data.count - authTagSize..<data.count)
let cryptoData = OTRCryptoData(data: cryptedData, authTag: authTag)
do {
data = try OTRCryptoUtility.decryptAESGCMData(cryptoData, key: key, iv: iv)
} catch let error {
self.setError(error, onMessage: downloadMessage)
DDLogError("Error decrypting data: \(error)")
return
}
//DDLogVerbose("Decrpytion successful")
}
OTRMediaFileManager.sharedInstance().setData(data, for: mediaItem, buddyUniqueId: downloadMessage.threadId, completion: { (bytesWritten, error) in
if let error = error {
self.setError(error, onMessage: downloadMessage)
DDLogError("Error copying data: \(error)")
return
}
self.connection.asyncReadWrite({ (transaction) in
mediaItem.transferProgress = 1.0
if let audioItem = mediaItem as? OTRAudioItem, let url = OTRMediaServer.sharedInstance().url(for: mediaItem, buddyUniqueId: downloadMessage.threadId) {
audioItem.populateFromData(at: url)
}
mediaItem.save(with: transaction)
// downloadMessage.save(with: transaction)
if let message = downloadMessage.refetch(with: transaction) {
message.messageError = nil
message.save(with: transaction)
} else {
DDLogError("Failed to refetch download message WTF \(downloadMessage)")
}
UIApplication.shared.showLocalNotification(downloadMessage, transaction: transaction)
})
}, completionQueue: nil)
}
@objc public func cancelDownload(mediaItem: OTRMediaItem) {
self.internalQueue.async {
var downloadMessage:OTRDownloadMessage? = nil
self.connection.read { transaction in
downloadMessage = mediaItem.parentMessage(with: transaction) as? OTRDownloadMessage
}
if let downloadMessage = downloadMessage {
self.setError(FileTransferError.userCanceled, onMessage: downloadMessage)
if let url = downloadMessage.downloadableURL {
self.sessionManager.session.getTasksWithCompletionHandler { (tasks, _, _) in
// Bail out if we've already got a task for this
for task in tasks where task.originalRequest?.url == url {
DDLogWarn("Stopping download task: \(task)")
task.cancel()
break
}
}
}
}
}
}
}
extension OTRDownloadMessage {
/// Turn aesgcm links into https links
var downloadableURL: URL? {
guard var downloadableURL = url else { return nil }
if downloadableURL.isAesGcm, var components = URLComponents(url: downloadableURL, resolvingAgainstBaseURL: true) {
components.scheme = URLScheme.https.rawValue
if let rawURL = components.url {
downloadableURL = rawURL
}
}
return downloadableURL
}
}
public extension OTRMessageProtocol {
public var downloadableURLs: [URL] {
return self.messageText?.downloadableURLs ?? []
}
}
public extension OTRBaseMessage {
@objc public var downloadableNSURLs: [NSURL] {
return self.downloadableURLs as [NSURL]
}
}
public extension OTRXMPPRoomMessage {
@objc public var downloadableNSURLs: [NSURL] {
return self.downloadableURLs as [NSURL]
}
}
// MARK: - Extensions
fileprivate struct HTTPServer {
/// service jid for upload service
let jid: XMPPJID
/// max upload size in bytes
let maxSize: UInt
}
public extension XMLElement {
// For use on a <query> element
func supportsHTTPUpload() -> Bool {
let features = self.elements(forName: "feature")
var supported = false
for feature in features {
if let value = feature.attributeStringValue(forName: "var"),
value == XMPPHTTPFileUploadNamespace {
supported = true
break
}
}
return supported
}
/// Returns 0 on failure, or max file size in bytes
func maxHTTPUploadSize() -> UInt {
var maxSize: UInt = 0
let xes = self.elements(forXmlns: "jabber:x:data")
for x in xes {
let fields = x.elements(forName: "field")
var correctXEP = false
for field in fields {
if let value = field.forName("value") {
if value.stringValue == XMPPHTTPFileUploadNamespace {
correctXEP = true
}
if let varMaxFileSize = field.attributeStringValue(forName: "var"), varMaxFileSize == "max-file-size" {
maxSize = value.stringValueAsNSUInteger()
}
}
}
if correctXEP && maxSize > 0 {
break
}
}
return maxSize
}
}
enum URLScheme: String {
case https = "https"
case aesgcm = "aesgcm"
static let downloadableSchemes: [URLScheme] = [.https, .aesgcm]
}
extension URL {
/** URL scheme matches aesgcm:// */
var isAesGcm: Bool {
return scheme == URLScheme.aesgcm.rawValue
}
/** Has hex anchor with key and IV. 48 bytes w/ 16 iv + 32 key */
var anchorData: Data? {
guard let anchor = self.fragment else { return nil }
let data = anchor.dataFromHex()
return data
}
var aesGcmKey: (key: Data, iv: Data)? {
guard let data = self.anchorData, data.count == 48 else { return nil }
let iv = data.subdata(in: 0..<16)
let key = data.subdata(in: 16..<48)
return (key, iv)
}
}
public extension NSString {
public var isSingleURLOnly: Bool {
return (self as String).isSingleURLOnly
}
}
public extension String {
private var urlRanges: ([URL], [NSRange]) {
guard let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) else {
return ([], [])
}
var urls: [URL] = []
var ranges: [NSRange] = []
let matches = detector.matches(in: self, options: NSRegularExpression.MatchingOptions(rawValue: 0), range: NSMakeRange(0, self.count))
for match in matches where match.resultType == .link {
if let url = match.url {
urls.append(url)
ranges.append(match.range)
}
}
return (urls, ranges)
}
/** Grab any URLs from a string */
public var urls: [URL] {
let (urls, _) = urlRanges
return urls
}
/** Returns true if the message is ONLY a single URL */
public var isSingleURLOnly: Bool {
let (_, ranges) = urlRanges
guard ranges.count == 1,
let range = ranges.first,
range.length == self.count else {
return false
}
return true
}
/** Use this for extracting potentially downloadable URLs from a message. Currently checks for https:// and aesgcm:// */
public var downloadableURLs: [URL] {
return urlsMatchingSchemes(URLScheme.downloadableSchemes)
}
fileprivate func urlsMatchingSchemes(_ schemes: [URLScheme]) -> [URL] {
let urls = self.urls.filter {
guard let scheme = $0.scheme else { return false }
for inScheme in schemes where inScheme.rawValue == scheme {
return true
}
return false
}
return urls
}
}
public extension FileTransferManager {
/// Returns whether or not message should be displayed or hidden from collection. Single incoming URLs should be hidden, for example.
@objc public static func shouldDisplayMessage(_ message: OTRMessageProtocol, transaction: YapDatabaseReadTransaction) -> Bool {
// Always show media messages
if message.messageMediaItemKey != nil {
return true
}
// Always show downloads
if message is OTRDownloadMessage {
return true
}
// Hide non-media messages that have no text
guard let messageText = message.messageText else {
return false
}
// Filter out messages that are aesgcm scheme file transfers
if messageText.contains("aesgcm://"),
message.messageError == nil {
return false
}
// Filter out messages that are just URLs and have downloads
if messageText.isSingleURLOnly,
message.hasExistingDownloads(with: transaction) {
return false
}
return true
}
}