diff --git a/AliyunpanSDK.xcodeproj/project.pbxproj b/AliyunpanSDK.xcodeproj/project.pbxproj
index 71f2220..41d1d07 100644
--- a/AliyunpanSDK.xcodeproj/project.pbxproj
+++ b/AliyunpanSDK.xcodeproj/project.pbxproj
@@ -60,7 +60,6 @@
F498215C2B188A6D006559CC /* GetVipFeatureList.swift in Sources */ = {isa = PBXBuildFile; fileRef = F498213D2B188A6D006559CC /* GetVipFeatureList.swift */; };
F498215E2B188A6D006559CC /* GetVipFeatureTrial.swift in Sources */ = {isa = PBXBuildFile; fileRef = F498213F2B188A6D006559CC /* GetVipFeatureTrial.swift */; };
F49821602B188C4C006559CC /* AliyunpanSpaceInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = F498215F2B188C4C006559CC /* AliyunpanSpaceInfo.swift */; };
- F4B3F7152B202CB500D9E122 /* DownloaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4B3F7142B202CB500D9E122 /* DownloaderTests.swift */; };
F4B3F7332B29859100D9E122 /* CredentialTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4B3F7322B29859100D9E122 /* CredentialTests.swift */; };
F4BD1B642B29A11B002BEA2A /* Platform.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4BD1B632B29A11B002BEA2A /* Platform.swift */; };
F4BD1C322B2AAB07002BEA2A /* AliyunpanQRCodeCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4BD1C312B2AAB07002BEA2A /* AliyunpanQRCodeCredentials.swift */; };
@@ -68,13 +67,22 @@
F4BD1C362B2AD8E1002BEA2A /* Task+AliyunpanSDK.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4BD1C352B2AD8E1002BEA2A /* Task+AliyunpanSDK.swift */; };
F4BD1C382B2BF075002BEA2A /* GetAuthorizeQRCodeStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4BD1C372B2BF075002BEA2A /* GetAuthorizeQRCodeStatus.swift */; };
F4BD1C412B2C47E9002BEA2A /* TaskTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4BD1C3F2B2C47C2002BEA2A /* TaskTests.swift */; };
+ F4BD1C482B304757002BEA2A /* AliyunpanDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4BD1C472B304757002BEA2A /* AliyunpanDownloader.swift */; };
+ F4BD1C4A2B304B3A002BEA2A /* Weak.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4BD1C492B304B3A002BEA2A /* Weak.swift */; };
+ F4BD1C4C2B304F3F002BEA2A /* AliyunpanDownloadTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4BD1C452B303A6B002BEA2A /* AliyunpanDownloadTask.swift */; };
+ F4BD1C4E2B3136B1002BEA2A /* AsyncOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4BD1C4D2B3136B1002BEA2A /* AsyncOperation.swift */; };
+ F4BD1C542B3150B9002BEA2A /* AliyunpanDownloadChunk.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4BD1C532B3150B9002BEA2A /* AliyunpanDownloadChunk.swift */; };
+ F4BD1C552B3151AE002BEA2A /* DownloaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4B3F7142B202CB500D9E122 /* DownloaderTests.swift */; };
+ F4BD1C572B317DE1002BEA2A /* DownloadChunkOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4BD1C562B317DE1002BEA2A /* DownloadChunkOperation.swift */; };
+ F4BD1C5F2B32BFD9002BEA2A /* ThreadSafe.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4BD1C5E2B32BFD9002BEA2A /* ThreadSafe.swift */; };
+ F4BD1C622B34366E002BEA2A /* DownloaderOperationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4BD1C602B343423002BEA2A /* DownloaderOperationTests.swift */; };
+ F4BD1C652B34403F002BEA2A /* DownloaderTaskTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4BD1C632B343FD6002BEA2A /* DownloaderTaskTests.swift */; };
F4C6F2312B060C4B003A06B3 /* AliyunpanSDK.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F4C6F2262B060C4B003A06B3 /* AliyunpanSDK.framework */; };
F4E9F9692B148ABA00DC8DBF /* Crypto.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E9F9672B148ABA00DC8DBF /* Crypto.swift */; };
F4E9F96A2B148ABA00DC8DBF /* URL+AliyunpanSDK.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E9F9682B148ABA00DC8DBF /* URL+AliyunpanSDK.swift */; };
F4E9F96C2B148AEE00DC8DBF /* AliyunpanMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E9F96B2B148AEE00DC8DBF /* AliyunpanMessage.swift */; };
F4EE79482B1EFE780061DB13 /* MessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4EE79472B1EFE780061DB13 /* MessageTests.swift */; };
F4EE794A2B1F06A80061DB13 /* JSONTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4EE79492B1F06A80061DB13 /* JSONTest.swift */; };
- F4F3D4BE2B1D7D82000E465E /* AliyunpanDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4F3D4BD2B1D7D82000E465E /* AliyunpanDownloader.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -151,6 +159,15 @@
F4BD1C352B2AD8E1002BEA2A /* Task+AliyunpanSDK.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Task+AliyunpanSDK.swift"; sourceTree = ""; };
F4BD1C372B2BF075002BEA2A /* GetAuthorizeQRCodeStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetAuthorizeQRCodeStatus.swift; sourceTree = ""; };
F4BD1C3F2B2C47C2002BEA2A /* TaskTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskTests.swift; sourceTree = ""; };
+ F4BD1C452B303A6B002BEA2A /* AliyunpanDownloadTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AliyunpanDownloadTask.swift; sourceTree = ""; };
+ F4BD1C472B304757002BEA2A /* AliyunpanDownloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AliyunpanDownloader.swift; sourceTree = ""; };
+ F4BD1C492B304B3A002BEA2A /* Weak.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Weak.swift; sourceTree = ""; };
+ F4BD1C4D2B3136B1002BEA2A /* AsyncOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncOperation.swift; sourceTree = ""; };
+ F4BD1C532B3150B9002BEA2A /* AliyunpanDownloadChunk.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AliyunpanDownloadChunk.swift; sourceTree = ""; };
+ F4BD1C562B317DE1002BEA2A /* DownloadChunkOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadChunkOperation.swift; sourceTree = ""; };
+ F4BD1C5E2B32BFD9002BEA2A /* ThreadSafe.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThreadSafe.swift; sourceTree = ""; };
+ F4BD1C602B343423002BEA2A /* DownloaderOperationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloaderOperationTests.swift; sourceTree = ""; };
+ F4BD1C632B343FD6002BEA2A /* DownloaderTaskTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloaderTaskTests.swift; sourceTree = ""; };
F4C6F2262B060C4B003A06B3 /* AliyunpanSDK.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AliyunpanSDK.framework; sourceTree = BUILT_PRODUCTS_DIR; };
F4C6F2302B060C4B003A06B3 /* AliyunpanSDKTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AliyunpanSDKTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
F4E9F9672B148ABA00DC8DBF /* Crypto.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Crypto.swift; sourceTree = ""; };
@@ -158,7 +175,6 @@
F4E9F96B2B148AEE00DC8DBF /* AliyunpanMessage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AliyunpanMessage.swift; sourceTree = ""; };
F4EE79472B1EFE780061DB13 /* MessageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageTests.swift; sourceTree = ""; };
F4EE79492B1F06A80061DB13 /* JSONTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONTest.swift; sourceTree = ""; };
- F4F3D4BD2B1D7D82000E465E /* AliyunpanDownloader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AliyunpanDownloader.swift; sourceTree = ""; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -222,13 +238,13 @@
F47FADC02B14767D00EC0D8D /* HTTPRequest */ = {
isa = PBXGroup;
children = (
+ F4BD1C422B302EF0002BEA2A /* Download */,
F49821192B1742B6006559CC /* DebugDescription.swift */,
F47FADC12B14767D00EC0D8D /* URLConvertible.swift */,
F47FADC22B14767D00EC0D8D /* HTTPRequest.swift */,
F47FADC32B14767D00EC0D8D /* HTTPMethod.swift */,
F47FADC42B14767D00EC0D8D /* HTTPHeaders.swift */,
F47FADC52B14767D00EC0D8D /* JSON+Codable.swift */,
- F4F3D4BD2B1D7D82000E465E /* AliyunpanDownloader.swift */,
);
path = HTTPRequest;
sourceTree = "";
@@ -275,6 +291,8 @@
F47FADDB2B14767D00EC0D8D /* CryptoTests.swift */,
F47FADDD2B14767D00EC0D8D /* HTTPRequestTests.swift */,
F4B3F7142B202CB500D9E122 /* DownloaderTests.swift */,
+ F4BD1C602B343423002BEA2A /* DownloaderOperationTests.swift */,
+ F4BD1C632B343FD6002BEA2A /* DownloaderTaskTests.swift */,
F4EE79472B1EFE780061DB13 /* MessageTests.swift */,
F4EE79492B1F06A80061DB13 /* JSONTest.swift */,
F4BD1C3F2B2C47C2002BEA2A /* TaskTests.swift */,
@@ -349,6 +367,17 @@
path = Vip;
sourceTree = "";
};
+ F4BD1C422B302EF0002BEA2A /* Download */ = {
+ isa = PBXGroup;
+ children = (
+ F4BD1C532B3150B9002BEA2A /* AliyunpanDownloadChunk.swift */,
+ F4BD1C562B317DE1002BEA2A /* DownloadChunkOperation.swift */,
+ F4BD1C452B303A6B002BEA2A /* AliyunpanDownloadTask.swift */,
+ F4BD1C472B304757002BEA2A /* AliyunpanDownloader.swift */,
+ );
+ path = Download;
+ sourceTree = "";
+ };
F4C6F21C2B060C4B003A06B3 = {
isa = PBXGroup;
children = (
@@ -370,9 +399,12 @@
F4E9F9662B148ABA00DC8DBF /* Utils */ = {
isa = PBXGroup;
children = (
+ F4BD1C5E2B32BFD9002BEA2A /* ThreadSafe.swift */,
F4E9F9672B148ABA00DC8DBF /* Crypto.swift */,
F4E9F9682B148ABA00DC8DBF /* URL+AliyunpanSDK.swift */,
F4BD1C352B2AD8E1002BEA2A /* Task+AliyunpanSDK.swift */,
+ F4BD1C492B304B3A002BEA2A /* Weak.swift */,
+ F4BD1C4D2B3136B1002BEA2A /* AsyncOperation.swift */,
);
path = Utils;
sourceTree = "";
@@ -519,14 +551,16 @@
F49821532B188A6D006559CC /* CreateFile.swift in Sources */,
F47FADE12B14767D00EC0D8D /* AliyunpanAppJumper.swift in Sources */,
F49821482B188A6D006559CC /* GetUsersScopes.swift in Sources */,
+ F4BD1C5F2B32BFD9002BEA2A /* ThreadSafe.swift in Sources */,
+ F4BD1C482B304757002BEA2A /* AliyunpanDownloader.swift in Sources */,
F47FADF52B14767D00EC0D8D /* AliyunpanClient.swift in Sources */,
F47FADE32B14767D00EC0D8D /* AliyunpanServerCredentials.swift in Sources */,
F49821452B188A6D006559CC /* GetDriveInfo.swift in Sources */,
F4E9F9692B148ABA00DC8DBF /* Crypto.swift in Sources */,
F49821472B188A6D006559CC /* GetSpaceInfo.swift in Sources */,
+ F4BD1C542B3150B9002BEA2A /* AliyunpanDownloadChunk.swift in Sources */,
F498214D2B188A6D006559CC /* DeleteFile.swift in Sources */,
F49821552B188A6D006559CC /* CompleteUpload.swift in Sources */,
- F4F3D4BE2B1D7D82000E465E /* AliyunpanDownloader.swift in Sources */,
F49821572B188A6D006559CC /* SearchFile.swift in Sources */,
F49821502B188A6D006559CC /* GetStarredList.swift in Sources */,
F4BD1C382B2BF075002BEA2A /* GetAuthorizeQRCodeStatus.swift in Sources */,
@@ -537,9 +571,11 @@
F49821402B188A6D006559CC /* GetVideoPreviewPlayMeta.swift in Sources */,
F49821442B188A6D006559CC /* GetVipInfo.swift in Sources */,
F49821492B188A6D006559CC /* GetAccessToken.swift in Sources */,
+ F4BD1C572B317DE1002BEA2A /* DownloadChunkOperation.swift in Sources */,
F47FADF22B14767D00EC0D8D /* AliyunpanScope.swift in Sources */,
F4E9F96C2B148AEE00DC8DBF /* AliyunpanMessage.swift in Sources */,
F47FADDF2B14767D00EC0D8D /* AliyunpanPKCECredentials.swift in Sources */,
+ F4BD1C4E2B3136B1002BEA2A /* AsyncOperation.swift in Sources */,
F47FADE72B14767D00EC0D8D /* HTTPRequest.swift in Sources */,
F498214E2B188A6D006559CC /* TrashFileToRecyclebin.swift in Sources */,
F47FADEB2B14767D00EC0D8D /* AliyunpanError.swift in Sources */,
@@ -548,10 +584,12 @@
F498215A2B188A6D006559CC /* GetAsyncTask.swift in Sources */,
F49821542B188A6D006559CC /* GetUploadURL.swift in Sources */,
F47FADF42B14767D00EC0D8D /* AliyunpanFile.swift in Sources */,
+ F4BD1C4C2B304F3F002BEA2A /* AliyunpanDownloadTask.swift in Sources */,
F4BD1C342B2AAB84002BEA2A /* GetAuthorizeQRCode.swift in Sources */,
F498215E2B188A6D006559CC /* GetVipFeatureTrial.swift in Sources */,
F498215C2B188A6D006559CC /* GetVipFeatureList.swift in Sources */,
F49821412B188A6D006559CC /* GetVideoPreviewPlayInfo.swift in Sources */,
+ F4BD1C4A2B304B3A002BEA2A /* Weak.swift in Sources */,
F49821512B188A6D006559CC /* GetFile.swift in Sources */,
F4BD1C362B2AD8E1002BEA2A /* Task+AliyunpanSDK.swift in Sources */,
F4BD1B642B29A11B002BEA2A /* Platform.swift in Sources */,
@@ -575,13 +613,15 @@
files = (
F49745FF2B1F100C0043A4D7 /* AliyunpanClientTests.swift in Sources */,
F47FADFE2B14779600EC0D8D /* AliyunpanSDKTests.swift in Sources */,
+ F4BD1C652B34403F002BEA2A /* DownloaderTaskTests.swift in Sources */,
F4BD1C412B2C47E9002BEA2A /* TaskTests.swift in Sources */,
F4EE79482B1EFE780061DB13 /* MessageTests.swift in Sources */,
+ F4BD1C622B34366E002BEA2A /* DownloaderOperationTests.swift in Sources */,
F47FADFD2B14779200EC0D8D /* CryptoTests.swift in Sources */,
F4EE794A2B1F06A80061DB13 /* JSONTest.swift in Sources */,
+ F4BD1C552B3151AE002BEA2A /* DownloaderTests.swift in Sources */,
F47FADFF2B14779900EC0D8D /* HTTPRequestTests.swift in Sources */,
F4B3F7332B29859100D9E122 /* CredentialTests.swift in Sources */,
- F4B3F7152B202CB500D9E122 /* DownloaderTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -761,7 +801,7 @@
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
- TARGETED_DEVICE_FAMILY = "1,2";
+ TARGETED_DEVICE_FAMILY = "1,2,3";
};
name = Debug;
};
@@ -808,7 +848,7 @@
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
- TARGETED_DEVICE_FAMILY = "1,2";
+ TARGETED_DEVICE_FAMILY = "1,2,3";
};
name = Release;
};
diff --git a/Demo/Demo/Demo-iOS/FileCell.swift b/Demo/Demo/Demo-iOS/FileCell.swift
index ff6a9c9..8217a4f 100644
--- a/Demo/Demo/Demo-iOS/FileCell.swift
+++ b/Demo/Demo/Demo-iOS/FileCell.swift
@@ -9,10 +9,10 @@ import UIKit
import AliyunpanSDK
protocol FileCellDelegate: AnyObject {
- func getDownloader(for item: DisplayItem) -> AliyunpanDownloader?
-
- func fileCell(_ cell: FileCell, didUpdateDownloadResult result: AliyunpanDownloadResult, for item: DisplayItem)
func fileCell(_ cell: FileCell, willOpen item: DisplayItem)
+ func fileCell(_ cell: FileCell, willDownload item: DisplayItem)
+ func fileCell(_ cell: FileCell, willPause item: DisplayItem)
+ func fileCell(_ cell: FileCell, willResume item: DisplayItem)
}
class FileCell: UICollectionViewListCell {
@@ -20,13 +20,6 @@ class FileCell: UICollectionViewListCell {
weak var client: AliyunpanClient?
private var item: DisplayItem?
-
- private var downloader: AliyunpanDownloader? {
- guard let item else {
- return nil
- }
- return delegate?.getDownloader(for: item)
- }
private lazy var pauseButton: UIButton = {
let pauseButton = UIButton()
@@ -62,37 +55,14 @@ class FileCell: UICollectionViewListCell {
guard let item else {
return
}
-
- if downloader?.state == .pause {
- downloader?.resume()
- return
- }
-
- downloader?.download { [weak self] progress in
- DispatchQueue.main.async { [weak self] in
- guard let self else {
- return
- }
- self.delegate?.fileCell(self, didUpdateDownloadResult: .progressing(progress), for: item)
- }
- } completionHandle: { [weak self] result in
- if let url = try? result.get() {
- DispatchQueue.main.async { [weak self] in
- guard let self else {
- return
- }
- self.delegate?.fileCell(self, didUpdateDownloadResult: .completed(url), for: item)
- }
- }
- }
+ delegate?.fileCell(self, willDownload: item)
}
@objc private func pause() {
- downloader?.pause()
-
- if let item {
- fill(item)
+ guard let item else {
+ return
}
+ delegate?.fileCell(self, willPause: item)
}
@objc private func openFile() {
@@ -109,22 +79,33 @@ class FileCell: UICollectionViewListCell {
return
}
- if let progress = item.downloadResult?.progress {
- progressLabel.text = "\(String(format: "%.2f", progress * 100))%"
- } else {
- progressLabel.text = nil
- }
-
var views: [UIView] = [progressLabel]
- if item.downloadResult?.url != nil {
- views.append(openButton)
- } else {
- if downloader?.state == .downloading {
+
+ if let downloadState = item.downloadState {
+ switch downloadState {
+ case .waiting:
+ progressLabel.text = "等待下载"
+
+ views.append(pauseButton)
+ case .downloading(let progress):
+ progressLabel.text = "\(String(format: "%.2f", progress * 100))%"
+
views.append(pauseButton)
- } else {
+ case .pause(let progress):
+ progressLabel.text = "\(String(format: "%.2f", progress * 100))%"
+
views.append(downloadButton)
+ case .finished:
+ progressLabel.text = nil
+
+ views.append(openButton)
+ case .failed:
+ progressLabel.text = nil
}
+ } else {
+ views.append(downloadButton)
}
+
accessories = views.map {
UICellAccessory.customView(
configuration: .init(customView: $0, placement: .trailing()))
diff --git a/Demo/Demo/Demo-iOS/FileListViewController.swift b/Demo/Demo/Demo-iOS/FileListViewController.swift
index 2b5b4a2..40cbb35 100644
--- a/Demo/Demo/Demo-iOS/FileListViewController.swift
+++ b/Demo/Demo/Demo-iOS/FileListViewController.swift
@@ -10,9 +10,18 @@ import AliyunpanSDK
import AVKit
class FileListViewController: UIViewController {
- private var client: AliyunpanClient {
- return (UIApplication.shared.delegate as! AppDelegate).client
- }
+ private lazy var networkSpeedLabel: UILabel = {
+ let label = UILabel()
+ label.font = UIFont.monospacedSystemFont(ofSize: 12, weight: .regular)
+ return label
+ }()
+
+ private lazy var client: AliyunpanClient = {
+ let client = (UIApplication.shared.delegate as! AppDelegate).client
+ client.downloader.addDelegate(self)
+ client.downloader.enableNetworkSpeedMonitor()
+ return client
+ }()
private lazy var dataSource: UICollectionViewDiffableDataSource = {
let cellRegistration = UICollectionView.CellRegistration { [weak self] cell, indexPath, item in
@@ -56,17 +65,6 @@ class FileListViewController: UIViewController {
private var displayItems: [DisplayItem] = []
- private var downloaderMap: [AliyunpanFile: AliyunpanDownloader] = [:]
- private var downloadSpeedMap: [AliyunpanFile: Int64] = [:] {
- didSet {
- let totalSpeed = downloadSpeedMap.values.reduce(0, +)
- let label = UILabel()
- label.text = "\(String(format: "%.2f", Double(totalSpeed) / 1_000_000))MB/s"
- label.font = UIFont.monospacedSystemFont(ofSize: 12, weight: .regular)
- self.navigationItem.rightBarButtonItem = UIBarButtonItem(customView: label)
- }
- }
-
override func viewDidLoad() {
super.viewDidLoad()
@@ -79,17 +77,6 @@ class FileListViewController: UIViewController {
dataSource.apply(snapshot)
}
- private func updateDownloadResult(_ result: AliyunpanDownloadResult, for item: DisplayItem) {
- guard let index = displayItems.firstIndex(where: { $0.file == item.file }) else {
- return
- }
- displayItems[index] = DisplayItem(file: item.file, downloadResult: result)
- var snapshot = NSDiffableDataSourceSnapshot()
- snapshot.appendSections([0])
- snapshot.appendItems(displayItems)
- dataSource.apply(snapshot, animatingDifferences: false)
- }
-
/// 播放音频
@MainActor
private func playMedia(_ url: URL) {
@@ -179,26 +166,38 @@ extension FileListViewController: UICollectionViewDelegate {
}
extension FileListViewController: FileCellDelegate {
- func getDownloader(for item: DisplayItem) -> AliyunpanDownloader? {
- if let downloader = downloaderMap[item.file] {
- return downloader
- }
+ func fileCell(_ cell: FileCell, willDownload item: DisplayItem) {
guard let url = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first else {
- return nil
+ return
+ }
+
+ if let task = client.downloader.tasks.first(where: { $0.file.isSameFile(item.file) }) {
+ // 恢复下载
+ client.downloader.resume(task)
+ } else {
+ let file = item.file
+ let filename = file.name.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed) ?? ""
+ let destination = url.appendingPathComponent(filename)
+ client.downloader.download(file: file, to: destination)
}
- let file = item.file
- let filename = file.name.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed) ?? ""
- let destination = url.appendingPathComponent(filename)
- let downloader = client.downloader(file, to: destination)
- downloader.networkSpeedMonitor = { [weak self] bytes in
- self?.downloadSpeedMap[item.file] = bytes
+ }
+
+ func fileCell(_ cell: FileCell, willPause item: DisplayItem) {
+ guard let task = client.downloader.tasks.first(where: { $0.file.isSameFile(item.file) }) else {
+ return
+ }
+ client.downloader.pause(task)
+ }
+
+ func fileCell(_ cell: FileCell, willResume item: DisplayItem) {
+ guard let task = client.downloader.tasks.first(where: { $0.file.isSameFile(item.file) }) else {
+ return
}
- downloaderMap[item.file] = downloader
- return downloader
+ client.downloader.resume(task)
}
func fileCell(_ cell: FileCell, willOpen item: DisplayItem) {
- guard let url = item.downloadResult?.url else {
+ guard case .finished(let url) = item.downloadState else {
return
}
if item.file.category == .video {
@@ -209,10 +208,6 @@ extension FileListViewController: FileCellDelegate {
viewController.presentPreview(animated: true)
}
}
-
- func fileCell(_ cell: FileCell, didUpdateDownloadResult result: AliyunpanDownloadResult, for item: DisplayItem) {
- updateDownloadResult(result, for: item)
- }
}
extension FileListViewController: UIDocumentInteractionControllerDelegate {
@@ -220,3 +215,21 @@ extension FileListViewController: UIDocumentInteractionControllerDelegate {
return self
}
}
+
+extension FileListViewController: AliyunpanDownloadDelegate {
+ func downloader(_ downloader: AliyunpanDownloader, didUpdatedNetworkSpeed networkSpeed: Int64) {
+ networkSpeedLabel.text = "\(String(format: "%.2f", Double(networkSpeed) / 1_000_000))MB/s"
+ navigationItem.rightBarButtonItem = UIBarButtonItem(customView: networkSpeedLabel)
+ }
+
+ func downloader(_ downloader: AliyunpanDownloader, didUpdateTaskState state: AliyunpanDownloadTask.State, for task: AliyunpanDownloadTask) {
+ guard let index = displayItems.firstIndex(where: { $0.file.isSameFile(task.file) }) else {
+ return
+ }
+ displayItems[index] = DisplayItem(file: task.file, downloadState: state)
+ var snapshot = NSDiffableDataSourceSnapshot()
+ snapshot.appendSections([0])
+ snapshot.appendItems(displayItems)
+ dataSource.apply(snapshot, animatingDifferences: false)
+ }
+}
diff --git a/Demo/Demo/DisplayItem.swift b/Demo/Demo/DisplayItem.swift
index 354e039..b77e93a 100644
--- a/Demo/Demo/DisplayItem.swift
+++ b/Demo/Demo/DisplayItem.swift
@@ -18,36 +18,49 @@ extension AliyunpanFile: Hashable {
}
}
-extension AliyunpanDownloadResult: Hashable {
+extension AliyunpanDownloadTask.State: Hashable {
public func hash(into hasher: inout Hasher) {
- hasher.combine(progress)
- hasher.combine(url)
+ switch self {
+ case .waiting:
+ hasher.combine("waiting")
+ case .downloading(let progress):
+ hasher.combine("downloading")
+ hasher.combine(progress)
+ case .pause(let progress):
+ hasher.combine("pause")
+ hasher.combine(progress)
+ case .finished(let url):
+ hasher.combine("finished")
+ hasher.combine(url)
+ case .failed:
+ hasher.combine("failed")
+ }
}
- public static func == (lhs: AliyunpanDownloadResult, rhs: AliyunpanDownloadResult) -> Bool {
+ public static func == (lhs: AliyunpanSDK.AliyunpanDownloadTask.State, rhs: AliyunpanSDK.AliyunpanDownloadTask.State) -> Bool {
lhs.hashValue == rhs.hashValue
}
}
struct DisplayItem: Hashable {
let file: AliyunpanFile
- let downloadResult: AliyunpanDownloadResult?
+ let downloadState: AliyunpanDownloadTask.State?
public func hash(into hasher: inout Hasher) {
hasher.combine(file)
- hasher.combine(downloadResult)
+ hasher.combine(downloadState)
}
public static func == (lhs: DisplayItem, rhs: DisplayItem) -> Bool {
lhs.hashValue == rhs.hashValue
}
- init(file: AliyunpanFile, downloadResult: AliyunpanDownloadResult?) {
+ init(file: AliyunpanFile, downloadState: AliyunpanDownloadTask.State?) {
self.file = file
- self.downloadResult = downloadResult
+ self.downloadState = downloadState
}
init(_ file: AliyunpanFile) {
- self.init(file: file, downloadResult: nil)
+ self.init(file: file, downloadState: nil)
}
}
diff --git a/Demo/Podfile.lock b/Demo/Podfile.lock
index 8e0bf56..905c359 100644
--- a/Demo/Podfile.lock
+++ b/Demo/Podfile.lock
@@ -9,7 +9,7 @@ EXTERNAL SOURCES:
:path: "../"
SPEC CHECKSUMS:
- AliyunpanSDK: 50ec1a24e175833e55a8029ddb0a8d059eadab3d
+ AliyunpanSDK: 4ebb89bb2d27c2ed94e1b700d32220bc942f5f6e
PODFILE CHECKSUM: e82420c5d47daad41bcef90410d3046b609d0c2a
diff --git a/README.md b/README.md
index 312bce7..77b7875 100644
--- a/README.md
+++ b/README.md
@@ -9,37 +9,32 @@
This is the open-source SDK for Aliyunpan OpenAPI.
- Examples
+ 示例
·
- Report Bug
+ 反馈 Bug
·
- Request Feature
- ·
- 简体中文
+ 提交需求
-## Getting Started
+## 准备工作
-To begin using the sdk, visit our guide that will walk you through the setup process:
+在开始前,请查看阿里云盘开放平台接入指南:
-[👉 Guide](https://www.yuque.com/aliyundrive/zpfszx/tyzl591kxmft4e81)
+[👉 如何注册三方开发者](https://www.yuque.com/aliyundrive/zpfszx/tyzl591kxmft4e81)
-## Quick start
+## 快速开始
-### 1. Create a client
+### 1. 创建 Client
-You can create a client either by using a credentials.
+你可以使用 SDK 提供的任意授权方式创建 Client
#### [Credentials](https://alibaba.github.io/aliyunpan-ios-sdk/Enums/AliyunpanCredentials.html)
-- .pkce
-
- serverless authorization, require AliyunDrive client.
-- .server(AliyunpanBizServer)
- server authorization, require AliyunDrive client.
-- .qrCode(AliyunpanQRCodeContainer)
-
- serverless authorization and does not require AliyunDrive client.
+| 授权方式 | 描述 | **不需要** Server | **不需要**阿里云盘客户端 |
+| :----: | :----: | :----: | :----: |
+| pkce | pkce 授权 | ✅ | ❌ |
+| server | 业务后端授权 | ❌ | ❌ |
+| qrCode | 二维码授权 | ✅ | ✅ |
```swift
let client: AliyunpanClient = AliyunpanClient(
@@ -49,9 +44,9 @@ let client: AliyunpanClient = AliyunpanClient(
credentials: YOUR_CREDENTIALS))
```
-### 2. Send Commands
+### 2. 发送命令
-With this SDK, you can easily interface all openAPIs and their request/response models.
+使用 SDK,你可以轻松使用所有已提供的 OpenAPI 和它们的请求体、返回体模型
```swift
// Concurrency
@@ -69,54 +64,60 @@ client.send(
}
```
-## Advanced Usage
-
-This SDK also provides advanced functionalities to make your development faster and smoother.
-
-### Download
+## 高级功能
+### 下载
```swift
-let downloader = client.downloader(file, to: destination)
-
-downloader.download { progress in
- // do something..
-} completionHandle: { result in
- if let url = try? result.get() {
- // File is downloaded, process the file
- } else {
- // Handle other cases
- }
-}
-
-downloader.networkSpeedMonitor = { bytesReceived in
- // This closure is called with the number of bytes downloaded in the last second.
- // You can use `bytesReceived` to update the UI or perform other actions based on the current network speed.
-}
+let downloader = client.downloader
+
+// 下载
+let task = downloader.download(file: file, to: destination)
+// let task = downloader.tasks.first
+
+// 修改并发数,默认为10
+downloader.maxConcurrentOperationCount = 10
+
+// 暂停
+downloader.pause(task)
+// 恢复
+downloader.resume(task)
+// 取消
+downloader.cancel(task)
+
+// AliyunpanDownloadDelegate
+// 下载速度变化
+// func downloader(_ downloader: AliyunpanDownloader, didUpdatedNetworkSpeed networkSpeed: Int64)
+// 下载任务状态变化
+// func downloader(_ downloader: AliyunpanDownloader, didUpdateTaskState state: AliyunpanDownloadTask.State, for task: AliyunpanDownloadTask)
+downloadr.addDelegate(DELEGATE)
```
-## Requirements
-
-- iOS 13.0+
-- Swift 5.0+
+#### 示例
+[FileListViewController](Demo/Demo/Demo-iOS/FileListViewController.swift)
-## Installation
+## 安装方式
#### Swift Package Manager
- File > Swift Packages > Add Package Dependency
-- Add `https://github.com/alibaba/aliyunpan-ios-sdk.git`
+- 添加 `https://github.com/alibaba/aliyunpan-ios-sdk.git`
#### CocoaPods
```ruby
target 'MyApp' do
- pod 'AliyunpanSDK', '~> 0.1.0'
+ pod 'AliyunpanSDK', '~> 0.1'
end
```
-## Documents
+## 要求
+
+- iOS 13.0+ (CocoaPods)
+- Swift 5.0+
+
+## 文档
-[👉 Documents](https://alibaba.github.io/aliyunpan-ios-sdk/)
+[👉 文档](https://alibaba.github.io/aliyunpan-ios-sdk/)
## License
diff --git a/README.zh.md b/README.zh.md
deleted file mode 100644
index 130e6c0..0000000
--- a/README.zh.md
+++ /dev/null
@@ -1,123 +0,0 @@
-
-
AliyunpanSDK
-
-
-
-
-
-
- This is the open-source SDK for Aliyunpan OpenAPI.
-
-
- 示例
- ·
- 反馈 Bug
- ·
- 提交需求
- ·
- English
-
-
-
-## 准备工作
-
-在开始前,请查看阿里云盘开放平台接入指南:
-
-[👉 接入指南](https://www.yuque.com/aliyundrive/zpfszx/tyzl591kxmft4e81)
-
-## 快速开始
-
-### 1. 创建 Client
-
-你可以使用 SDK 提供的任意授权方式创建 Client
-#### [Credentials](https://alibaba.github.io/aliyunpan-ios-sdk/Enums/AliyunpanCredentials.html)
-- .pkce
-
- 无需服务端,需要已安装阿里云盘客户端
-- .server(AliyunpanBizServer)
-
- 需要有服务端,需要已安装阿里云盘客户端
-- .qrCode(AliyunpanQRCodeContainer)
- 二维码授权,无需服务端,无需安装阿里云盘客户端
-
-
-```swift
-let client: AliyunpanClient = AliyunpanClient(
- .init(
- appId: "YOUR_APP_ID",
- scope: "YOUR_SCOPE", // e.g. user:base,file:all:read
- credentials: YOUR_CREDENTIALS))
-```
-
-### 2. 发送命令
-
-使用 SDK,你可以轻松使用所有已提供的 OpenAPI 和它们的请求体、返回体模型
-
-```swift
-// Concurrency
-try await client.send(
- AliyunpanScope.User.GetUsersInfo()) // -> GetUsersInfo.Response
-
-try await client.send(
- AliyunpanScope.File.GetFileList(
- .init(drive_id: driveId, parent_file_id: "root")))) // -> GetFileList.Response
-
-// Closure
-client.send(
- AliyunpanScope.User.GetUsersInfo()) { result in
- /// do something
-}
-```
-
-## 高级使用
-
-SDK 封装了命令集合来使你的开发更快、更好
-
-### 下载
-
-```swift
-let downloader = client.downloader(file, to: destination)
-
-downloader.download { progress in
- // do something..
-} completionHandle: { result in
- if let url = try? result.get() {
- // File is downloaded, process the file
- } else {
- // Handle other cases
- }
-}
-
-downloader.networkSpeedMonitor = { bytesReceived in
- // This closure is called with the number of bytes downloaded in the last second.
- // You can use `bytesReceived` to update the UI or perform other actions based on the current network speed.
-}
-```
-
-## 要求
-
-- iOS 13.0+
-- Swift 5.0+
-
-## 安装方式
-
-#### Swift Package Manager
-
-- File > Swift Packages > Add Package Dependency
-- 添加 `https://github.com/alibaba/aliyunpan-ios-sdk.git`
-
-#### CocoaPods
-
-```ruby
-target 'MyApp' do
- pod 'AliyunpanSDK', '~> 0.1.0'
-end
-```
-
-## 文档
-
-[👉 文档](https://alibaba.github.io/aliyunpan-ios-sdk/)
-
-## License
-
-This project is licensed under the [MIT License](LICENSE).
diff --git a/Sources/AliyunpanSDK/AliyunpanClient.swift b/Sources/AliyunpanSDK/AliyunpanClient.swift
index e96172f..ca5d4c1 100644
--- a/Sources/AliyunpanSDK/AliyunpanClient.swift
+++ b/Sources/AliyunpanSDK/AliyunpanClient.swift
@@ -34,7 +34,7 @@ public class AliyunpanClient {
private let config: AliyunpanClientConfig
private var tokenStorageKey: String {
- "com.smartdrive.AliyunpanSDK.accessToken_\(config.appId)_\(config.identifier ?? "-")"
+ "com.aliyunpanSDK.accessToken_\(config.appId)_\(config.identifier ?? "-")"
}
@MainActor var token: AliyunpanToken? {
@@ -51,6 +51,13 @@ public class AliyunpanClient {
token?.access_token
}
+ /// 下载器
+ public lazy var downloader: AliyunpanDownloader = {
+ let downloader = AliyunpanDownloader()
+ downloader.client = self
+ return downloader
+ }()
+
public init(_ config: AliyunpanClientConfig) {
self.config = config
@@ -98,7 +105,7 @@ public class AliyunpanClient {
return result
} catch {
/// 授权过期重试
- if let error = error as? AliyunpanServerError,
+ if let error = error as? AliyunpanError.ServerError,
error.isAccessTokenInvalidOrExpired {
await MainActor.run { [weak self] in
self?.token = nil
@@ -130,23 +137,3 @@ public class AliyunpanClient {
}
}
}
-
-extension AliyunpanClient {
- /// 下载文件,会根据 4_000_000 字节自动分片
- /// - Parameters:
- /// - file: 文件模型
- /// - destination: 期望目标地址
- /// - maxConcurrentOperationCount: 最大并发数,必须小于等于 10,默认 10
- ///
- public func downloader(
- _ file: AliyunpanFile,
- to destination: URL,
- maxConcurrentOperationCount: Int = 10) -> AliyunpanDownloader {
- let downloader = AliyunpanDownloader(
- file: file,
- destination: destination,
- maxConcurrentOperationCount: maxConcurrentOperationCount)
- downloader.client = self
- return downloader
- }
-}
diff --git a/Sources/AliyunpanSDK/AliyunpanCredentials/AliyunpanAppJumper.swift b/Sources/AliyunpanSDK/AliyunpanCredentials/AliyunpanAppJumper.swift
index 68b3ef2..f3648dd 100644
--- a/Sources/AliyunpanSDK/AliyunpanCredentials/AliyunpanAppJumper.swift
+++ b/Sources/AliyunpanSDK/AliyunpanCredentials/AliyunpanAppJumper.swift
@@ -19,7 +19,7 @@ class AliyunpanAppJumper {
func jump(to url: URL) async throws -> String {
guard Platform.canOpenURL(url) else {
- throw AliyunpanAuthorizeError.notInstalledApp
+ throw AliyunpanError.AuthorizeError.notInstalledApp
}
let message = try AliyunpanMessage(url)
@@ -43,7 +43,7 @@ class AliyunpanAppJumper {
continuation.resume(with: .success(authCode))
} else {
continuation.resume(with: .failure(
- AliyunpanAuthorizeError.authorizeFailed(
+ AliyunpanError.AuthorizeError.authorizeFailed(
error: authMessage.error,
errorMsg: authMessage.errorMsg)))
}
diff --git a/Sources/AliyunpanSDK/AliyunpanCredentials/AliyunpanMessage.swift b/Sources/AliyunpanSDK/AliyunpanCredentials/AliyunpanMessage.swift
index 7773bf2..9041465 100644
--- a/Sources/AliyunpanSDK/AliyunpanCredentials/AliyunpanMessage.swift
+++ b/Sources/AliyunpanSDK/AliyunpanCredentials/AliyunpanMessage.swift
@@ -14,7 +14,7 @@ class AliyunpanMessage {
init(_ url: URL) throws {
guard url.scheme?.lowercased().starts(with: "smartdrive") == true else {
- throw AliyunpanAuthorizeError.invaildAuthorizeURL
+ throw AliyunpanError.AuthorizeError.invalidAuthorizeURL
}
let queryItems = url.queryItems
self.originalURL = url
diff --git a/Sources/AliyunpanSDK/AliyunpanCredentials/AliyunpanQRCodeCredentials.swift b/Sources/AliyunpanSDK/AliyunpanCredentials/AliyunpanQRCodeCredentials.swift
index aecbc8e..71cf650 100644
--- a/Sources/AliyunpanSDK/AliyunpanCredentials/AliyunpanQRCodeCredentials.swift
+++ b/Sources/AliyunpanSDK/AliyunpanCredentials/AliyunpanQRCodeCredentials.swift
@@ -57,7 +57,7 @@ class AliyunpanQRCodeCredentials: AliyunpanCredentialsProtocol {
continuation.finish()
}
} catch is CancellationError {
- continuation.finish(throwing: AliyunpanAuthorizeError.qrCodeAuthorizeTimeout)
+ continuation.finish(throwing: AliyunpanError.AuthorizeError.qrCodeAuthorizeTimeout)
} catch {
continuation.finish(throwing: error)
}
@@ -102,7 +102,7 @@ class AliyunpanQRCodeCredentials: AliyunpanCredentialsProtocol {
return token
} else {
// 实际不会走到
- throw AliyunpanAuthorizeError.qrCodeAuthorizeTimeout
+ throw AliyunpanError.AuthorizeError.qrCodeAuthorizeTimeout
}
}
}
diff --git a/Sources/AliyunpanSDK/AliyunpanError.swift b/Sources/AliyunpanSDK/AliyunpanError.swift
index 6ef20da..27bf508 100644
--- a/Sources/AliyunpanSDK/AliyunpanError.swift
+++ b/Sources/AliyunpanSDK/AliyunpanError.swift
@@ -7,70 +7,84 @@
import Foundation
-/// 授权错误
-public enum AliyunpanAuthorizeError: Error {
- /// 错误的授权链接
- case invaildAuthorizeURL
- /// 当前设备未安装阿里云盘
- case notInstalledApp
+public struct AliyunpanError {
/// 授权错误
- case authorizeFailed(error: String?, errorMsg: String?)
- /// 验证码授权超时
- case qrCodeAuthorizeTimeout
-}
+ public enum AuthorizeError: Error {
+ /// 错误的授权链接
+ case invalidAuthorizeURL
+ /// 当前设备未安装阿里云盘
+ case notInstalledApp
+ /// 授权错误
+ case authorizeFailed(error: String?, errorMsg: String?)
+ /// 验证码授权超时
+ case qrCodeAuthorizeTimeout
+ }
-/// 网络层错误
-public struct AliyunpanServerError: Error, Decodable {
- public enum Code: String, Decodable {
- /// 二维码过期
- case qrCodeExpired = "QRCodeExpired"
- /// 容量超限
- case quotaExhaustedDrive = "QuotaExhausted.Drive"
- /// access_token 过期
- case accessTokenExpired = "AccessTokenExpired"
- /// access_token 格式不对
- case accessTokenInvalid = "AccessTokenInvalid"
- /// refresh_token 过期
- case refreshTokenExpired = "RefreshTokenExpired"
- /// refresh_token 格式不对
- case refreshTokenInvalid = "RefreshTokenInvalid"
- /// 用户已取消授权,或权限已失效,或 token 无效。需要重新发起授权
- case permissionDenied = "PermissionDenied"
- /// 回收站文件不允许操作
- case forbiddenFileInTheRecycleBin = "ForbiddenFileInTheRecycleBin"
- /// 用户容量超限,限制播放,需要扩容或者删除不必要的文件释放空间
- case exceedCapacityForbidden = "ExceedCapacityForbidden"
- /// 文件找不到
- case notFound = "NotFound.FileId"
- /// 请求过快
- case tooManyRequests = "TooManyRequests"
- /// 应用不存在
- case appNotExists = "AppNotExists"
- /// 应用密钥不对
- case invalidClientSecret = "InvalidClientSecret"
- /// 授权码为空或过期
- case invalidCode = "InvalidCode"
- /// 应用ID和构造授权链接时填的不一致
- case invalidClientId = "InvalidClientId"
- /// 无效的担保类型,目前仅支持 authorization_code 和 refresh_token
- case invalidGrantType = "InvalidGrantType"
- /// 文件drive被锁,操作无法执行
- case forbiddenDriveLocked = "ForbiddenDriveLocked"
- /// 非法访问drive
- case forbiddenDriveNotValid = "ForbiddenDriveNotValid"
+ /// 网络层错误
+ public struct ServerError: Error, Decodable {
+ public enum Code: String, Decodable {
+ /// 二维码过期
+ case qrCodeExpired = "QRCodeExpired"
+ /// 容量超限
+ case quotaExhaustedDrive = "QuotaExhausted.Drive"
+ /// access_token 过期
+ case accessTokenExpired = "AccessTokenExpired"
+ /// access_token 格式不对
+ case accessTokenInvalid = "AccessTokenInvalid"
+ /// refresh_token 过期
+ case refreshTokenExpired = "RefreshTokenExpired"
+ /// refresh_token 格式不对
+ case refreshTokenInvalid = "RefreshTokenInvalid"
+ /// 用户已取消授权,或权限已失效,或 token 无效。需要重新发起授权
+ case permissionDenied = "PermissionDenied"
+ /// 回收站文件不允许操作
+ case forbiddenFileInTheRecycleBin = "ForbiddenFileInTheRecycleBin"
+ /// 用户容量超限,限制播放,需要扩容或者删除不必要的文件释放空间
+ case exceedCapacityForbidden = "ExceedCapacityForbidden"
+ /// 文件找不到
+ case notFound = "NotFound.FileId"
+ /// 请求过快
+ case tooManyRequests = "TooManyRequests"
+ /// 应用不存在
+ case appNotExists = "AppNotExists"
+ /// 应用密钥不对
+ case invalidClientSecret = "InvalidClientSecret"
+ /// 授权码为空或过期
+ case invalidCode = "InvalidCode"
+ /// 应用ID和构造授权链接时填的不一致
+ case invalidClientId = "InvalidClientId"
+ /// 无效的担保类型,目前仅支持 authorization_code 和 refresh_token
+ case invalidGrantType = "InvalidGrantType"
+ /// 文件drive被锁,操作无法执行
+ case forbiddenDriveLocked = "ForbiddenDriveLocked"
+ /// 非法访问drive
+ case forbiddenDriveNotValid = "ForbiddenDriveNotValid"
+ }
+
+ public let code: Code?
+ public let message: String?
+ public let requestId: String?
+
+ public var isAccessTokenInvalidOrExpired: Bool {
+ code == .accessTokenExpired || code == .accessTokenInvalid
+ }
}
-
- public let code: Code?
- public let message: String?
- public let requestId: String?
-
- public var isAccessTokenInvalidOrExpired: Bool {
- code == .accessTokenExpired || code == .accessTokenInvalid
+
+ /// 下载错误
+ public enum DownloadError: Error {
+ /// 下载链接过期
+ case downloadURLExpired
+ /// 错误的下载链接
+ case invalidDownloadURL
+ /// 主动取消
+ case userCancelled
+ /// 缺少 client
+ case invalidClient
}
-}
-public enum AliyunpanNetworkSystemError: Error {
- case invaildURL
- case invaildClient
- case httpError(statusCode: Int, data: Data, response: HTTPURLResponse)
+ /// 系统级网络层错误
+ public enum NetworkSystemError: Error {
+ case invalidURL
+ case httpError(statusCode: Int, data: Data, response: HTTPURLResponse)
+ }
}
diff --git a/Sources/AliyunpanSDK/HTTPRequest/AliyunpanDownloader.swift b/Sources/AliyunpanSDK/HTTPRequest/AliyunpanDownloader.swift
deleted file mode 100644
index b9ea46c..0000000
--- a/Sources/AliyunpanSDK/HTTPRequest/AliyunpanDownloader.swift
+++ /dev/null
@@ -1,447 +0,0 @@
-//
-// AliyunpanDownloader.swift
-// AliyunpanSDK
-//
-// Created by zhaixian on 2023/12/1.
-//
-
-import Foundation
-
-public struct AliyunpanDownloadResult {
- public let progress: Double
- public let url: URL?
-
- public static func completed(_ url: URL) -> AliyunpanDownloadResult {
- AliyunpanDownloadResult(progress: 1, url: url)
- }
-
- public static func progressing(_ progress: Double) -> AliyunpanDownloadResult {
- AliyunpanDownloadResult(progress: progress, url: nil)
- }
-}
-
-public enum DownloadState {
- case idle
- case downloading
- case pause
-}
-
-struct DownloadChunk {
- let start: Int64
- let end: Int64
-
- init(start: Int64, end: Int64) {
- self.start = start
- self.end = end
- }
-
- init?(rangeString: String, fileSize: Int64) {
- let rangeValue = rangeString.split(separator: "=").last ?? ""
- let array = rangeValue.split(separator: "-")
- guard array.count >= 1,
- let start = Int64(array[0]) else {
- return nil
- }
- let end: Int64
- if array.count == 2, let value = Int64(array[1]) {
- end = value + 1
- } else {
- end = fileSize
- }
- self = Self(start: start, end: end)
- }
-}
-
-public class AliyunpanDownloader: NSObject {
- actor DownloadRequester {
- var url: URL?
- var expiration: Date?
-
- func getDownloadURL(with file: AliyunpanFile, by client: AliyunpanClient) async throws -> URL {
- // 当前下载链接未过期
- if let url, let expiration, expiration > Date() {
- return url
- }
- let response = try await client.send(
- AliyunpanScope.File.GetFileDownloadUrl(
- .init(drive_id: file.drive_id, file_id: file.file_id)))
- url = response.url
- expiration = response.expiration
- return response.url
- }
- }
-
- class StateProvider {
- let progressHandler: (Int64, Int64) -> Void
- let completionHandler: (Result) -> Void
-
- init(progressHandler: @escaping (Int64, Int64) -> Void, completionHandler: @escaping (Result) -> Void) {
- self.progressHandler = progressHandler
- self.completionHandler = completionHandler
- }
- }
-
- private lazy var session: URLSession = {
- URLSession(
- configuration: .default,
- delegate: self,
- delegateQueue: downloadQueue)
- }()
-
- /// 已写数据大小
- private var totalWritedSize: Int64 = 0
-
- /// 目标文件
- let file: AliyunpanFile
- /// 期望下载位置
- let destination: URL
- /// 分片
- let chunks: [DownloadChunk]
- /// 分片间距
- let chunkSize: Int
- /// 最大并发数,必须小于 10
- let maxConcurrentOperationCount: Int
- /// 文件总大小
- let totalSize: Int64
- let downloadQueue: OperationQueue
-
- private var stateProvider: StateProvider?
-
- let requester = DownloadRequester()
- let fileManager = FileManager.default
-
- weak var client: AliyunpanClient?
-
- public var state: DownloadState = .idle
-
- private lazy var lastTotalWritedSize: Int64 = {
- totalWritedSize
- }()
-
- private lazy var networkSpeedTimer: Timer = {
- return Timer(timeInterval: 1, target: self, selector: #selector(updateNetworkSpeed), userInfo: nil, repeats: true)
- }()
-
- /// 每秒传输的字节数
- public var networkSpeedMonitor: ((Int64) -> Void)? {
- didSet {
- RunLoop.current.add(networkSpeedTimer, forMode: .common)
- networkSpeedTimer.fire()
- }
- }
-
- deinit {
- networkSpeedTimer.invalidate()
- }
-
- init(file: AliyunpanFile,
- destination: URL,
- chunkSize: Int = 4_000_000,
- maxConcurrentOperationCount: Int) {
- self.file = file
- self.destination = destination
- self.maxConcurrentOperationCount = min(10, maxConcurrentOperationCount)
- self.chunkSize = chunkSize
- let totalSize = file.size ?? 0
- self.totalSize = totalSize
- self.chunks = stride(from: 0, to: totalSize, by: chunkSize).map {
- DownloadChunk(start: $0, end: min($0 + Int64(chunkSize), totalSize))
- }
- self.downloadQueue = OperationQueue(
- name: "com.AliyunpanSDK.downloader.queue",
- maxConcurrentOperationCount: maxConcurrentOperationCount)
- super.init()
- }
-
- /// 合并分片
- private func merge() throws {
- Logger.log(.info, msg: "[Downloader][\(file.name)] start merge...")
- if fileManager.fileExists(atPath: destination.path) {
- try fileManager.removeItem(at: destination)
- }
-
- fileManager.createFile(atPath: destination.path, contents: nil)
- let fileHandle = try FileHandle(forWritingTo: destination)
- try chunks.forEach { chunk in
- let chunkFileURL = getChunkFilePath(with: chunk)
- let data = try Data(contentsOf: chunkFileURL)
- fileHandle.write(data)
- }
- try fileManager.removeItem(at: getChunkDirectory())
- try fileHandle.close()
- Logger.log(.info, msg: "[Downloader][\(file.name)] merge success.")
- }
-
- private func retry(task: URLSessionTask) {
- if let request = task.originalRequest {
- let task = session.downloadTask(with: request)
- downloadQueue.addOperation {
- task.resume()
- }
- }
- }
-
- @objc private func updateNetworkSpeed() {
- let offset = max(totalWritedSize - lastTotalWritedSize, 0)
- networkSpeedMonitor?(offset)
- lastTotalWritedSize = totalWritedSize
- }
-
- /// 清除现场
- private func clean() {
- Logger.log(.debug, msg: "[Downloader][\(file.name)] clean")
- try? fileManager.removeItem(at: getChunkDirectory())
- try? fileManager.removeItem(at: destination)
- }
-}
-
-extension AliyunpanDownloader {
- private func download(chunks: [DownloadChunk]) -> AsyncThrowingStream {
- AsyncThrowingStream { continuation in
- continuation.onTermination = { [weak self] _ in
- self?.session.invalidateAndCancel()
- }
-
- stateProvider = StateProvider { current, total in
- let progress = min(1, max(0, Double(current) / Double(total)))
- continuation.yield(AliyunpanDownloadResult.progressing(progress))
- Logger.log(.debug, msg: "[Downloader] downloading, progress:\(progress)")
- } completionHandler: { [weak self] result in
- guard let self else {
- return
- }
- switch result {
- case .success(let localURL):
- Logger.log(.info, msg: "[Downloader][\(self.file.name)] finshed, url:\(localURL.absoluteString)")
- continuation.yield(AliyunpanDownloadResult.completed(localURL))
- continuation.finish()
- case .failure(let error):
- Logger.log(.info, msg: "[Downloader][\(self.file.name)] finshed with error:\(error)")
- continuation.finish(throwing: error)
- }
- }
-
- // 初始化
- let progress = Double(totalWritedSize) / Double(totalSize)
- continuation.yield(AliyunpanDownloadResult.progressing(progress))
-
- if isFinished {
- do {
- try merge()
- } catch {
- stateProvider?.completionHandler(.failure(error))
- }
- } else {
- startDownload(chunks: chunks)
- }
- }
- }
-
- private func startDownload(chunks: [DownloadChunk]) {
- Logger.log(.info, msg: "[Downloader][\(file.name)] request chunks, \(chunks.count)/\(self.chunks.count)")
-
- totalWritedSize = getTotalWritedSize()
- session.invalidateAndCancel()
- session = URLSession(
- configuration: .default,
- delegate: self,
- delegateQueue: downloadQueue)
-
- guard let client else {
- stateProvider?.completionHandler(.failure(AliyunpanNetworkSystemError.invaildClient))
- return
- }
-
- Task {
- do {
- let downloadURL = try await requester.getDownloadURL(with: file, by: client)
- let tasks = chunks.map { chunk in
- var urlRequest = URLRequest(url: downloadURL)
- if chunk.end >= totalSize {
- urlRequest.setValue("bytes=\(chunk.start)-", forHTTPHeaderField: "Range")
- } else {
- urlRequest.setValue("bytes=\(chunk.start)-\(chunk.end - 1)", forHTTPHeaderField: "Range")
- }
- return session.downloadTask(with: urlRequest)
- }
-
- tasks.forEach { task in
- downloadQueue.addOperation {
- task.resume()
- }
- }
- } catch {
- stateProvider?.completionHandler(.failure(error))
- }
- }
- }
-
- /// 下载文件
- /// - Parameters:
- /// - continue: 是否继续上一次未完成的下载,如 false,则会重新下载
- func download(continue: Bool = true) -> AsyncThrowingStream {
- Logger.log(.info, msg: "[Downloader][\(file.name)] start, continue:\(`continue`), destination:\(destination.path)")
- if `continue` {
- return download(chunks: unfininshedChunks)
- } else {
- clean()
- return download(chunks: chunks)
- }
- }
-
- /// 下载文件
- /// - Parameters:
- /// - continue: 是否继续上一次未完成的下载,如 false,则会重新下载
- public func download(
- continue: Bool = true,
- progressHandle: ((Double) -> Void)? = nil,
- completionHandle: @escaping (Result) -> Void) {
- guard state != .downloading else {
- return
- }
- state = .downloading
- Task {
- do {
- for try await result in download(continue: `continue`) {
- if let url = result.url {
- completionHandle(.success(url))
- } else {
- progressHandle?(result.progress)
- }
- }
- } catch {
- completionHandle(.failure(error))
- }
- }
- }
-
- /// 恢复下载
- public func resume() {
- guard state == .pause else {
- return
- }
- Logger.log(.info, msg: "[Downloader][\(file.name)] resume")
- state = .downloading
- startDownload(chunks: unfininshedChunks)
- }
-
- /// 暂停下载
- public func pause() {
- guard state == .downloading else {
- return
- }
- Logger.log(.info, msg: "[Downloader][\(file.name)] pause")
- state = .pause
- session.invalidateAndCancel()
- }
-
- /// 取消下载
- /// 会同时清理已下载分片
- public func cancel() {
- Logger.log(.info, msg: "[Downloader][\(file.name)] cancel")
- state = .idle
- session.invalidateAndCancel()
- clean()
- }
-}
-
-extension AliyunpanDownloader: URLSessionDownloadDelegate {
- public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
- if let error = error as? NSError, error.domain == NSURLErrorDomain {
- switch error.code {
- case NSURLErrorTimedOut:
- retry(task: task)
- case NSURLErrorCancelled:
- return
- default:
- stateProvider?.completionHandler(.failure(error))
- }
- } else if let response = task.response as? HTTPURLResponse,
- response.statusCode == 403 {
- Logger.log(.warn, msg: "[Downloader][\(file.name)] request has expired.")
- startDownload(chunks: unfininshedChunks)
- }
- }
-
- public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
- totalWritedSize += bytesWritten
- stateProvider?.progressHandler(totalWritedSize, totalSize)
- }
-
- public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
- do {
- if let target = getChunkPath(by: downloadTask) {
- let directory = target.deletingLastPathComponent()
- try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
- if fileManager.fileExists(atPath: target.path) {
- try? fileManager.removeItem(at: target)
- }
- try fileManager.moveItem(at: location, to: target)
- let range = downloadTask.currentRequest?.allHTTPHeaderFields?["Range"] ?? ""
- Logger.log(.info, msg: "[Downloader][\(file.name)] the chunk has been downloaded, range:\(range)")
- }
-
- if isFinished {
- try merge()
- stateProvider?.completionHandler(.success(destination))
- }
- } catch {
- stateProvider?.completionHandler(.failure(error))
- }
- }
-}
-
-extension AliyunpanDownloader {
- /// 未完成分片
- private var unfininshedChunks: [DownloadChunk] {
- chunks.filter { chunk in
- let path = getChunkFilePath(with: chunk).path
- if !fileManager.fileExists(atPath: path) {
- return true
- }
- let attributes = try? fileManager.attributesOfItem(atPath: path)
- let size = attributes?[.size] as? Int64 ?? 0
- let targetSize = chunk.end - chunk.start
- return size < targetSize
- }
- }
-
- /// 全部完成
- private var isFinished: Bool {
- return unfininshedChunks.isEmpty
- }
-
- private func getTotalWritedSize() -> Int64 {
- return chunks.compactMap { chunk in
- let path = getChunkFilePath(with: chunk).path
- guard fileManager.fileExists(atPath: path) else {
- return nil
- }
- let attributes = try? fileManager.attributesOfItem(atPath: path)
- return attributes?[.size] as? Int64
- }.reduce(0, +)
- }
-}
-
-extension AliyunpanDownloader {
- private func getChunkPath(by task: URLSessionDownloadTask) -> URL? {
- let rangeString = task.originalRequest?.allHTTPHeaderFields?["Range"] ?? ""
- guard let chunk = DownloadChunk(rangeString: rangeString, fileSize: totalSize) else {
- return nil
- }
- return getChunkFilePath(with: chunk)
- }
-
- private func getChunkFilePath(with chunk: DownloadChunk) -> URL {
- var url = getChunkDirectory()
- url.appendPathComponent(
- "\(destination.lastPathComponent)_\(chunk.start)-\(chunk.end).mp4")
- return url
- }
-
- private func getChunkDirectory() -> URL {
- var url = destination.deletingLastPathComponent()
- url.appendPathComponent("\(file.drive_id)_\(file.file_id)~aliyunpansdk/")
- return url
- }
-}
diff --git a/Sources/AliyunpanSDK/HTTPRequest/Download/AliyunpanDownloadChunk.swift b/Sources/AliyunpanSDK/HTTPRequest/Download/AliyunpanDownloadChunk.swift
new file mode 100644
index 0000000..5f0ec66
--- /dev/null
+++ b/Sources/AliyunpanSDK/HTTPRequest/Download/AliyunpanDownloadChunk.swift
@@ -0,0 +1,35 @@
+//
+// AliyunpanDownloadChunk.swift
+// AliyunpanSDK
+//
+// Created by zhaixian on 2023/12/19.
+//
+
+import Foundation
+
+///
+public struct AliyunpanDownloadChunk: Equatable {
+ public let start: Int64
+ public let end: Int64
+
+ init(start: Int64, end: Int64) {
+ self.start = start
+ self.end = end
+ }
+
+ init?(rangeString: String, fileSize: Int64) {
+ let rangeValue = rangeString.split(separator: "=").last ?? ""
+ let array = rangeValue.split(separator: "-")
+ guard array.count >= 1,
+ let start = Int64(array[0]) else {
+ return nil
+ }
+ let end: Int64
+ if array.count == 2, let value = Int64(array[1]) {
+ end = value + 1
+ } else {
+ end = fileSize
+ }
+ self = Self(start: start, end: end)
+ }
+}
diff --git a/Sources/AliyunpanSDK/HTTPRequest/Download/AliyunpanDownloadTask.swift b/Sources/AliyunpanSDK/HTTPRequest/Download/AliyunpanDownloadTask.swift
new file mode 100644
index 0000000..8aa7c02
--- /dev/null
+++ b/Sources/AliyunpanSDK/HTTPRequest/Download/AliyunpanDownloadTask.swift
@@ -0,0 +1,307 @@
+//
+// AliyunpanDownloadTask.swift
+// AliyunpanSDK
+//
+// Created by zhaixian on 2023/12/18.
+//
+
+import Foundation
+
+protocol AliyunpanDownloadTaskDelegate: AnyObject {
+ func getFileDownloadUrl(driveId: String, fileId: String) async throws -> AliyunpanScope.File.GetFileDownloadUrl.Response
+
+ func getOperationQueue() -> OperationQueue
+
+ func downloadTask(_ task: AliyunpanDownloadTask, didUpdateState state: AliyunpanDownloadTask.State)
+
+ func downloadTask(task: AliyunpanDownloadTask, didWriteData bytesWritten: Int64)
+}
+
+/// 使用 actor 实现串行刷新 downloadURL
+actor DownloadURLActor {
+ private var url: URL?
+ private var expiration: Date?
+ private var refreshTask: Task?
+
+ private func refreshDownloadURL(with file: AliyunpanFile, by delegate: AliyunpanDownloadTaskDelegate) async throws -> URL {
+ let response = try await delegate.getFileDownloadUrl(
+ driveId: file.drive_id,
+ fileId: file.file_id)
+ url = response.url
+ expiration = response.expiration
+ return response.url
+ }
+
+ func getDownloadURL(
+ with file: AliyunpanFile,
+ by delegate: AliyunpanDownloadTaskDelegate) async throws -> URL {
+ if let url = self.url, let expiration = self.expiration, expiration > Date() {
+ return url
+ }
+ if let refreshTask {
+ return try await refreshTask.value
+ }
+ let task = Task {
+ let url = try await refreshDownloadURL(with: file, by: delegate)
+ refreshTask = nil
+ return url
+ }
+ refreshTask = task
+ return try await task.value
+ }
+}
+
+public class AliyunpanDownloadTask: NSObject, Identifiable {
+ public lazy var id: String = {
+ "\(file.drive_id)_\(file.file_id)_\(Int.random(in: 0...1000))"
+ }()
+
+ /// 下载状态
+ public enum State {
+ /// 等待下载
+ case waiting
+ /// 下载中
+ case downloading(progress: Float)
+ /// 暂停中
+ case pause(progress: Float)
+ /// 已完成
+ case finished(URL)
+ /// 失败
+ case failed(Error)
+ }
+
+ public let file: AliyunpanFile
+ let destination: URL
+
+ private let chunkSize = 4_000_000
+ private let downloadURLActor = DownloadURLActor()
+ private let fileManager = FileManager.default
+
+ private weak var delegate: AliyunpanDownloadTaskDelegate?
+
+ public private(set) var state: State = .waiting {
+ didSet {
+ delegate?.downloadTask(self, didUpdateState: state)
+ }
+ }
+
+ private(set) var urlSession: URLSession?
+
+ private var writedSize: Int64 = 0
+ var unfinishedChunks: [AliyunpanDownloadChunk] = []
+
+ init(file: AliyunpanFile, destination: URL, delegate: AliyunpanDownloadTaskDelegate?) {
+ self.file = file
+ self.destination = destination
+ self.delegate = delegate
+ super.init()
+ delegate?.downloadTask(self, didUpdateState: state)
+ }
+
+ func start() {
+ state = .waiting
+
+ writedSize = getWritedSize()
+ unfinishedChunks = chunks.filter {
+ !isFinishedChunk($0)
+ }
+
+ unfinishedChunks.map {
+ getOperation(with: $0)
+ }.forEach {
+ delegate?.getOperationQueue().addOperation($0)
+ }
+ }
+
+ func pause() {
+ removeAllOperations()
+
+ state = .pause(progress: progress)
+ }
+
+ func cancel() {
+ removeAllOperations()
+
+ clean()
+ state = .failed(AliyunpanError.DownloadError.userCancelled)
+ }
+
+ private func removeAllOperations() {
+ delegate?.getOperationQueue().operations.compactMap {
+ $0 as? DownloadChunkOperation
+ }.filter {
+ $0.taskIdentifier == id
+ }.forEach {
+ $0.cancel()
+ }
+ }
+
+ private func clean() {
+ try? fileManager.removeItem(at: getChunkDirectory())
+ try? fileManager.removeItem(at: destination)
+ }
+
+ private func retry(chunk: AliyunpanDownloadChunk) {
+ let operation = getOperation(with: chunk)
+ operation.queuePriority = .high
+ delegate?.getOperationQueue().addOperation(operation)
+ }
+
+ private func merge() throws {
+ if fileManager.fileExists(atPath: destination.path) {
+ try fileManager.removeItem(at: destination)
+ }
+
+ fileManager.createFile(atPath: destination.path, contents: nil)
+ let fileHandle = try FileHandle(forWritingTo: destination)
+ try chunks.forEach { chunk in
+ let chunkFileURL = getChunkFilePath(with: chunk)
+ let data = try Data(contentsOf: chunkFileURL)
+ fileHandle.write(data)
+ }
+ try fileManager.removeItem(at: getChunkDirectory())
+ try fileHandle.close()
+ }
+}
+
+extension AliyunpanDownloadTask {
+ private var progress: Float {
+ let totalSize = file.size ?? 0
+ let writedSize = Float(writedSize)
+ return writedSize / Float(totalSize)
+ }
+
+ private var totalSize: Int64 {
+ file.size ?? 0
+ }
+
+ var chunks: [AliyunpanDownloadChunk] {
+ stride(from: 0, to: totalSize, by: chunkSize).map {
+ AliyunpanDownloadChunk(start: $0, end: min($0 + Int64(chunkSize), totalSize))
+ }
+ }
+
+ private func isFinishedChunk(_ chunk: AliyunpanDownloadChunk) -> Bool {
+ let path = getChunkFilePath(with: chunk).path
+ if !fileManager.fileExists(atPath: path) {
+ return false
+ }
+ let attributes = try? fileManager.attributesOfItem(atPath: path)
+ let size = attributes?[.size] as? Int64 ?? 0
+ let targetSize = chunk.end - chunk.start
+
+ return size >= targetSize
+ }
+
+ /// 获取已写入大小
+ private func getWritedSize() -> Int64 {
+ return chunks.compactMap { chunk in
+ let path = getChunkFilePath(with: chunk).path
+ guard fileManager.fileExists(atPath: path) else {
+ return nil
+ }
+ let attributes = try? fileManager.attributesOfItem(atPath: path)
+ return attributes?[.size] as? Int64
+ }.reduce(0, +)
+ }
+
+ private func getOperation(with chunk: AliyunpanDownloadChunk) -> DownloadChunkOperation {
+ let chunkDestination = getChunkFilePath(with: chunk)
+ let operation = DownloadChunkOperation(
+ chunk: chunk,
+ destination: chunkDestination,
+ taskIdentifier: id
+ )
+ operation.delegate = self
+ operation.dataSource = self
+ return operation
+ }
+
+ private func getChunkFilePath(with chunk: AliyunpanDownloadChunk) -> URL {
+ var url = getChunkDirectory()
+ url.appendPathComponent(
+ "\(destination.lastPathComponent)_\(chunk.start)-\(chunk.end)")
+ return url
+ }
+
+ private func getChunkDirectory() -> URL {
+ var url = destination.deletingLastPathComponent()
+ url.appendPathComponent("\(file.drive_id)_\(file.file_id)~aliyunpansdk/")
+ return url
+ }
+}
+
+extension AliyunpanDownloadTask: DownloadChunkOperationDelegate {
+ func chunkOperation(_ operation: DownloadChunkOperation, didUpdatedState state: AsyncOperation.State) {
+ let chunkIndex = chunks.firstIndex(where: { $0.start == operation.chunk.start }) ?? -1
+
+ do {
+ guard try operation.result?.get() != nil else {
+ // cancelled
+ return
+ }
+
+ switch (state, self.state) {
+ case (.ready, _):
+ Logger.log(.info, msg: "[Downloader][\(file.name)], \(chunkIndex)/\(chunks.count) ready")
+
+ case (.executing, .waiting):
+ // 分片开始执行
+ Logger.log(.info, msg: "[Downloader][\(file.name)], \(chunkIndex)/\(chunks.count) executing...")
+ case (.finished, .downloading):
+ unfinishedChunks.removeAll(where: { $0 == operation.chunk })
+ // 分片全部完成
+ if unfinishedChunks.isEmpty {
+ Logger.log(.info, msg: "[Downloader][\(file.name)] start merge...")
+
+ try merge()
+
+ Logger.log(.info, msg: "[Downloader][\(file.name)] merge success.")
+ self.state = .finished(destination)
+ } else {
+ Logger.log(.info, msg: "[Downloader][\(file.name)], \(chunkIndex)/\(chunks.count) finished")
+ self.state = .downloading(progress: progress)
+ }
+ default:
+ break
+ }
+ } catch AliyunpanError.DownloadError.downloadURLExpired {
+ Logger.log(.error, msg: "[Downloader][\(file.name)], \(chunkIndex)/\(chunks.count) error, downloadURLExpired")
+
+ // 下载链接过期
+ retry(chunk: operation.chunk)
+ } catch let error as NSError where error.domain == NSURLErrorDomain {
+ Logger.log(.error, msg: "[Downloader][\(file.name)], \(chunkIndex)/\(chunks.count) error, \(error)")
+
+ switch error.code {
+ case NSURLErrorTimedOut:
+ retry(chunk: operation.chunk)
+ case NSURLErrorCancelled:
+ return
+ default:
+ self.state = .failed(error)
+ }
+ } catch {
+ Logger.log(.error, msg: "[Downloader][\(file.name)], \(chunkIndex)/\(chunks.count) error, \(error)")
+
+ self.state = .failed(error)
+ }
+ }
+
+ func chunkOperationDidWriteData(_ bytesWritten: Int64) {
+ writedSize += bytesWritten
+ state = .downloading(progress: progress)
+
+ // 通知下载器更新
+ delegate?.downloadTask(task: self, didWriteData: bytesWritten)
+ }
+}
+
+extension AliyunpanDownloadTask: DownloadChunkOperationDataSource {
+ func getFileDownloadUrl() async throws -> URL {
+ guard let delegate else {
+ throw AliyunpanError.DownloadError.invalidClient
+ }
+ return try await downloadURLActor.getDownloadURL(with: file, by: delegate)
+ }
+}
diff --git a/Sources/AliyunpanSDK/HTTPRequest/Download/AliyunpanDownloader.swift b/Sources/AliyunpanSDK/HTTPRequest/Download/AliyunpanDownloader.swift
new file mode 100644
index 0000000..dc741d1
--- /dev/null
+++ b/Sources/AliyunpanSDK/HTTPRequest/Download/AliyunpanDownloader.swift
@@ -0,0 +1,168 @@
+//
+// AliyunpanDownloader.swift
+// AliyunpanSDK
+//
+// Created by zhaixian on 2023/12/18.
+//
+
+import Foundation
+
+public typealias DownloadTasks = [AliyunpanDownloadTask]
+
+extension DownloadTasks {
+ mutating func finish(_ task: Element) {
+ removeAll(where: {
+ $0.id == task.id
+ })
+ }
+}
+
+public protocol AliyunpanDownloadDelegate: AnyObject {
+ /// 下载速度更新
+ @MainActor
+ func downloader(_ downloader: AliyunpanDownloader, didUpdatedNetworkSpeed networkSpeed: Int64)
+
+ /// 下载进度发生变化
+ @MainActor
+ func downloader(_ downloader: AliyunpanDownloader, didUpdateTaskState state: AliyunpanDownloadTask.State, for task: AliyunpanDownloadTask)
+}
+
+/// 下载器
+public class AliyunpanDownloader: NSObject {
+ /// 最大并发数,默认为10
+ public var maxConcurrentOperationCount: Int {
+ get {
+ operationQueue.maxConcurrentOperationCount
+ }
+ set {
+ operationQueue.maxConcurrentOperationCount = newValue
+ }
+ }
+
+ /// 当前下载任务
+ public private(set) var tasks: DownloadTasks = []
+
+ private var operationQueue = OperationQueue(
+ name: "com.aliyunpanSDK.downloader.queue",
+ maxConcurrentOperationCount: 10)
+
+ private var delegates: [Weak] = []
+
+ /// 网速监听 Timer
+ private lazy var networkSpeedTimer: Timer = {
+ let timer = Timer(timeInterval: 1, repeats: true) { [weak self] _ in
+ guard let self else {
+ return
+ }
+ let offset = currentWritedSize - lastWritedSize
+ lastWritedSize = currentWritedSize
+ self.delegates.compactMap { $0.value as? AliyunpanDownloadDelegate }
+ .forEach { delegate in
+ DispatchQueue.main.async { [weak self] in
+ guard let self else {
+ return
+ }
+ delegate.downloader(self, didUpdatedNetworkSpeed: offset)
+ }
+ }
+ }
+ RunLoop.current.add(timer, forMode: .common)
+ return timer
+ }()
+
+ private var lastWritedSize: Int64 = 0
+
+ private var currentWritedSize: Int64 = 0
+
+ weak var client: AliyunpanClient?
+
+ deinit {
+ networkSpeedTimer.invalidate()
+ }
+
+ override init() {
+ super.init()
+ }
+}
+
+extension AliyunpanDownloader {
+ /// 添加代理
+ public func addDelegate(_ delegate: AliyunpanDownloadDelegate) {
+ delegates = (delegates + [.init(value: delegate)]).filter {
+ $0.value != nil
+ }
+ }
+
+ /// 开启网速监听
+ public func enableNetworkSpeedMonitor() {
+ networkSpeedTimer.fire()
+ }
+
+ /// 下载文件
+ /// - Parameters:
+ /// - file: 目标文件
+ /// - destination: 目标目录
+ /// - Returns: DownloadTask
+ @discardableResult
+ public func download(file: AliyunpanFile, to destination: URL) -> AliyunpanDownloadTask {
+ Logger.log(.info, msg: "[Downloader] download \(file.name), to:\(destination)")
+
+ let task = AliyunpanDownloadTask(file: file, destination: destination, delegate: self)
+ tasks.append(task)
+ task.start()
+ return task
+ }
+
+ /// 暂停下载
+ /// - Parameter task: 目标任务
+ public func pause(_ task: AliyunpanDownloadTask) {
+ Logger.log(.info, msg: "[Downloader] pause \(task.file.name)")
+ task.pause()
+ }
+
+ /// 恢复下载
+ /// - Parameter task: 目标任务
+ public func resume(_ task: AliyunpanDownloadTask) {
+ Logger.log(.info, msg: "[Downloader] resume \(task.file.name)")
+ task.start()
+ }
+
+ /// 取消下载,会清空已下载内容
+ /// - Parameter task: 目标任务
+ public func cancel(_ task: AliyunpanDownloadTask) {
+ Logger.log(.info, msg: "[Downloader] cancel \(task.file.name)")
+ task.cancel()
+ tasks.finish(task)
+ }
+}
+
+extension AliyunpanDownloader: AliyunpanDownloadTaskDelegate {
+ func getFileDownloadUrl(driveId: String, fileId: String) async throws -> AliyunpanScope.File.GetFileDownloadUrl.Response {
+ guard let client else {
+ throw AliyunpanError.DownloadError.invalidClient
+ }
+ return try await client.send(
+ AliyunpanScope.File.GetFileDownloadUrl(
+ .init(drive_id: driveId, file_id: fileId)))
+ }
+
+ func getOperationQueue() -> OperationQueue {
+ operationQueue
+ }
+
+ func downloadTask(_ task: AliyunpanDownloadTask, didUpdateState state: AliyunpanDownloadTask.State) {
+ delegates.compactMap { $0.value as? AliyunpanDownloadDelegate }
+ .forEach { delegate in
+ DispatchQueue.main.async { [weak self] in
+ guard let self else {
+ return
+ }
+ delegate.downloader(self, didUpdateTaskState: state, for: task)
+ }
+ }
+ }
+
+ func downloadTask(task: AliyunpanDownloadTask, didWriteData bytesWritten: Int64) {
+ currentWritedSize += bytesWritten
+ }
+}
diff --git a/Sources/AliyunpanSDK/HTTPRequest/Download/DownloadChunkOperation.swift b/Sources/AliyunpanSDK/HTTPRequest/Download/DownloadChunkOperation.swift
new file mode 100644
index 0000000..bb54416
--- /dev/null
+++ b/Sources/AliyunpanSDK/HTTPRequest/Download/DownloadChunkOperation.swift
@@ -0,0 +1,193 @@
+//
+// DownloadChunkOperation.swift
+// AliyunpanSDK
+//
+// Created by zhaixian on 2023/12/19.
+//
+
+import Foundation
+
+protocol DownloadChunkOperationDelegate: AnyObject {
+ func chunkOperation(_ operation: DownloadChunkOperation, didUpdatedState state: AsyncOperation.State)
+
+ func chunkOperationDidWriteData(_ bytesWritten: Int64)
+}
+
+protocol DownloadChunkOperationDataSource: AnyObject {
+ func getFileDownloadUrl() async throws -> URL
+}
+
+class DownloadChunkOperation: AsyncThrowOperation {
+ class StateProvider {
+ let completionHandler: (Result) -> Void
+
+ init(completionHandler: @escaping (Result) -> Void) {
+ self.completionHandler = completionHandler
+ }
+ }
+
+ weak var delegate: DownloadChunkOperationDelegate?
+ weak var dataSource: DownloadChunkOperationDataSource?
+
+ let chunk: AliyunpanDownloadChunk
+ let destination: URL
+ let taskIdentifier: String?
+
+ private var fileManager: FileManager {
+ FileManager.default
+ }
+
+ private var stateProvider: StateProvider?
+ private var sessionTask: URLSessionTask?
+ private var task: Task<(), Never>?
+
+ private lazy var urlSession: URLSession? =
+ URLSession(
+ configuration: .default,
+ delegate: self,
+ delegateQueue: .init(name: "com.aliyunpanSDK.downloadTaskQueue"))
+
+ init(chunk: AliyunpanDownloadChunk, destination: URL, taskIdentifier: String) {
+ self.chunk = chunk
+ self.destination = destination
+ self.taskIdentifier = taskIdentifier
+ }
+
+ override func main() {
+ guard let dataSource else {
+ cancel()
+ return
+ }
+
+ task = Task {
+ do {
+ try await Task.sleep(seconds: 0.3)
+ // 下载
+ let data = try await download(chunk: chunk, with: dataSource)
+
+ // 写入
+ let directory = destination.deletingLastPathComponent()
+ try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
+
+ if fileManager.fileExists(atPath: destination.path) {
+ try fileManager.removeItem(at: destination)
+ }
+
+ fileManager.createFile(atPath: destination.path, contents: data)
+
+ result = .success(destination)
+ finish()
+ } catch {
+ result = .failure(error)
+ finish()
+ }
+ }
+ }
+
+ override func finish() {
+ guard !isFinished else {
+ return
+ }
+ clean {
+ super.finish()
+ }
+ }
+
+ override func cancel() {
+ clean {
+ super.cancel()
+ }
+ }
+
+ private func clean(_ completion: () -> Void) {
+ sessionTask?.cancel()
+ sessionTask = nil
+ urlSession?.invalidateAndCancel()
+ urlSession = nil
+ task?.cancel()
+ completion()
+ }
+
+ override func updateState(state: AsyncOperation.State, oldValue: AsyncOperation.State) {
+ delegate?.chunkOperation(self, didUpdatedState: state)
+ }
+}
+
+extension DownloadChunkOperation {
+ private func download(
+ chunk: AliyunpanDownloadChunk,
+ with dataSource: DownloadChunkOperationDataSource
+ ) async throws -> Data {
+ try await withCheckedThrowingContinuation { continuation in
+ stateProvider = StateProvider { result in
+ switch result {
+ case .success(let data):
+ continuation.resume(returning: data)
+ case .failure(let error):
+ continuation.resume(throwing: error)
+ }
+ }
+
+ Task {
+ do {
+ let downloadURL = try await dataSource.getFileDownloadUrl()
+ var urlRequest = URLRequest(url: downloadURL)
+
+ urlRequest.setValue("bytes=\(chunk.start)-\(chunk.end - 1)", forHTTPHeaderField: "Range")
+
+ let urlSession = self.urlSession ?? URLSession(
+ configuration: .default,
+ delegate: self,
+ delegateQueue: .init(name: "com.aliyunpanSDK.downloadTaskQueue"))
+
+ let sessionTask = urlSession.downloadTask(with: urlRequest)
+ sessionTask.resume()
+ self.sessionTask = sessionTask
+ } catch {
+ continuation.resume(throwing: error)
+ }
+ }
+ }
+ }
+}
+
+extension DownloadChunkOperation {
+ private func chunkOperationDidCompleteWithError(_ error: Error) {
+ stateProvider?.completionHandler(.failure(error))
+ stateProvider = nil
+ }
+
+ private func chunkOperatioDidFinishDownload(_ data: Data) {
+ stateProvider?.completionHandler(.success(data))
+ stateProvider = nil
+ }
+}
+
+extension DownloadChunkOperation: URLSessionDownloadDelegate {
+ func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
+ if let error {
+
+ chunkOperationDidCompleteWithError(error)
+ } else if let response = task.response as? HTTPURLResponse,
+ response.statusCode == 403,
+ // https://help.aliyun.com/zh/oss/support/0002-00000069
+ response.value(forHTTPHeaderField: "x-oss-ec") == "0002-00000069" {
+
+ chunkOperationDidCompleteWithError(
+ AliyunpanError.DownloadError.downloadURLExpired)
+ }
+ }
+
+ func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
+ delegate?.chunkOperationDidWriteData(bytesWritten)
+ }
+
+ func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
+ do {
+ let data = try Data(contentsOf: location)
+ chunkOperatioDidFinishDownload(data)
+ } catch {
+ chunkOperationDidCompleteWithError(error)
+ }
+ }
+}
diff --git a/Sources/AliyunpanSDK/HTTPRequest/HTTPRequest.swift b/Sources/AliyunpanSDK/HTTPRequest/HTTPRequest.swift
index 8762953..bda322b 100644
--- a/Sources/AliyunpanSDK/HTTPRequest/HTTPRequest.swift
+++ b/Sources/AliyunpanSDK/HTTPRequest/HTTPRequest.swift
@@ -37,7 +37,7 @@ extension OperationQueue {
}
extension OperationQueue {
- static let rootQueue = OperationQueue(name: "com.smartdrive.AliyunpanSDK.session.rootQueue")
+ static let rootQueue = OperationQueue(name: "com.aliyunpanSDK.session.rootQueue")
}
extension URLSession {
@@ -63,7 +63,7 @@ class HTTPRequest {
func asURLRequest() throws -> URLRequest {
guard let url = try? AliyunpanURL(uri: command.uri).asURL() else {
- throw AliyunpanNetworkSystemError.invaildURL
+ throw AliyunpanError.NetworkSystemError.invalidURL
}
var urlRequest = URLRequest(url: url)
// set httpMethod
@@ -94,10 +94,10 @@ class HTTPRequest {
Logger.log(.debug, msg: DebugDescription.description(of: response, data: data))
if let response = response as? HTTPURLResponse, response.statusCode != 200 {
- if let error = try? decoder.decode(AliyunpanServerError.self, from: data) {
+ if let error = try? decoder.decode(AliyunpanError.ServerError.self, from: data) {
throw error
} else {
- throw AliyunpanNetworkSystemError.httpError(statusCode: response.statusCode, data: data, response: response)
+ throw AliyunpanError.NetworkSystemError.httpError(statusCode: response.statusCode, data: data, response: response)
}
}
return data
diff --git a/Sources/AliyunpanSDK/HTTPRequest/URLConvertible.swift b/Sources/AliyunpanSDK/HTTPRequest/URLConvertible.swift
index 96c034e..cd91f3a 100644
--- a/Sources/AliyunpanSDK/HTTPRequest/URLConvertible.swift
+++ b/Sources/AliyunpanSDK/HTTPRequest/URLConvertible.swift
@@ -14,7 +14,7 @@ protocol URLConvertible {
extension String: URLConvertible {
func asURL() throws -> URL {
guard let url = URL(string: self) else {
- throw AliyunpanNetworkSystemError.invaildURL
+ throw AliyunpanError.NetworkSystemError.invalidURL
}
return url
}
diff --git a/Sources/AliyunpanSDK/Model/AliyunpanFile.swift b/Sources/AliyunpanSDK/Model/AliyunpanFile.swift
index 08834b2..d780046 100644
--- a/Sources/AliyunpanSDK/Model/AliyunpanFile.swift
+++ b/Sources/AliyunpanSDK/Model/AliyunpanFile.swift
@@ -66,6 +66,11 @@ extension AliyunpanFile {
public var isFolder: Bool {
type == .folder
}
+
+ /// 是否同一份文件,仅判断 drive_id、file_id 是否相同
+ public func isSameFile(_ other: AliyunpanFile) -> Bool {
+ return drive_id == other.drive_id && file_id == other.file_id
+ }
}
extension AliyunpanFile {
diff --git a/Sources/AliyunpanSDK/Platform.swift b/Sources/AliyunpanSDK/Platform.swift
index 775cd91..4d924ca 100644
--- a/Sources/AliyunpanSDK/Platform.swift
+++ b/Sources/AliyunpanSDK/Platform.swift
@@ -20,31 +20,22 @@ import TVUIKit
class Platform {
static func canOpenURL(_ url: URL) -> Bool {
-#if canImport(UIKit)
+#if canImport(UIKit) || canImport(TVUIKit)
return UIApplication.shared.canOpenURL(url)
#endif
#if canImport(AppKit) && !targetEnvironment(macCatalyst)
return NSWorkspace.shared.open(url)
#endif
-
-#if canImport(TVUIKit)
- return UIApplication.shared.canOpenURL(url)
-#endif
- return false
}
static func open(_ url: URL) async {
-#if canImport(UIKit)
+#if canImport(UIKit) || canImport(TVUIKit)
await UIApplication.shared.open(url)
#endif
#if canImport(AppKit) && !targetEnvironment(macCatalyst)
NSWorkspace.shared.open(url)
#endif
-
-#if canImport(TVUIKit)
- await UIApplication.shared.open(url)
-#endif
}
}
diff --git a/Sources/AliyunpanSDK/Utils/AsyncOperation.swift b/Sources/AliyunpanSDK/Utils/AsyncOperation.swift
new file mode 100644
index 0000000..4100ee4
--- /dev/null
+++ b/Sources/AliyunpanSDK/Utils/AsyncOperation.swift
@@ -0,0 +1,80 @@
+//
+// AsyncOperation.swift
+// AliyunpanSDK
+//
+// Created by zhaixian on 2023/12/19.
+//
+
+import Foundation
+
+class AsyncOperation: Operation {
+ @objc enum State: Int {
+ case ready
+ case executing
+ case finished
+ case cancel
+ }
+
+ @ThreadSafe
+ @objc var state: State = .ready {
+ willSet {
+ willChangeValue(for: \.isReady)
+ willChangeValue(for: \.isExecuting)
+ willChangeValue(for: \.isFinished)
+ willChangeValue(for: \.isCancelled)
+ }
+ didSet {
+ didChangeValue(for: \.isReady)
+ didChangeValue(for: \.isExecuting)
+ didChangeValue(for: \.isFinished)
+ didChangeValue(for: \.isCancelled)
+
+ updateState(state: state, oldValue: oldValue)
+ }
+ }
+
+ override var isAsynchronous: Bool {
+ true
+ }
+
+ override var isReady: Bool {
+ state == .ready && super.isReady
+ }
+
+ override var isExecuting: Bool {
+ state == .executing
+ }
+
+ override var isFinished: Bool {
+ state == .finished || state == .cancel
+ }
+
+ override var isCancelled: Bool {
+ state == .cancel
+ }
+
+ override func start() {
+ guard !isCancelled else {
+ cancel()
+ return
+ }
+
+ state = .executing
+
+ main()
+ }
+
+ func finish() {
+ state = .finished
+ }
+
+ override func cancel() {
+ state = .cancel
+ }
+
+ func updateState(state: State, oldValue: State) {}
+}
+
+class AsyncThrowOperation: AsyncOperation {
+ var result: Result?
+}
diff --git a/Sources/AliyunpanSDK/Utils/ThreadSafe.swift b/Sources/AliyunpanSDK/Utils/ThreadSafe.swift
new file mode 100644
index 0000000..dc0089c
--- /dev/null
+++ b/Sources/AliyunpanSDK/Utils/ThreadSafe.swift
@@ -0,0 +1,34 @@
+//
+// ThreadSafe.swift
+// Pods
+//
+// Created by zhaixian on 2023/12/20.
+//
+
+import Foundation
+
+@propertyWrapper
+struct ThreadSafe {
+ var wrappedValue: Element {
+ get {
+ queue.sync {
+ projectValue
+ }
+ }
+ set {
+ queue.sync {
+ projectValue = newValue
+ }
+ }
+ }
+
+ private let queue = DispatchQueue(
+ label: "com.aliyunpanSDK.\(String(describing: Element.self))",
+ attributes: .concurrent)
+
+ var projectValue: Element
+
+ init(wrappedValue: Element) {
+ self.projectValue = wrappedValue
+ }
+}
diff --git a/Sources/AliyunpanSDK/Utils/Weak.swift b/Sources/AliyunpanSDK/Utils/Weak.swift
new file mode 100644
index 0000000..b2d5559
--- /dev/null
+++ b/Sources/AliyunpanSDK/Utils/Weak.swift
@@ -0,0 +1,15 @@
+//
+// Weak.swift
+// AliyunpanSDK
+//
+// Created by zhaixian on 2023/12/18.
+//
+
+import Foundation
+
+class Weak {
+ weak var value: T?
+ init(value: T) {
+ self.value = value
+ }
+}
diff --git a/Tests/AliyunpanSDK.xctestplan b/Tests/AliyunpanSDK.xctestplan
index 777d603..2499506 100644
--- a/Tests/AliyunpanSDK.xctestplan
+++ b/Tests/AliyunpanSDK.xctestplan
@@ -18,6 +18,13 @@
"identifier" : "F4C6F22F2B060C4B003A06B3",
"name" : "AliyunpanSDKTests"
}
+ },
+ {
+ "target" : {
+ "containerPath" : "container:",
+ "identifier" : "AliyunpanSDKTests",
+ "name" : "AliyunpanSDKTests"
+ }
}
],
"version" : 1
diff --git a/Tests/AliyunpanSDKTests/DownloaderOperationTests.swift b/Tests/AliyunpanSDKTests/DownloaderOperationTests.swift
new file mode 100644
index 0000000..e0d7ae4
--- /dev/null
+++ b/Tests/AliyunpanSDKTests/DownloaderOperationTests.swift
@@ -0,0 +1,108 @@
+//
+// DownloaderOperationTests.swift
+// AliyunpanSDK
+//
+// Created by zhaixian on 2023/12/21.
+//
+
+import XCTest
+@testable import AliyunpanSDK
+
+class DownloaderOperationTests: XCTestCase {
+ let destination = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first!
+
+ let operationQueue = OperationQueue(
+ name: "DownloaderOperationTests.queue",
+ maxConcurrentOperationCount: 10)
+
+ var completionHandle: ((DownloadChunkOperation, AsyncOperation.State) -> Void)?
+
+ var chunkOperation: DownloadChunkOperation?
+
+ func testSuccess() async throws {
+ let chunkOperation = {
+ let chunk = AliyunpanDownloadChunk(start: 0, end: 1000)
+ let chunkOperation = DownloadChunkOperation(chunk: chunk, destination: destination.appendingPathComponent("testfile"), taskIdentifier: "")
+ chunkOperation.delegate = self
+ chunkOperation.dataSource = self
+ return chunkOperation
+ }()
+ self.chunkOperation = chunkOperation
+
+ XCTAssertEqual(chunkOperation.state, .ready)
+
+ let stream = AsyncThrowingStream<(DownloadChunkOperation, AsyncOperation.State), Error> { continuation in
+ completionHandle = { operation, state in
+ continuation.yield((operation, state))
+ if state == .finished {
+ continuation.finish()
+ }
+ }
+ operationQueue.addOperation(chunkOperation)
+ }
+
+ var index = 0
+ for try await result in stream {
+ let url = try? result.0.result?.get()
+ if index == 0 {
+ XCTAssertEqual(url, nil)
+ XCTAssertEqual(result.1, .executing)
+ } else if index == 1 {
+ XCTAssertEqual(result.1, .finished)
+ }
+ index += 1
+ }
+ }
+
+ func testFailure() async throws {
+ let chunkOperation = {
+ let chunk = AliyunpanDownloadChunk(start: 1000, end: 4000)
+ let chunkOperation = DownloadChunkOperation(chunk: chunk, destination: destination, taskIdentifier: "")
+ chunkOperation.delegate = self
+ chunkOperation.dataSource = self
+ return chunkOperation
+ }()
+ self.chunkOperation = chunkOperation
+
+ XCTAssertEqual(chunkOperation.state, .ready)
+
+ let stream = AsyncThrowingStream<(DownloadChunkOperation, AsyncOperation.State), Error> { continuation in
+ completionHandle = { operation, state in
+ continuation.yield((operation, state))
+ if state == .finished {
+ continuation.finish()
+ }
+ }
+ operationQueue.addOperation(chunkOperation)
+ }
+
+ var index = 0
+ for try await result in stream {
+ if index == 0 {
+ let url = try? result.0.result?.get()
+ XCTAssertEqual(url, nil)
+ XCTAssertEqual(result.1, .executing)
+ } else if index == 1 {
+ XCTAssertThrowsError(try result.0.result?.get())
+ XCTAssertEqual(result.1, .finished)
+ }
+ index += 1
+ }
+ }
+}
+
+extension DownloaderOperationTests: DownloadChunkOperationDelegate {
+ func chunkOperationDidWriteData(_ bytesWritten: Int64) {
+
+ }
+
+ func chunkOperation(_ operation: AliyunpanSDK.DownloadChunkOperation, didUpdatedState state: AliyunpanSDK.AsyncOperation.State) {
+ completionHandle?(operation, state)
+ }
+}
+
+extension DownloaderOperationTests: DownloadChunkOperationDataSource {
+ func getFileDownloadUrl() async throws -> URL {
+ URL(string: "https://aliyunpan.com")!
+ }
+}
diff --git a/Tests/AliyunpanSDKTests/DownloaderTaskTests.swift b/Tests/AliyunpanSDKTests/DownloaderTaskTests.swift
new file mode 100644
index 0000000..f3e6645
--- /dev/null
+++ b/Tests/AliyunpanSDKTests/DownloaderTaskTests.swift
@@ -0,0 +1,140 @@
+//
+// DownloaderTaskTests.swift
+// AliyunpanSDK
+//
+// Created by zhaixian on 2023/12/21.
+//
+
+import XCTest
+@testable import AliyunpanSDK
+
+let file = AliyunpanFile(
+ drive_id: "drive_id",
+ file_id: "file_id",
+ parent_file_id: "",
+ name: "",
+ size: 12_345_678,
+ file_extension: nil,
+ content_hash: nil,
+ type: .file,
+ thumbnail: nil,
+ url: nil,
+ created_at: nil,
+ updated_at: nil,
+ play_cursor: nil,
+ image_media_metadata: nil,
+ video_media_metadata: nil)
+
+class DownloaderTaskTests: XCTestCase, AliyunpanDownloadTaskDelegate {
+ let destination = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first!
+
+ private let operationQueue = OperationQueue(
+ name: "test",
+ maxConcurrentOperationCount: 10)
+
+ func testTask() throws {
+ let task = AliyunpanDownloadTask(
+ file: file,
+ destination: destination,
+ delegate: self)
+
+ task.start()
+ XCTAssertEqual(operationQueue.operations.count, 4)
+
+ task.cancel()
+ XCTAssertEqual(operationQueue.operations.count, 0)
+
+ task.start()
+ XCTAssertEqual(operationQueue.operations.count, 4)
+
+ task.pause()
+ XCTAssertEqual(operationQueue.operations.count, 0)
+ }
+
+ func getFileDownloadUrl(driveId: String, fileId: String) async throws -> AliyunpanScope.File.GetFileDownloadUrl.Response {
+ try await Task.sleep(seconds:1)
+ return AliyunpanScope.File.GetFileDownloadUrl.Response(
+ url: URL(string: "https://alipan.com")!,
+ expiration: Date().addingTimeInterval(100),
+ method: "GET")
+ }
+
+ func getOperationQueue() -> OperationQueue {
+ operationQueue
+ }
+
+ func downloadTask(_ task: AliyunpanDownloadTask, didUpdateState state: AliyunpanDownloadTask.State) {
+
+ }
+
+ func downloadTask(task: AliyunpanDownloadTask, didWriteData bytesWritten: Int64) {
+
+ }
+}
+
+class DownloaderActorTests: XCTestCase, AliyunpanDownloadTaskDelegate {
+ private var getDownloadURLCount = 0
+
+ func testDownloadActor() async throws {
+ let actor = DownloadURLActor()
+ _ = try await actor.getDownloadURL(with: file, by: self)
+ _ = try await actor.getDownloadURL(with: file, by: self)
+ _ = try await actor.getDownloadURL(with: file, by: self)
+ _ = try await actor.getDownloadURL(with: file, by: self)
+ _ = try await actor.getDownloadURL(with: file, by: self)
+ _ = try await actor.getDownloadURL(with: file, by: self)
+ _ = try await actor.getDownloadURL(with: file, by: self)
+ _ = try await actor.getDownloadURL(with: file, by: self)
+ _ = try await actor.getDownloadURL(with: file, by: self)
+ _ = try await actor.getDownloadURL(with: file, by: self)
+ _ = try await actor.getDownloadURL(with: file, by: self)
+
+ XCTAssertEqual(getDownloadURLCount, 1)
+
+ try await Task.sleep(seconds: 1)
+
+ _ = try await actor.getDownloadURL(with: file, by: self)
+ _ = try await actor.getDownloadURL(with: file, by: self)
+ _ = try await actor.getDownloadURL(with: file, by: self)
+ _ = try await actor.getDownloadURL(with: file, by: self)
+ _ = try await actor.getDownloadURL(with: file, by: self)
+ _ = try await actor.getDownloadURL(with: file, by: self)
+ _ = try await actor.getDownloadURL(with: file, by: self)
+ _ = try await actor.getDownloadURL(with: file, by: self)
+ _ = try await actor.getDownloadURL(with: file, by: self)
+ _ = try await actor.getDownloadURL(with: file, by: self)
+ _ = try await actor.getDownloadURL(with: file, by: self)
+
+ XCTAssertEqual(getDownloadURLCount, 2)
+
+ _ = try await actor.getDownloadURL(with: file, by: self)
+ _ = try await actor.getDownloadURL(with: file, by: self)
+ _ = try await actor.getDownloadURL(with: file, by: self)
+
+ XCTAssertEqual(getDownloadURLCount, 2)
+ }
+
+ func getFileDownloadUrl(driveId: String, fileId: String) async throws -> AliyunpanScope.File.GetFileDownloadUrl.Response {
+ try await Task.sleep(seconds: 0.5)
+
+ getDownloadURLCount += 1
+
+ return AliyunpanScope.File.GetFileDownloadUrl.Response(
+ url: URL(string: "https://alipan.com")!,
+ expiration: Date().addingTimeInterval(1),
+ method: "GET")
+ }
+
+ func getOperationQueue() -> OperationQueue {
+ OperationQueue()
+ }
+
+ func downloadTask(_ task: AliyunpanDownloadTask, didUpdateState state: AliyunpanDownloadTask.State) {
+
+ }
+
+ func downloadTask(task: AliyunpanDownloadTask, didWriteData bytesWritten: Int64) {
+
+ }
+}
+
diff --git a/Tests/AliyunpanSDKTests/DownloaderTests.swift b/Tests/AliyunpanSDKTests/DownloaderTests.swift
index 320d28a..a09a7fb 100644
--- a/Tests/AliyunpanSDKTests/DownloaderTests.swift
+++ b/Tests/AliyunpanSDKTests/DownloaderTests.swift
@@ -8,12 +8,6 @@
import XCTest
@testable import AliyunpanSDK
-extension DownloadChunk: Equatable {
- public static func == (lhs: DownloadChunk, rhs: DownloadChunk) -> Bool {
- lhs.start == rhs.start && lhs.end == rhs.end
- }
-}
-
class DownloaderTests: XCTestCase {
let file = AliyunpanFile(
drive_id: "drive_id",
@@ -33,47 +27,32 @@ class DownloaderTests: XCTestCase {
video_media_metadata: nil)
let destination = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first!
+
func testChunk() {
- let chunk1 = DownloadChunk(rangeString: "bytes=0-999", fileSize: file.size ?? 0)
+ let chunk1 = AliyunpanDownloadChunk(rangeString: "bytes=0-999", fileSize: file.size ?? 0)
XCTAssertEqual(chunk1?.start, 0)
XCTAssertEqual(chunk1?.end, 1000)
-
- let chunk2 = DownloadChunk(rangeString: "bytes=1000-", fileSize: file.size ?? 0)
+
+ let chunk2 = AliyunpanDownloadChunk(rangeString: "bytes=1000-", fileSize: file.size ?? 0)
XCTAssertEqual(chunk2?.start, 1000)
XCTAssertEqual(chunk2?.end, 12_345_678)
-
- let chunk3 = DownloadChunk(rangeString: "bytes=-", fileSize: file.size ?? 0)
+
+ let chunk3 = AliyunpanDownloadChunk(rangeString: "bytes=-", fileSize: file.size ?? 0)
XCTAssertNil(chunk3)
-
- let chunk4 = DownloadChunk(rangeString: "bytes", fileSize: file.size ?? 0)
+
+ let chunk4 = AliyunpanDownloadChunk(rangeString: "bytes", fileSize: file.size ?? 0)
XCTAssertNil(chunk4)
-
- let downloader = AliyunpanDownloader(
+
+ let downloadTask = AliyunpanDownloadTask(
file: file,
destination: destination,
- maxConcurrentOperationCount: 10)
- let chunks = downloader.chunks
+ delegate: nil)
+ let chunks = downloadTask.chunks
XCTAssertEqual(chunks[0], .init(start: 0, end: 4_000_000))
XCTAssertEqual(chunks[1], .init(start: 4_000_000, end: 8_000_000))
XCTAssertEqual(chunks[2], .init(start: 8_000_000, end: 12_000_000))
XCTAssertEqual(chunks[3], .init(start: 12_000_000, end: 12_345_678))
XCTAssertEqual(chunks.count, 4)
}
-
- func testDownload() throws {
- let downloader = AliyunpanDownloader(
- file: file,
- destination: destination,
- maxConcurrentOperationCount: 10)
- XCTAssertEqual(downloader.state, .idle)
- downloader.download { _ in }
- XCTAssertEqual(downloader.state, .downloading)
- downloader.cancel()
- XCTAssertEqual(downloader.state, .idle)
- downloader.resume()
- XCTAssertEqual(downloader.state, .idle)
- downloader.download { _ in }
- XCTAssertEqual(downloader.state, .downloading)
- }
}
diff --git a/Tests/AliyunpanSDKTests/MessageTests.swift b/Tests/AliyunpanSDKTests/MessageTests.swift
index 287ef1a..3b8d68d 100644
--- a/Tests/AliyunpanSDKTests/MessageTests.swift
+++ b/Tests/AliyunpanSDKTests/MessageTests.swift
@@ -8,11 +8,11 @@
import XCTest
@testable import AliyunpanSDK
-extension AliyunpanAuthorizeError: Equatable {
+extension AliyunpanError.AuthorizeError: Equatable {
var stringValue: String {
switch self {
- case .invaildAuthorizeURL:
- return "invaildAuthorizeURL"
+ case .invalidAuthorizeURL:
+ return "invalidAuthorizeURL"
case .notInstalledApp:
return "notInstalledApp"
case .authorizeFailed(let error, let errorMsg):
@@ -22,7 +22,7 @@ extension AliyunpanAuthorizeError: Equatable {
}
}
- public static func == (lhs: AliyunpanAuthorizeError, rhs: AliyunpanAuthorizeError) -> Bool {
+ public static func == (lhs: AliyunpanError.AuthorizeError, rhs: AliyunpanError.AuthorizeError) -> Bool {
lhs.stringValue == rhs.stringValue
}
}
@@ -40,7 +40,7 @@ class MessageTests: XCTestCase {
let url2 = URL(string: "abcd://authorize?state=\(state)")!
XCTAssertThrowsError(try AliyunpanMessage(url2)) { error in
- XCTAssertEqual(error as! AliyunpanAuthorizeError, AliyunpanAuthorizeError.invaildAuthorizeURL)
+ XCTAssertEqual(error as! AliyunpanError.AuthorizeError, AliyunpanError.AuthorizeError.invalidAuthorizeURL)
}
}
@@ -58,7 +58,7 @@ class MessageTests: XCTestCase {
let url2 = URL(string: "abcd://authorize?state=\(state)&code=\(code)")!
XCTAssertThrowsError(try AliyunpanAuthorizeMessage(url2)) { error in
- XCTAssertEqual(error as! AliyunpanAuthorizeError, AliyunpanAuthorizeError.invaildAuthorizeURL)
+ XCTAssertEqual(error as! AliyunpanError.AuthorizeError, AliyunpanError.AuthorizeError.invalidAuthorizeURL)
}
}
}