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")