diff --git a/Package.resolved b/Package.resolved
index f367b8c..beba15e 100644
--- a/Package.resolved
+++ b/Package.resolved
@@ -68,8 +68,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/getsentry/sentry-cocoa",
"state" : {
- "revision" : "d2ced2d961b34573ebd2ea0567a2f1408e90f0ae",
- "version" : "8.34.0"
+ "revision" : "54cc2e3e4fcbf9d4c7708ce00d3b6eee29aecbb1",
+ "version" : "8.38.0"
}
},
{
diff --git a/Sources/InfomaniakCore/Chunking/ChunkProvider.swift b/Sources/InfomaniakCore/Chunking/ChunkProvider.swift
new file mode 100644
index 0000000..f9972ed
--- /dev/null
+++ b/Sources/InfomaniakCore/Chunking/ChunkProvider.swift
@@ -0,0 +1,115 @@
+/*
+ Infomaniak kDrive - iOS App
+ Copyright (C) 2021 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
+
+/// Something that builds chunks and provide them with an iterator.
+public protocol ChunkProvidable: IteratorProtocol {
+ init?(fileURL: URL, ranges: [DataRange])
+}
+
+/// Something that can chunk a file part by part, in memory, given specified ranges.
+///
+/// Memory considerations: Max memory use ≈sizeOf(one chunk). So from 1Mb to 50Mb
+/// Thread safety: Not thread safe
+///
+@available(macOS 10.15.4, iOS 13.4, watchOS 6.2, tvOS 13.4, *)
+public final class ChunkProvider: ChunkProvidable {
+ public typealias Element = Data
+
+ let fileHandle: FileHandlable
+
+ var ranges: [DataRange]
+
+ deinit {
+ do {
+ // For the sake of consistency
+ try fileHandle.close()
+ } catch {}
+ }
+
+ public init?(fileURL: URL, ranges: [DataRange]) {
+ self.ranges = ranges
+
+ do {
+ fileHandle = try FileHandle(forReadingFrom: fileURL)
+ } catch {
+ return nil
+ }
+ }
+
+ /// Internal testing method
+ init(mockedHandlable: FileHandlable, ranges: [DataRange]) {
+ self.ranges = ranges
+ fileHandle = mockedHandlable
+ }
+
+ /// Will provide chunks one by one, using the IteratorProtocol
+ /// Starting by the first range available.
+ public func next() -> Data? {
+ guard !ranges.isEmpty else {
+ return nil
+ }
+
+ let range = ranges.removeFirst()
+
+ do {
+ let chunk = try readChunk(range: range)
+ return chunk
+ } catch {
+ return nil
+ }
+ }
+
+ // MARK: Internal
+
+ func readChunk(range: DataRange) throws -> Data? {
+ let offset = range.lowerBound
+ try fileHandle.seek(toOffset: offset)
+
+ let byteCount = Int(range.upperBound - range.lowerBound) + 1
+ let chunk = try fileHandle.read(upToCount: byteCount)
+ return chunk
+ }
+}
+
+/// Print the FileHandle shows the current offset
+extension FileHandle {
+ override open var description: String {
+ if #available(macOS 10.15.4, iOS 13.4, watchOS 6.2, tvOS 13.4, *) {
+ let superDescription = super.description
+
+ let offsetString: String
+ do {
+ let offset = try offset()
+ offsetString = "\(offset)"
+ } catch {
+ offsetString = "\(error)"
+ }
+
+ let buffer = """
+ <\(superDescription)>
+
+ """
+
+ return buffer
+ } else {
+ return super.description
+ }
+ }
+}
diff --git a/Sources/InfomaniakCore/Chunking/FileHandlable.swift b/Sources/InfomaniakCore/Chunking/FileHandlable.swift
new file mode 100644
index 0000000..64b4d27
--- /dev/null
+++ b/Sources/InfomaniakCore/Chunking/FileHandlable.swift
@@ -0,0 +1,46 @@
+/*
+ Infomaniak kDrive - iOS App
+ 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
+
+/// Something that matches most of the FileHandle specification, used for testing
+@available(macOS 10.15.4, iOS 13.4, watchOS 6.2, tvOS 13.4, *)
+protocol FileHandlable {
+ var availableData: Data { get }
+
+ var description: String { get }
+
+ func seek(toOffset offset: UInt64) throws
+
+ func truncate(atOffset offset: UInt64) throws
+
+ func synchronize() throws
+
+ func close() throws
+
+ func readToEnd() throws -> Data?
+
+ func read(upToCount count: Int) throws -> Data?
+
+ func offset() throws -> UInt64
+
+ func seekToEnd() throws -> UInt64
+}
+
+/// Protocol conformance
+extension FileHandle: FileHandlable {}
diff --git a/Sources/InfomaniakCore/Chunking/RangeProvider.swift b/Sources/InfomaniakCore/Chunking/RangeProvider.swift
new file mode 100644
index 0000000..38de8ec
--- /dev/null
+++ b/Sources/InfomaniakCore/Chunking/RangeProvider.swift
@@ -0,0 +1,110 @@
+/*
+ Infomaniak kDrive - iOS App
+ Copyright (C) 2021 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
+
+/// A range of Bytes on a `Data` buffer
+/// - start: start byte index, first index at 0 by convention.
+/// - end: end byte index, last index at fileSize -1 by convention.
+public typealias DataRange = ClosedRange
+
+/// Something that can provide a sequence of ranges where the file should be split if necessary.
+public protocol RangeProvidable {
+ /// Computes and return all the contiguous ranges for a file at the moment of calling.
+ ///
+ /// Result may change over time if file is modified in between calls.
+ /// Throws if file too large or too small, also if file system issue.
+ /// Minimum size support is one byte (low bound == high bound)
+ var allRanges: [DataRange] { get throws }
+
+ /// Return the file size in bytes at the moment of calling.
+ var fileSize: UInt64 { get throws }
+}
+
+public struct RangeProvider: RangeProvidable {
+ /// Encapsulating API parameters used to compute ranges
+ public enum APIConstants {
+ static let chunkMinSize: UInt64 = 1 * 1024 * 1024
+ static let chunkMaxSizeClient: UInt64 = 50 * 1024 * 1024
+ static let chunkMaxSizeServer: UInt64 = 1 * 1024 * 1024 * 1024
+ static let optimalChunkCount: UInt64 = 200
+ static let maxTotalChunks: UInt64 = 10000
+ static let minTotalChunks: UInt64 = 1
+
+ /// the limit supported by the app
+ static let fileMaxSizeClient = APIConstants.maxTotalChunks * APIConstants.chunkMaxSizeClient
+
+ /// the limit supported by the server
+ static let fileMaxSizeServer = APIConstants.maxTotalChunks * APIConstants.chunkMaxSizeServer
+ }
+
+ enum ErrorDomain: Error {
+ /// Unable to read file system metadata
+ case UnableToReadFileAttributes
+
+ /// file is over the supported size
+ case FileTooLarge
+
+ /// We ask for chunks that do not make sense
+ case ChunkedSizeLargerThanSourceFile
+
+ /// At least one chunk is expected
+ case IncorrectTotalChunksCount
+
+ /// A non zero size is expected
+ case IncorrectChunkSize
+ }
+
+ /// The internal methods split into another type, make testing easier
+ var guts: RangeProviderGutsable
+
+ public init(fileURL: URL) {
+ guts = RangeProviderGuts(fileURL: fileURL)
+ }
+
+ public var fileSize: UInt64 {
+ get throws {
+ let size = try guts.readFileByteSize()
+ return size
+ }
+ }
+
+ public var allRanges: [DataRange] {
+ get throws {
+ let size = try fileSize
+
+ // Check for files too large to be processed by mobile app or the server
+ guard size < APIConstants.fileMaxSizeClient,
+ size < APIConstants.fileMaxSizeServer else {
+ // TODO: notify Sentry
+ throw ErrorDomain.FileTooLarge
+ }
+
+ let preferredChunkSize = guts.preferredChunkSize(for: size)
+
+ // Make sure an empty file resolves to one chunk
+ let totalChunksCount = max(size / max(preferredChunkSize, 1), 1)
+
+ let ranges = try guts.buildRanges(fileSize: size,
+ totalChunksCount: totalChunksCount,
+ chunkSize: preferredChunkSize)
+
+ return ranges
+ }
+ }
+}
diff --git a/Sources/InfomaniakCore/Chunking/RangeProviderGuts.swift b/Sources/InfomaniakCore/Chunking/RangeProviderGuts.swift
new file mode 100644
index 0000000..0406fcd
--- /dev/null
+++ b/Sources/InfomaniakCore/Chunking/RangeProviderGuts.swift
@@ -0,0 +1,135 @@
+/*
+ Infomaniak kDrive - iOS App
+ 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
+
+/// The internal methods of RangeProviderGuts, made testable
+public protocol RangeProviderGutsable {
+ /// Build ranges for a file
+ ///
+ /// Empty file will return zero chunk.
+ /// the range `0...0` represents the first Byte of a file
+ ///
+ /// - Parameters:
+ /// - fileSize: the total file size, in **Bytes**
+ /// - totalChunksCount: the total number of chunks that should be used
+ /// - chunkSize: the size of a chunk that should be used
+ /// - Returns: a collection of contiguous (so ordered) ranges.
+ /// - Throws: if some preconditions are not met
+ func buildRanges(fileSize: UInt64, totalChunksCount: UInt64, chunkSize: UInt64) throws -> [DataRange]
+
+ /// Get the size of a file, in **Bytes**
+ /// - Returns: the file size at the moment of execution
+ func readFileByteSize() throws -> UInt64
+
+ /// Mimmic the Android logic and returns what is preferred by the API for a specific file size
+ /// - Parameter fileSize: the input file size, in **Bytes**
+ /// - Returns: The _preferred_ size of one chunk
+ func preferredChunkSize(for fileSize: UInt64) -> UInt64
+}
+
+/// Subdivided **RangeProvider**, so it is easier to test
+public struct RangeProviderGuts: RangeProviderGutsable {
+ /// The URL of the local file to scan
+ public let fileURL: URL
+
+ public func buildRanges(fileSize: UInt64, totalChunksCount: UInt64, chunkSize: UInt64) throws -> [DataRange] {
+ // malformed requests
+ guard totalChunksCount > 0 else {
+ throw RangeProvider.ErrorDomain.IncorrectTotalChunksCount
+ }
+ guard chunkSize > 0 else {
+ throw RangeProvider.ErrorDomain.IncorrectChunkSize
+ }
+
+ // Empty files
+ guard fileSize > 0 else {
+ // An empty file is supported but has no range, represented by an empty collection.
+ return []
+ }
+
+ // sanity file size check
+ let totalChunckedSize = totalChunksCount * chunkSize
+ guard totalChunckedSize <= fileSize else {
+ throw RangeProvider.ErrorDomain.ChunkedSizeLargerThanSourceFile
+ }
+
+ // The high bound for a 0 indexed list of bytes
+ let chunkBound = chunkSize - 1
+
+ var ranges: [DataRange] = []
+ for index in 0 ... totalChunksCount - 1 {
+ let startOffset = index * chunkBound + index
+ let endOffset = startOffset + chunkBound
+ let range: DataRange = startOffset ... endOffset
+
+ ranges.append(range)
+ }
+
+ // Add the remainder in a last chuck
+ let lastChunkSize = fileSize - totalChunckedSize
+ if lastChunkSize != 0 {
+ let startOffset = totalChunksCount * chunkSize
+ assert((startOffset + lastChunkSize) == fileSize, "sanity, this should match")
+
+ let endOfFileoffset = fileSize - 1
+ let range: DataRange = startOffset ... endOfFileoffset
+
+ ranges.append(range)
+ }
+
+ return ranges
+ }
+
+ public func readFileByteSize() throws -> UInt64 {
+ let fileAttributes = try FileManager.default.attributesOfItem(atPath: fileURL.path)
+ guard let fileSize = fileAttributes[.size] as? UInt64 else {
+ throw RangeProvider.ErrorDomain.UnableToReadFileAttributes
+ }
+
+ return fileSize
+ }
+
+ public func preferredChunkSize(for fileSize: UInt64) -> UInt64 {
+ // In extension to reduce memory footprint, we reduce drastically chunk size
+ guard !Bundle.main.isExtension else {
+ let capChunkSize = min(fileSize, RangeProvider.APIConstants.chunkMinSize)
+ return capChunkSize
+ }
+
+ let potentialChunkSize = fileSize / RangeProvider.APIConstants.optimalChunkCount
+
+ let chunkSize: UInt64
+ switch potentialChunkSize {
+ case 0 ..< RangeProvider.APIConstants.chunkMinSize:
+ chunkSize = RangeProvider.APIConstants.chunkMinSize
+
+ case RangeProvider.APIConstants.chunkMinSize ... RangeProvider.APIConstants.chunkMaxSizeClient:
+ chunkSize = potentialChunkSize
+
+ /// Strictly higher than `APIConstants.chunkMaxSize`
+ default:
+ chunkSize = RangeProvider.APIConstants.chunkMaxSizeClient
+ }
+
+ /// Set a lower bound to chunk size
+ let capChunkSize = min(fileSize, chunkSize)
+ return capChunkSize
+ }
+}
diff --git a/Tests/InfomaniakCoreTests/Chunking/ITChunkProvider.swift b/Tests/InfomaniakCoreTests/Chunking/ITChunkProvider.swift
new file mode 100644
index 0000000..23ac633
--- /dev/null
+++ b/Tests/InfomaniakCoreTests/Chunking/ITChunkProvider.swift
@@ -0,0 +1,79 @@
+/*
+ Infomaniak kDrive - iOS App
+ 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
+
+/// Integration Tests of the ChunkProvider
+@available(macOS 10.15.4, iOS 13.4, watchOS 6.2, tvOS 13.4, *)
+final class ITChunkProvider: XCTestCase {
+ /// Image from wikimedia under CC.
+ static let file = "Matterhorn_as_seen_from_Zermatt,_Wallis,_Switzerland,_2012_August,Wikimedia_Commons"
+
+ func testAllRanges_image() throws {
+ // GIVEN
+ let expectedParts = 5
+ let bundle = Bundle.module
+ guard let pathURL = bundle.url(forResource: Self.file, withExtension: "jpg") else {
+ XCTFail("unexpected")
+ return
+ }
+
+ do {
+ let expectedData = try Data(contentsOf: pathURL)
+ let rangeProvider = RangeProvider(fileURL: pathURL)
+ let ranges = try rangeProvider.allRanges
+ guard let chunkProvider = ChunkProvider(fileURL: pathURL, ranges: ranges) else {
+ XCTFail("Unexpected")
+ return
+ }
+
+ // WHEN
+ var chunks: [Data] = []
+ while let chunk = chunkProvider.next() {
+ chunks.append(chunk)
+ }
+
+ // THEN
+ XCTAssertEqual(chunks.count, expectedParts)
+
+ // ZIP data and ranges, check consistency
+ let zip = zip(ranges, chunks)
+ for tuple in zip {
+ let range = tuple.0
+ let data = tuple.1
+ print(range)
+ print(data)
+ let byteCounts = range.upperBound - range.lowerBound + 1
+ XCTAssertEqual(byteCounts, UInt64(data.count))
+ }
+
+ // Merge chunks and check file matches the original
+ let magic = chunks.reduce(Data()) { partialResult, chunk in
+ var partialResult = partialResult
+ partialResult.append(chunk)
+ return partialResult
+ }
+
+ XCTAssertEqual(magic, expectedData, "files are not matching, something is corrupted")
+
+ } catch {
+ XCTFail("Unexpected \(error)")
+ }
+ }
+}
diff --git a/Tests/InfomaniakCoreTests/Chunking/ITRangeProvider.swift b/Tests/InfomaniakCoreTests/Chunking/ITRangeProvider.swift
new file mode 100644
index 0000000..2f12bcc
--- /dev/null
+++ b/Tests/InfomaniakCoreTests/Chunking/ITRangeProvider.swift
@@ -0,0 +1,61 @@
+/*
+ Infomaniak kDrive - iOS App
+ 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
+
+/// Integration Tests of the RangeProvider
+final class ITRangeProvider: XCTestCase {
+ /// Image from wikimedia under CC.
+ static let file = "Matterhorn_as_seen_from_Zermatt,_Wallis,_Switzerland,_2012_August,Wikimedia_Commons"
+
+ // MARK: - allRanges
+
+ /// Here I test that I can chunk a file and re-glue it together.
+ func testAllRanges_image() throws {
+ // GIVEN
+ let bundle = Bundle.module
+ guard let path = bundle.path(forResource: Self.file, ofType: "jpg"),
+ let imageData = NSData(contentsOfFile: path) else {
+ XCTFail("unexpected")
+ return
+ }
+
+ guard let pathURL = bundle.url(forResource: Self.file, withExtension: "jpg") else {
+ XCTFail("unexpected")
+ return
+ }
+
+ let rangeProvider = RangeProvider(fileURL: pathURL)
+
+ // WHEN
+ do {
+ let ranges = try rangeProvider.allRanges
+
+ // THEN
+ XCTAssertNotNil(ranges)
+ try UTRangeProviderGuts.checkContinuity(ranges: ranges)
+ } catch {
+ XCTFail("Unexpected \(error)")
+ }
+
+ // THEN
+ XCTAssertNotNil(rangeProvider)
+ XCTAssertNotNil(imageData)
+ }
+}
diff --git a/Tests/InfomaniakCoreTests/Chunking/ITRangeProviderGuts.swift b/Tests/InfomaniakCoreTests/Chunking/ITRangeProviderGuts.swift
new file mode 100644
index 0000000..39ada2f
--- /dev/null
+++ b/Tests/InfomaniakCoreTests/Chunking/ITRangeProviderGuts.swift
@@ -0,0 +1,168 @@
+/*
+ Infomaniak kDrive - iOS App
+ 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 .
+ */
+
+@testable import InfomaniakCore
+import XCTest
+
+/// Integration Tests of the RangeProviderGuts
+final class ITRangeProviderGuts: XCTestCase {
+ // MARK: - readFileByteSize
+
+ let file = "Matterhorn_as_seen_from_Zermatt,_Wallis,_Switzerland,_2012_August,Wikimedia_Commons"
+
+ func testReadFileByteSize() {
+ // GIVEN
+ let expectedFileBytes = UInt64(4_865_229)
+ let bundle = Bundle.module
+ let pathURL = bundle.url(forResource: file, withExtension: "jpg")!
+ let guts = RangeProviderGuts(fileURL: pathURL)
+
+ // WHEN
+ do {
+ let size = try guts.readFileByteSize()
+
+ // THEN
+ XCTAssertEqual(size, expectedFileBytes)
+ } catch {
+ XCTFail("Unexpected \(error)")
+ }
+ }
+
+ func testReadFileByteSize_FileDoesNotExists() {
+ // GIVEN
+ let notThereFileURL = URL(string: "file:///Arcalod_2117.jpg")!
+ let rangeProvider = RangeProviderGuts(fileURL: notThereFileURL)
+
+ // WHEN
+ do {
+ _ = try rangeProvider.readFileByteSize()
+
+ // THEN
+ XCTFail("Unexpected")
+ } catch {
+ // THEN
+ // "No such file or directory"
+ XCTAssertEqual((error as NSError).domain, "NSCocoaErrorDomain")
+ XCTAssertEqual((error as NSError).code, 260)
+ }
+ }
+
+ // MARK: - buildRanges(fileSize: totalChunksCount: chunkSize:)
+
+ func testBuildRanges_fromImage() {
+ // GIVEN
+ let bundle = Bundle.module
+ let pathURL = bundle.url(forResource: file, withExtension: "jpg")!
+ let guts = RangeProviderGuts(fileURL: pathURL)
+ let fileChunks = UInt64(4)
+ let expectedChunks = 5
+ let chunksSize = UInt64(1 * 1024 * 1024)
+
+ // WHEN
+ do {
+ let size = try guts.readFileByteSize()
+ let chunks = try guts.buildRanges(fileSize: size, totalChunksCount: fileChunks, chunkSize: chunksSize)
+
+ // THEN
+ try UTRangeProviderGuts.checkContinuity(ranges: chunks)
+ XCTAssertEqual(chunks.count, expectedChunks)
+
+ // Check that last range is the end of the file
+ let lastChunk = chunks[expectedChunks - 1]
+ // The last offset is size -1
+ let endOfFileOffset = size - 1
+ guard lastChunk.upperBound == endOfFileOffset else {
+ XCTFail("EOF not reached")
+ return
+ }
+
+ } catch {
+ XCTFail("Unexpected \(error)")
+ }
+ }
+
+ func testBuildRanges_fromEmptyFile() {
+ // GIVEN
+ let guts = RangeProviderGuts(fileURL: URL(string: "http://infomaniak.ch")!)
+ let emptyFileSize = UInt64(0)
+ let fileChunks = UInt64(1)
+ let chunksSize = UInt64(1)
+ let expectedChunks = 0
+
+ // WHEN
+ do {
+ let chunks = try guts.buildRanges(fileSize: emptyFileSize, totalChunksCount: fileChunks, chunkSize: chunksSize)
+
+ // THEN
+ XCTAssertEqual(chunks.count, expectedChunks)
+ try UTRangeProviderGuts.checkContinuity(ranges: chunks)
+ } catch {
+ XCTFail("Unexpected \(error)")
+ }
+ }
+
+ // MARK: - preferredChunkSize(for fileSize:)
+
+ func testPreferredChunkSize_fromImage() {
+ // GIVEN
+ let bundle = Bundle.module
+ let pathURL = bundle.url(forResource: file, withExtension: "jpg")!
+ let guts = RangeProviderGuts(fileURL: pathURL)
+
+ // WHEN
+ do {
+ let size = try guts.readFileByteSize()
+ let preferredChunkSize = guts.preferredChunkSize(for: size)
+
+ // THEN
+ XCTAssertTrue(preferredChunkSize > 0)
+ XCTAssertTrue(preferredChunkSize <= size)
+
+ // this should not be strictly imposed but I can quickly check behaviour here
+ // XCTAssertTrue(preferredChunkSize >= RangeProvider.APIConstants.chunkMinSize)
+ // XCTAssertTrue(preferredChunkSize <= RangeProvider.APIConstants.chunkMaxSize)
+ } catch {
+ XCTFail("Unexpected \(error)")
+ }
+ }
+
+ func testPreferredChunkSize_0() {
+ // GIVEN
+ let guts = RangeProviderGuts(fileURL: URL(string: "http://infomaniak.ch")!)
+
+ // WHEN
+ let preferredChunkSize = guts.preferredChunkSize(for: 0)
+
+ // THEN
+ XCTAssertTrue(preferredChunkSize == 0)
+ }
+
+ func testPreferredChunkSize_notLargerThanFileSize() {
+ // GIVEN
+ let guts = RangeProviderGuts(fileURL: URL(string: "http://infomaniak.ch")!)
+ let superSmallFileSize: UInt64 = 10
+
+ // WHEN
+ let preferredChunkSize = guts.preferredChunkSize(for: superSmallFileSize)
+
+ // THEN
+ XCTAssertEqual(preferredChunkSize,
+ superSmallFileSize,
+ "we expect the chunk size to be capped at the file size for small files")
+ }
+}
diff --git a/Tests/InfomaniakCoreTests/Chunking/Mocks/MCKChunkProvidable.swift b/Tests/InfomaniakCoreTests/Chunking/Mocks/MCKChunkProvidable.swift
new file mode 100644
index 0000000..31a8950
--- /dev/null
+++ b/Tests/InfomaniakCoreTests/Chunking/Mocks/MCKChunkProvidable.swift
@@ -0,0 +1,47 @@
+/*
+ Infomaniak kDrive - iOS App
+ 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 InfomaniakCore
+
+/// A public mock for the MCKChunkProvidable module
+public final class MCKChunkProvidable: ChunkProvidable {
+ var fileURL: URL
+ var ranges: [DataRange]
+ public init?(fileURL: URL, ranges: [DataRange]) {
+ self.fileURL = fileURL
+ self.ranges = ranges
+ }
+
+ // MARK: - IteratorProtocol
+
+ public typealias Element = Data
+
+ var nextCalled: Bool { nextCallCount > 0 }
+ var nextCallCount = 0
+ var nextClosure: (() -> Data?)?
+ public func next() -> Data? {
+ nextCallCount += 1
+ if let nextClosure {
+ let data = nextClosure()
+ return data
+ } else {
+ return nil
+ }
+ }
+}
diff --git a/Tests/InfomaniakCoreTests/Chunking/Mocks/MCKFileHandlable.swift b/Tests/InfomaniakCoreTests/Chunking/Mocks/MCKFileHandlable.swift
new file mode 100644
index 0000000..c1e0752
--- /dev/null
+++ b/Tests/InfomaniakCoreTests/Chunking/Mocks/MCKFileHandlable.swift
@@ -0,0 +1,137 @@
+/*
+ Infomaniak kDrive - iOS App
+ 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
+@testable import InfomaniakCore
+
+/// Mocking part of the `FileHandle` API
+///
+/// Inherits from NSObject for free description implementation
+final class MCKFileHandlable: NSObject, FileHandlable {
+ var availableData: Data = .init()
+
+ // MARK: - seek(toOffset:)
+
+ var seekToOffsetCalled: Bool { seekToOffsetCallCount > 0 }
+ var seekToOffsetCallCount = 0
+ var seekToOffsetClosure: ((UInt64) -> Void)?
+ var seekToOffsetError: Error?
+ func seek(toOffset offset: UInt64) throws {
+ seekToOffsetCallCount += 1
+ if let seekToOffsetError {
+ throw seekToOffsetError
+ } else if let seekToOffsetClosure {
+ seekToOffsetClosure(offset)
+ }
+ }
+
+ // MARK: - truncate(atOffset:)
+
+ var truncateCalled: Bool { truncateCallCount > 0 }
+ var truncateCallCount = 0
+ var truncateClosure: ((UInt64) -> Void)?
+ func truncate(atOffset offset: UInt64) throws {
+ truncateCallCount += 1
+ if let truncateClosure {
+ truncateClosure(offset)
+ }
+ }
+
+ // MARK: - synchronize
+
+ var synchronizeCalled: Bool { synchronizeCallCount > 0 }
+ var synchronizeCallCount = 0
+ var synchronizeClosure: (() -> Void)?
+ func synchronize() throws {
+ synchronizeCallCount += 1
+ if let synchronizeClosure {
+ synchronizeClosure()
+ }
+ }
+
+ // MARK: - close
+
+ var closeCalled: Bool { closeCallCount > 0 }
+ var closeCallCount = 0
+ var closeClosure: (() -> Void)?
+ func close() throws {
+ closeCallCount += 1
+ if let closeClosure {
+ closeClosure()
+ }
+ }
+
+ // MARK: - readToEnd
+
+ var readToEndCalled: Bool { readToEndCallCount > 0 }
+ var readToEndCallCount = 0
+ var readToEndClosure: (() -> Data)?
+ func readToEnd() -> Data? {
+ readToEndCallCount += 1
+ if let readToEndClosure {
+ return readToEndClosure()
+ } else {
+ return nil
+ }
+ }
+
+ // MARK: - read(upToCount:)
+
+ var readUpToCountCalled: Bool { readUpToCountCallCount > 0 }
+ var readUpToCountCallCount = 0
+ var readUpToCountClosure: ((Int) -> Data)?
+ var readUpToCountError: Error?
+ func read(upToCount count: Int) throws -> Data? {
+ readUpToCountCallCount += 1
+ if let readUpToCountError {
+ throw readUpToCountError
+ } else if let readUpToCountClosure {
+ return readUpToCountClosure(count)
+ } else {
+ return nil
+ }
+ }
+
+ // MARK: - offset
+
+ var offsetCalled: Bool { offsetCallCount > 0 }
+ var offsetCallCount = 0
+ var offsetClosure: (() -> UInt64)?
+ func offset() -> UInt64 {
+ offsetCallCount += 1
+ if let offsetClosure {
+ return offsetClosure()
+ } else {
+ return UInt64(NSNotFound)
+ }
+ }
+
+ // MARK: - seekToEnd
+
+ var seekToEndCalled: Bool { seekToEndCallCount > 0 }
+ var seekToEndCallCount = 0
+ var seekToEndClosure: (() -> UInt64)?
+ func seekToEnd() -> UInt64 {
+ seekToEndCallCount += 1
+ if let seekToEndClosure {
+ return seekToEndClosure()
+ } else {
+ return UInt64(NSNotFound)
+ }
+ }
+}
diff --git a/Tests/InfomaniakCoreTests/Chunking/Mocks/MCKRangeProvidable.swift b/Tests/InfomaniakCoreTests/Chunking/Mocks/MCKRangeProvidable.swift
new file mode 100644
index 0000000..d02e60c
--- /dev/null
+++ b/Tests/InfomaniakCoreTests/Chunking/Mocks/MCKRangeProvidable.swift
@@ -0,0 +1,41 @@
+/*
+ Infomaniak kDrive - iOS App
+ 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
+
+/// A mock of RangeProvidable
+public final class MCKRangeProvidable: RangeProvidable {
+ var allRangesCalled: Bool { allRangesCallCount > 0 }
+ var allRangesCallCount = 0
+ var allRangesThrows: Error?
+ var allRangesClosure: (() -> [DataRange])?
+ public var allRanges: [DataRange] {
+ get throws {
+ allRangesCallCount += 1
+ if let allRangesThrows {
+ throw allRangesThrows
+ } else if let allRangesClosure {
+ return allRangesClosure()
+ } else {
+ return []
+ }
+ }
+ }
+
+ public var fileSize: UInt64 = 0
+}
diff --git a/Tests/InfomaniakCoreTests/Chunking/Mocks/MCKRangeProviderGuts.swift b/Tests/InfomaniakCoreTests/Chunking/Mocks/MCKRangeProviderGuts.swift
new file mode 100644
index 0000000..57d6ff5
--- /dev/null
+++ b/Tests/InfomaniakCoreTests/Chunking/Mocks/MCKRangeProviderGuts.swift
@@ -0,0 +1,43 @@
+/*
+ Infomaniak kDrive - iOS App
+ 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
+
+/// A manualy written mock of the RangeProviderGutsable protocol
+final class MCKRangeProviderGutsable: RangeProviderGutsable {
+ var buildRangesCalled = false
+ var buildRangesReturnValue: [DataRange] = []
+ func buildRanges(fileSize: UInt64, totalChunksCount: UInt64, chunkSize: UInt64) -> [DataRange] {
+ buildRangesCalled = true
+ return buildRangesReturnValue
+ }
+
+ var readFileByteSizeCalled = false
+ var readFileByteSizeReturnValue: UInt64 = 0
+ func readFileByteSize() throws -> UInt64 {
+ readFileByteSizeCalled = true
+ return readFileByteSizeReturnValue
+ }
+
+ var preferredChunkSizeCalled = false
+ var preferredChunkSizeReturnValue: UInt64 = 0
+ func preferredChunkSize(for fileSize: UInt64) -> UInt64 {
+ preferredChunkSizeCalled = true
+ return preferredChunkSizeReturnValue
+ }
+}
diff --git a/Tests/InfomaniakCoreTests/Chunking/UTChunkProvider.swift b/Tests/InfomaniakCoreTests/Chunking/UTChunkProvider.swift
new file mode 100644
index 0000000..64db526
--- /dev/null
+++ b/Tests/InfomaniakCoreTests/Chunking/UTChunkProvider.swift
@@ -0,0 +1,190 @@
+/*
+ Infomaniak kDrive - iOS App
+ 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
+@testable import InfomaniakCore
+import XCTest
+
+/// Unit Tests of the ChunkProvider
+@available(macOS 10.15.4, iOS 13.4, watchOS 6.2, tvOS 13.4, *)
+final class UTChunkProvider: XCTestCase {
+ // MARK: - next
+
+ func testNext_emptyRanges() throws {
+ // GIVEN
+ let ranges = [DataRange]()
+ let mckFileHandle = MCKFileHandlable()
+
+ let chunkProvider = ChunkProvider(mockedHandlable: mckFileHandle, ranges: ranges)
+
+ // WHEN
+ let chunk = chunkProvider.next()
+
+ // THEN
+ XCTAssertEqual(mckFileHandle.seekToOffsetCallCount, 0)
+ XCTAssertEqual(mckFileHandle.readUpToCountCallCount, 0)
+ XCTAssertNil(chunk)
+ }
+
+ func testNext_hasOneRange() throws {
+ // GIVEN
+ let ranges: [DataRange] = [
+ 0 ... 1024
+ ]
+ let mckFileHandle = MCKFileHandlable()
+ mckFileHandle.readUpToCountClosure = { _ in Data() }
+
+ let chunkProvider = ChunkProvider(mockedHandlable: mckFileHandle, ranges: ranges)
+
+ // WHEN
+ let chunk = chunkProvider.next()
+
+ // THEN
+ XCTAssertEqual(mckFileHandle.seekToOffsetCallCount, 1)
+ XCTAssertEqual(mckFileHandle.readUpToCountCallCount, 1)
+ XCTAssertNotNil(chunk)
+ }
+
+ func testNext_hasRanges() throws {
+ // GIVEN
+ let ranges: [DataRange] = [
+ 0 ... 1,
+ 1 ... 2,
+ 2 ... 4
+ ]
+ let mckFileHandle = MCKFileHandlable()
+ mckFileHandle.readUpToCountClosure = { _ in Data() }
+
+ let chunkProvider = ChunkProvider(mockedHandlable: mckFileHandle, ranges: ranges)
+
+ // WHEN
+ let chunk = chunkProvider.next()
+
+ // THEN
+ XCTAssertEqual(mckFileHandle.seekToOffsetCallCount, 1)
+ XCTAssertEqual(mckFileHandle.readUpToCountCallCount, 1)
+ XCTAssertNotNil(chunk)
+ }
+
+ func testNext_enumarateAll() throws {
+ // GIVEN
+ let ranges: [DataRange] = [
+ 0 ... 1,
+ 1 ... 2,
+ 2 ... 4
+ ]
+
+ let expectedRangesCount = ranges.count
+ let mckFileHandle = MCKFileHandlable()
+ mckFileHandle.readUpToCountClosure = { _ in Data() }
+
+ let chunkProvider = ChunkProvider(mockedHandlable: mckFileHandle, ranges: ranges)
+
+ // WHEN
+ var chunks: [Data] = []
+ while let chunk = chunkProvider.next() {
+ chunks.append(chunk)
+ }
+
+ // THEN
+ XCTAssertEqual(mckFileHandle.seekToOffsetCallCount, expectedRangesCount)
+ XCTAssertEqual(mckFileHandle.readUpToCountCallCount, expectedRangesCount)
+ XCTAssertEqual(chunks.count, expectedRangesCount)
+ }
+
+ // MARK: - readChunk(range:)
+
+ func testReadChunk_validChunk() throws {
+ // GIVEN
+ let range: DataRange = 0 ... 1
+ let mckFileHandle = MCKFileHandlable()
+ mckFileHandle.readUpToCountClosure = { _ in Data() }
+ mckFileHandle.seekToOffsetClosure = { index in
+ XCTAssertEqual(index, range.lowerBound)
+ }
+
+ let chunkProvider = ChunkProvider(mockedHandlable: mckFileHandle, ranges: [])
+
+ // WHEN
+ do {
+ let chunk = try chunkProvider.readChunk(range: range)
+ XCTAssertNotNil(chunk)
+ } catch {
+ XCTFail("Unexpected :\(error)")
+ return
+ }
+
+ // THEN
+ XCTAssertEqual(mckFileHandle.seekToOffsetCallCount, 1)
+ XCTAssertEqual(mckFileHandle.readUpToCountCallCount, 1)
+ }
+
+ func testReadChunk_throwErrorOnSeek() throws {
+ // GIVEN
+ let range: DataRange = 0 ... 1
+ let mckFileHandle = MCKFileHandlable()
+ mckFileHandle.readUpToCountClosure = { _ in Data() }
+ mckFileHandle.seekToOffsetError = NSError(domain: "k", code: 1337)
+
+ let chunkProvider = ChunkProvider(mockedHandlable: mckFileHandle, ranges: [])
+
+ // WHEN
+ do {
+ _ = try chunkProvider.readChunk(range: range)
+ XCTFail("Unexpected")
+ return
+ } catch {
+ guard (error as NSError).code == 1337 else {
+ XCTFail("Unexpected")
+ return
+ }
+ // all good
+ }
+
+ // THEN
+ XCTAssertEqual(mckFileHandle.seekToOffsetCallCount, 1)
+ XCTAssertEqual(mckFileHandle.readUpToCountCallCount, 0)
+ }
+
+ func testReadChunk_throwErrorOnRead() throws {
+ // GIVEN
+ let range: DataRange = 0 ... 1
+ let mckFileHandle = MCKFileHandlable()
+ mckFileHandle.readUpToCountClosure = { _ in Data() }
+ mckFileHandle.readUpToCountError = NSError(domain: "k", code: 1337)
+
+ let chunkProvider = ChunkProvider(mockedHandlable: mckFileHandle, ranges: [])
+
+ // WHEN
+ do {
+ _ = try chunkProvider.readChunk(range: range)
+ XCTFail("Unexpected")
+ return
+ } catch {
+ guard (error as NSError).code == 1337 else {
+ XCTFail("Unexpected")
+ return
+ }
+ // all good
+ }
+
+ // THEN
+ XCTAssertEqual(mckFileHandle.seekToOffsetCallCount, 1)
+ XCTAssertEqual(mckFileHandle.readUpToCountCallCount, 1)
+ }
+}
diff --git a/Tests/InfomaniakCoreTests/Chunking/UTRangeProvider.swift b/Tests/InfomaniakCoreTests/Chunking/UTRangeProvider.swift
new file mode 100644
index 0000000..37ee35b
--- /dev/null
+++ b/Tests/InfomaniakCoreTests/Chunking/UTRangeProvider.swift
@@ -0,0 +1,92 @@
+/*
+ Infomaniak kDrive - iOS App
+ 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 .
+ */
+
+@testable import InfomaniakCore
+import XCTest
+
+/// Unit Tests of the RangeProvider
+final class UTRangeProvider: XCTestCase {
+ func testAllRanges_zeroes() throws {
+ // GIVEN
+ let stubURL = URL(string: "file:///Arcalod_2117.jpg")!
+ var rangeProvider = RangeProvider(fileURL: stubURL)
+ let mckGuts = MCKRangeProviderGutsable( /* all zeroes by default */ )
+
+ rangeProvider.guts = mckGuts
+
+ // WHEN
+ do {
+ _ = try rangeProvider.allRanges
+
+ // THEN
+ } catch {
+ XCTFail("Unexpected")
+ }
+ }
+
+ func testAllRanges_FileTooLarge() throws {
+ // GIVEN
+ let stubURL = URL(string: "file:///Arcalod_2117.jpg")!
+ var rangeProvider = RangeProvider(fileURL: stubURL)
+ let mckGuts = MCKRangeProviderGutsable()
+ mckGuts.readFileByteSizeReturnValue = RangeProvider.APIConstants.fileMaxSizeClient + 1
+ rangeProvider.guts = mckGuts
+
+ // WHEN
+ do {
+ _ = try rangeProvider.allRanges
+
+ // THEN
+ XCTFail("Unexpected")
+ } catch {
+ // Expecting a .FileTooLarge error
+ guard case .FileTooLarge = error as? RangeProvider.ErrorDomain else {
+ XCTFail("Unexpected")
+ return
+ }
+
+ // success
+ }
+ }
+
+ func testAllRanges_Success() throws {
+ // GIVEN
+ let stubURL = URL(string: "file:///Arcalod_2117.jpg")!
+ var rangeProvider = RangeProvider(fileURL: stubURL)
+ let mckGuts = MCKRangeProviderGutsable()
+ mckGuts.readFileByteSizeReturnValue = RangeProvider.APIConstants.chunkMinSize + 1
+ mckGuts.preferredChunkSizeReturnValue = 1 * 1024 * 1024
+
+ rangeProvider.guts = mckGuts
+
+ // WHEN
+ do {
+ let ranges = try rangeProvider.allRanges
+
+ // THEN
+ XCTAssertNotNil(ranges)
+ XCTAssertEqual(ranges.count, 0)
+
+ XCTAssertTrue(mckGuts.buildRangesCalled)
+ XCTAssertTrue(mckGuts.preferredChunkSizeCalled)
+ XCTAssertTrue(mckGuts.readFileByteSizeCalled)
+ } catch {
+ XCTFail("Unexpected \(error)")
+ }
+ }
+}
diff --git a/Tests/InfomaniakCoreTests/Chunking/UTRangeProviderGuts.swift b/Tests/InfomaniakCoreTests/Chunking/UTRangeProviderGuts.swift
new file mode 100644
index 0000000..1670e0a
--- /dev/null
+++ b/Tests/InfomaniakCoreTests/Chunking/UTRangeProviderGuts.swift
@@ -0,0 +1,492 @@
+/*
+ Infomaniak kDrive - iOS App
+ 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 .
+ */
+
+@testable import InfomaniakCore
+@testable import InfomaniakDI
+import XCTest
+
+/// Unit tests of the RangeProviderGuts
+final class UTRangeProviderGuts: XCTestCase {
+ // MARK: - readFileByteSize
+
+ // covered by IT
+
+ // MARK: - buildRanges(fileSize: totalChunksCount: chunkSize:)
+
+ // MARK: zero
+
+ func testBuildRanges_0ChunkSize() {
+ // GIVEN
+ let fileBytes = UInt64(4_865_229)
+ let fileChunks = UInt64(4)
+ let chunksSize = UInt64(0)
+
+ let stubURL = URL(string: "file:///Arcalod_2117.jpg")!
+ let guts = RangeProviderGuts(fileURL: stubURL)
+
+ // WHEN
+ do {
+ _ = try guts.buildRanges(fileSize: fileBytes, totalChunksCount: fileChunks, chunkSize: chunksSize)
+
+ // THEN
+ XCTFail("Expected to throw")
+ } catch {
+ guard case .IncorrectChunkSize = error as? RangeProvider.ErrorDomain else {
+ XCTFail("Unexpected")
+ return
+ }
+ }
+ }
+
+ func testBuildRanges_0FileLength() {
+ // GIVEN
+ let fileBytes = UInt64(0)
+ let fileChunks = UInt64(4)
+ let chunksSize = UInt64(10 * 1024 * 1024)
+
+ let stubURL = URL(string: "file:///Arcalod_2117.jpg")!
+ let guts = RangeProviderGuts(fileURL: stubURL)
+
+ // WHEN
+ do {
+ let ranges = try guts.buildRanges(fileSize: fileBytes, totalChunksCount: fileChunks, chunkSize: chunksSize)
+
+ // THEN
+ XCTAssertEqual(ranges.count, 0, "An empty file has no range")
+ try Self.checkContinuity(ranges: ranges)
+ } catch {
+ XCTFail("Chunks not continuous: \(error)")
+ }
+ }
+
+ func testBuildRanges_0Chunk() {
+ // GIVEN
+ let fileBytes = UInt64(4_865_229)
+ let fileChunks = UInt64(0)
+ let chunksSize = UInt64(10 * 1024 * 1024)
+
+ let stubURL = URL(string: "file:///Arcalod_2117.jpg")!
+ let guts = RangeProviderGuts(fileURL: stubURL)
+
+ // WHEN
+ do {
+ _ = try guts.buildRanges(fileSize: fileBytes, totalChunksCount: fileChunks, chunkSize: chunksSize)
+
+ // THEN
+ XCTFail("Expected to throw")
+ } catch {
+ guard case .IncorrectTotalChunksCount = error as? RangeProvider.ErrorDomain else {
+ XCTFail("Unexpected \(error)")
+ return
+ }
+ }
+ }
+
+ // MARK: one byte
+
+ func testBuildRanges_1ChunkSize() {
+ // GIVEN
+ let fileBytes = UInt64(4_865_229)
+ let fileChunks = UInt64(4)
+ let chunksSize = UInt64(1)
+
+ let stubURL = URL(string: "file:///Arcalod_2117.jpg")!
+ let guts = RangeProviderGuts(fileURL: stubURL)
+
+ // WHEN
+ do {
+ let ranges = try guts.buildRanges(fileSize: fileBytes, totalChunksCount: fileChunks, chunkSize: chunksSize)
+
+ // THEN
+ XCTAssertEqual(ranges.count, 5)
+
+ try Self.checkContinuity(ranges: ranges)
+ } catch {
+ XCTFail("Unexpected: \(error)")
+ }
+ }
+
+ func testBuildRanges_1FileLength_valid() {
+ // GIVEN
+ let fileBytes = UInt64(1)
+ let fileChunks = UInt64(1)
+ let chunksSize = UInt64(1)
+ // read as: The first Byte goes from index O to O
+ let firstByteRange: DataRange = 0 ... 0
+
+ let stubURL = URL(string: "file:///Arcalod_2117.jpg")!
+ let guts = RangeProviderGuts(fileURL: stubURL)
+
+ // WHEN
+ do {
+ let ranges = try guts.buildRanges(fileSize: fileBytes, totalChunksCount: fileChunks, chunkSize: chunksSize)
+
+ // THEN
+ XCTAssertEqual(ranges.count, 1)
+ XCTAssertEqual(ranges.first, firstByteRange)
+
+ try Self.checkContinuity(ranges: ranges)
+ } catch {
+ XCTFail("Unexpected: \(error)")
+ }
+ }
+
+ func testBuildRanges_1FileLength_invalid() {
+ // GIVEN
+ let fileBytes = UInt64(1)
+ let fileChunks = UInt64(4)
+ let chunksSize = UInt64(10 * 1024 * 1024)
+
+ let stubURL = URL(string: "file:///Arcalod_2117.jpg")!
+ let guts = RangeProviderGuts(fileURL: stubURL)
+
+ // WHEN
+ do {
+ _ = try guts.buildRanges(fileSize: fileBytes, totalChunksCount: fileChunks, chunkSize: chunksSize)
+
+ // THEN
+ XCTFail("Expected to throw")
+ } catch {
+ guard case .ChunkedSizeLargerThanSourceFile = error as? RangeProvider.ErrorDomain else {
+ XCTFail("Unexpected")
+ return
+ }
+ }
+ }
+
+ func testBuildRanges_1Chunk() {
+ // GIVEN
+ let fileBytes = UInt64(4_865_229)
+ let fileChunks = UInt64(1)
+ let chunksSize = UInt64(1 * 1024 * 1024)
+ let expectedChunks = 2 // One plus remainer
+
+ let stubURL = URL(string: "file:///Arcalod_2117.jpg")!
+ let guts = RangeProviderGuts(fileURL: stubURL)
+
+ // WHEN
+ do {
+ let ranges = try guts.buildRanges(fileSize: fileBytes, totalChunksCount: fileChunks, chunkSize: chunksSize)
+
+ // THEN
+ XCTAssertEqual(ranges.count, expectedChunks)
+
+ try Self.checkContinuity(ranges: ranges)
+ } catch {
+ XCTFail("Unexpected: \(error)")
+ }
+ }
+
+ func testBuildRanges_1ChunkBiggerThanFile() {
+ // GIVEN
+ let fileBytes = UInt64(4_865_229)
+ let fileChunks = UInt64(1)
+ let chunksSize = UInt64(10 * 1024 * 1024)
+
+ let stubURL = URL(string: "file:///Arcalod_2117.jpg")!
+ let guts = RangeProviderGuts(fileURL: stubURL)
+
+ // WHEN
+ do {
+ _ = try guts.buildRanges(fileSize: fileBytes, totalChunksCount: fileChunks, chunkSize: chunksSize)
+
+ // THEN
+ XCTFail("Expected to throw")
+ } catch {
+ guard case .ChunkedSizeLargerThanSourceFile = error as? RangeProvider.ErrorDomain else {
+ XCTFail("Unexpected")
+ return
+ }
+ }
+ }
+
+ // MARK: pseudo arbitrary sizes
+
+ func testBuildRanges_ChunkBiggerThanFile() {
+ // GIVEN
+ // Asking for 4 chunks of 10Mi is larger than the file, should exit
+ let fileBytes = UInt64(4_865_229)
+ let fileChunks = UInt64(4)
+ let chunksSize = UInt64(10 * 1024 * 1024)
+
+ let stubURL = URL(string: "file:///Arcalod_2117.jpg")!
+ let guts = RangeProviderGuts(fileURL: stubURL)
+
+ // WHEN
+ do {
+ _ = try guts.buildRanges(fileSize: fileBytes, totalChunksCount: fileChunks, chunkSize: chunksSize)
+
+ // THEN
+ XCTFail("Expected to throw")
+ } catch {
+ guard case .ChunkedSizeLargerThanSourceFile = error as? RangeProvider.ErrorDomain else {
+ XCTFail("Unexpected")
+ return
+ }
+ }
+ }
+
+ func testBuildRanges_ChunksWithoutRemainder() {
+ // GIVEN
+ // Asking for exactly 4 chunks of 1Mi
+ let fileBytes = UInt64(4 * 1024 * 1024)
+ let fileChunks = UInt64(4)
+ let chunksSize = UInt64(1 * 1024 * 1024)
+
+ let stubURL = URL(string: "file:///Arcalod_2117.jpg")!
+ let guts = RangeProviderGuts(fileURL: stubURL)
+
+ // WHEN
+ do {
+ let ranges = try guts.buildRanges(fileSize: fileBytes, totalChunksCount: fileChunks, chunkSize: chunksSize)
+
+ // THEN
+ XCTAssertEqual(ranges.count, 4)
+
+ try Self.checkContinuity(ranges: ranges)
+ } catch {
+ XCTFail("Unexpected: \(error)")
+ }
+ }
+
+ func testBuildRanges_ChunksWithRemainder() {
+ // GIVEN
+ // Asking for 4 chunks of 1Mi + some extra chunk with the remainder
+ let fileBytes = UInt64(4_865_229)
+ let fileChunks = UInt64(4)
+ let chunksSize = UInt64(1 * 1024 * 1024)
+
+ let stubURL = URL(string: "file:///Arcalod_2117.jpg")!
+ let guts = RangeProviderGuts(fileURL: stubURL)
+
+ // WHEN
+ do {
+ let ranges = try guts.buildRanges(fileSize: fileBytes, totalChunksCount: fileChunks, chunkSize: chunksSize)
+
+ // THEN
+ XCTAssertEqual(ranges.count, 5)
+
+ try Self.checkContinuity(ranges: ranges)
+ } catch {
+ XCTFail("Unexpected: \(error)")
+ }
+ }
+
+ // MARK: - preferredChunkSize(for fileSize:)
+
+ /// This is just testing the android heuristic, the min size in enforced at a higher level
+ func testPreferredChunkSize_smallerThanMinChunk() {
+ // GIVEN
+ let fileBytes = UInt64(769)
+ let chunkMinSize = RangeProvider.APIConstants.chunkMinSize
+ XCTAssertTrue(chunkMinSize > fileBytes, "this precondition should be true")
+
+ let stubURL = URL(string: "file:///Arcalod_2117.jpg")!
+ let guts = RangeProviderGuts(fileURL: stubURL)
+
+ // WHEN
+ let preferredChunkSize = guts.preferredChunkSize(for: fileBytes)
+
+ // THEN
+ XCTAssertEqual(preferredChunkSize, fileBytes)
+ }
+
+ func testPreferredChunkSize_equalsMinChunk() {
+ // GIVEN
+ let chunkMinSize = RangeProvider.APIConstants.chunkMinSize
+ let fileBytes = chunkMinSize
+
+ let stubURL = URL(string: "file:///Arcalod_2117.jpg")!
+ let guts = RangeProviderGuts(fileURL: stubURL)
+
+ // WHEN
+ let preferredChunkSize = guts.preferredChunkSize(for: fileBytes)
+
+ // THEN
+ XCTAssertEqual(preferredChunkSize, chunkMinSize)
+ }
+
+ func testPreferredChunkSize_betweensMinAndMax() {
+ // GIVEN
+ let fileBytes = UInt64(5 * 1025 * 1024)
+ XCTAssertGreaterThanOrEqual(fileBytes, RangeProvider.APIConstants.chunkMinSize)
+ XCTAssertLessThanOrEqual(fileBytes, RangeProvider.APIConstants.chunkMaxSizeClient)
+
+ let stubURL = URL(string: "file:///Arcalod_2117.jpg")!
+ let guts = RangeProviderGuts(fileURL: stubURL)
+
+ // WHEN
+ let preferredChunkSize = guts.preferredChunkSize(for: fileBytes)
+
+ // THEN
+ XCTAssertGreaterThanOrEqual(preferredChunkSize, RangeProvider.APIConstants.chunkMinSize)
+ XCTAssertLessThanOrEqual(preferredChunkSize, RangeProvider.APIConstants.chunkMaxSizeClient)
+ }
+
+ func testPreferredChunkSize_EqualMax() {
+ // GIVEN
+ let fileBytes = RangeProvider.APIConstants.chunkMaxSizeClient
+ let stubURL = URL(string: "file:///Arcalod_2117.jpg")!
+ let guts = RangeProviderGuts(fileURL: stubURL)
+
+ // WHEN
+ let preferredChunkSize = guts.preferredChunkSize(for: fileBytes)
+
+ // THEN
+ XCTAssertGreaterThanOrEqual(preferredChunkSize, RangeProvider.APIConstants.chunkMinSize)
+ XCTAssertLessThanOrEqual(preferredChunkSize, RangeProvider.APIConstants.chunkMaxSizeClient)
+ }
+
+ func testPreferredChunkSize_10Times() {
+ // GIVEN
+ let fileBytes = UInt64(10 * RangeProvider.APIConstants.chunkMaxSizeClient)
+ let stubURL = URL(string: "file:///Arcalod_2117.jpg")!
+ let guts = RangeProviderGuts(fileURL: stubURL)
+
+ // WHEN
+ let preferredChunkSize = guts.preferredChunkSize(for: fileBytes)
+
+ // THEN
+ XCTAssertGreaterThanOrEqual(preferredChunkSize, RangeProvider.APIConstants.chunkMinSize)
+ XCTAssertLessThanOrEqual(preferredChunkSize, RangeProvider.APIConstants.chunkMaxSizeClient)
+ }
+
+ func testPreferredChunkSize_10KTimes() {
+ // GIVEN
+ let fileBytes = UInt64(10000 * RangeProvider.APIConstants.chunkMaxSizeClient)
+ let stubURL = URL(string: "file:///Arcalod_2117.jpg")!
+ let guts = RangeProviderGuts(fileURL: stubURL)
+
+ // WHEN
+ let preferredChunkSize = guts.preferredChunkSize(for: fileBytes)
+
+ // THEN
+ XCTAssertGreaterThanOrEqual(preferredChunkSize, RangeProvider.APIConstants.chunkMinSize)
+ XCTAssertLessThanOrEqual(preferredChunkSize, RangeProvider.APIConstants.chunkMaxSizeClient)
+ }
+
+ func testPreferredChunkSize_100KTimes() {
+ // GIVEN
+ let fileBytes = UInt64(100_000 * RangeProvider.APIConstants.chunkMaxSizeClient)
+ let stubURL = URL(string: "file:///Arcalod_2117.jpg")!
+ let guts = RangeProviderGuts(fileURL: stubURL)
+
+ // WHEN
+ let preferredChunkSize = guts.preferredChunkSize(for: fileBytes)
+
+ // THEN
+ XCTAssertGreaterThanOrEqual(preferredChunkSize, RangeProvider.APIConstants.chunkMinSize)
+ XCTAssertLessThanOrEqual(preferredChunkSize, RangeProvider.APIConstants.chunkMaxSizeClient)
+ }
+
+ func testPreferredChunkSize_100MTimes() {
+ // GIVEN
+ let fileBytes = UInt64(100_000_000 * RangeProvider.APIConstants.chunkMaxSizeClient)
+ let stubURL = URL(string: "file:///Arcalod_2117.jpg")!
+ let guts = RangeProviderGuts(fileURL: stubURL)
+
+ // WHEN
+ let preferredChunkSize = guts.preferredChunkSize(for: fileBytes)
+
+ // THEN
+ XCTAssertGreaterThanOrEqual(preferredChunkSize, RangeProvider.APIConstants.chunkMinSize)
+ XCTAssertLessThanOrEqual(preferredChunkSize, RangeProvider.APIConstants.chunkMaxSizeClient)
+ }
+
+ // MARK: - Helpers
+
+ /// Yo dawg you tested the test helper function ?
+ func testContinuityHelper_continuous() {
+ // GIVEN
+ let rangeA = DataRange(uncheckedBounds: (lower: 0, upper: 1337))
+ let rangeB = DataRange(uncheckedBounds: (lower: 1338, upper: 2047))
+ let continuousRanges = [rangeA, rangeB]
+
+ // WHEN
+ do {
+ try Self.checkContinuity(ranges: continuousRanges)
+
+ // THEN
+ // no exception, all good
+ } catch {
+ XCTFail("Unexpected \(error)")
+ }
+ }
+
+ func testContinuityHelper_notContinuous() {
+ // GIVEN
+ let rangeA = DataRange(uncheckedBounds: (lower: 0, upper: 1337))
+ let rangeB = DataRange(uncheckedBounds: (lower: 1339, upper: 2047))
+ let notContinuousRanges = [rangeA, rangeB]
+
+ // WHEN
+ do {
+ try Self.checkContinuity(ranges: notContinuousRanges)
+
+ // THEN
+ XCTFail("Unexpected")
+ } catch {
+ guard error is UTRangeProviderGuts.DomainError else {
+ XCTFail("Unexpected")
+ return
+ }
+
+ // an exception, all good
+ }
+ }
+
+ func testContinuityHelper_empty() {
+ // GIVEN
+ // note: Is ø formally continuous? No, but easier this way.
+ let emptyRanges: [DataRange] = []
+
+ // WHEN
+ do {
+ try Self.checkContinuity(ranges: emptyRanges)
+
+ // THEN
+ // no exception, all good
+ } catch {
+ XCTFail("Unexpected \(error)")
+ }
+ }
+
+ enum DomainError: Error {
+ case gap(leftUpper: UInt64, rightLower: UInt64)
+ case nonZeroStarted
+ }
+
+ /// Check that the chunks provided are continuously describing chunks without gaps.
+ public static func checkContinuity(ranges: [DataRange]) throws {
+ /// Create an offseted sequence
+ let offsetedRanges = ranges.dropFirst()
+
+ /// Create a zip to check continuity
+ let zip = zip(ranges, offsetedRanges)
+ for tuple in zip {
+ let leftUpperBound = tuple.0.upperBound
+ let rightLowerBound = tuple.1.lowerBound
+
+ // print("range [leftUpperBound: \(leftUpperBound), rightLowerBound: \(rightLowerBound)]")
+
+ guard leftUpperBound + 1 == rightLowerBound else {
+ throw DomainError.gap(leftUpper: leftUpperBound, rightLower: rightLowerBound)
+ }
+ }
+ }
+}