diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e836239..d53e47e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,9 +17,9 @@ jobs: - name: Checkout uses: actions/checkout@v2 - name: Build - run: swift build + run: xcodebuild -scheme InfomaniakCore build -destination "platform=iOS Simulator,name=iPhone 13,OS=latest" - name: Test - run: swift test + run: xcodebuild -scheme InfomaniakCore test -destination "platform=iOS Simulator,name=iPhone 13,OS=latest" build_and_test_macOS: name: Build and Test project on macOS diff --git a/.sonarcloud.properties b/.sonarcloud.properties new file mode 100644 index 0000000..92742db --- /dev/null +++ b/.sonarcloud.properties @@ -0,0 +1,15 @@ +## Path to sources +sonar.sources=Sources +# sonar.exclusions= +# sonar.inclusions= + +## Path to tests +sonar.tests=Tests +# sonar.test.exclusions= +# sonar.test.inclusions= + +## Source encoding +# sonar.sourceEncoding= + +# Exclusions for copy-paste detection +# sonar.cpd.exclusions= diff --git a/Package.resolved b/Package.resolved index 565875b..cc7ad8c 100644 --- a/Package.resolved +++ b/Package.resolved @@ -5,17 +5,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Alamofire/Alamofire", "state" : { - "revision" : "bc268c28fb170f494de9e9927c371b8342979ece", - "version" : "5.7.1" + "revision" : "3dc6a42c7727c49bf26508e29b0a0b35f9c7e1ad", + "version" : "5.8.1" } }, { "identity" : "cocoalumberjack", "kind" : "remoteSourceControl", - "location" : "https://github.com/CocoaLumberjack/CocoaLumberjack.git", + "location" : "https://github.com/CocoaLumberjack/CocoaLumberjack", "state" : { - "revision" : "0188d31089b5881a269e01777be74c7316924346", - "version" : "3.8.0" + "revision" : "363ed23d19a931809ea834a7d722da830353806a", + "version" : "3.8.2" } }, { @@ -23,8 +23,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Infomaniak/ios-dependency-injection", "state" : { - "revision" : "41a99aeb652d5294355129bfa70d1ea8c17b9980", - "version" : "1.1.10" + "revision" : "8dc9e67e6d3d9f4f5bd02d693a7ce1f93b125bcd", + "version" : "2.0.1" + } + }, + { + "identity" : "ios-login", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Infomaniak/ios-login", + "state" : { + "revision" : "904c1ac39b4db56212302b464a0b2e023d9b5791", + "version" : "6.0.0" } }, { @@ -32,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/realm/realm-core.git", "state" : { - "revision" : "f1434caadda443b4ed2261b91ea4f43ab1ee2aa5", - "version" : "13.15.1" + "revision" : "7227d6a447821c28895daa099b6c7cd4c99d461b", + "version" : "13.25.1" } }, { @@ -41,8 +50,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/realm/realm-swift", "state" : { - "revision" : "b287dc102036ff425bd8a88483f0a5596871f05e", - "version" : "10.41.0" + "revision" : "836cc4b8619886f979f8961c3f592a82b0741591", + "version" : "10.45.3" } }, { @@ -50,8 +59,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/getsentry/sentry-cocoa", "state" : { - "revision" : "e46936ed191c0112cd3276e1c10c0bb7f865268e", - "version" : "8.9.1" + "revision" : "3b9a8e69ca296bd8cd0e317ad7a448e5daf4a342", + "version" : "8.18.0" } }, { @@ -59,8 +68,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "32e8d724467f8fe623624570367e3d50c5638e46", - "version" : "1.5.2" + "revision" : "532d8b529501fb73a2455b179e0bbb6d49b652ed", + "version" : "1.5.3" } }, { @@ -68,8 +77,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/weichsel/ZIPFoundation.git", "state" : { - "revision" : "43ec568034b3731101dbf7670765d671c30f54f3", - "version" : "0.9.16" + "revision" : "a3f5c2bae0f04b0bce9ef3c4ba6bd1031a0564c4", + "version" : "0.9.17" } } ], diff --git a/Package.swift b/Package.swift index a222199..1ac21f0 100644 --- a/Package.swift +++ b/Package.swift @@ -16,11 +16,12 @@ let package = Package( ) ], dependencies: [ - .package(url: "https://github.com/Infomaniak/ios-dependency-injection", .upToNextMajor(from: "1.1.10")), - .package(url: "https://github.com/Alamofire/Alamofire", .upToNextMajor(from: "5.2.2")), - .package(url: "https://github.com/getsentry/sentry-cocoa", .upToNextMajor(from: "8.3.1")), - .package(url: "https://github.com/realm/realm-swift", .upToNextMajor(from: "10.0.0")), - .package(url: "https://github.com/CocoaLumberjack/CocoaLumberjack", .upToNextMajor(from: "3.7.0")), + .package(url: "https://github.com/Infomaniak/ios-dependency-injection", .upToNextMajor(from: "2.0.0")), + .package(url: "https://github.com/Infomaniak/ios-login", .upToNextMajor(from: "6.0.0")), + .package(url: "https://github.com/Alamofire/Alamofire", .upToNextMajor(from: "5.8.0")), + .package(url: "https://github.com/getsentry/sentry-cocoa", .upToNextMajor(from: "8.18.0")), + .package(url: "https://github.com/realm/realm-swift", .upToNextMajor(from: "10.45.0")), + .package(url: "https://github.com/CocoaLumberjack/CocoaLumberjack", .upToNextMajor(from: "3.8.0")), .package(url: "https://github.com/weichsel/ZIPFoundation.git", .upToNextMajor(from: "0.9.0")) ], targets: [ @@ -29,6 +30,7 @@ let package = Package( dependencies: [ "Alamofire", .product(name: "InfomaniakDI", package: "ios-dependency-injection"), + .product(name: "InfomaniakLogin", package: "ios-login"), .product(name: "Sentry", package: "sentry-cocoa"), .product(name: "RealmSwift", package: "realm-swift"), .product(name: "CocoaLumberjackSwift", package: "CocoaLumberjack"), diff --git a/Sources/InfomaniakCore/Account/Account.swift b/Sources/InfomaniakCore/Account/Account.swift index 6617e74..81ad1b1 100644 --- a/Sources/InfomaniakCore/Account/Account.swift +++ b/Sources/InfomaniakCore/Account/Account.swift @@ -17,6 +17,7 @@ */ import Foundation +import InfomaniakLogin public protocol AccountUpdateDelegate { func didUpdateCurrentAccount(_ account: Account) diff --git a/Sources/InfomaniakCore/Account/KeychainHelper.swift b/Sources/InfomaniakCore/Account/KeychainHelper.swift index ad067cf..7eb3974 100644 --- a/Sources/InfomaniakCore/Account/KeychainHelper.swift +++ b/Sources/InfomaniakCore/Account/KeychainHelper.swift @@ -1,43 +1,44 @@ /* Infomaniak Core - iOS Copyright (C) 2023 Infomaniak Network SA - + 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, see . */ import CocoaLumberjackSwift import Foundation +import InfomaniakLogin import Sentry public class KeychainHelper { let accessGroup: String let tag = "ch.infomaniak.token".data(using: .utf8)! let keychainQueue = DispatchQueue(label: "com.infomaniak.keychain") - + let lockedKey = "isLockedKey" let lockedValue = "locked".data(using: .utf8)! var accessibilityValueWritten = false - + public init(accessGroup: String) { self.accessGroup = accessGroup } - + public var isKeychainAccessible: Bool { if !accessibilityValueWritten { initKeychainAccessibility() } - + let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: lockedKey, @@ -47,13 +48,13 @@ public class KeychainHelper { kSecReturnRef as String: kCFBooleanTrue as Any, kSecMatchLimit as String: kSecMatchLimitAll ] - + var result: AnyObject? - + let resultCode = withUnsafeMutablePointer(to: &result) { SecItemCopyMatching(query as CFDictionary, UnsafeMutablePointer($0)) } - + if resultCode == noErr, let array = result as? [[String: Any]] { for item in array { if let value = item[kSecValueData as String] as? Data { @@ -66,7 +67,7 @@ public class KeychainHelper { return false } } - + func initKeychainAccessibility() { accessibilityValueWritten = true let queryAdd: [String: Any] = [ @@ -81,7 +82,7 @@ public class KeychainHelper { "[Keychain] Successfully init KeychainHelper ? \(resultCode == noErr || resultCode == errSecDuplicateItem), \(resultCode)" ) } - + public func deleteToken(for userId: Int) { keychainQueue.sync { let queryDelete: [String: Any] = [ @@ -93,7 +94,7 @@ public class KeychainHelper { DDLogInfo("Successfully deleted token ? \(resultCode == noErr)") } } - + public func deleteAllTokens() { keychainQueue.sync { let queryDelete: [String: Any] = [ @@ -104,27 +105,38 @@ public class KeychainHelper { DDLogInfo("Successfully deleted all tokens ? \(resultCode == noErr)") } } - + public func storeToken(_ token: ApiToken) { var resultCode: OSStatus = noErr let tokenData = try! JSONEncoder().encode(token) - + if let savedToken = getSavedToken(for: token.userId) { keychainQueue.sync { + let queryUpdate: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: "\(token.userId)" + ] + + let attributes: [String: Any] = [ + kSecValueData as String: tokenData + ] + // Save token only if it's more recent - if savedToken.expirationDate <= token.expirationDate { - let queryUpdate: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: "\(token.userId)" - ] - - let attributes: [String: Any] = [ - kSecValueData as String: tokenData - ] + if let savedTokenExpirationDate = savedToken.expirationDate, + let newTokenExpirationDate = token.expirationDate, + savedTokenExpirationDate <= newTokenExpirationDate { resultCode = SecItemUpdate(queryUpdate as CFDictionary, attributes as CFDictionary) DDLogInfo("Successfully updated token ? \(resultCode == noErr)") SentrySDK.addBreadcrumb(token.generateBreadcrumb(level: .info, message: "Successfully updated token")) + } else if savedToken.expirationDate == nil || token.expirationDate == nil { + // Or if one of them is now an infinite refresh token + resultCode = SecItemUpdate(queryUpdate as CFDictionary, attributes as CFDictionary) + DDLogInfo("Successfully updated unlimited token ? \(resultCode == noErr)") + SentrySDK.addBreadcrumb(token.generateBreadcrumb( + level: .info, + message: "Successfully updated unlimited token" + )) } } } else { @@ -149,7 +161,7 @@ public class KeychainHelper { .generateBreadcrumb(level: .error, message: "Failed saving token", keychainError: resultCode)) } } - + public func getSavedToken(for userId: Int) -> ApiToken? { var savedToken: ApiToken? keychainQueue.sync { @@ -164,11 +176,11 @@ public class KeychainHelper { kSecMatchLimit as String: kSecMatchLimitOne ] var result: AnyObject? - + let resultCode = withUnsafeMutablePointer(to: &result) { SecItemCopyMatching(queryFindOne as CFDictionary, UnsafeMutablePointer($0)) } - + let jsonDecoder = JSONDecoder() if resultCode == noErr, let keychainItem = result as? [String: Any], @@ -179,7 +191,7 @@ public class KeychainHelper { } return savedToken } - + public func loadTokens() -> [ApiToken] { var values = [ApiToken]() keychainQueue.sync { @@ -192,14 +204,14 @@ public class KeychainHelper { kSecReturnRef as String: kCFBooleanTrue as Any, kSecMatchLimit as String: kSecMatchLimitAll ] - + var result: AnyObject? - + let resultCode = withUnsafeMutablePointer(to: &result) { SecItemCopyMatching(query as CFDictionary, UnsafeMutablePointer($0)) } DDLogInfo("Successfully loaded tokens ? \(resultCode == noErr)") - + guard resultCode == noErr else { let crumb = Breadcrumb(level: .error, category: "Token") crumb.type = "error" @@ -208,7 +220,7 @@ public class KeychainHelper { SentrySDK.addBreadcrumb(crumb) return } - + if let array = result as? [[String: Any]] { let jsonDecoder = JSONDecoder() for item in array { @@ -226,3 +238,17 @@ public class KeychainHelper { return values } } + +public extension ApiToken { + func generateBreadcrumb(level: SentryLevel, message: String, keychainError: OSStatus = noErr) -> Breadcrumb { + let crumb = Breadcrumb(level: level, category: "Token") + crumb.type = level == .info ? "info" : "error" + crumb.message = message + crumb.data = ["User id": userId, + "Expiration date": expirationDate?.timeIntervalSince1970 ?? "infinite", + "Access Token": truncatedAccessToken, + "Refresh Token": truncatedRefreshToken, + "Keychain error code": keychainError] + return crumb + } +} diff --git a/Sources/InfomaniakCore/Account/UserDefaults+Extension.swift b/Sources/InfomaniakCore/Account/UserDefaults+Extension.swift index d646228..b0db01f 100644 --- a/Sources/InfomaniakCore/Account/UserDefaults+Extension.swift +++ b/Sources/InfomaniakCore/Account/UserDefaults+Extension.swift @@ -26,6 +26,8 @@ public extension UserDefaults { self.rawValue = rawValue } + // TODO: Clean hotfix + static let legacyIsFirstLaunch = Keys(rawValue: "isFirstLaunch") static let currentUserId = Keys(rawValue: "currentUserId") } @@ -41,6 +43,20 @@ public extension UserDefaults { set(newValue, forKey: key(.currentUserId)) } } + + // TODO: Clean hotfix + var legacyIsFirstLaunch: Bool { + get { + if object(forKey: key(.legacyIsFirstLaunch)) != nil { + return bool(forKey: key(.legacyIsFirstLaunch)) + } else { + return true + } + } + set { + set(newValue, forKey: key(.legacyIsFirstLaunch)) + } + } } // MARK: - Internal extension diff --git a/Sources/InfomaniakCore/Asynchronous/ParallelTaskMapper.swift b/Sources/InfomaniakCore/Asynchronous/ParallelTaskMapper.swift index f550655..a7b2047 100644 --- a/Sources/InfomaniakCore/Asynchronous/ParallelTaskMapper.swift +++ b/Sources/InfomaniakCore/Asynchronous/ParallelTaskMapper.swift @@ -29,6 +29,7 @@ public typealias SequenceableCollection = Sequence & Collection /// Use default settings for optimised queue depth /// @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +@available(*, deprecated, message: "Use .concurrentMap from the InfomaniakConcurrency package instead") public struct ParallelTaskMapper { /// private processing TaskQueue private let taskQueue: TaskQueue diff --git a/Sources/InfomaniakCore/DeeplinkService/DeeplinkService.swift b/Sources/InfomaniakCore/DeeplinkService/DeeplinkService.swift new file mode 100644 index 0000000..4fa6704 --- /dev/null +++ b/Sources/InfomaniakCore/DeeplinkService/DeeplinkService.swift @@ -0,0 +1,42 @@ +/* + Infomaniak Core - iOS + Copyright (C) 2023 Infomaniak Network SA + + 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, see . + */ + +import Foundation +import InfomaniakDI +import Sentry + +public struct DeeplinkService { + @LazyInjectService private var urlOpener: URLOpenable + + private let group = "group.com.infomaniak" + private let kdriveAppStore = "https://itunes.apple.com/app/id1482778676" + + public init() { /* Empty on purpose */ } + + public func shareFileToKdrive(_ url: URL) throws { + guard let destination = try GroupContainerService.writeToGroupContainer(group: group, file: url) else { return } + + var targetUrl = URLComponents(string: "kdrive-file-sharing://file") + targetUrl?.queryItems = [URLQueryItem(name: "url", value: destination.path)] + if let targetAppUrl = targetUrl?.url, urlOpener.canOpen(url: targetAppUrl) { + urlOpener.openUrl(targetAppUrl) + } else { + urlOpener.openUrl(URL(string: "https://itunes.apple.com/app/id1482778676")!) + } + } +} diff --git a/Sources/InfomaniakCore/DeeplinkService/GroupContainerService.swift b/Sources/InfomaniakCore/DeeplinkService/GroupContainerService.swift new file mode 100644 index 0000000..20eb2b8 --- /dev/null +++ b/Sources/InfomaniakCore/DeeplinkService/GroupContainerService.swift @@ -0,0 +1,39 @@ +/* + Infomaniak Core - iOS + Copyright (C) 2023 Infomaniak Network SA + + 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, see . + */ + +import Foundation + +struct GroupContainerService { + static func writeToGroupContainer(group: String, file: URL) throws -> URL? { + guard let sharedContainerURL: URL = FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: group) else { return nil } + + let groupContainer = sharedContainerURL.appendingPathComponent("Library/Caches/file-sharing", isDirectory: true) + let destination = groupContainer.appendingPathComponent(file.lastPathComponent) + + if FileManager.default.fileExists(atPath: groupContainer.path) { + try FileManager.default.removeItem(at: groupContainer) + } + try FileManager.default.createDirectory(at: groupContainer, withIntermediateDirectories: false) + try FileManager.default.copyItem( + at: file, + to: destination + ) + return destination + } +} diff --git a/Sources/InfomaniakCore/DeeplinkService/URLOpenable.swift b/Sources/InfomaniakCore/DeeplinkService/URLOpenable.swift new file mode 100644 index 0000000..7991182 --- /dev/null +++ b/Sources/InfomaniakCore/DeeplinkService/URLOpenable.swift @@ -0,0 +1,25 @@ +/* + Infomaniak Core - iOS + Copyright (C) 2023 Infomaniak Network SA + + 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, see . + */ + +import Foundation + +public protocol URLOpenable { + func canOpen(url: URL) -> Bool + + func openUrl(_ url: URL) +} diff --git a/Sources/InfomaniakCore/Extensions/Array+Chunks.swift b/Sources/InfomaniakCore/Extensions/Array+Chunks.swift index 64d77ca..ffe9e2f 100644 --- a/Sources/InfomaniakCore/Extensions/Array+Chunks.swift +++ b/Sources/InfomaniakCore/Extensions/Array+Chunks.swift @@ -22,6 +22,7 @@ public extension Array { /// Subdivide an array into smaller parts /// - Parameter size: the size of the sub-division /// - Returns: The chunked structure + @available(*, deprecated, message: "Use chunks(ofCount: Int) form apple/swift-algorithms instead") func chunked(into size: Int) -> [[Element]] { return stride(from: 0, to: count, by: size).map { Array(self[$0 ..< Swift.min($0 + size, count)]) diff --git a/Sources/InfomaniakCore/Extensions/Collection+Identity.swift b/Sources/InfomaniakCore/Extensions/Collection+Identity.swift new file mode 100644 index 0000000..2250f28 --- /dev/null +++ b/Sources/InfomaniakCore/Extensions/Collection+Identity.swift @@ -0,0 +1,31 @@ +/* + Infomaniak Core - iOS + Copyright (C) 2023 Infomaniak Network SA + + 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, see . + */ + +import Foundation +import SwiftUI + +@available(iOS 13.0, *) +public extension Collection where Element: Identifiable { + /// Compute a stable id for the given collection + func collectionId(baseId: AnyHashable? = nil) -> Int { + var hasher = Hasher() + hasher.combine(baseId) + forEach { hasher.combine($0.id) } + return hasher.finalize() + } +} diff --git a/Sources/InfomaniakCore/Hashing/StreamHasher.swift b/Sources/InfomaniakCore/Hashing/StreamHasher.swift index 611f733..88770dd 100644 --- a/Sources/InfomaniakCore/Hashing/StreamHasher.swift +++ b/Sources/InfomaniakCore/Hashing/StreamHasher.swift @@ -46,9 +46,9 @@ public final class StreamHasher { private var isNotDone: Bool { switch state { case .begin, .progress: - true + return true case .done: - false + return false } } diff --git a/Sources/InfomaniakCore/ItemProviderRepresentation/ItemProviderFileRepresentation.swift b/Sources/InfomaniakCore/ItemProviderRepresentation/ItemProviderFileRepresentation.swift index a671d74..048346c 100644 --- a/Sources/InfomaniakCore/ItemProviderRepresentation/ItemProviderFileRepresentation.swift +++ b/Sources/InfomaniakCore/ItemProviderRepresentation/ItemProviderFileRepresentation.swift @@ -43,7 +43,7 @@ public final class ItemProviderFileRepresentation: NSObject, ProgressResultable case UnableToLoadFile } - public typealias Success = URL + public typealias Success = (url: URL, title: String) public typealias Failure = Error /// Init method @@ -55,7 +55,7 @@ public final class ItemProviderFileRepresentation: NSObject, ProgressResultable var typeIdentifiers = itemProvider.registeredTypeIdentifiers // make sure live photo identifier is at the end of supported formats - if let matchIndex = typeIdentifiers.index(of: Self.livePhotoIdentifier) { + if let matchIndex = typeIdentifiers.firstIndex(of: Self.livePhotoIdentifier) { typeIdentifiers.remove(at: matchIndex) typeIdentifiers.append(Self.livePhotoIdentifier) } @@ -91,12 +91,13 @@ public final class ItemProviderFileRepresentation: NSObject, ProgressResultable @InjectService var pathProvider: AppGroupPathProvidable let temporaryURL = try URL.temporaryUniqueFolderURL() + let title = (fileProviderURL.lastPathComponent as NSString).deletingPathExtension let fileName = fileProviderURL.appendingPathExtension(for: uti).lastPathComponent let temporaryFileURL = temporaryURL.appendingPathComponent(fileName) try fileManager.copyItem(atPath: fileProviderURL.path, toPath: temporaryFileURL.path) completionProgress.completedUnitCount += Self.progressStep - flowToAsync.sendSuccess(temporaryFileURL) + flowToAsync.sendSuccess((temporaryFileURL, title)) } catch { completionProgress.completedUnitCount += Self.progressStep flowToAsync.sendFailure(error) @@ -109,7 +110,7 @@ public final class ItemProviderFileRepresentation: NSObject, ProgressResultable public var progress: Progress - public var result: Result { + public var result: Result { get async { await flowToAsync.result } diff --git a/Sources/InfomaniakCore/ItemProviderRepresentation/ItemProviderTextRepresentation.swift b/Sources/InfomaniakCore/ItemProviderRepresentation/ItemProviderTextRepresentation.swift index 1e1f060..b833cc5 100644 --- a/Sources/InfomaniakCore/ItemProviderRepresentation/ItemProviderTextRepresentation.swift +++ b/Sources/InfomaniakCore/ItemProviderRepresentation/ItemProviderTextRepresentation.swift @@ -135,7 +135,7 @@ public final class ItemProviderTextRepresentation: NSObject, ProgressResultable public var progress: Progress - public var result: Result { + public var result: Result { get async { return await flowToAsync.result } diff --git a/Sources/InfomaniakCore/ItemProviderRepresentation/ItemProviderURLRepresentation.swift b/Sources/InfomaniakCore/ItemProviderRepresentation/ItemProviderURLRepresentation.swift index 35bb9eb..bced305 100644 --- a/Sources/InfomaniakCore/ItemProviderRepresentation/ItemProviderURLRepresentation.swift +++ b/Sources/InfomaniakCore/ItemProviderRepresentation/ItemProviderURLRepresentation.swift @@ -47,7 +47,7 @@ public final class ItemProviderURLRepresentation: NSObject, ProgressResultable { case localFileNotFound } - public typealias Success = URL + public typealias Success = (url: URL, title: String) public typealias Failure = Error public init(from itemProvider: NSItemProvider) throws { @@ -91,18 +91,23 @@ public final class ItemProviderURLRepresentation: NSObject, ProgressResultable { .deletingPathExtension .replacingOccurrences(of: "/", with: "") let fileName: String + let fileTitle: String if currentName.isEmpty { if #available(iOS 16.0, macOS 13.0, *), - let hostName = url.host()? - .replacingOccurrences(of: "www.", with: "") - .replacingOccurrences(of: ".", with: "_"), - !hostName.isEmpty { + let hostName = url.host()? + .replacingOccurrences(of: "www.", with: "") + .replacingOccurrences(of: ".", with: "_"), + !hostName.isEmpty { fileName = "\(hostName).webloc" + fileTitle = hostName } else { - fileName = "\(URL.defaultFileName()).webloc" + let defaultFileName = URL.defaultFileName() + fileName = "\(defaultFileName).webloc" + fileTitle = fileName } } else { fileName = "\(currentName).webloc" + fileTitle = currentName } let targetURL = try URL.temporaryUniqueFolderURL().appendingPathComponent(fileName) @@ -111,7 +116,7 @@ public final class ItemProviderURLRepresentation: NSObject, ProgressResultable { try data.write(to: targetURL) completionProgress.completedUnitCount += Self.progressStep - flowToAsync.sendSuccess(targetURL) + flowToAsync.sendSuccess((targetURL, fileTitle)) } /// Move a local file for later use @@ -133,7 +138,9 @@ public final class ItemProviderURLRepresentation: NSObject, ProgressResultable { try fileManager.copyItem(at: url, to: targetURL) completionProgress.completedUnitCount += Self.progressStep - flowToAsync.sendSuccess(targetURL) + + let fileNameWithoutExtension = (fileName as NSString).deletingPathExtension + flowToAsync.sendSuccess((targetURL, fileNameWithoutExtension)) return true } @@ -142,7 +149,7 @@ public final class ItemProviderURLRepresentation: NSObject, ProgressResultable { public var progress: Progress - public var result: Result { + public var result: Result { get async { return await flowToAsync.result } diff --git a/Sources/InfomaniakCore/ItemProviderRepresentation/ItemProviderZipRepresentation.swift b/Sources/InfomaniakCore/ItemProviderRepresentation/ItemProviderZipRepresentation.swift index 1a9ff41..30d0a93 100644 --- a/Sources/InfomaniakCore/ItemProviderRepresentation/ItemProviderZipRepresentation.swift +++ b/Sources/InfomaniakCore/ItemProviderRepresentation/ItemProviderZipRepresentation.swift @@ -44,7 +44,7 @@ public final class ItemProviderZipRepresentation: NSObject, ProgressResultable { case notADirectory } - public typealias Success = URL + public typealias Success = (url: URL, title: String) public typealias Failure = Error public init(from itemProvider: NSItemProvider) throws { @@ -87,7 +87,7 @@ public final class ItemProviderZipRepresentation: NSObject, ProgressResultable { try self.fileManager.copyItem(at: zipURL, to: targetURL) completionProgress.completedUnitCount += Self.progressStep - self.flowToAsync.sendSuccess(targetURL) + self.flowToAsync.sendSuccess((targetURL, fileName)) } catch { completionProgress.completedUnitCount += Self.progressStep self.flowToAsync.sendFailure(error) @@ -101,7 +101,7 @@ public final class ItemProviderZipRepresentation: NSObject, ProgressResultable { public var progress: Progress - public var result: Result { + public var result: Result { get async { return await flowToAsync.result } diff --git a/Sources/InfomaniakCore/ItemProviderRepresentation/NSItemProvider+Detect.swift b/Sources/InfomaniakCore/ItemProviderRepresentation/NSItemProvider+Detect.swift index 39b222e..5b1e624 100644 --- a/Sources/InfomaniakCore/ItemProviderRepresentation/NSItemProvider+Detect.swift +++ b/Sources/InfomaniakCore/ItemProviderRepresentation/NSItemProvider+Detect.swift @@ -21,9 +21,8 @@ import InfomaniakDI /// Extending NSItemProvider for detecting file type, business logic. public extension NSItemProvider { - /// image identifiers supported by the app + /// image file identifiers supported by the app private static let imageUTIIdentifiers = [ - UTI.image.identifier, UTI.jpeg.identifier, UTI.tiff.identifier, UTI.gif.identifier, @@ -87,13 +86,13 @@ public extension NSItemProvider { return .isText } else if hasItemConformingToAnyOfTypeIdentifiers(Self.imageUTIIdentifiers) { return .isImageData + } else if registeredTypeIdentifiers.count == 1 && + registeredTypeIdentifiers.first == UTI.image.identifier { + return .isUIImage } else if hasItemConformingToAnyOfTypeIdentifiers(Self.directoryUTIIdentifiers) { return .isDirectory } else if hasItemConformingToAnyOfTypeIdentifiers(Self.compressedUTIIdentifiers) { return .isCompressedData(identifier: typeIdentifier) - } else if registeredTypeIdentifiers.count == 1 && - registeredTypeIdentifiers.first == UTI.image.identifier { - return .isUIImage } else { return .isMiscellaneous(identifier: typeIdentifier) } diff --git a/Sources/InfomaniakCore/ItemProviderRepresentation/UTI.swift b/Sources/InfomaniakCore/ItemProviderRepresentation/UTI.swift index e9fb271..2984c53 100644 --- a/Sources/InfomaniakCore/ItemProviderRepresentation/UTI.swift +++ b/Sources/InfomaniakCore/ItemProviderRepresentation/UTI.swift @@ -311,6 +311,7 @@ public struct UTI: RawRepresentable { public static let webArchive = UTI(rawValue: kUTTypeWebArchive) + /// Typical for UIImage public static let image = UTI(rawValue: kUTTypeImage) public static let jpeg = UTI(rawValue: kUTTypeJPEG) diff --git a/Sources/InfomaniakCore/Networking/ApiFetcher.swift b/Sources/InfomaniakCore/Networking/ApiFetcher.swift index 4e418c2..48b46a1 100644 --- a/Sources/InfomaniakCore/Networking/ApiFetcher.swift +++ b/Sources/InfomaniakCore/Networking/ApiFetcher.swift @@ -19,6 +19,7 @@ import Alamofire import Foundation import InfomaniakDI +import InfomaniakLogin import Sentry public protocol RefreshTokenDelegate: AnyObject { @@ -30,6 +31,13 @@ public protocol RefreshTokenDelegate: AnyObject { open class ApiFetcher { public typealias RequestModifier = (inout URLRequest) throws -> Void + /// All status except 401 are handled by our code, 401 status is handled by Alamofire's Authenticator code + private static var handledHttpStatus: Set = { + var allStatus = Set(200 ... 500) + allStatus.remove(401) + return allStatus + }() + public var authenticatedSession: Session! public static var decoder: JSONDecoder = { let decoder = JSONDecoder() @@ -130,9 +138,10 @@ open class ApiFetcher { open func perform(request: DataRequest, decoder: JSONDecoder = ApiFetcher.decoder) async throws -> (data: T, responseAt: Int?) { - let response = await request.serializingDecodable(ApiResponse.self, - automaticallyCancelling: true, - decoder: decoder).response + let validatedRequest = request.validate(statusCode: ApiFetcher.handledHttpStatus) + let response = await validatedRequest.serializingDecodable(ApiResponse.self, + automaticallyCancelling: true, + decoder: decoder).response let apiResponse = try response.result.get() return try handleApiResponse(apiResponse, responseStatusCode: response.response?.statusCode ?? -1) } @@ -206,6 +215,9 @@ open class OAuthAuthenticator: Authenticator { extension ApiToken: AuthenticationCredential { public var requiresRefresh: Bool { + guard let expirationDate else { + return false + } return Date() > expirationDate } } diff --git a/Sources/InfomaniakCore/Networking/ApiToken.swift b/Sources/InfomaniakCore/Networking/ApiToken.swift deleted file mode 100644 index 71ba410..0000000 --- a/Sources/InfomaniakCore/Networking/ApiToken.swift +++ /dev/null @@ -1,91 +0,0 @@ -/* - Infomaniak Core - iOS - Copyright (C) 2023 Infomaniak Network SA - - 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, see . - */ - -import Foundation -import Sentry - -@objc public class ApiToken: NSObject, Codable { - @objc public var accessToken: String - @objc public var expiresIn: Int - @objc public var refreshToken: String - @objc public var scope: String - @objc public var tokenType: String - @objc public var userId: Int - @objc public var expirationDate: Date - - enum CodingKeys: String, CodingKey { - case accessToken = "access_token" - case expiresIn = "expires_in" - case refreshToken = "refresh_token" - case tokenType = "token_type" - case userId = "user_id" - case scope - case expirationDate - } - - public required init(from decoder: Decoder) throws { - let values = try decoder.container(keyedBy: CodingKeys.self) - accessToken = try values.decode(String.self, forKey: .accessToken) - expiresIn = try values.decode(Int.self, forKey: .expiresIn) - refreshToken = try values.decode(String.self, forKey: .refreshToken) - scope = try values.decode(String.self, forKey: .scope) - tokenType = try values.decode(String.self, forKey: .tokenType) - userId = try values.decode(Int.self, forKey: .userId) - - let newExpirationDate = Date().addingTimeInterval(TimeInterval(Double(expiresIn))) - expirationDate = try values.decodeIfPresent(Date.self, forKey: .expirationDate) ?? newExpirationDate - } - - public init(accessToken: String, expiresIn: Int, refreshToken: String, scope: String, tokenType: String, userId: Int, expirationDate: Date) { - self.accessToken = accessToken - self.expiresIn = expiresIn - self.refreshToken = refreshToken - self.scope = scope - self.tokenType = tokenType - self.userId = userId - self.expirationDate = expirationDate - } -} - -// MARK: - Token Logging - -extension ApiToken { - public var truncatedAccessToken: String { - truncateToken(accessToken) - } - - public var truncatedRefreshToken: String { - truncateToken(refreshToken) - } - - func truncateToken(_ token: String) -> String { - String(token.prefix(4) + "-*****-" + token.suffix(4)) - } - - public func generateBreadcrumb(level: SentryLevel, message: String, keychainError: OSStatus = noErr) -> Breadcrumb { - let crumb = Breadcrumb(level: level, category: "Token") - crumb.type = level == .info ? "info" : "error" - crumb.message = message - crumb.data = ["User id": userId, - "Expiration date": expirationDate.timeIntervalSince1970, - "Access Token": truncatedAccessToken, - "Refresh Token": truncatedRefreshToken, - "Keychain error code": keychainError] - return crumb - } -} diff --git a/Sources/InfomaniakCore/Networking/Requests/InfomaniakNetworkLogin.swift b/Sources/InfomaniakCore/Networking/Requests/InfomaniakNetworkLogin.swift deleted file mode 100644 index 31e77d6..0000000 --- a/Sources/InfomaniakCore/Networking/Requests/InfomaniakNetworkLogin.swift +++ /dev/null @@ -1,194 +0,0 @@ -/* - Infomaniak Core - iOS - Copyright (C) 2023 Infomaniak Network SA - - 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, see . - */ - -import Foundation - -public enum Constants { - public static let LOGIN_URL = "https://login.infomaniak.com/" - public static let DELETEACCOUNT_URL = "https://manager.infomaniak.com/v3/ng/profile/user/dashboard?open-terminate-account-modal" - public static let RESPONSE_TYPE = "code" - public static let ACCESS_TYPE = "offline" - public static let HASH_MODE = "SHA-256" - public static let HASH_MODE_SHORT = "S256" - - public static func autologinUrl(to destination: String) -> URL? { - return URL(string: "https://manager.infomaniak.com/v3/mobile_login/?url=\(destination)") - } -} - -/// Something that can keep the network stack authenticated -public protocol InfomaniakNetworkLoginable { - /// Get an api token async (callback on background thread) - func getApiTokenUsing(code: String, codeVerifier: String, completion: @escaping (ApiToken?, Error?) -> Void) - - /// Get an api token async from an application password (callback on background thread) - func getApiToken(username: String, applicationPassword: String, completion: @escaping (ApiToken?, Error?) -> Void) - - /// Refresh api token async (callback on background thread) - func refreshToken(token: ApiToken, completion: @escaping (ApiToken?, Error?) -> Void) - - /// Delete an api token async - func deleteApiToken(token: ApiToken, onError: @escaping (Error) -> Void) -} - -public class InfomaniakNetworkLogin: InfomaniakNetworkLoginable { - private static let LOGIN_API_URL = "https://login.infomaniak.com/" - private static let GET_TOKEN_API_URL = LOGIN_API_URL + "token" - - private var clientId: String - private var loginBaseUrl: String - private var redirectUri: String - - // MARK: Public - - public init(clientId: String, - loginUrl: String = Constants.LOGIN_URL, - redirectUri: String = "\(Bundle.main.bundleIdentifier ?? "")://oauth2redirect") { - self.loginBaseUrl = loginUrl - self.clientId = clientId - self.redirectUri = redirectUri - } - - public func getApiTokenUsing(code: String, codeVerifier: String, completion: @escaping (ApiToken?, Error?) -> Void) { - var request = URLRequest(url: URL(string: Self.GET_TOKEN_API_URL)!) - - let parameterDictionary: [String: Any] = [ - "grant_type": "authorization_code", - "client_id": clientId, - "code": code, - "code_verifier": codeVerifier, - "redirect_uri": redirectUri - ] - request.httpMethod = "POST" - request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - request.httpBody = parameterDictionary.percentEncoded() - - getApiToken(request: request, completion: completion) - } - - public func getApiToken(username: String, applicationPassword: String, completion: @escaping (ApiToken?, Error?) -> Void) { - var request = URLRequest(url: URL(string: Self.GET_TOKEN_API_URL)!) - - let parameterDictionary: [String: Any] = [ - "grant_type": "password", - "access_type": "offline", - "client_id": clientId, - "username": username, - "password": applicationPassword - ] - request.httpMethod = "POST" - request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - request.httpBody = parameterDictionary.percentEncoded() - - getApiToken(request: request, completion: completion) - } - - public func refreshToken(token: ApiToken, completion: @escaping (ApiToken?, Error?) -> Void) { - var request = URLRequest(url: URL(string: Self.GET_TOKEN_API_URL)!) - - let parameterDictionary: [String: Any] = [ - "grant_type": "refresh_token", - "client_id": clientId, - "refresh_token": token.refreshToken - ] - request.httpMethod = "POST" - request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - request.httpBody = parameterDictionary.percentEncoded() - - getApiToken(request: request, completion: completion) - } - - public func deleteApiToken(token: ApiToken, onError: @escaping (Error) -> Void) { - var request = URLRequest(url: URL(string: Self.GET_TOKEN_API_URL)!) - request.addValue("Bearer \(token.accessToken)", forHTTPHeaderField: "Authorization") - request.httpMethod = "DELETE" - - URLSession.shared.dataTask(with: request) { data, response, sessionError in - guard let response = response as? HTTPURLResponse, let data else { - if let sessionError { - onError(sessionError) - } - return - } - - do { - if !response.isSuccessful() { - let apiDeleteToken = try JSONDecoder().decode(ApiDeleteToken.self, from: data) - onError(NSError(domain: apiDeleteToken.error!, code: response.statusCode, userInfo: ["Error": apiDeleteToken.error!])) - } - } catch { - onError(error) - } - }.resume() - } - - // MARK: Private - - /// Make the get token network call - private func getApiToken(request: URLRequest, completion: @escaping (ApiToken?, Error?) -> Void) { - let session = URLSession.shared - session.dataTask(with: request) { data, response, sessionError in - guard let response = response as? HTTPURLResponse, - let data = data, data.count > 0 else { - completion(nil, sessionError) - return - } - - do { - if response.isSuccessful() { - let apiToken = try JSONDecoder().decode(ApiToken.self, from: data) - completion(apiToken, nil) - } else { - let apiError = try JSONDecoder().decode(LoginApiError.self, from: data) - completion(nil, NSError(domain: apiError.error, code: response.statusCode, userInfo: ["Error": apiError])) - } - } catch { - completion(nil, error) - } - }.resume() - } -} - -extension HTTPURLResponse { - func isSuccessful() -> Bool { - return statusCode >= 200 && statusCode <= 299 - } -} - -extension Dictionary { - func percentEncoded() -> Data? { - return map { key, value in - let escapedKey = "\(key)".addingPercentEncoding(withAllowedCharacters: .urlQueryValueAllowed) ?? "" - let escapedValue = "\(value)".addingPercentEncoding(withAllowedCharacters: .urlQueryValueAllowed) ?? "" - return escapedKey + "=" + escapedValue - } - .joined(separator: "&") - .data(using: .utf8) - } -} - -extension CharacterSet { - static let urlQueryValueAllowed: CharacterSet = { - let generalDelimitersToEncode = ":#[]@" // does not include "?" or "/" due to RFC 3986 - Section 3.4 - let subDelimitersToEncode = "!$&'()*+,;=" - - var allowed = CharacterSet.urlQueryAllowed - allowed.remove(charactersIn: "\(generalDelimitersToEncode)\(subDelimitersToEncode)") - return allowed - }() -} diff --git a/Tests/InfomaniakCoreTests/Extensions/UTCollection+Identity.swift b/Tests/InfomaniakCoreTests/Extensions/UTCollection+Identity.swift new file mode 100644 index 0000000..0a17895 --- /dev/null +++ b/Tests/InfomaniakCoreTests/Extensions/UTCollection+Identity.swift @@ -0,0 +1,107 @@ +/* + Infomaniak Core - iOS + Copyright (C) 2023 Infomaniak Network SA + + 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, see . + */ + +import InfomaniakCore +import XCTest + +extension Int: Identifiable { + public var id: Int { + return self + } +} + +@available(iOS 13.0, *) +final class UTCollectionIdentity: XCTestCase { + func testIntSameArraySameHash() { + // GIVEN + let lhsArray = [1, 2, 3, 4] + let rhsArray = [1, 2, 3, 4] + + // WHEN + let lhsId = lhsArray.collectionId() + let rhsId = rhsArray.collectionId() + + // THEN + XCTAssertEqual(lhsId, rhsId, "We expect the ids to be the same") + } + + func testIntSameArraySameHashWithBase() { + // GIVEN + let lhsArray = [1, 2, 3, 4] + let rhsArray = [1, 2, 3, 4] + + // WHEN + let lhsId = lhsArray.collectionId(baseId: 10) + let rhsId = rhsArray.collectionId(baseId: 10) + + // THEN + XCTAssertEqual(lhsId, rhsId, "We expect the ids to be the same") + } + + func testIntReversedArrayDifferentHash() { + // GIVEN + let lhsArray = [1, 2, 3, 4] + let rhsArray = [1, 2, 3, 4].reversed() + + // WHEN + let lhsId = lhsArray.collectionId() + let rhsId = rhsArray.collectionId() + + // THEN + XCTAssertNotEqual(lhsId, rhsId, "We expect the ids to not be the same") + } + + func testIntDifferentArrayDifferentHash() { + // GIVEN + let lhsArray = [1, 2, 3, 4] + let rhsArray = [1, 2, 3, 5] + + // WHEN + let lhsId = lhsArray.collectionId() + let rhsId = rhsArray.collectionId() + + // THEN + XCTAssertNotEqual(lhsId, rhsId, "We expect the ids to not be the same") + } + + func testIntDifferentArrayDifferentHashWithBase() { + // GIVEN + let lhsArray = [1, 2, 3, 4] + let rhsArray = [1, 2, 3, 5] + + // WHEN + let lhsId = lhsArray.collectionId(baseId: 10) + let rhsId = rhsArray.collectionId(baseId: 10) + + // THEN + XCTAssertNotEqual(lhsId, rhsId, "We expect the ids to not be the same") + } + + func testIntDifferentArrayDifferentHashWithDifferentBase() { + // GIVEN + let lhsArray = [1, 2, 3, 4] + let rhsArray = [1, 2, 3, 5] + + // WHEN + let lhsId = lhsArray.collectionId(baseId: 10) + let rhsId = rhsArray.collectionId(baseId: 11) + + // THEN + XCTAssertNotEqual(lhsId, rhsId, "We expect the ids to not be the same") + } +} diff --git a/Tests/InfomaniakCoreTests/UTItemProviderFileRepresentation/UTItemProviderFileRepresentation.swift b/Tests/InfomaniakCoreTests/UTItemProviderFileRepresentation/UTItemProviderFileRepresentation.swift index 1d039c5..8e41c26 100644 --- a/Tests/InfomaniakCoreTests/UTItemProviderFileRepresentation/UTItemProviderFileRepresentation.swift +++ b/Tests/InfomaniakCoreTests/UTItemProviderFileRepresentation/UTItemProviderFileRepresentation.swift @@ -51,6 +51,7 @@ final class UTItemProviderFileRepresentation: XCTestCase { func testFile() async { // GIVEN + let expectedTitle = "text" let someText: NSString = "Some Text" // for NSCoding let item = NSItemProvider(item: someText, typeIdentifier: "\(UTI.text.rawValue)") @@ -78,9 +79,10 @@ final class UTItemProviderFileRepresentation: XCTestCase { // THEN XCTAssertTrue(progress.isFinished, "Expecting the progress to reflect that the task is finished") - XCTAssertEqual(success.pathExtension.lowercased(), "txt") + XCTAssertEqual(success.title, expectedTitle) + XCTAssertEqual(success.url.pathExtension.lowercased(), "txt") - let stringResult = try String(contentsOf: success, encoding: .utf8) as NSString // for NSCoding + let stringResult = try String(contentsOf: success.url, encoding: .utf8) as NSString // for NSCoding XCTAssertEqual(stringResult, someText) } catch { @@ -94,6 +96,7 @@ final class UTItemProviderFileRepresentation: XCTestCase { func testJPGImageNoChange() async { // GIVEN + let expectedTitle = "JPEG image" guard let imgUrlJpg = Bundle.module.url(forResource: Self.imageFile, withExtension: "jpg") else { XCTFail("unexpected") return @@ -114,9 +117,10 @@ final class UTItemProviderFileRepresentation: XCTestCase { // THEN XCTAssertTrue(progress.isFinished, "Expecting the progress to reflect that the task is finished") - XCTAssertEqual(success.pathExtension.lowercased(), "jpeg") + XCTAssertEqual(success.title, expectedTitle) + XCTAssertEqual(success.url.pathExtension.lowercased(), "jpeg") - let imageData = try Data(contentsOf: success) + let imageData = try Data(contentsOf: success.url) XCTAssertTrue(imageData.count > 0, "expecting to find a non empty file") } catch { XCTFail("Unexpected \(error)") @@ -125,6 +129,7 @@ final class UTItemProviderFileRepresentation: XCTestCase { func testHEICImageNoChange() async { // GIVEN + let expectedTitle = "HEIF Image" guard let imgUrlHeic = Bundle.module.url(forResource: Self.imageFile, withExtension: "heic") else { XCTFail("unexpected") return @@ -145,9 +150,10 @@ final class UTItemProviderFileRepresentation: XCTestCase { // THEN XCTAssertTrue(progress.isFinished, "Expecting the progress to reflect that the task is finished") - XCTAssertEqual(success.pathExtension.lowercased(), "heic") + XCTAssertEqual(success.title, expectedTitle) + XCTAssertEqual(success.url.pathExtension.lowercased(), "heic") - let imageData = try Data(contentsOf: success) + let imageData = try Data(contentsOf: success.url) XCTAssertTrue(imageData.count > 0, "expecting to find a non empty file") } catch { XCTFail("Unexpected \(error)") @@ -158,6 +164,7 @@ final class UTItemProviderFileRepresentation: XCTestCase { func testHEICtoJPEG_success() async { // GIVEN + let expectedTitle = "JPEG image" guard let imgUrlHeic = Bundle.module.url(forResource: Self.imageFile, withExtension: "heic"), let imgUrlJpg = Bundle.module.url(forResource: Self.imageFile, withExtension: "jpg") else { XCTFail("unexpected") @@ -182,7 +189,8 @@ final class UTItemProviderFileRepresentation: XCTestCase { // THEN XCTAssertTrue(progress.isFinished, "Expecting the progress to reflect that the task is finished") - XCTAssertEqual(success.pathExtension.lowercased(), "jpeg") + XCTAssertEqual(success.title, expectedTitle) + XCTAssertEqual(success.url.pathExtension.lowercased(), "jpeg") } catch { XCTFail("Unexpected \(error)") } @@ -190,6 +198,7 @@ final class UTItemProviderFileRepresentation: XCTestCase { func testJPEGtoHEIC_success() async { // GIVEN + let expectedTitle = "JPEG image" guard let imgUrlHeic = Bundle.module.url(forResource: Self.imageFile, withExtension: "heic"), let imgUrlJpg = Bundle.module.url(forResource: Self.imageFile, withExtension: "jpg") else { XCTFail("unexpected") @@ -214,7 +223,8 @@ final class UTItemProviderFileRepresentation: XCTestCase { // THEN XCTAssertTrue(progress.isFinished, "Expecting the progress to reflect that the task is finished") - XCTAssertEqual(success.pathExtension.lowercased(), "jpeg") + XCTAssertEqual(success.title, expectedTitle) + XCTAssertEqual(success.url.pathExtension.lowercased(), "jpeg") } catch { XCTFail("Unexpected \(error)") } @@ -224,6 +234,7 @@ final class UTItemProviderFileRepresentation: XCTestCase { func testHEICtoJPG_fallbackToHEIC() async { // GIVEN + let expectedTitle = "HEIF Image" guard let imgUrlHeic = Bundle.module.url(forResource: Self.imageFile, withExtension: "heic") else { XCTFail("unexpected") return @@ -247,9 +258,10 @@ final class UTItemProviderFileRepresentation: XCTestCase { // THEN XCTAssertTrue(progress.isFinished, "Expecting the progress to reflect that the task is finished") + XCTAssertEqual(success.title, expectedTitle) // We expect a HEIC fallback when no JPG is available - XCTAssertEqual(success.pathExtension.lowercased(), "heic") + XCTAssertEqual(success.url.pathExtension.lowercased(), "heic") } catch { XCTFail("Unexpected \(error)") } @@ -257,6 +269,7 @@ final class UTItemProviderFileRepresentation: XCTestCase { func testJPGtoHEIC_fallbackToJPEG() async { // GIVEN + let expectedTitle = "JPEG image" guard let imgUrlJpg = Bundle.module.url(forResource: Self.imageFile, withExtension: "jpg") else { XCTFail("unexpected") return @@ -280,9 +293,10 @@ final class UTItemProviderFileRepresentation: XCTestCase { // THEN XCTAssertTrue(progress.isFinished, "Expecting the progress to reflect that the task is finished") + XCTAssertEqual(success.title, expectedTitle) // We expect a JPEG fallback when no HEIC is available - XCTAssertEqual(success.pathExtension.lowercased(), "jpeg") + XCTAssertEqual(success.url.pathExtension.lowercased(), "jpeg") } catch { XCTFail("Unexpected \(error)") } @@ -292,6 +306,7 @@ final class UTItemProviderFileRepresentation: XCTestCase { func testJPGfromText_fallback() async { // GIVEN + let expectedTitle = "text" let someText: NSString = "Some Text" // for NSCoding let item = NSItemProvider(item: someText, typeIdentifier: "\(UTI.text.rawValue)") @@ -319,9 +334,10 @@ final class UTItemProviderFileRepresentation: XCTestCase { // THEN we still get an unchanged TXT file XCTAssertTrue(progress.isFinished, "Expecting the progress to reflect that the task is finished") - XCTAssertEqual(success.pathExtension.lowercased(), "txt") + XCTAssertEqual(success.title, expectedTitle) + XCTAssertEqual(success.url.pathExtension.lowercased(), "txt") - let stringResult = try String(contentsOf: success, encoding: .utf8) as NSString // for NSCoding + let stringResult = try String(contentsOf: success.url, encoding: .utf8) as NSString // for NSCoding XCTAssertEqual(stringResult, someText) } catch { diff --git a/Tests/InfomaniakCoreTests/UTItemProviderFileRepresentation/UTItemProviderURLRepresentation.swift b/Tests/InfomaniakCoreTests/UTItemProviderFileRepresentation/UTItemProviderURLRepresentation.swift index 87acac8..61e036b 100644 --- a/Tests/InfomaniakCoreTests/UTItemProviderFileRepresentation/UTItemProviderURLRepresentation.swift +++ b/Tests/InfomaniakCoreTests/UTItemProviderFileRepresentation/UTItemProviderURLRepresentation.swift @@ -71,8 +71,9 @@ final class UTItemProviderURLRepresentation: XCTestCase { // THEN XCTAssertTrue(progress.isFinished, "Expecting the progress to reflect that the task is finished") - XCTAssertEqual(success.lastPathComponent, Self.imageFile + ".jpg") - XCTAssertTrue(success.lastPathComponent.hasSuffix(".jpg")) + XCTAssertEqual(success.url.lastPathComponent, Self.imageFile + ".jpg") + XCTAssertTrue(success.url.lastPathComponent.hasSuffix(".jpg")) + XCTAssertEqual(success.title, UTItemProviderURLRepresentation.imageFile) } catch { XCTFail("Unexpected \(error)") @@ -94,21 +95,23 @@ final class UTItemProviderURLRepresentation: XCTestCase { // THEN XCTAssertTrue(progress.isFinished, "Expecting the progress to reflect that the task is finished") - XCTAssertGreaterThanOrEqual(success.lastPathComponent.count, 17 + ".webloc".count, "non empty title") - XCTAssertLessThanOrEqual(success.lastPathComponent.count, 30 + ".webloc".count, "smaller than UUID") - XCTAssertTrue(success.lastPathComponent.hasSuffix(".webloc")) + XCTAssertGreaterThanOrEqual(success.url.lastPathComponent.count, 17 + ".webloc".count, "non empty title") + XCTAssertLessThanOrEqual(success.url.lastPathComponent.count, 30 + ".webloc".count, "smaller than UUID") + XCTAssertTrue(success.url.lastPathComponent.hasSuffix(".webloc")) + XCTAssertGreaterThanOrEqual(success.title.count, 10, "title should be non empty") } catch { XCTFail("Unexpected \(error)") } } - + @available(iOS 16.0, *) func testWebloc_successWebMainPage() async { // GIVEN let someURL = URL(string: "https://infomaniak.com/")! let item = NSItemProvider(contentsOf: someURL)! - let expectedFileName = "infomaniak_com.webloc" + let expectedTitle = "infomaniak_com" + let expectedFileName = "\(expectedTitle).webloc" do { let provider = try ItemProviderURLRepresentation(from: item) @@ -117,24 +120,26 @@ final class UTItemProviderURLRepresentation: XCTestCase { // WHEN let success = try await provider.result.get() - let title = success.lastPathComponent + let title = success.url.lastPathComponent // THEN XCTAssertTrue(progress.isFinished, "Expecting the progress to reflect that the task is finished") XCTAssertTrue(title.hasSuffix(".webloc"), "Expecting a .webloc extension, got:\(title)") XCTAssertEqual(title, expectedFileName) + XCTAssertEqual(success.title, expectedTitle) } catch { XCTFail("Unexpected \(error)") } } - + @available(iOS 16.0, *) func testWebloc_successWebMainPageWWW() async { // GIVEN let someURL = URL(string: "https://www.infomaniak.com/")! let item = NSItemProvider(contentsOf: someURL)! - let expectedFileName = "infomaniak_com.webloc" + let expectedTitle = "infomaniak_com" + let expectedFileName = "\(expectedTitle).webloc" do { let provider = try ItemProviderURLRepresentation(from: item) @@ -143,12 +148,13 @@ final class UTItemProviderURLRepresentation: XCTestCase { // WHEN let success = try await provider.result.get() - let title = success.lastPathComponent + let title = success.url.lastPathComponent // THEN XCTAssertTrue(progress.isFinished, "Expecting the progress to reflect that the task is finished") XCTAssertTrue(title.hasSuffix(".webloc"), "Expecting a .webloc extension, got:\(title)") XCTAssertEqual(title, expectedFileName) + XCTAssertEqual(success.title, expectedTitle) } catch { XCTFail("Unexpected \(error)") @@ -160,7 +166,8 @@ final class UTItemProviderURLRepresentation: XCTestCase { // GIVEN let someURL = URL(string: "https://infomaniak.com")! let item = NSItemProvider(contentsOf: someURL)! - let expectedFileName = "infomaniak_com.webloc" + let expectedTitle = "infomaniak_com" + let expectedFileName = "\(expectedTitle).webloc" do { let provider = try ItemProviderURLRepresentation(from: item) @@ -169,12 +176,13 @@ final class UTItemProviderURLRepresentation: XCTestCase { // WHEN let success = try await provider.result.get() - let title = success.lastPathComponent + let title = success.url.lastPathComponent // THEN XCTAssertTrue(progress.isFinished, "Expecting the progress to reflect that the task is finished") XCTAssertTrue(title.hasSuffix(".webloc"), "Expecting a .webloc extension, got:\(title)") XCTAssertEqual(title, expectedFileName) + XCTAssertEqual(success.title, expectedTitle) } catch { XCTFail("Unexpected \(error)") @@ -183,7 +191,8 @@ final class UTItemProviderURLRepresentation: XCTestCase { func testWebloc_successWebIndexPage() async { // GIVEN - let someURL = URL(string: "https://infomaniak.com/index.html")! + let expectedTitle = "index" + let someURL = URL(string: "https://infomaniak.com/\(expectedTitle).html")! let item = NSItemProvider(contentsOf: someURL)! do { @@ -196,7 +205,8 @@ final class UTItemProviderURLRepresentation: XCTestCase { // THEN XCTAssertTrue(progress.isFinished, "Expecting the progress to reflect that the task is finished") - XCTAssertEqual(success.lastPathComponent, "index.webloc") + XCTAssertEqual(success.url.lastPathComponent, "index.webloc") + XCTAssertEqual(success.title, expectedTitle) } catch { XCTFail("Unexpected \(error)") diff --git a/Tests/InfomaniakCoreTests/UTItemProviderFileRepresentation/UTItemProviderZipRepresentation.swift b/Tests/InfomaniakCoreTests/UTItemProviderFileRepresentation/UTItemProviderZipRepresentation.swift index 0320a1f..83f2066 100644 --- a/Tests/InfomaniakCoreTests/UTItemProviderFileRepresentation/UTItemProviderZipRepresentation.swift +++ b/Tests/InfomaniakCoreTests/UTItemProviderFileRepresentation/UTItemProviderZipRepresentation.swift @@ -58,6 +58,14 @@ final class UTItemProviderZipRepresentation: XCTestCase { // URL of a file somewhere we have access to let mockedFileURL = try await provider.result.get() + let pathComponents = mockedFileURL.pathComponents + let pathComponentsCount = pathComponents.count + let fileName = mockedFileURL.lastPathComponent + guard let folderName = mockedFileURL.pathComponents[safe: pathComponentsCount - 2] else { + XCTFail("Unexpected, should be able to get a folder name") + return + } + // Sanity check XCTAssertTrue(mockedFileURL.lastPathComponent.hasSuffix("txt")) let folderToZipURL = mockedFileURL.deletingLastPathComponent() @@ -72,29 +80,40 @@ final class UTItemProviderZipRepresentation: XCTestCase { XCTAssertFalse(progress.isFinished, "Expecting the progress to reflect that the task has not started yet") // WHEN We get the path of the zipped file - let successURL = try await zipFileRepresentation.result.get() + let success = try await zipFileRepresentation.result.get() // THEN XCTAssertTrue(progress.isFinished, "Expecting the progress to reflect that the task is finished") - XCTAssertGreaterThanOrEqual(successURL.lastPathComponent.count, 30+".zip".count, "it should reflect the folder used, a UUID here") - XCTAssertEqual(successURL.pathExtension, "zip") - + XCTAssertEqual(success.title, folderName, "Expecting the generated zip file to match the compressed folder name") + XCTAssertGreaterThanOrEqual( + success.url.lastPathComponent.count, + 30 + ".zip".count, + "it should reflect the folder used, a UUID here" + ) + XCTAssertEqual(success.url.pathExtension, "zip") + // We create a folder to unzip content - let unzipFolder = successURL.deletingLastPathComponent() + let unzipFolder = success.url.deletingLastPathComponent() .appendingPathComponent("Unzip", isDirectory: true) try fileManager.createDirectory(at: unzipFolder, withIntermediateDirectories: true) // We build the URL of the unzipped folder - let fileName = successURL.lastPathComponent - let unzipFolderName = fileName.replacingOccurrences(of: ".zip", with: "") + let fileNameFromUrl = success.url.lastPathComponent + let unzipFolderName = fileNameFromUrl.replacingOccurrences(of: ".zip", with: "") let unzipFolderPath = unzipFolder.appendingPathComponent(unzipFolderName, isDirectory: true) // We perform the unzip - try fileManager.unzipItem(at: successURL, to: unzipFolder) + try fileManager.unzipItem(at: success.url, to: unzipFolder) // check the content of the unzipped folder let items = try fileManager.contentsOfDirectory(at: unzipFolderPath, includingPropertiesForKeys: nil) XCTAssertEqual(items.count, 1) + guard let unzippedItem = items.first else { + XCTFail("Unexpected empty folder") + return + } + + XCTAssertEqual(unzippedItem.lastPathComponent, fileName, "file name should not change once unzipped") guard let unzipFileURL = items.first else { XCTFail("Expecting to find exactly one file in unzipped folder")