blob: 2f3c17394939c4807defc8f4bbebf1d4044f1b98 [file] [log] [blame]
/*
* Copyright (C) 2018-2019 Savoir-faire Linux Inc.
*
* Author: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com>
* Author: Quentin Muret <quentin.muret@savoirfairelinux.com>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
import Foundation
import SwiftyBeaver
import RxSwift
import Foundation
import MobileCoreServices
import Photos
// swiftlint:disable identifier_name
enum DataTransferServiceError: Error {
case createTransferError
case updateTransferError
}
enum DataTransferStatus: CustomStringConvertible {
var description: String {
switch self {
case .created: return "created"
case .awaiting: return "awaiting"
case .canceled: return "canceled"
case .ongoing: return "ongoing"
case .success: return "success"
case .error: return "error"
case .unknown: return "unknown"
}
}
case created
case awaiting
case canceled
case ongoing
case success
case error
case unknown
}
// swiftlint:disable cyclomatic_complexity
func stringFromEventCode(with code: NSDataTransferEventCode) -> String {
switch code {
case .invalid: return "Invalid"
case .created: return "initializing transfer"
case .unsupported: return "unsupported"
case .wait_host_acceptance: return "waiting peer acceptance"
case .wait_peer_acceptance: return "waiting host acceptance"
case .ongoing: return "ongoing"
case .finished: return "finished"
case .closed_by_host: return "closed by host"
case .closed_by_peer: return "closed by peer"
case .invalid_pathname: return "invalid pathname"
case .unjoinable_peer: return "unjoinable peer"
}
}
// swiftlint:enable cyclomatic_complexity
public final class DataTransferService: DataTransferAdapterDelegate {
private let log = SwiftyBeaver.self
//contain image if transfering file is image type, othewise contain nil
typealias ImageTuple = (isImage: Bool, data: UIImage?)
private var transferedImages = [String: ImageTuple]()
fileprivate let dataTransferAdapter: DataTransferAdapter
fileprivate let disposeBag = DisposeBag()
fileprivate let responseStream = PublishSubject<ServiceEvent>()
var sharedResponseStream: Observable<ServiceEvent>
init(withDataTransferAdapter dataTransferAdapter: DataTransferAdapter) {
self.responseStream.disposed(by: disposeBag)
self.sharedResponseStream = responseStream.share()
self.dataTransferAdapter = dataTransferAdapter
DataTransferAdapter.delegate = self
}
let dbManager = DBManager(profileHepler: ProfileDataHelper(), conversationHelper: ConversationDataHelper(), interactionHepler: InteractionDataHelper())
// MARK: public
func getTransferInfo(withId transferId: UInt64) -> NSDataTransferInfo? {
let info = NSDataTransferInfo()
let err = self.dataTransferAdapter.dataTransferInfo(withId: transferId, with: info)
if err != .success {
self.log.error("DataTransferService: error getting transfer info for id: \(transferId)")
return nil
}
return info
}
func acceptTransfer(withId transferId: UInt64,
interactionID: Int64,
fileName: inout String,
accountID: String,
conversationID: String) -> NSDataTransferError {
guard let info = getTransferInfo(withId: transferId) else {
return NSDataTransferError.invalid_argument
}
// accept transfer
if let pathUrl = getFilePathForTransfer(forFile: info.displayName, accountID: accountID,
conversationID: conversationID) {
// if file name was changed because the same name already exist, update db
if pathUrl.lastPathComponent != info.displayName {
let fileSizeWithUnit = ByteCountFormatter.string(fromByteCount: info.totalSize, countStyle: .file)
let name = pathUrl.lastPathComponent + "\n" + fileSizeWithUnit
fileName = name
//update db
self.dbManager.updateFileName(interactionID: interactionID, name: name).subscribe(onCompleted: { [weak self] in
self?.log.debug("file name updated")
}, onError: { [weak self] _ in
self?.log.error("update name failed")
}).disposed(by: self.disposeBag)
}
self.log.debug("DataTransferService: saving file to: \(pathUrl.path))")
return acceptFileTransfer(withId: transferId, withPath: pathUrl.path)
} else {
self.log.error("DataTransferService: saving file error: bad local path")
return .io
}
}
func getFileUrl(fileName: String, accountID: String, conversationID: String) -> URL? {
guard let pathUrl = getFilePath(fileName: fileName, accountID: accountID, conversationID: conversationID) else {return nil}
let fileManager = FileManager.default
var file: URL?
if fileManager.fileExists(atPath: pathUrl.path) {
file = NSURL.fileURL(withPath: pathUrl.path)
}
return file
}
/*
to avoid creating images multiple time keep images in dictionary
images saved in app document folder referenced by conversationId concatinated with image name
images from photo librairy referenced by local identifier
*/
func getImage(for name: String, maxSize: CGFloat, identifier: String? = nil,
accountID: String, conversationID: String) -> UIImage? {
if let localImageIdentifier = identifier {
if let image = self.transferedImages[localImageIdentifier] {
return image.data
}
return self.getImageFromPhotoLibrairy(identifier: localImageIdentifier, maxSize: maxSize, name: name)
}
if let image = self.transferedImages[conversationID + name] {
return image.data
}
return self.getImageFromFile(for: name, maxSize: maxSize, accountID: accountID,
conversationID: conversationID)
}
func getImageFromPhotoLibrairy(identifier: String, maxSize: CGFloat, name: String) -> UIImage? {
let imageManager = PHImageManager.default()
let requestOptions = PHImageRequestOptions()
requestOptions.resizeMode = PHImageRequestOptionsResizeMode.fast
requestOptions.deliveryMode = PHImageRequestOptionsDeliveryMode.fastFormat
requestOptions.isSynchronous = true
var photo: UIImage?
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [identifier], options: PHFetchOptions()).firstObject else {
return photo
}
imageManager.requestImage(for: asset, targetSize: CGSize(width: maxSize, height: maxSize), contentMode: .aspectFit, options: requestOptions, resultHandler: {(result, _) -> Void in
self.transferedImages[identifier] = (true, result!)
photo = result!
})
return photo
}
func getImageFromFile(for name: String,
maxSize: CGFloat,
accountID: String,
conversationID: String) -> UIImage? {
guard let pathUrl = getFilePath(fileName: name, accountID: accountID,
conversationID: conversationID) else {return nil}
let fileExtension = pathUrl.pathExtension as CFString
guard let uti = UTTypeCreatePreferredIdentifierForTag(
kUTTagClassFilenameExtension,
fileExtension,
nil) else {return nil}
if UTTypeConformsTo(uti.takeRetainedValue(), kUTTypeImage) {
let fileManager = FileManager.default
if fileManager.fileExists(atPath: pathUrl.path) {
if fileExtension as String == "gif" {
let image = UIImage.gifImageWithUrl(pathUrl)
return image
}
let image = UIImage(contentsOfFile: pathUrl.path)
self.transferedImages[conversationID + name] = (true, image)
return image
}
} else {
self.transferedImages[conversationID + name] = (false, nil)
}
return nil
}
func isTransferImage(withId transferId: UInt64, accountID: String, conversationID: String) -> Bool? {
guard let info = getTransferInfo(withId: transferId) else { return nil }
guard let pathUrl = getFilePath(fileName: info.displayName,
accountID: accountID, conversationID: conversationID) else { return nil }
let fileExtension = pathUrl.pathExtension as CFString
guard let uti = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension,
fileExtension,
nil) else {return nil}
return UTTypeConformsTo(uti.takeRetainedValue(), kUTTypeImage)
}
func cancelTransfer(withId transferId: UInt64) -> NSDataTransferError {
let err = cancelDataTransfer(withId: transferId)
if err != .success {
self.log.error("couldn't cancel transfer with id: \(transferId)")
}
return err
}
func sendFile(filePath: String, displayName: String, accountId: String, peerInfoHash: String, localIdentifier: String?) {
var transferId: UInt64 = 0
let info = NSDataTransferInfo()
info.accountId = accountId
info.peer = peerInfoHash
info.path = filePath
info.mimetype = ""
info.displayName = displayName
let err = sendFile(withId: &transferId, withInfo: info)
if err != .success {
self.log.error("sendFile failed")
} else {
let serviceEventType: ServiceEventType = .dataTransferCreated
var serviceEvent = ServiceEvent(withEventType: serviceEventType)
serviceEvent.addEventInput(.transferId, value: transferId)
if let localIdentifier = localIdentifier {
serviceEvent.addEventInput(.localPhotolID, value: localIdentifier)
}
self.responseStream.onNext(serviceEvent)
}
}
func sendAndSaveFile(displayName: String,
accountId: String,
peerInfoHash: String,
imageData: Data,
conversationId: String) {
guard let imagePath = self.getFilePathForTransfer(forFile: displayName,
accountID: accountId,
conversationID: conversationId) else {return}
do {
try imageData.write(to: URL(fileURLWithPath: imagePath.path), options: .atomic)
} catch {
self.log.error("couldn't copy image to cache")
}
self.sendFile(filePath: imagePath.path, displayName: imagePath.lastPathComponent, accountId: accountId, peerInfoHash: peerInfoHash, localIdentifier: nil)
}
func getTransferProgress(withId transferId: UInt64) -> Float? {
var total: Int64 = 0
var progress: Int64 = 0
let err = dataTransferBytesProgress(withId: transferId, withTotal: &total, withProgress: &progress)
if err != .success {
return nil
}
let progressValue = Float(progress) / Float(total)
return progressValue
}
// MARK: private
fileprivate func getFilePath(fileName: String, accountID: String, conversationID: String) -> URL? {
let downloadsFolderName = "downloads"
guard let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
return nil
}
let directoryURL = documentsURL.appendingPathComponent(downloadsFolderName)
.appendingPathComponent(accountID).appendingPathComponent(conversationID)
return directoryURL.appendingPathComponent(fileName)
}
fileprivate func getFilePathForTransfer(forFile fileName: String, accountID: String, conversationID: String) -> URL? {
let downloadsFolderName = "downloads"
let fileNameOnly = (fileName as NSString).deletingPathExtension
let fileExtensionOnly = (fileName as NSString).pathExtension
var filePathUrl: URL?
guard let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
return nil
}
let directoryURL = documentsURL.appendingPathComponent(downloadsFolderName)
.appendingPathComponent(accountID).appendingPathComponent(conversationID)
var isDirectory = ObjCBool(false)
let directoryExists = FileManager.default.fileExists(atPath: directoryURL.path, isDirectory: &isDirectory)
if directoryExists && isDirectory.boolValue {
// check if file exists, if so add " (<duplicates+1>)" or "_<duplicates+1>"
// first check /.../AppData/Documents/downloads/<fileNameOnly>.<fileExtensionOnly>
var finalFileName = fileNameOnly + "." + fileExtensionOnly
var filePathCheck = directoryURL.appendingPathComponent(finalFileName)
var fileExists = FileManager.default.fileExists(atPath: filePathCheck.path, isDirectory: &isDirectory)
var duplicates = 2
while fileExists {
// check /.../AppData/Documents/downloads/<fileNameOnly>_<duplicates>.<fileExtensionOnly>
finalFileName = fileNameOnly + "_" + String(duplicates) + "." + fileExtensionOnly
filePathCheck = directoryURL.appendingPathComponent(finalFileName)
fileExists = FileManager.default.fileExists(atPath: filePathCheck.path, isDirectory: &isDirectory)
duplicates += 1
}
return filePathCheck
}
// need to create dir
do {
try FileManager.default.createDirectory(atPath: directoryURL.path, withIntermediateDirectories: true, attributes: nil)
filePathUrl = directoryURL.appendingPathComponent(fileName)
return filePathUrl
} catch _ as NSError {
self.log.error("DataTransferService: error creating dir")
return nil
}
}
// MARK: DataTransferAdapter
fileprivate func dataTransferIdList() -> [UInt64]? {
return self.dataTransferAdapter.dataTransferList() as? [UInt64]
}
fileprivate func sendFile(withId transferId: inout UInt64, withInfo info: NSDataTransferInfo) -> NSDataTransferError {
var err: NSDataTransferError = .unknown
let _id = UnsafeMutablePointer<UInt64>.allocate(capacity: 1)
err = self.dataTransferAdapter.sendFile(with: info, withTransferId: _id)
transferId = _id.pointee
return err
}
fileprivate func acceptFileTransfer(withId transferId: UInt64, withPath filePath: String, withOffset offset: Int64 = 0) -> NSDataTransferError {
return self.dataTransferAdapter.acceptFileTransfer(withId: transferId, withFilePath: filePath, withOffset: offset)
}
fileprivate func cancelDataTransfer(withId transferId: UInt64) -> NSDataTransferError {
return self.dataTransferAdapter.cancelDataTransfer(withId: transferId)
}
fileprivate func dataTransferInfo(withId transferId: UInt64, withInfo info: inout NSDataTransferInfo) -> NSDataTransferError {
return self.dataTransferAdapter.dataTransferInfo(withId: transferId, with: info)
}
fileprivate func dataTransferBytesProgress(withId transferId: UInt64, withTotal total: inout Int64, withProgress progress: inout Int64) -> NSDataTransferError {
var err: NSDataTransferError = .unknown
let _total = UnsafeMutablePointer<Int64>.allocate(capacity: 1)
let _progress = UnsafeMutablePointer<Int64>.allocate(capacity: 1)
err = self.dataTransferAdapter.dataTransferBytesProgress(withId: transferId, withTotal: _total, withProgress: _progress)
total = _total.pointee
progress = _progress.pointee
return err
}
// MARK: DataTransferAdapterDelegate
func dataTransferEvent(withTransferId transferId: UInt64, withEventCode eventCode: Int) {
guard let event = NSDataTransferEventCode(rawValue: UInt32(eventCode)) else {
self.log.error("DataTransferService: can't get transfer code")
return
}
self.log.info("DataTransferService: event: \(stringFromEventCode(with: event))")
let info = getTransferInfo(withId: transferId)
// do not emit an created event for outgoing transfer, since it already saved in db
if event == .created && info?.flags != 1 {
return
}
// we aggregate all non-create type transfer events into the update category
// emit service event
let serviceEventType: ServiceEventType = event == .created ? .dataTransferCreated : .dataTransferChanged
var serviceEvent = ServiceEvent(withEventType: serviceEventType)
serviceEvent.addEventInput(.transferId, value: transferId)
self.responseStream.onNext(serviceEvent)
}
}