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